Fix HP demo: emit tag on first line; seeder reconciles demos in place

The reasoning model truncated replies before the end-of-reply [HP-N]
tag, so damage was never reported and HP stuck at 100. Move the tag to
the FIRST line of the reply so it survives truncation, and clarify the
wording (concrete example numbers instead of the literal "[HP-N]" that
confused the model).

Also make the seeder the source of truth for demo content: it now
reconciles an existing seeded scenario in place when a seed file
changes (keeping the scenario row and its adventure FKs), instead of
only inserting when missing — otherwise fixes to already-seeded demos
never reach production. No-op when content already matches, so it stays
cheap on every boot.

Verified end-to-end through the quickjs engine, including a truncated
reply still applying damage, and the in-place update path.

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-20 19:02:41 +05:30
co-authored by Claude Opus 4.8
parent ff09254878
commit 2fc167327d
2 changed files with 72 additions and 24 deletions
+70 -22
View File
@@ -7,9 +7,12 @@ can start an adventure from them, while nobody can edit them. Starting an
adventure copies the scenario's story cards and scripts into the adventure, so adventure copies the scenario's story cards and scripts into the adventure, so
the seeded scripts run for guests too. the seeded scripts run for guests too.
The seed is idempotent: a scenario is inserted only if no public, unowned Seed files are the source of truth for demo content: a scenario is inserted if
scenario with the same title already exists, so it is safe to run on every boot missing, and reconciled in place when a seed file's content changes (so edits
and it re-heals a database that lost its demo content. ship on the next deploy). When a seed already matches, nothing is written, so
this stays cheap to run on every boot. Existing adventures already started from
a demo keep their own copied cards/scripts and are unaffected — only new
adventures pick up the updated content.
""" """
import json import json
@@ -25,6 +28,10 @@ logger = logging.getLogger(__name__)
SEED_DIR = Path(__file__).resolve().parent / "seed_data" SEED_DIR = Path(__file__).resolve().parent / "seed_data"
_SCALARS = ("description", "prompt", "memory", "authors_note", "ai_instructions", "tags")
_CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
_SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js")
def seed_public_scenarios(engine: Engine) -> None: def seed_public_scenarios(engine: Engine) -> None:
if not SEED_DIR.is_dir(): if not SEED_DIR.is_dir():
@@ -35,7 +42,7 @@ def seed_public_scenarios(engine: Engine) -> None:
db = SessionLocal() db = SessionLocal()
try: try:
created = 0 changed = 0
for path in files: for path in files:
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
@@ -47,7 +54,7 @@ def seed_public_scenarios(engine: Engine) -> None:
if not title: if not title:
continue continue
exists = ( existing = (
db.query(models.Scenario) db.query(models.Scenario)
.filter( .filter(
models.Scenario.title == title, models.Scenario.title == title,
@@ -56,15 +63,16 @@ def seed_public_scenarios(engine: Engine) -> None:
) )
.first() .first()
) )
if exists is not None: if existing is None:
continue
_insert_scenario(db, data) _insert_scenario(db, data)
created += 1 changed += 1
elif not _matches(existing, data):
_update_scenario(db, existing, data)
changed += 1
if created: if changed:
db.commit() db.commit()
logger.info("Seeded %d public demo scenario(s).", created) logger.info("Seeded/updated %d public demo scenario(s).", changed)
except Exception: except Exception:
db.rollback() db.rollback()
# A seed failure must never take the app down; log and carry on. # A seed failure must never take the app down; log and carry on.
@@ -73,21 +81,61 @@ def seed_public_scenarios(engine: Engine) -> None:
db.close() db.close()
def _insert_scenario(db, data: dict) -> None: def _card_tuple(source, get) -> tuple:
scenario = models.Scenario( return tuple(get(source, f) for f in _CARD_FIELDS)
user_id=None,
is_public=True,
title=data.get("title", ""), def _script_tuple(source, get) -> tuple:
description=data.get("description", ""), return tuple(get(source, f) for f in _SCRIPT_FIELDS)
prompt=data.get("prompt", ""),
memory=data.get("memory", ""),
authors_note=data.get("authors_note", ""), def _matches(scenario: models.Scenario, data: dict) -> bool:
ai_instructions=data.get("ai_instructions", ""), """True when the DB scenario already equals the seed file, so we can skip
tags=data.get("tags", ""), the write and avoid churning rows on every boot."""
if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS):
return False
have_cards = sorted(_card_tuple(c, lambda o, f: getattr(o, f)) for c in scenario.story_cards)
want_cards = sorted(
_card_tuple(c, lambda o, f: o.get(f, ""))
for c in (data.get("story_cards") or []) if isinstance(c, dict)
) )
if have_cards != want_cards:
return False
have_scripts = sorted(_script_tuple(s, lambda o, f: getattr(o, f)) for s in scenario.scripts)
want_scripts = sorted(
_script_tuple(s, lambda o, f: (o.get(f, "") or ("Script" if f == "name" else "")))
for s in (data.get("scripts") or []) if isinstance(s, dict)
)
return have_scripts == want_scripts
def _insert_scenario(db, data: dict) -> None:
scenario = models.Scenario(user_id=None, is_public=True, title=data.get("title", ""))
_apply_scalars(scenario, data)
db.add(scenario) db.add(scenario)
db.flush() db.flush()
_populate_children(db, scenario, data)
def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
_apply_scalars(scenario, data)
# Replace child content wholesale — demo content is server-owned and cheap
# to rebuild, and this keeps the scenario row (and adventure FKs) intact.
for card in list(scenario.story_cards):
db.delete(card)
for script in list(scenario.scripts):
db.delete(script)
scenario.scripts = []
db.flush()
_populate_children(db, scenario, data)
def _apply_scalars(scenario: models.Scenario, data: dict) -> None:
for field in _SCALARS:
setattr(scenario, field, data.get(field, ""))
def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
for card in data.get("story_cards") or []: for card in data.get("story_cards") or []:
if not isinstance(card, dict): if not isinstance(card, dict):
continue continue
+1 -1
View File
@@ -13,7 +13,7 @@
"description": "AI-driven health. The script tells the AI the current HP and to tag damage/healing as [HP-N]/[HP+N]; the AI decides the amounts, the script applies them to state.hp and hides the tags. Watch state.hp in the left State panel.", "description": "AI-driven health. The script tells the AI the current HP and to tag damage/healing as [HP-N]/[HP+N]; the AI decides the amounts, the script applies them to state.hp and hides the tags. Watch state.hp in the left State panel.",
"library_js": "// Shared HP helpers — available to every hook of this script.\nvar HP_MAX = 100;\nfunction getHp() {\n if (typeof state.hp !== \"number\") state.hp = HP_MAX;\n return state.hp;\n}\nfunction setHp(value) {\n state.hp = Math.max(0, Math.min(HP_MAX, Math.round(value)));\n return state.hp;\n}\n", "library_js": "// Shared HP helpers — available to every hook of this script.\nvar HP_MAX = 100;\nfunction getHp() {\n if (typeof state.hp !== \"number\") state.hp = HP_MAX;\n return state.hp;\n}\nfunction setHp(value) {\n state.hp = Math.max(0, Math.min(HP_MAX, Math.round(value)));\n return state.hp;\n}\n",
"input_js": "", "input_js": "",
"context_js": "// Tell the AI the current HP and how to report changes it decides on.\nvar modifier = function (text) {\n var hp = getHp();\n var note =\n \"\\n\\n[HP SYSTEM] The player currently has \" + hp + \" of \" + HP_MAX + \" HP. \" +\n \"You, the narrator, decide when and how much the player is hurt or healed based on the story. \" +\n \"When the player takes damage (an attack, trap, or fall) or heals (a potion, rest, or spell), \" +\n \"append a tag on its own line at the very end of your reply: [HP-N] for damage or [HP+N] for healing, \" +\n \"where N is a whole number. For example a solid sword blow might be [HP-14]; a healing potion might be [HP+25]. \" +\n \"Include the tag only when HP actually changes, and never explain or mention the tag in the prose.\";\n return { text: text + note };\n};\nmodifier(text);\n", "context_js": "// Tell the AI the current HP and how to report changes it decides on.\n// The tag goes on the FIRST line so it survives even if the reply is cut off.\nvar modifier = function (text) {\n var hp = getHp();\n var note =\n \"\\n\\n[HP SYSTEM] The player currently has \" + hp + \" of \" + HP_MAX + \" health. \" +\n \"Based on the story, you decide whether the player loses health (an attack, fall, or trap) \" +\n \"or gains it (a potion, rest, or spell) this turn. \" +\n \"If their health changes, make the FIRST line of your reply the change by itself in square brackets: \" +\n \"write [HP-14] to show losing 14, or [HP+25] to show gaining 25 (choose the number that fits the moment). \" +\n \"Then write the story on the following lines. If health does not change, do not write any bracket tag. \" +\n \"Never mention this tag or the health system in the story prose itself.\";\n return { text: text + note };\n};\nmodifier(text);\n",
"output_js": "// Read the AI's [HP+N]/[HP-N] tags, apply them to state, hide them from the player.\nvar modifier = function (text) {\n var re = /\\[HP([+-])(\\d{1,3})\\]/g;\n var hp = getHp();\n var changed = false;\n var m;\n while ((m = re.exec(text)) !== null) {\n var amount = parseInt(m[2], 10);\n hp += (m[1] === \"-\" ? -amount : amount);\n changed = true;\n }\n if (changed) setHp(hp);\n var clean = text\n .replace(/\\[HP[+-]\\d{1,3}\\]/g, \"\")\n .replace(/[ \\t]+\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n if (changed && getHp() <= 0 && !state.dead) {\n state.dead = true;\n clean += \"\\n\\nYour strength gives out and the world goes dark. (0 HP)\";\n }\n return { text: clean };\n};\nmodifier(text);\n" "output_js": "// Read the AI's [HP+N]/[HP-N] tags, apply them to state, hide them from the player.\nvar modifier = function (text) {\n var re = /\\[HP([+-])(\\d{1,3})\\]/g;\n var hp = getHp();\n var changed = false;\n var m;\n while ((m = re.exec(text)) !== null) {\n var amount = parseInt(m[2], 10);\n hp += (m[1] === \"-\" ? -amount : amount);\n changed = true;\n }\n if (changed) setHp(hp);\n var clean = text\n .replace(/\\[HP[+-]\\d{1,3}\\]/g, \"\")\n .replace(/[ \\t]+\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n if (changed && getHp() <= 0 && !state.dead) {\n state.dead = true;\n clean += \"\\n\\nYour strength gives out and the world goes dark. (0 HP)\";\n }\n return { text: clean };\n};\nmodifier(text);\n"
} }
] ]