diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 0ed1dd6..332e2b2 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -71,6 +71,10 @@ MIGRATIONS: list[tuple[int, str]] = [ (21, "ALTER TABLE settings ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE"), (22, "UPDATE settings SET user_id = 1"), (23, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_user_id ON settings (user_id)"), + # Link each adventure-script copy back to its library Script so it can be + # re-synced on demand. NULL for copies made before this column existed. + (24, "ALTER TABLE adventure_scripts ADD COLUMN source_script_id INTEGER " + "REFERENCES scripts(id) ON DELETE SET NULL"), ] LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) diff --git a/backend/app/models.py b/backend/app/models.py index b4ba3f2..df6c624 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -205,13 +205,21 @@ class Script(Base): class AdventureScript(Base): """A script copied into an adventure at creation, so library edits don't - change running adventures. `state` lives on Adventure.script_state (one - shared state per adventure, as in AI Dungeon).""" + change running adventures unless the player explicitly re-syncs it from + `source_script_id`. `state` lives on Adventure.script_state (one shared + state per adventure, as in AI Dungeon).""" __tablename__ = "adventure_scripts" id: Mapped[int] = mapped_column(primary_key=True) adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE")) + # The library Script this copy was made from, so it can be re-synced on + # demand. NULL for legacy copies (predate this column) and demo-derived + # ones whose source isn't owned by the player — those fall back to a + # name match, or simply aren't syncable. + source_script_id: Mapped[int | None] = mapped_column( + ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True + ) position: Mapped[int] = mapped_column(Integer, default=0) enabled: Mapped[bool] = mapped_column(Boolean, default=True) name: Mapped[str] = mapped_column(String(200), default="Untitled Script") diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index b10b434..93ca82b 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -110,6 +110,7 @@ def create_adventure( db.add( models.AdventureScript( adventure_id=adventure.id, + source_script_id=script.id, position=position, name=script.name, description=script.description, @@ -600,11 +601,77 @@ def import_adventure( # ---------- Adventure scripts ---------- +# Fields copied from a library Script into its adventure-script snapshot, and +# compared to decide whether a copy is out of date. +SYNC_FIELDS = ("name", "description", "library_js", "input_js", "context_js", "output_js") + + +def resolve_library_script( + adv_script: models.AdventureScript, db: Session, user: models.User +) -> models.Script | None: + """The player-owned library Script an adventure-script can re-sync from: + the one it was copied from, or — for legacy copies with no link — one of + the player's own scripts sharing its name. Only the player's own scripts + are ever considered, so a demo-derived copy has nothing to sync to.""" + if adv_script.source_script_id is not None: + script = db.get(models.Script, adv_script.source_script_id) + if script is not None and script.user_id == user.id: + return script + return ( + db.query(models.Script) + .filter(models.Script.user_id == user.id, models.Script.name == adv_script.name) + .order_by(models.Script.updated_at.desc()) + .first() + ) + + +def _mark_out_of_date( + adv_script: models.AdventureScript, db: Session, user: models.User +) -> models.AdventureScript: + """Attach a transient `out_of_date` flag (read by AdventureScriptOut): + True/False when a syncable library version exists, None when it doesn't.""" + library = resolve_library_script(adv_script, db, user) + adv_script.out_of_date = ( + None if library is None + else any(getattr(adv_script, f) != getattr(library, f) for f in SYNC_FIELDS) + ) + return adv_script + + @router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut]) def list_adventure_scripts( adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser ): - return get_adventure_or_404(adventure_id, db, user).scripts + adventure = get_adventure_or_404(adventure_id, db, user) + return [_mark_out_of_date(s, db, user) for s in adventure.scripts] + + +@router.post( + "/{adventure_id}/scripts/{adv_script_id}/sync", + response_model=schemas.AdventureScriptOut, +) +def sync_adventure_script( + adventure_id: int, + adv_script_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Overwrite this copy's code with the latest from its library script, + keeping `enabled`, `position`, and the adventure's shared script_state.""" + get_adventure_or_404(adventure_id, db, user) + script = db.get(models.AdventureScript, adv_script_id) + if script is None or script.adventure_id != adventure_id: + raise HTTPException(404, "Script not found") + library = resolve_library_script(script, db, user) + if library is None: + raise HTTPException(404, "No library script to sync from") + for field in SYNC_FIELDS: + setattr(script, field, getattr(library, field)) + # Adopt the link so a name-matched legacy copy syncs by id next time. + script.source_script_id = library.id + db.commit() + db.refresh(script) + return _mark_out_of_date(script, db, user) @router.patch( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e45e61f..44a8fd7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -237,6 +237,9 @@ class AdventureScriptOut(ORMModel): input_js: str context_js: str output_js: str + # Set by the router (not stored): True when a syncable library version + # exists whose code differs from this copy; None when nothing to sync. + out_of_date: bool | None = None class AdventureScriptUpdate(BaseModel): diff --git a/frontend/src/api.js b/frontend/src/api.js index dcc2400..1743ad6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -109,6 +109,8 @@ export const api = { listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`), updateAdventureScript: (advId, scriptId, data) => request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }), + syncAdventureScript: (advId, scriptId) => + request(`/adventures/${advId}/scripts/${scriptId}/sync`, { method: 'POST' }), // Scripts listScripts: () => request('/scripts'), diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index d09c76e..f889f70 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -298,11 +298,24 @@ function ScriptsPanel({ advId }) { api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([])) }, [advId]) + const [syncingId, setSyncingId] = useState(null) + const toggle = async (script) => { const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled }) setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) } + // Pull the latest code from the library script this copy was made from. + const sync = async (script) => { + setSyncingId(script.id) + try { + const updated = await api.syncAdventureScript(advId, script.id) + setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) + } finally { + setSyncingId(null) + } + } + // Download as an import-compatible bundle (matches /scripts export), so demo // scripts can be forked into your own library via the Scripts page's Import. const download = (s) => { @@ -341,6 +354,16 @@ function ScriptsPanel({ advId }) { {s.name}