Remove three kinds of duplication in the backend

Stage 2, items 1, 4, and 5 of `plan/17-refactor.md`.

**One path resolver in `worldstate`.** `apply_delta` and `apply_override` routed
`flags.<name>`, `milestones.<id>`, `world.<stat>`, `player.<stat>`, and
`npc.<id>.<stat>` with parallel code, about 100 lines each. `_resolve` now says
what a path points at and returns either a target or the rejection to report.
Each function keeps its own write rule, because the rules genuinely differ: an
override sets a number rather than adding to it, ignores `cooldown`,
`max_delta_per_turn`, and the rule that a counter only counts up, and can un-set
a milestone.

A differential check ran both implementations over 3960 payloads: twenty paths,
fourteen values, three starting states, plus every three-path combination. The
results are identical except that 674 rejections from `apply_override` now carry
a `fix` string. `apply_delta` already worded those, and the world-state editor
renders them, so an override that names an unknown flag now explains itself the
way a delta does.

**`sse`, `SSE_HEADERS`, and `turn_error` move to `app/sse.py`.** Two routers
stream, and `chat.py` had to import from `routers.adventures` to reach them.

**`get_adventure_or_404` becomes the `current_adventure` dependency.** All 32
handlers repeated the call as their first statement. The ownership check now
reads in the signature and runs before the body. FastAPI caches a dependency for
one request, so the handler's `db` is the session the adventure came from.

The generated OpenAPI document is byte-identical except on `rename_branch`,
where `branch_id` is now listed before `adventure_id`, because that handler no
longer names `adventure_id` itself. Parameter order in the document is
cosmetic.

Six tests in `test_state_revert.py` call `undo_turn` and `retry_action`
directly rather than over HTTP. They pass the adventure they already hold
instead of an id.

549 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
This commit is contained in:
parththakkar106
2026-08-29 02:04:02 +05:30
co-authored by Claude Opus 5
parent 0623dd8b78
commit 2c57b1ceab
16 changed files with 258 additions and 261 deletions
+143 -154
View File
@@ -5,10 +5,105 @@ place. A proposed change that breaks a limit is recorded as refused rather than
dropped, because the player and the model both need to see that it did not land.
"""
import copy
from typing import NamedTuple
from .schema import STAT_SECTIONS, _initials, instantiate, npc_name
class _Target(NamedTuple):
"""Where one path writes, and what the schema says about the value there."""
kind: str # "flag", "milestone", or "stat"
section: str # "flags", "milestones", "player", "world", or "npc"
key: str # the key inside the container
stat_def: dict | None # the stat's definition, or None for a flag or milestone
npc_id: str | None # the character, for `npc.<id>.<stat>`, else None
npc_stats: dict | None # that character's stat defs, used to build a new block
def container(self, ws: dict) -> dict:
"""Returns the dict this path writes into, and creates it if it is missing.
Call this only when you are about to write. Creating the container is a
side effect, and a rejected path must not leave an empty section behind.
"""
if self.npc_id is None:
return ws.setdefault(self.section, {})
return ws.setdefault("npc", {}).setdefault(self.npc_id, _initials(self.npc_stats))
def _resolve(path: str, stat_schema: dict) -> tuple[_Target | None, dict | None]:
"""Routes one path to the value it names, without deciding what happens to it.
Returns `(target, None)` when the schema defines the path, and
`(None, rejection)` when it does not. The rejection is the entry that goes
into a report's `rejected` list, with its reason and its fix already worded.
`apply_delta` and `apply_override` route paths identically and differ only in
what they write, so the routing lives here and each function keeps its own
write rule.
"""
parts = path.split(".")
# `flags.<name>` is a boolean the scenario declares.
if parts[0] == "flags" and len(parts) == 2:
flag_defs = stat_schema.get("flags") or {}
if parts[1] not in flag_defs:
return None, {
"path": path, "reason": "unknown flag",
"fix": f"There is no flag `{parts[1]}`. {_names_phrase('flag', flag_defs)}",
}
return _Target("flag", "flags", parts[1], None, None, None), None
# `milestones.<id>` records that a milestone is reached.
if parts[0] == "milestones" and len(parts) == 2:
milestones = stat_schema.get("milestones") or {}
if parts[1] not in milestones:
return None, {
"path": path, "reason": "unknown milestone",
"fix": f"There is no milestone `{parts[1]}`. "
f"{_names_phrase('milestone', milestones)}",
}
return _Target("milestone", "milestones", parts[1], None, None, None), None
# `world.<stat>` and `player.<stat>`.
if parts[0] in STAT_SECTIONS and len(parts) == 2:
stat_defs = stat_schema.get(parts[0]) or {}
stat_def = stat_defs.get(parts[1])
if not isinstance(stat_def, dict):
return None, {
"path": path, "reason": "unknown stat",
"fix": f"`{parts[0]}` has no stat `{parts[1]}`. "
f"{_names_phrase('stat', stat_defs)}",
}
return _Target("stat", parts[0], parts[1], stat_def, None, None), None
# `npc.<id>.<stat>`. Each character carries its own stat definitions.
if parts[0] == "npc" and len(parts) == 3:
npcs = stat_schema.get("npcs") or {}
ndef = npcs.get(parts[1])
if not isinstance(ndef, dict):
return None, {
"path": path, "reason": "unknown npc",
"fix": f"There is no character `{parts[1]}`. "
f"{_names_phrase('character', npcs)}",
}
stat_defs = ndef.get("stats") or {}
stat_def = stat_defs.get(parts[2])
if not isinstance(stat_def, dict):
return None, {
"path": path, "reason": "unknown npc stat",
"fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. "
f"{_names_phrase('stat', stat_defs)}",
}
return _Target("stat", "npc", parts[2], stat_def, parts[1], stat_defs), None
return None, {
"path": path, "reason": "unknown path",
"fix": f"`{path}` is not a tracked value. Use player.<stat>, "
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
}
def _coerce_number(value):
if isinstance(value, bool): # `bool` is an `int` subclass, so reject it here.
return None
@@ -177,10 +272,6 @@ def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tup
if not isinstance(overrides, dict):
return ws, report
milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") or {}
npcs = stat_schema.get("npcs") or {}
def set_stat(container: dict, key: str, stat_def: dict, value, path: str) -> None:
if stat_def.get("type") == "text":
if not isinstance(value, str):
@@ -212,80 +303,42 @@ def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tup
for raw_path, value in overrides.items():
path = str(raw_path)
parts = path.split(".")
if parts[0] == "flags" and len(parts) == 2:
fid = parts[1]
if fid not in flag_defs:
report["rejected"].append({"path": path, "reason": "unknown flag"})
continue
if not isinstance(value, bool):
report["rejected"].append({"path": path, "reason": "not a boolean"})
continue
flags = ws.setdefault("flags", {})
old = bool(flags.get(fid, False))
flags[fid] = value
report["applied"].append({"path": path, "old": old, "new": value})
target, rejection = _resolve(path, stat_schema)
if rejection is not None:
report["rejected"].append(rejection)
continue
if parts[0] == "milestones" and len(parts) == 2:
mid = parts[1]
if mid not in milestones:
report["rejected"].append({"path": path, "reason": "unknown milestone"})
continue
if target.kind == "flag":
if not isinstance(value, bool):
report["rejected"].append({"path": path, "reason": "not a boolean"})
report["rejected"].append({
"path": path, "reason": "not a boolean",
"fix": f"`{path}` takes true or false.",
})
continue
reached = ws.setdefault("milestones", {})
old = bool(reached.get(mid, {}).get("reached"))
flags = target.container(ws)
old = bool(flags.get(target.key, False))
flags[target.key] = value
report["applied"].append({"path": path, "old": old, "new": value})
elif target.kind == "milestone":
# An override toggles a milestone in both directions, so unlike a
# delta it takes false as well as true.
if not isinstance(value, bool):
report["rejected"].append({
"path": path, "reason": "not a boolean",
"fix": f"`{path}` takes true or false.",
})
continue
reached = target.container(ws)
old = bool(reached.get(target.key, {}).get("reached"))
if value:
reached[mid] = {"reached": True}
reached[target.key] = {"reached": True}
else:
reached.pop(mid, None)
reached.pop(target.key, None)
report["applied"].append({"path": path, "old": old, "new": value})
continue
if parts[0] in STAT_SECTIONS and len(parts) == 2:
stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1])
if not isinstance(stat_def, dict):
report["rejected"].append({
"path": path, "reason": "unknown stat",
"fix": f"`{parts[0]}` has no stat `{parts[1]}`. "
f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}",
})
continue
container = ws.setdefault(parts[0], {})
set_stat(container, parts[1], stat_def, value, path)
continue
if parts[0] == "npc" and len(parts) == 3:
ndef = npcs.get(parts[1])
if not isinstance(ndef, dict):
report["rejected"].append({
"path": path, "reason": "unknown npc",
"fix": f"There is no character `{parts[1]}`. "
f"{_names_phrase('character', npcs)}",
})
continue
stat_defs = ndef.get("stats") or {}
stat_def = stat_defs.get(parts[2])
if not isinstance(stat_def, dict):
report["rejected"].append({
"path": path, "reason": "unknown npc stat",
"fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. "
f"{_names_phrase('stat', stat_defs)}",
})
continue
npc_state = ws.setdefault("npc", {})
container = npc_state.setdefault(parts[1], _initials(stat_defs))
set_stat(container, parts[2], stat_def, value, path)
continue
report["rejected"].append({
"path": path, "reason": "unknown path",
"fix": f"`{path}` is not a tracked value. Use player.<stat>, "
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
})
else:
set_stat(target.container(ws), target.key, target.stat_def, value, path)
return ws, report
@@ -307,46 +360,29 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
if not isinstance(delta, dict):
return ws, report
milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") or {}
npcs = stat_schema.get("npcs") or {}
for raw_path, change in delta.items():
path = str(raw_path)
parts = path.split(".")
target, rejection = _resolve(path, stat_schema)
if rejection is not None:
report["rejected"].append(rejection)
continue
# `flags.<name>` is a two-way boolean, and either value is accepted.
if parts[0] == "flags" and len(parts) == 2:
fid = parts[1]
if fid not in flag_defs:
report["rejected"].append({
"path": path, "reason": "unknown flag",
"fix": f"There is no flag `{fid}`. {_names_phrase('flag', flag_defs)}",
})
continue
if target.kind == "flag":
# A flag goes both ways, so either value is accepted.
if not isinstance(change, bool):
report["rejected"].append({
"path": path, "reason": "not a boolean",
"fix": f"`{path}` takes true or false.",
})
continue
flags = ws.setdefault("flags", {})
old = bool(flags.get(fid, False))
flags = target.container(ws)
old = bool(flags.get(target.key, False))
if change != old:
flags[fid] = change
flags[target.key] = change
report["applied"].append({"path": path, "old": old, "new": change})
continue
# `milestones.<id>` is a sticky boolean, and only `true` is accepted.
if parts[0] == "milestones" and len(parts) == 2:
mid = parts[1]
if mid not in milestones:
report["rejected"].append({
"path": path, "reason": "unknown milestone",
"fix": f"There is no milestone `{mid}`. "
f"{_names_phrase('milestone', milestones)}",
})
continue
elif target.kind == "milestone":
# A milestone is sticky, so only true is accepted.
if change is not True:
report["rejected"].append({
"path": path, "reason": "not true",
@@ -354,65 +390,18 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
f"reached once and never taken back.",
})
continue
reached = ws.setdefault("milestones", {})
if reached.get(mid, {}).get("reached"):
reached = target.container(ws)
if reached.get(target.key, {}).get("reached"):
continue # Already reached, so do nothing.
reached[mid] = {"reached": True, "at": action_index}
reached[target.key] = {"reached": True, "at": action_index}
report["applied"].append({"path": path, "old": False, "new": True})
continue
# world.<stat> / player.<stat>
if parts[0] in STAT_SECTIONS and len(parts) == 2:
stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1])
if not isinstance(stat_def, dict):
report["rejected"].append({
"path": path, "reason": "unknown stat",
"fix": f"`{parts[0]}` has no stat `{parts[1]}`. "
f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}",
})
continue
container = ws.setdefault(parts[0], {})
if stat_def.get("type") == "text":
_apply_text_stat(container, parts[1], stat_def, change, path,
action_index, meta, report)
else:
_apply_stat(container, parts[1], stat_def, change, path,
action_index, meta, report)
continue
elif target.stat_def.get("type") == "text":
_apply_text_stat(target.container(ws), target.key, target.stat_def, change,
path, action_index, meta, report)
# `npc.<npcId>.<stat>`. Each NPC has its own stat definitions.
if parts[0] == "npc" and len(parts) == 3:
ndef = npcs.get(parts[1])
if not isinstance(ndef, dict):
report["rejected"].append({
"path": path, "reason": "unknown npc",
"fix": f"There is no character `{parts[1]}`. "
f"{_names_phrase('character', npcs)}",
})
continue
stat_defs = ndef.get("stats") or {}
stat_def = stat_defs.get(parts[2])
if not isinstance(stat_def, dict):
report["rejected"].append({
"path": path, "reason": "unknown npc stat",
"fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. "
f"{_names_phrase('stat', stat_defs)}",
})
continue
npc_state = ws.setdefault("npc", {})
container = npc_state.setdefault(parts[1], _initials(stat_defs))
if stat_def.get("type") == "text":
_apply_text_stat(container, parts[2], stat_def, change, path,
action_index, meta, report)
else:
_apply_stat(container, parts[2], stat_def, change, path,
action_index, meta, report)
continue
report["rejected"].append({
"path": path, "reason": "unknown path",
"fix": f"`{path}` is not a tracked value. Use player.<stat>, "
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
})
else:
_apply_stat(target.container(ws), target.key, target.stat_def, change,
path, action_index, meta, report)
return ws, report