Add "Update from scenario" to pull scenario edits into a running adventure
An adventure copies its scenario's plot text and story cards at creation so
later authoring never disturbs a story in progress. This is the explicit
opt-out, alongside the existing per-script "Sync from library".
GET /adventures/{id}/refresh returns a plan (per-field old/new diff, card
add/update/remove, world-state added/removed paths, and any ${...} answers
still needed); POST applies it under the turn lock so it can't race a
generating turn. The Plot panel shows the plan in a confirm modal first.
Overwrites the plot fields and scenario-derived cards. Deliberately left
alone: the opening `start` action (the story is built on it, and it is baked
into memories and the summary), the adventure's own title and summary,
player-authored story cards, and the live value of every stat the schema
still defines.
Two enablers were needed:
- adventures.placeholders (migration 32). ${...} answers were used once at
creation and discarded, so re-copying scenario text would have re-injected
a literal ${Hero}. Adventures predating the column re-prompt once via the
existing modal, then the answers are saved.
- story_cards.source_ref (migration 33), "card:<id>" / "npc:<key>", NULL for
player-authored. Adventure cards had no link back to their source, so a
rename read as delete-plus-add and player cards would have been clobbered.
Legacy cards name-match once, then adopt the ref.
World state goes through a new worldstate.reconcile(): keep values the schema
still defines, add missing ones at their initial, drop removed ones and their
cooldown bookkeeping. Not instantiate(), which would heal the player to full
and wipe their milestones.
The confirm modal is portalled to <body>: .side-panel's panel-in animation
has fill mode `both`, which makes it the containing block for position:fixed
descendants, so an overlay rendered in place was trapped in the 420px panel
and clipped by its overflow.
14 new tests in backend/tests/test_scenario_refresh.py; 85 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzBCyXH4hBEVHqEaertcq4
This commit is contained in:
co-authored by
Claude Opus 5
parent
a170f5aed5
commit
aed28d9295
+159
-3
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||
@@ -52,8 +53,118 @@ const SECTION_LABELS = {
|
||||
front_memory: 'Front memory',
|
||||
}
|
||||
|
||||
function PlotPanel({ adventure, setAdventure }) {
|
||||
const FIELD_LABELS = {
|
||||
memory: 'Plot Essentials (Memory)',
|
||||
authors_note: "Author's Note",
|
||||
ai_instructions: 'AI Instructions',
|
||||
}
|
||||
|
||||
function clip(text, n = 90) {
|
||||
const one = (text || '').replace(/\s+/g, ' ').trim()
|
||||
return one.length > n ? `${one.slice(0, n)}…` : (one || '(empty)')
|
||||
}
|
||||
|
||||
// Confirms "Update from scenario" by showing exactly what it would change, and
|
||||
// collects any ${Placeholder} answers the adventure has no stored value for
|
||||
// (adventures started before those were saved, or a placeholder the author
|
||||
// added since). Destructive by design — it overwrites plot text and
|
||||
// scenario-derived cards — so nothing happens until Update is pressed.
|
||||
function RefreshModal({ plan, onConfirm, onCancel }) {
|
||||
const [values, setValues] = useState(
|
||||
Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])),
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const { added = [], updated = [], removed = [] } = plan.cards || {}
|
||||
const world = plan.world_state || {}
|
||||
const fields = Object.entries(plan.fields || {})
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
onConfirm(values).finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
// Portalled to <body>: this modal is opened from inside .side-panel, whose
|
||||
// panel-in animation (fill mode `both`) makes it the containing block for
|
||||
// position:fixed children — an overlay rendered in place would be trapped in
|
||||
// the 420px panel and clipped by its overflow. Same trap for any future modal
|
||||
// opened from a drawer or panel.
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<form className="modal refresh-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>Update from scenario</h2>
|
||||
{!plan.has_changes ? (
|
||||
<p className="modal-hint">
|
||||
This adventure already matches “{plan.scenario_title}”. Nothing to update.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="modal-hint">
|
||||
Pull the current content of “{plan.scenario_title}” down over this adventure.
|
||||
Your story, its title, summary and your own story cards are untouched — but
|
||||
the plot text below is <b>overwritten</b>, including any edits you made here.
|
||||
</p>
|
||||
<div className="refresh-changes">
|
||||
{fields.map(([field, diff]) => (
|
||||
<div key={field} className="refresh-change">
|
||||
<b>{FIELD_LABELS[field] || field}</b>
|
||||
<div className="dim refresh-old">− {clip(diff.old)}</div>
|
||||
<div className="refresh-new">+ {clip(diff.new)}</div>
|
||||
</div>
|
||||
))}
|
||||
{added.length > 0 && (
|
||||
<div className="refresh-change">Story cards added: <b>{added.join(', ')}</b></div>
|
||||
)}
|
||||
{updated.length > 0 && (
|
||||
<div className="refresh-change">Story cards overwritten: <b>{updated.join(', ')}</b></div>
|
||||
)}
|
||||
{removed.length > 0 && (
|
||||
<div className="refresh-change">Story cards removed: <b>{removed.join(', ')}</b></div>
|
||||
)}
|
||||
{world.added?.length > 0 && (
|
||||
<div className="refresh-change">
|
||||
New stats (at their starting value): <b>{world.added.join(', ')}</b>
|
||||
</div>
|
||||
)}
|
||||
{world.removed?.length > 0 && (
|
||||
<div className="refresh-change">Stats removed: <b>{world.removed.join(', ')}</b></div>
|
||||
)}
|
||||
</div>
|
||||
{(plan.placeholders_needed || []).length > 0 && (
|
||||
<>
|
||||
<p className="modal-hint">
|
||||
This scenario asks a few questions, and this adventure has no saved
|
||||
answers for them. They'll be remembered from now on.
|
||||
</p>
|
||||
{plan.placeholders_needed.map((name) => (
|
||||
<label key={name} className="field">
|
||||
<span className="label">{name}</span>
|
||||
<input type="text" value={values[name]}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="modal-buttons">
|
||||
<button type="button" onClick={onCancel}>{plan.has_changes ? 'Cancel' : 'Close'}</button>
|
||||
{plan.has_changes && (
|
||||
<button type="submit" className="primary" disabled={busy}>
|
||||
{busy ? 'Updating…' : 'Update'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) {
|
||||
const toast = useToast()
|
||||
const [plan, setPlan] = useState(null) // non-null while the modal is open
|
||||
const [planning, setPlanning] = useState(false)
|
||||
// One timer per field/card: a single shared timer would cancel the pending
|
||||
// save of whatever was edited previously within the debounce window.
|
||||
const saveTimers = useRef(new Map())
|
||||
@@ -109,8 +220,49 @@ function PlotPanel({ adventure, setAdventure }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the server what a refresh would do, then let the player confirm it.
|
||||
const openRefresh = async () => {
|
||||
setPlanning(true)
|
||||
try {
|
||||
setPlan(await api.previewRefresh(adventure.id))
|
||||
} catch (err) {
|
||||
// 404 = the scenario was deleted or unshared; there's nothing to sync to.
|
||||
toast(err.message, 'error')
|
||||
} finally {
|
||||
setPlanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyRefresh = async (placeholders) => {
|
||||
try {
|
||||
const updated = await api.refreshFromScenario(adventure.id, placeholders)
|
||||
setAdventure(updated)
|
||||
setPlan(null)
|
||||
onWorldStateChanged?.()
|
||||
toast('Updated from scenario.')
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{adventure.scenario_id != null && (
|
||||
<div className="plot-source">
|
||||
<span className="dim">
|
||||
Copied from its scenario when the adventure began; later scenario edits don't
|
||||
reach it on their own.
|
||||
</span>
|
||||
<button className="linklike" onClick={openRefresh} disabled={planning}
|
||||
title="Replace this adventure's plot text and scenario story cards with the scenario's current content">
|
||||
{planning ? 'Checking…' : '⟳ Update from scenario'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{plan && (
|
||||
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
|
||||
)}
|
||||
|
||||
<Field label="Plot Essentials (Memory)" value={adventure.memory}
|
||||
onChange={(v) => setField('memory', v)} textarea
|
||||
placeholder="Key facts the AI should always remember." />
|
||||
@@ -944,6 +1096,9 @@ export default function Play() {
|
||||
const [toast, setToast] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
|
||||
// Bumped when something outside the turn loop changes the drawers' state
|
||||
// (currently "Update from scenario"), which no action count would reflect.
|
||||
const [stateKey, setStateKey] = useState(0)
|
||||
const [inspectActionId, setInspectActionId] = useState(null)
|
||||
const storyEndRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
@@ -1144,7 +1299,7 @@ export default function Play() {
|
||||
|
||||
return (
|
||||
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
|
||||
<WorldStateDrawer advId={id} refreshKey={actions.length} />
|
||||
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
|
||||
<StatusDrawer advId={id} refreshKey={actions.length} />
|
||||
<div className="page play-page">
|
||||
<div className="page-header">
|
||||
@@ -1273,7 +1428,8 @@ export default function Play() {
|
||||
<button onClick={() => setPanel(null)}>✕</button>
|
||||
</div>
|
||||
{panel === 'plot' ? (
|
||||
<PlotPanel adventure={adventure} setAdventure={setAdventure} />
|
||||
<PlotPanel adventure={adventure} setAdventure={setAdventure}
|
||||
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
|
||||
) : panel === 'memory' ? (
|
||||
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
|
||||
refreshKey={actions.length} />
|
||||
|
||||
Reference in New Issue
Block a user