Files
interactive-story/backend/app/models.py
T
JesseMarkowitzandClaude Opus 5 62a997f364 M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-04 06:34:56 -04:00

643 lines
34 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import (
JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Text, event,
)
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
from .compression import CompressedJSON
from .database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
"""Phase 8: optional accounts.
Three kinds of row share this table:
- The local user has a NULL email and `is_guest` set to False. Single-user
mode creates this row automatically. It owns everything that a database
from before Phase 8 contained.
- Guests have a NULL email and `is_guest` set to True. Multi-user mode
creates one on a visitor's first visit and identifies it only by the
session cookie.
- Registered users have an email. Registration upgrades a guest row in
place, so the guest's data survives without being reassigned.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(300), nullable=True)
is_guest: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Was the shared demo key's per-day tally. M2 removed the demo key; these
# columns stay so existing databases open unchanged and are never written.
demo_turns_used: Mapped[int] = mapped_column(Integer, default=0)
demo_turns_date: Mapped[str] = mapped_column(String(10), default="")
class Scenario(Base):
__tablename__ = "scenarios"
id: Mapped[int] = mapped_column(primary_key=True)
# NULL owner + is_public = seeded demo content, readable by everyone.
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
title: Mapped[str] = mapped_column(String(200), default="Untitled Scenario")
description: Mapped[str] = mapped_column(Text, default="")
prompt: Mapped[str] = mapped_column(Text, default="")
# Plot components (AI Dungeon terminology; `memory` == Plot Essentials)
memory: Mapped[str] = mapped_column(Text, default="")
authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[str] = mapped_column(String(500), default="")
# Cover art. The value is either an "https://" URL or an inline
# "data:image/...;base64,..." URI. The editor downscales uploads before
# storing them. An empty value tells the UI to fall back to an emoji sigil
# or to generated art. The image is stored in the row rather than on disk,
# because Render's free tier provides no persistent volume. Storing it here
# also keeps export bundles self-contained.
image: Mapped[str] = mapped_column(Text, default="")
# A single emoji or glyph, used when `image` is empty. This is a separate
# column because the value is a character rather than a location, so
# nothing needs to fetch or cache it.
icon: Mapped[str] = mapped_column(String(16), default="")
# Phase 12: the RPG world-state template. It holds stat definitions, which
# include bands and rules, and milestones. A NULL or empty value means the
# scenario has no RPG layer.
stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
story_cards: Mapped[list["StoryCard"]] = relationship(
back_populates="scenario", cascade="all, delete-orphan"
)
adventures: Mapped[list["Adventure"]] = relationship(back_populates="scenario")
class Adventure(Base):
__tablename__ = "adventures"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
scenario_id: Mapped[int | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(200), default="Untitled Adventure")
memory: Mapped[str] = mapped_column(Text, default="")
authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="")
story_summary: Mapped[str] = mapped_column(Text, default="")
# Phase 18: who the player is playing as. The AI never writes these — they
# are user-only, which is what lets them sit in the cached system block
# rather than below the history with the values that change. An empty
# `persona_name` means the adventure has no persona, and every read below
# falls back to the wording used before this existed.
#
# These are adventure columns rather than part of the scenario's
# `stat_schema`, for two reasons. An adventure with no RPG layer still has a
# protagonist, and `worldstate.schema._initials` treats every dict inside a
# stat section as a stat definition, so a persona placed there would be
# instantiated, rendered and given an `initial` value as though it were one.
persona_name: Mapped[str] = mapped_column(String(80), default="")
persona_pronouns: Mapped[str] = mapped_column(String(40), default="")
persona_desc: Mapped[str] = mapped_column(Text, default="")
# Was the campaign scripting engine's shared `state` object. M2 removed
# scripting; the column stays so existing databases open unchanged, and it
# is never written with anything but an empty dict.
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# The ${Placeholder} answers collected when this adventure was started, kept
# so "Update from scenario" can re-fill freshly copied scenario text with the
# same values. NULL for adventures created before this column existed.
placeholders: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Phase 6: opt-in per adventure (extra AI calls)
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Phase 14, SP3: how far the memory pass and the summary pass have read,
# each as a coordinate. Each pair
# holds the branch and depth of the last action that pass covered. A
# position moves when an action in front of it is deleted, so the mark
# silently starts covering an action it never read. A depth is a coordinate
# along a path, so deleting an action does not move it. NO_DEPTH, which is
# -1, means that nothing is covered yet, so the first block needs no special
# case. These are plain integers rather than foreign keys, for the reason
# given on `head_branch_id` below. See `context/cursors.py`.
memory_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
memory_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
summary_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
summary_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
# Phase 14: where the story is being played. `head_branch_id` names the
# branch, and `head_depth` gives the depth of its newest node.
#
# `head_branch_id` is deliberately not a ForeignKey. `branches.adventure_id`
# already points from branches to adventures, so a constraint in this
# direction would make the two tables a cycle that `create_all` cannot
# order. The usual fix is `use_alter`, which needs an ALTER statement that
# SQLite does not provide. The column caches a pointer, and
# `tree.head_branch` treats a head that names a missing branch as a bug to
# recover from rather than a state to preserve.
head_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# The depth of the tip, so the next node is always head_depth + 1.
# NO_DEPTH (-1) for an adventure with no actions yet.
head_depth: Mapped[int] = mapped_column(Integer, default=-1)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
scenario: Mapped[Scenario | None] = relationship(back_populates="adventures")
story_cards: Mapped[list["StoryCard"]] = relationship(
back_populates="adventure", cascade="all, delete-orphan"
)
# Every action in the adventure, across all branches. This collection is
# the tree, not the story being played. Ordering it by depth does not make
# it a story, because a path is a selection out of the tree. Code that shows
# a story to a reader goes through `context.history`, which applies the
# branch clause. This relationship exists for ownership and for the
# delete-orphan cascade.
actions: Mapped[list["Action"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="Action.id",
)
memories: Mapped[list["Memory"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="Memory.id",
)
class Branch(Base):
"""Phase 14: one path through an adventure's story tree.
A branch does not own a copy of the story. It holds the nodes played on it,
and it inherits everything before its fork point from its ancestors. Reading
branch C means reading C's nodes, then B's nodes up to the depth where C
forked, then A's nodes up to the depth where B forked. The `lineage` column
records that list, so a read becomes one OR clause per entry instead of a
walk up parent pointers.
Until forking ships, each adventure has one root branch and every node
belongs to it. This is not a partly migrated state. A linear story is a tree
with one branch, which is why writing these columns changes nothing that a
reader can observe.
This class defines no ORM relationships, by design. `actions.branch_id` and
`memories.branch_id` both use ON DELETE CASCADE, so the database removes a
deleted branch's nodes. A relationship would make SQLAlchemy load those rows
first, and loading every action of a branch is what the windowed reads exist
to avoid.
"""
__tablename__ = "branches"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
# NULL on a root branch.
parent_branch_id: Mapped[int | None] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
# The depth at which this branch left its parent. The fork records this
# value, and no code infers it later. Deriving it from the first depth where
# two branches' nodes differ would produce a wrong answer whenever an
# attempt repeats its parent's text.
fork_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# The ancestry, newest first, as [[branch_id, max_depth], ...]. A NULL
# `max_depth` means the entry extends to the tip of that branch. Any other
# value is the fork depth of the branch below it, inclusive. The fork
# computes this list once from the parent's lineage plus one entry, so no
# read has to reconstruct it.
lineage: Mapped[list] = mapped_column(JSON, default=list)
# The name the player gave this line of the story, or NULL if no one named
# it. The column stores NULL rather than a generated name such as
# "branch 4", because it records what the player chose rather than what the
# app derived. A stored default would also become wrong as soon as an
# earlier branch is deleted and the ordinals shift. The UI labels an unnamed
# branch by its fork depth, which deleting a branch does not change.
name: Mapped[str | None] = mapped_column(String(80), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# M3: the disposition `DATA-MODEL.md` §5 gives a branch, stored as the fact
# that produced it. NULL means active. A value means a divergent write left
# this branch at `superseded_depth`, so its nodes past that depth are
# retained history that no active head is reading.
#
# Nothing reads these to decide behaviour. Redo follows the lineage, so a
# wrong value here cannot make the story wrong; they exist for the cleanup
# and discarded-history features that `STORY-BRANCH-SEMANTICS.md` §28-29
# leave to a later version. See `head.mark_superseded`.
superseded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Checkpoint(Base):
"""M4: a Save Point — a durable named pointer to a story position.
"Save Point" is what the user reads; `checkpoint` is what the code calls it
(`BROWSER-UX-SPEC.md` §23).
The row holds a name and a coordinate, and no story. `DATA-MODEL.md` §8
describes the pointer as naming a turn; the coordinate here is
`(branch_id, depth)`, which is what M3 made the head and what
`head.node_at` resolves. Restoring one is therefore head movement with a
bounds check rather than a restore system of its own — see ADR 012 and
`head.move_to_node`.
A coordinate rather than an action id, deliberately. One coordinate can
hold several attempts at a turn and exactly one of them is live, so a
retry replaces the row a Save Point would have pinned. "Turn 42 of this
line" survives a retry; "action 918" would point at a take the story no
longer tells.
`branch_id` is the branch the node itself sits on, not the branch that was
being read when the Save Point was made. Those differ whenever the head is
resting in a shared prefix, and the node's own branch is the one that still
names the position after the reader has moved elsewhere.
**Nothing removes a Save Point but the user.** They are not cleaned up for
going stale, for being behind the head, or for pointing into a future the
story has left (`STORY-BRANCH-SEMANTICS.md` §19).
That includes deleting a branch. `branch_id` carries `ON DELETE CASCADE` as
referential integrity — a Save Point must never point at a branch that is
gone — but the branch endpoint refuses to delete a branch any Save Point
names, so the cascade does not fire through the application
(`routers/adventures/branches.py`, `STORY-BRANCH-SEMANTICS.md` §19.1). The
user deletes the Save Point first, which deletes no story, and then the
branch. Deleting the whole campaign does cascade, and should: that is what
the user asked for.
"""
__tablename__ = "checkpoints"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120), default="")
# `DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
# optional second field. Empty is the ordinary case.
note: Mapped[str] = mapped_column(Text, default="")
branch_id: Mapped[int] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE")
)
depth: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# Bumped by a rename, which is the only edit a Save Point allows. The
# coordinate is never rewritten: `STORY-BRANCH-SEMANTICS.md` §24 keeps a
# Save Point's meaning auditable by making "move it" delete-and-recreate.
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow
)
class Memory(Base):
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
The vector lives in `embedding_blob` as packed float32 (see vectors.py).
NULL until embedded, which also marks it for backfill when an embedding
model becomes available.
Cosine ranking runs in Python, so the vectors travel over the wire. Measure
that cost in bytes rather than in rows. A few hundred vectors stored as JSON
come to about 10 MB, fetched again on every turn. Size any new column by the
bytes it adds, not by the number of rows.
"""
__tablename__ = "memories"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, default="")
# The vector, stored as little-endian float32. This column is deferred
# because it is wider than the rest of the row combined and only one code
# path reads it. Code that loads memories in bulk, such as the Memories
# drawer, eviction, and the embed queue, must select the columns it needs
# instead of loading whole entities.
embedding_blob: Mapped[bytes | None] = mapped_column(
LargeBinary, nullable=True, deferred=True
)
# The stretch of story this memory summarizes, given as depths on
# `branch_id`. Both are NULL for a hand-written memory, which summarizes no
# actions. `source_end` is the depth of the node the memory
# attaches to, and `depth` below mirrors it. `source_start` is where the
# stretch begins, which is where the summarizer resumes if the memory is
# withdrawn.
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Phase 14: the node that produced this memory, meaning the last action the
# memory summarizes. Derived data attaches to the node it came from, which
# is what makes forking cheap. Memories on a shared ancestor are shared
# automatically, and a memory that covers part of branch B is not visible
# from any path that does not go through B.
#
# Every memory has a coordinate, including a hand-written one, which takes
# the head as of the moment it was written (SP7). A NULL depth used to mean
# that the memory belonged to the adventure rather than to a path. A fork
# cannot cap a NULL, so such a memory followed the reader onto branches
# whose story it did not describe.
branch_id: Mapped[int | None] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Whether `embedding_blob` is set. `memorybank.set_vector` keeps this column
# current. Readers need only the yes-or-no answer, and fetching six
# kilobytes of vector to get it is too expensive.
embedded: Mapped[bool] = mapped_column(Boolean, default=False)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
use_count: Mapped[int] = mapped_column(Integer, default=0)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="memories")
class StoryCard(Base):
"""Owned by either a scenario or an adventure (exactly one set)."""
__tablename__ = "story_cards"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int | None] = mapped_column(
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=True
)
adventure_id: Mapped[int | None] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE"), nullable=True
)
type: Mapped[str] = mapped_column(String(100), default="")
name: Mapped[str] = mapped_column(String(200), default="")
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
entry: Mapped[str] = mapped_column(Text, default="")
notes: Mapped[str] = mapped_column(Text, default="")
# Set on adventure copies only. It records which piece of the scenario the
# card came from, as either "card:<scenario_card_id>" or "npc:<npc_key>".
# The "Update from scenario" action refreshes or removes exactly these
# cards. A NULL value means the player wrote the card, so the update leaves
# it alone, or that the copy predates this column, in which case the update
# matches it by name and then sets this value.
source_ref: Mapped[str | None] = mapped_column(String(64), nullable=True)
scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards")
adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards")
def _change_label(parts: list[str]) -> str:
"""Names a world-state path for the inline turn summary.
`npc.gwen.trust` becomes "gwen trust". Every other shape uses its last
segment, so `player.hp` becomes "hp".
"""
if len(parts) == 3 and parts[0] == "npc":
return f"{parts[1]} {parts[2]}"
return parts[-1] if parts else ""
class Action(Base):
__tablename__ = "actions"
# Phase 14: every story read selects one branch up to one depth, then
# another branch up to another depth, and so on. The pair (branch_id, depth)
# is the index those clauses need.
__table_args__ = (Index("ix_actions_branch_depth", "branch_id", "depth"),)
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
# Phase 14: the node's place in the tree. `depth` is a position along one
# path rather than a global turn number. Node A4 and node B4 are
# alternatives, not duplicates.
#
# Both columns are nullable because ALTER TABLE cannot add a NOT NULL column
# without a default, and no default makes sense for a branch. The migration
# fills these columns for existing rows, and `tree.place_action` fills them
# for new rows. From SP2 onward, a NULL `branch_id` marks a row that no read
# can see.
branch_id: Mapped[int | None] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Phase 14, SP9: the node that this node was played after. It names the take
# that was live when this row was written, not whatever sits at depth - 1
# now.
#
# This column answers one question: which takes belong to the same turn. A
# coordinate cannot answer it. A take that is forked onto its own branch
# leaves the coordinate that its siblings still occupy, so the pager would
# show it as 1/1 next to their 1/3. Forking a branch does not change a
# node's parent.
#
# Code reads this column only to group takes, using one indexed lookup
# rather than a walk. Paths still resolve through `lineage`, which is why
# adding this column required no change to any read of the story.
#
# The value is NULL on a root node, and on pre-SP9 rows that the migration
# could not place. For those rows, `attempts.group` falls back to the
# coordinate, which is how they were written.
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="SET NULL"), nullable=True, index=True
)
# Phase 14, SP4: whether this node is the one the story uses at its
# coordinate. Retry no longer rewrites a row. It writes a sibling at the
# same branch and depth, so one coordinate can hold several attempts while
# exactly one of them is on the path. `lineage.Path.clause` is the only
# place that reads this column, for the same reason it is the only place
# that knows about branches. If a discarded attempt reaches a read, the page
# renders the same turn twice.
#
# A node with no siblings is live, so the default is True and every pre-SP4
# row is already correct. The migration does not need to visit them.
live: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
text: Mapped[str] = mapped_column(Text, default="")
# Reasoning-model "thinking" that preceded the text (AI actions only).
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
# The full assembled prompt for this turn, used by the Insights viewer.
# This is the largest column in the database. It averages 163 KB per row in
# production and 232 KB on the longest adventure, and it accounts for 89% of
# everything stored. Only one endpoint reads it, one action at a time.
#
# The column is expensive in two ways, so it has two protections.
# `deferred=True` protects reads, because SQLAlchemy loads the column only
# when code touches the attribute. A page load therefore costs nothing. Code
# that reads actions in bulk must not touch this attribute, which is why
# `world_delta` below exists. `CompressedJSON` protects storage, because
# this column determines when the free tier's 512 MB limit is reached. The
# attribute behaves like a plain dict in both cases. See compression.py.
context_snapshot: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
# The small slice of the snapshot that IS needed in bulk: this turn's RPG
# state changes, for the inline chips under an AI message (world_changes)
# and for re-attaching the emit block when replaying history to the model.
# Mirrors the active variant, same as text/reasoning/context_snapshot.
world_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Phase 14, SP4: the RPG world state as it stood after this node was
# played. These columns record the node's outcome rather than its starting
# position. (`state_after` held the scripting engine's state, which M2
# removed; it is now always written empty.)
#
# Two operations need this outcome, and neither can use a snapshot taken
# before the turn. Switching between siblings must restore the state that
# the chosen attempt produced, and the attempts differ in exactly that.
# Rolling back to before a turn means restoring the state that the preceding
# node left behind, which is one lookup along the path.
#
# The value is NULL on pre-SP4 rows for which the migration could not derive
# one. Every caller tolerates that. A missing snapshot means that the caller
# leaves the live state unchanged. It never means reset the state.
#
# These columns are deferred, because code reads them only for the single
# node being switched to, undone, or retried past.
state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
world_state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
@property
def world_changes(self) -> list[dict]:
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
under an AI message. Labels are path-based (no schema needed):
`npc.gwen.trust` -> "gwen trust".
The summary reports refused changes as well as accepted ones. A stat the
engine clamped carries `clamped`, and a stat it refused outright becomes
a `rejected` entry carrying the reason. Reporting only the accepted
changes made a clamp indistinguishable from a change that never
happened: a value the model pushed past its ceiling came back as a
delta of 0 and rendered as an ordinary chip, so a refused update read on
screen as an applied one.
Reads `world_delta`, never `context_snapshot`. This runs for every
action in a list response, and touching the deferred snapshot here would
drag the entire prompt archive out of the database."""
wd = self.world_delta if isinstance(self.world_delta, dict) else None
if wd is None:
return []
clamped_paths = {
str(e.get("path", "")) for e in (wd.get("clamped") or []) if isinstance(e, dict)
}
out: list[dict] = []
for entry in wd.get("applied") or []:
path = str(entry.get("path", ""))
parts = path.split(".")
section, name = parts[0], parts[-1]
if section == "flags":
out.append({"kind": "flag", "label": name, "on": bool(entry.get("new"))})
elif section == "milestones":
out.append({"kind": "milestone", "label": name})
else:
old, new = entry.get("old"), entry.get("new")
delta = new - old if isinstance(old, (int, float)) and isinstance(new, (int, float)) else None
chip = {
"kind": "stat",
"label": _change_label(parts),
"delta": delta,
"value": new,
"clamped": path in clamped_paths,
}
# Carried only when the engine wrote one. It is empty for every
# accepted change, and a key per chip per action is paid on
# every page load.
if entry.get("fix"):
chip["fix"] = str(entry["fix"])
out.append(chip)
for entry in wd.get("rejected") or []:
if not isinstance(entry, dict):
continue
parts = str(entry.get("path", "")).split(".")
chip = {
"kind": "rejected",
"label": _change_label(parts),
"reason": str(entry.get("reason", "")),
}
if entry.get("fix"):
chip["fix"] = str(entry["fix"])
out.append(chip)
return out
class Settings(Base):
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True)
# Phase 8: one row per user (pre-Phase-8 DBs had a single id=1 row, which
# the migration assigns to the local user).
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, unique=True
)
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
# Was a cloud provider's API key, encrypted at rest. Ollama does not use
# one and M2 removed cloud providers, so nothing reads or writes this now.
# The column stays so existing databases open unchanged; an old value is
# left where it is rather than migrated or decrypted.
api_key: Mapped[str] = mapped_column(String(500), default="")
model: Mapped[str] = mapped_column(String(200), default="")
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
temperature: Mapped[float] = mapped_column(Float, default=0.8)
# 800 leaves room for a full scene; 400 tended to truncate mid-paragraph
# and left reasoning models with nothing after their thinking.
max_output_tokens: Mapped[int] = mapped_column(Integer, default=800)
# Was an OpenRouter-style thinking budget. Ollama's OpenAI-compatible
# endpoint ignores the field, so M2 stopped sending it and removed it from
# the Settings API and UI. The column stays so existing databases open
# unchanged and is never read.
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
context_token_budget: Mapped[int] = mapped_column(Integer, default=16384)
# How long to wait for the model, in seconds, before giving up on a turn.
# A cold load of a mid-sized model on a CPU-only machine can take minutes,
# while the same turn takes seconds once the model is resident. See
# `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
model_timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
narrator_prompt: Mapped[str] = mapped_column(
Text,
default=(
"You are a masterful storyteller continuing an interactive adventure. "
"Continue the story naturally in second person, staying consistent with "
"everything established so far. Write vivid prose. Never speak for the "
"player or break character. Do not conclude the story; always leave room "
"for the player's next action."
),
)
# Phase 6: auto-summarization + memory bank
summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model
embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled
# This was 200. It was lowered mainly to improve retrieval quality. Ranking
# 200 memories to choose 5 selects from a large amount of noise, and the
# oldest memories describe a part of the story that the player has left
# behind. Cheaper reads are a secondary benefit rather than the reason.
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=80)
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
# The hosted visitor dashboard's two counter tables and the access log that
# recorded sign-ins, addresses and devices used to be mapped here. M2 removed
# the hosted deployment they served.
#
# The tables are left in the database rather than dropped: they are inert,
# nothing reads or writes them, and a destructive migration would risk an
# existing campaign database for tidiness alone. They are not product
# functionality.
@event.listens_for(Session, "before_flush")
def _place_new_nodes_on_the_tree(session, flush_context, instances):
from . import tree
tree.place_new_nodes(session)