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
71 lines
2.7 KiB
Python
71 lines
2.7 KiB
Python
"""Exporting an adventure to a bundle, and importing one back.
|
|
|
|
`app/bundle.py` owns the format and the version handling. These two endpoints
|
|
only check ownership and hand the work over.
|
|
"""
|
|
|
|
from fastapi import Body, Depends, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import analytics, bundle, limits, models, schemas
|
|
from ...database import get_db
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
|
|
|
|
@router.get("/{adventure_id}/export")
|
|
def export_adventure(
|
|
db: Session = Depends(get_db),
|
|
adv: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns a full backup: plot components, story cards, scripts, state, and tree.
|
|
|
|
`app/bundle.py` owns the format, in both of its versions. A backup outlives
|
|
the schema, so no call site decides anything about its shape.
|
|
"""
|
|
return bundle.export(db, adv)
|
|
|
|
|
|
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
|
|
def import_adventure(
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
):
|
|
version = bundle.check_format(payload)
|
|
limits.rate_limit("import", request, user)
|
|
limits.check_row_cap("adventures", db, user)
|
|
limits.check_bundle_lists(
|
|
story_cards=payload.get("storyCards"),
|
|
memories=payload.get("memories"),
|
|
actions=payload.get("actions"),
|
|
branches=payload.get("branches"),
|
|
)
|
|
# Check the tree before the adventure row exists, so that an inconsistent
|
|
# file returns a 400 rather than leaving a half-imported adventure with a
|
|
# gap in its story.
|
|
story = bundle.plan(payload, version)
|
|
# Count again, this time over what is written. The check above reads the
|
|
# file's own lists, and in a v1 file one turn is one entry that carries its
|
|
# retries in a `variants` array. `plan()` expands that into one row per
|
|
# attempt, because SP4 made every attempt a node. A file of 5,000 turns with
|
|
# ten attempts each therefore passes a 5,000-action cap and writes 50,000
|
|
# rows, well inside the 20 MB body limit. `plan()` has no side effects and
|
|
# the adventure does not exist yet, so this check costs only the planning.
|
|
limits.check_bundle_lists(
|
|
actions=story["nodes"],
|
|
memories=story["memories"],
|
|
branches=story["branches"],
|
|
)
|
|
|
|
adventure = bundle.materialize(db, payload, story, user.id)
|
|
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
# This is not a funnel step. A returning player imports a bundle, so it
|
|
# says nothing about how far a first-time visitor got. It is counted anyway,
|
|
# because it is the clearest evidence that anyone uses the export format.
|
|
analytics.record_event(analytics.EV_IMPORT, user)
|
|
return adventure
|