Add per-adventure "Sync from library" for scripts

Adventure scripts are frozen snapshots taken at creation. Add an opt-in
way to pull the latest library code into a live adventure:

- AdventureScript.source_script_id links a copy to its library Script
  (migration 24; set at adventure creation)
- list endpoint flags out_of_date by diffing copy vs library
- POST /adventures/{id}/scripts/{sid}/sync overwrites the copy's code,
  preserving enabled/position/script_state
- legacy copies with no link fall back to a name match, then adopt the link
- Play page shows a Sync button only when a copy differs from its library

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
This commit is contained in:
parththakkar106
2026-07-21 00:56:01 +05:30
co-authored by Claude Opus 4.8
parent 94db6e055b
commit 328ae8942d
6 changed files with 110 additions and 3 deletions
+4
View File
@@ -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)
+10 -2
View File
@@ -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")
+68 -1
View File
@@ -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(
+3
View File
@@ -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):