Merge main into the Phase 17 refactor
`main` changed the four files this branch split into packages, so all four came back as modify/delete conflicts. Each change is ported to the module that now holds the code, unchanged in behavior: - `delete_action` moves to `routers/adventures/actions.py`. It reaches the turn lock as `turns.acquire_turn_lock` and `turns._active_turns`, which is the rule this branch set: the package root no longer re-exports the names a test rebinds. - `EMIT_RULE` moves to `worldstate/parse.py`, and `_describe_stat` and `render_reference` to `worldstate/render.py`. - The chip-wrap rules move to `styles/schema-editor.css`, and `StateChangeChips` to `pages/Play/reports.jsx`. `index.css` keeps this branch's import list. `test_delete_state.py` needed four edits to run here. It drops the eight-line database prologue, because `conftest.py` does that once now. It imports the shared `ScriptedProvider` from `fakes.py` instead of carrying a copy. It patches `adventures.turns.OpenAICompatibleProvider` and reaches the lock the same way. And it no longer passes `index=0` when it builds the start action, because migration 71 drops that column. 564 tests pass. `npm run lint` and `npm run build` are clean, and the CSS bundle is 56.71 kB, which is the pre-merge size plus main's new rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,12 @@ coordinate through `nodes.delete_turn`.
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import models, schemas, tree
|
||||
from ... import attempts, models, schemas, tree
|
||||
from ...database import get_db
|
||||
|
||||
from . import turns
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .nodes import delete_turn
|
||||
from .nodes import db_tip, delete_turn
|
||||
from .paging import ACTION_PAGE, action_window, annotate_takes
|
||||
|
||||
|
||||
@@ -72,15 +73,33 @@ def delete_action(
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
# This works like undo. The turn is deleted with all of its attempts, and
|
||||
# whatever it produced is withdrawn. Nothing else is needed, because the
|
||||
# marks are depths, and a depth does not move when an action before it is
|
||||
# deleted.
|
||||
delete_turn(db, adventure, action)
|
||||
db.flush()
|
||||
db.expire(adventure, ["actions"])
|
||||
# Deleting the newest action moves the tip. Deleting an action in the
|
||||
# middle leaves a gap in the depths, which is intended. See
|
||||
# `_backfill_tree`.
|
||||
tree.refresh_head(db, adventure)
|
||||
db.commit()
|
||||
# The lock is held for the same reason undo holds it: this endpoint puts
|
||||
# the shared state back, and a turn that is still generating is about to
|
||||
# write it.
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
# This works like undo. The turn is deleted with all of its attempts,
|
||||
# and whatever it produced is withdrawn. The marks are depths, and a
|
||||
# depth does not move when an action before it is deleted.
|
||||
delete_turn(db, adventure, action)
|
||||
db.flush()
|
||||
db.expire(adventure, ["actions"])
|
||||
# Deleting the newest action moves the tip. Deleting an action in the
|
||||
# middle leaves a gap in the depths, which is intended. See
|
||||
# `_backfill_tree`.
|
||||
tree.refresh_head(db, adventure)
|
||||
# The script state and the world state belong to the adventure, not to
|
||||
# the node, so deleting the node does not take back what it did to
|
||||
# them. Put them back to what the story now ends with, which is the
|
||||
# same restore a branch switch does.
|
||||
#
|
||||
# The world state carries the cooldown clock in `_meta.last_changed`,
|
||||
# and that clock is a depth. Leaving it set marked the deleted turn's
|
||||
# changes as having happened at the depth the next turn is played at,
|
||||
# so the referee refused them as changed too recently — on a turn the
|
||||
# story no longer contains. Deleting a middle action restores the tip's
|
||||
# own outcome, which is the state the adventure is already in.
|
||||
attempts.restore_state(adventure, db_tip(db, adventure))
|
||||
db.commit()
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
@@ -23,8 +23,9 @@ EMIT_RULE = (
|
||||
"while a large change — or reaching a stat's minimum or maximum — is reserved for a "
|
||||
"genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a "
|
||||
"lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of "
|
||||
'its range in a single ordinary turn. Use the paths exactly as shown in the world state: '
|
||||
'"player.<stat>", '
|
||||
'its range in a single ordinary turn. Every stat is listed by its exact path in the '
|
||||
'stat guide and again beside its live value — copy a path from there rather than '
|
||||
'building one out of a name. The shapes are "player.<stat>", '
|
||||
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
|
||||
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
|
||||
'"milestones.<id>": true when an objective is completed. Some stats marked (free text) in '
|
||||
|
||||
@@ -83,20 +83,31 @@ def render_state_section(world_state: dict, stat_schema: dict,
|
||||
|
||||
|
||||
def _describe_stat(name: str, d: dict) -> str | None:
|
||||
"""Returns one reference line for a stat.
|
||||
"""Returns one reference line for a stat, named by the path the AI writes.
|
||||
|
||||
`name` is that path. The guide is the model's only complete list of what
|
||||
exists, so a stat named any other way leaves it to work the path out from
|
||||
the live values, and those cover only what is on screen this turn.
|
||||
|
||||
The description and the band ladder are independent, and each is included
|
||||
only when present, so a stat may have either, both, or neither.
|
||||
"""
|
||||
is_text = d.get("type") == "text"
|
||||
# `EMIT_RULE` sends the model here to find out which stats take a whole
|
||||
# value instead of a change, and it names this marker, so the two have to
|
||||
# be written the same way.
|
||||
label = f"{name} (free text)" if is_text else name
|
||||
bits: list[str] = []
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
# Fragments are joined with "; " and end with a single ".", so remove
|
||||
# any trailing period the author put on the description.
|
||||
bits.append(desc.strip().rstrip("."))
|
||||
if d.get("type") == "text":
|
||||
bits.append("free text")
|
||||
return f"{name} — {'; '.join(bits)}." if bits else None
|
||||
if is_text:
|
||||
# Free text has no range and no bands, so the marker is the whole
|
||||
# entry. It still earns a line without a description, because the
|
||||
# marker is what stops the model sending a delta.
|
||||
return f"{label} — {'; '.join(bits)}." if bits else f"{label}."
|
||||
lo, hi = d.get("min"), d.get("max")
|
||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||
bits.append(f"range {lo}–{hi}")
|
||||
@@ -108,7 +119,7 @@ def _describe_stat(name: str, d: dict) -> str | None:
|
||||
)
|
||||
if ladder:
|
||||
bits.append(f"bands: {ladder}")
|
||||
return f"{name} — {'; '.join(bits)}." if bits else None
|
||||
return f"{label} — {'; '.join(bits)}." if bits else None
|
||||
|
||||
|
||||
def render_reference(stat_schema: dict) -> str:
|
||||
@@ -122,7 +133,7 @@ def render_reference(stat_schema: dict) -> str:
|
||||
for section in STAT_SECTIONS:
|
||||
for name, d in (stat_schema.get(section) or {}).items():
|
||||
if isinstance(d, dict):
|
||||
row = _describe_stat(name, d)
|
||||
row = _describe_stat(f"{section}.{name}", d)
|
||||
if row:
|
||||
lines.append(row)
|
||||
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
||||
@@ -130,18 +141,27 @@ def render_reference(stat_schema: dict) -> str:
|
||||
continue
|
||||
name = npc_name(ndef, npc_key)
|
||||
desc = ndef.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.")
|
||||
# The header is written even for an NPC with no description, because it
|
||||
# is the only line that ties a display name to the id the AI has to
|
||||
# address. The live values state it too, but only for the NPCs a scene
|
||||
# has mentioned, so without this an NPC off screen can only be guessed
|
||||
# at — and a guess is refused as a character or a stat that does not
|
||||
# exist.
|
||||
head = f"NPC {name} (npc.{npc_key})"
|
||||
lines.append(
|
||||
f"{head} — {desc.strip().rstrip('.')}."
|
||||
if isinstance(desc, str) and desc.strip() else f"{head}."
|
||||
)
|
||||
for sname, sdef in (ndef.get("stats") or {}).items():
|
||||
if isinstance(sdef, dict):
|
||||
row = _describe_stat(f"{name} {sname}", sdef)
|
||||
row = _describe_stat(f"npc.{npc_key}.{sname}", sdef)
|
||||
if row:
|
||||
lines.append(row)
|
||||
for name, d in (stat_schema.get("flags") or {}).items():
|
||||
if isinstance(d, dict):
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.")
|
||||
lines.append(f"flags.{name} — {desc.strip().rstrip('.')}.")
|
||||
if not lines:
|
||||
return ""
|
||||
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
|
||||
|
||||
Reference in New Issue
Block a user