94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The milestone is subtraction, and what is left is the single-user local storyteller the specification describes. Removed in full: campaign scripting and its QuickJS sandbox; multi-user accounts, guest sessions, login, registration and the shared demo key; the visitor-analytics tables, dashboard and page beacon; the access log of sign-ins, addresses and devices; per-IP and per-user rate limiting and quotas; Render deployment config; Postgres and psycopg; cloud inference providers, the API-key field and the key encryption that existed to store it; session-cookie signing. None of it was hidden behind a flag — the routes are gone and answer 404. Two things were kept that the brief allowed keeping. The `users` table and its foreign keys stay as an internal ownership detail, because rewriting them out means a migration across most of the schema to delete a column that costs nothing; nothing creates a second user and no request carries an identity. Five inert tables and four inert columns stay for the same reason, so an M1 campaign database opens unchanged. The one addition is app/endpoints.py, which decides where a story may be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an explicit allowlist of networks, not a guess at what `ipaddress` means by "private", which calls the documentation ranges private and IPv6 loopback reserved. Every address a hostname resolves to must be in it, so a split answer does not squeak through, and the rule runs both when the endpoint is saved and before every outbound request, because a name that resolved to the LAN this morning can resolve elsewhere this afternoon. Known cloud hosts are named in the refusal so the error says why rather than looking like broken DNS. TLS is never traded against it: M1's shared trust context is intact on all four clients and there is no way to skip verification. The hardcoded 120-second model timeout is now a setting. That was not theoretical — on this GPU-less four-core host a cold load of qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short at 10s so a wrong address still fails fast; the read timeout defaults to 300s and is bounded at 3600, because "wait longer" must stay a number. Two defects found while testing and fixed here. An unknown /api path fell through the SPA catch-all and came back as HTML with status 200, so a client asking for JSON parsed a web page instead of learning the route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an unauthenticated loopback API would hand every page on the Internet a write handle on the campaign database; it now refuses to start. Verified rather than assumed. Offline, on a network with no route out and no DNS: five turns, retry with both takes retained, restart with an identical transcript digest, a failed model call leaving the accepted AI-turn count untouched, and a capture with zero non-loopback unicast packets. Against a real second machine on the LAN over HTTPS with a private CA: four turns, restart, and a capture showing 289 packets to the approved host, 344 loopback, zero anywhere else, zero DNS queries. Cloud and public endpoints refused with their reasons; no API key settable; every removed route 404. 604 backend tests pass, down from 648 by the fifteen retired with the subsystems they tested and up by the twenty-nine added for the endpoint policy and the removed surface. The scripting tests were not deleted: eight files used a JavaScript counter as instrumentation for the state snapshot and rollback machinery, which M2 does not touch, so the counter moved to the world-state engine and those tests still assert what they always did. Frontend lint and build are clean; the image builds, and its wheel-building stage is gone with quickjs. No M3 work. Undo is still destructive and there is still no Redo.
307 lines
13 KiB
Python
307 lines
13 KiB
Python
"""Listing, creating, reading, renaming, and deleting adventures.
|
|
|
|
The world-state and script-state readers are here too, because they report an
|
|
adventure's stored state rather than play a turn.
|
|
"""
|
|
|
|
from fastapi import Body, Depends, HTTPException
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.orm.attributes import set_committed_value
|
|
|
|
from ... import attempts, images, limits, memorybank, models, schemas, tree, worldstate
|
|
from ...database import get_db
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
from .paging import action_window, annotate_takes
|
|
from .scenario_text import fill_placeholders, scenario_card_specs
|
|
|
|
|
|
# How many characters of the last narration a Continue card shows. The limit is
|
|
# long enough to re-establish the scene and short enough to keep the card
|
|
# small.
|
|
SNIPPET_MAX = 220
|
|
|
|
|
|
def _snippet(text: str) -> str:
|
|
"""Condenses stored action text into a single line for a card."""
|
|
# `turns._generate_turn` strips the world-state block before storing AI text,
|
|
# so this function only has to normalize whitespace.
|
|
collapsed = " ".join((text or "").split())
|
|
if len(collapsed) <= SNIPPET_MAX:
|
|
return collapsed
|
|
# Cut at a word boundary rather than mid-word. CSS adds the ellipsis.
|
|
cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0]
|
|
return f"{cut}…"
|
|
|
|
|
|
# Action types that read as narration. `start` is the scenario's opening prompt,
|
|
# which is the only text a newly created adventure has. Without `start`, a new
|
|
# story's card would show no text at all. `do` and `say` are excluded because
|
|
# the card quotes the story's voice rather than the player's.
|
|
NARRATION_TYPES = ("ai", "story", "start")
|
|
|
|
|
|
def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]:
|
|
"""Maps each adventure id to the text of its most recent narrated action.
|
|
|
|
This runs one window-function query rather than one lookup per adventure, so
|
|
the list endpoint makes a fixed number of round trips.
|
|
|
|
The query is scoped by head branch rather than by the full lineage, and this
|
|
is the only place in the codebase that does so. A lineage clause per
|
|
adventure would add a hundred OR terms to the index screen's query to select
|
|
one row each. The two scopes differ only for a branch with no nodes of its
|
|
own, and playing a turn onto a branch is what creates it, so that state does
|
|
not occur. An adventure with no branch has no story to quote.
|
|
"""
|
|
branch_ids = [b for b in head_branches.values() if b is not None]
|
|
if not branch_ids:
|
|
return {}
|
|
ranked = (
|
|
db.query(
|
|
models.Action.adventure_id.label("adventure_id"),
|
|
models.Action.text.label("text"),
|
|
func.row_number()
|
|
.over(
|
|
partition_by=models.Action.adventure_id,
|
|
order_by=(models.Action.depth.desc(), models.Action.id.desc()),
|
|
)
|
|
.label("rank"),
|
|
)
|
|
.filter(
|
|
models.Action.adventure_id.in_(list(head_branches)),
|
|
models.Action.branch_id.in_(branch_ids),
|
|
models.Action.type.in_(NARRATION_TYPES),
|
|
# Sibling attempts share a depth, and the newest has the highest
|
|
# id. Without this filter the snippet quotes the attempt written
|
|
# last rather than the one the story tells. After you switch back to
|
|
# an earlier attempt, the index screen would quote the discarded one
|
|
# and disagree with the story on screen.
|
|
models.Action.live.is_(True),
|
|
)
|
|
.subquery()
|
|
)
|
|
rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all()
|
|
return {adventure_id: text for adventure_id, text in rows}
|
|
|
|
|
|
@router.get("", response_model=list[schemas.AdventureListItem])
|
|
def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
|
|
# Select named columns rather than the whole Adventure entity. The entity is
|
|
# sixteen columns wide and includes `script_state`, `world_state`,
|
|
# `placeholders`, `story_summary`, `memory`, `authors_note`, and
|
|
# `ai_instructions`. That is about 15 kB per row in production, fetched once
|
|
# per adventure on every index load, and this screen uses none of it. Naming
|
|
# the columns also means a wide column added to Adventure later has to opt
|
|
# in to being listed here.
|
|
rows = (
|
|
db.query(
|
|
models.Adventure.id,
|
|
models.Adventure.scenario_id,
|
|
models.Adventure.title,
|
|
models.Adventure.updated_at,
|
|
models.Adventure.head_branch_id,
|
|
func.count(models.Action.id),
|
|
models.Scenario.title,
|
|
models.Scenario.image,
|
|
models.Scenario.icon,
|
|
models.Scenario.updated_at,
|
|
)
|
|
.outerjoin(models.Action)
|
|
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
|
|
.filter(models.Adventure.user_id == user.id)
|
|
# Group by both primary keys. Postgres requires every selected column
|
|
# to be grouped or aggregated. The Adventure columns are covered by its
|
|
# own grouped primary key, but the Scenario columns come from a joined
|
|
# table and have to be listed as well. SQLite accepts the shorter form,
|
|
# and Postgres rejects it.
|
|
.group_by(
|
|
models.Adventure.id,
|
|
models.Scenario.id,
|
|
models.Scenario.title,
|
|
models.Scenario.image,
|
|
models.Scenario.icon,
|
|
models.Scenario.updated_at,
|
|
)
|
|
.order_by(models.Adventure.updated_at.desc())
|
|
.all()
|
|
)
|
|
narration = _latest_narration(db, {row[0]: row[4] for row in rows})
|
|
return [
|
|
schemas.AdventureListItem(
|
|
id=adv_id,
|
|
scenario_id=scenario_id,
|
|
scenario_title=scenario_title,
|
|
title=title,
|
|
updated_at=updated_at,
|
|
action_count=count,
|
|
snippet=_snippet(narration.get(adv_id, "")),
|
|
# The art belongs to the scenario, so the cache-busting stamp uses
|
|
# the scenario's `updated_at`, not the adventure's.
|
|
image_url=images.public_url(scenario_id, image or "", scenario_updated),
|
|
icon=icon or "",
|
|
)
|
|
# `count` counts every action in the adventure, not only the ones on
|
|
# the path. With one branch the two numbers are equal. After forking
|
|
# ships, the index screen overstates a story that has sibling branches.
|
|
# The fix belongs to SP5, which is where a fork can first exist.
|
|
for (adv_id, scenario_id, title, updated_at, _head_branch_id, count,
|
|
scenario_title, image, icon, scenario_updated) in rows
|
|
]
|
|
|
|
|
|
@router.post("", response_model=schemas.AdventureOut, status_code=201)
|
|
def create_adventure(
|
|
payload: schemas.AdventureCreate,
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
):
|
|
limits.check_row_cap("adventures", db, user)
|
|
scenario = None
|
|
if payload.scenario_id is not None:
|
|
scenario = db.get(models.Scenario, payload.scenario_id)
|
|
# A scenario is playable if the user owns it or if it is public.
|
|
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
|
raise HTTPException(404, "Scenario not found")
|
|
|
|
values = payload.placeholders
|
|
adventure = models.Adventure(
|
|
user_id=user.id,
|
|
scenario_id=scenario.id if scenario else None,
|
|
title=payload.title or (scenario.title if scenario else "Untitled Adventure"),
|
|
memory=fill_placeholders(scenario.memory, values) if scenario else "",
|
|
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
|
|
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
|
|
# Phase 12: seed the live RPG state from the scenario's template.
|
|
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
|
|
# Stored so that a later "Update from scenario" fills the copied text
|
|
# with the same answers instead of inserting literal `${...}` tokens.
|
|
placeholders=dict(values),
|
|
# Phase 18: the persona the player named before starting. It belongs to
|
|
# the adventure rather than the scenario, so nothing is copied here and
|
|
# "Update from scenario" never touches it.
|
|
persona_name=payload.persona_name.strip(),
|
|
persona_pronouns=payload.persona_pronouns.strip(),
|
|
persona_desc=payload.persona_desc.strip(),
|
|
)
|
|
db.add(adventure)
|
|
db.flush()
|
|
# Give every adventure a story tree as soon as it exists, before anything
|
|
# is played onto it. Otherwise the tree code has to tolerate a NULL head
|
|
# everywhere, which buys nothing.
|
|
tree.head_branch(db, adventure)
|
|
|
|
if scenario:
|
|
for ref, spec in scenario_card_specs(scenario, values).items():
|
|
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
|
|
if scenario.prompt.strip():
|
|
opening = models.Action(
|
|
adventure_id=adventure.id,
|
|
type="start",
|
|
text=fill_placeholders(scenario.prompt, values),
|
|
)
|
|
# Record the starting state on the opening node, so undoing or
|
|
# retrying the first turn has a state to roll back to.
|
|
attempts.snapshot_outcome(adventure, opening)
|
|
tree.place_action(db, adventure, opening)
|
|
db.add(opening)
|
|
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
return adventure
|
|
|
|
|
|
@router.get("/{adventure_id}", response_model=schemas.AdventureOut)
|
|
def get_adventure(
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns the adventure and the newest window of its story.
|
|
|
|
`actions` holds the last `ACTION_PAGE` actions, not all of them.
|
|
`action_count` reports the real total, so the reader knows that more actions
|
|
exist above. `GET /{id}/actions` serves the older pages as the reader
|
|
scrolls up.
|
|
"""
|
|
actions, total, _ = action_window(db, adventure)
|
|
# Annotate before handing over the window. This path serializes through the
|
|
# relationship rather than building `ActionOut` itself, so the pager numbers
|
|
# have to be on the rows before Pydantic reads them.
|
|
annotate_takes(db, adventure.id, actions)
|
|
# Attach the window as if the relationship had loaded it.
|
|
# `set_committed_value` is the only safe way to do this. Assigning
|
|
# `adventure.actions = [...]` marks the collection dirty, and the
|
|
# relationship cascades delete-orphan, so the next flush deletes every
|
|
# action outside the window. `set_committed_value` records the rows as the
|
|
# already-loaded, unmodified value, so serialization triggers no lazy load
|
|
# and leaves nothing pending.
|
|
set_committed_value(adventure, "actions", actions)
|
|
out = schemas.AdventureOut.model_validate(adventure)
|
|
out.action_count = total
|
|
return out
|
|
|
|
|
|
@router.get("/{adventure_id}/world-state")
|
|
def get_world_state(
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns the live RPG world state and the scenario's `stat_schema`.
|
|
|
|
The play view uses both to render the character sheet and the milestones.
|
|
`schema` is null when the adventure has no RPG layer.
|
|
"""
|
|
schema = adventure.scenario.stat_schema if adventure.scenario else None
|
|
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
|
return {
|
|
"state": state,
|
|
"schema": schema if worldstate.has_schema(schema) else None,
|
|
}
|
|
|
|
|
|
@router.put("/{adventure_id}/world-state")
|
|
def override_world_state(
|
|
overrides: dict = Body(...),
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Edits the live RPG values directly, as a manual correction rather than a turn.
|
|
|
|
`overrides` maps paths such as `player.hp`, `npc.gwen.trust`, `flags.x`, and
|
|
`milestones.y` to their new absolute values. The endpoint rejects unknown
|
|
paths and wrong types one at a time, and applies the rest.
|
|
"""
|
|
schema = adventure.scenario.stat_schema if adventure.scenario else None
|
|
if not worldstate.has_schema(schema):
|
|
raise HTTPException(400, "This adventure has no RPG world-state layer")
|
|
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
|
new_state, report = worldstate.apply_override(state, schema, overrides)
|
|
adventure.world_state = new_state
|
|
db.commit()
|
|
return {"state": new_state, "report": report}
|
|
|
|
|
|
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
|
|
def update_adventure(
|
|
payload: schemas.AdventureUpdate,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(adventure, field, value)
|
|
db.commit()
|
|
return adventure
|
|
|
|
|
|
@router.delete("/{adventure_id}", status_code=204)
|
|
def delete_adventure(
|
|
adventure_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
db.delete(adventure)
|
|
db.commit()
|
|
# No later request reads this adventure's vectors, so drop them now. The
|
|
# cache would otherwise hold them until the process restarted.
|
|
memorybank.forget_cached_vectors(adventure_id)
|