Give every new guest an adventure that is already played
An empty account gives a visitor nothing to read, and the daily demo turns are limited, so learning what the app does cost one of them. `app/starter.py` now copies a shipped export bundle into each new guest at the point the row is created. The bundle is two exchanges of the Pokemon demo, which ends on a knockout and shows an applied change, a refused one, and a milestone. The guest row is committed before the copy is attempted, so a failure there still leaves them with an account, and the copy runs inside a savepoint. The row building that `POST /adventures/import` did inline moved into `bundle.materialize`, which both callers use. The rate and size checks stayed in the endpoint: the starter writes a file the server ships, so it has no untrusted list to cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae39514d1e
commit
6cbf6d996f
@@ -630,6 +630,70 @@ def settle(db: Session, adventure: models.Adventure, story: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def materialize(
|
||||||
|
db: Session, payload: dict, story: dict, user_id: int | None
|
||||||
|
) -> models.Adventure:
|
||||||
|
"""Writes a planned bundle into a new adventure owned by `user_id`.
|
||||||
|
|
||||||
|
Call `check_format` and `plan` first, and apply any rate or size limits
|
||||||
|
before calling this. The split exists because those checks belong to the
|
||||||
|
import endpoint alone: the guest starter writes a file the server ships, so
|
||||||
|
it has nothing to rate-limit and no untrusted list to cap.
|
||||||
|
|
||||||
|
The caller commits. Two flushes happen here, because the anchors and the
|
||||||
|
legacy counts describe one boundary in two coordinate systems, and aligning
|
||||||
|
them needs the actions to be queryable.
|
||||||
|
"""
|
||||||
|
# A raw-dict import bypasses the schemas, so truncate strings bound for
|
||||||
|
# VARCHAR columns. Postgres enforces the widths. See `schemas.py`.
|
||||||
|
adventure = models.Adventure(
|
||||||
|
user_id=user_id,
|
||||||
|
title=str(payload.get("title") or "Imported Adventure")[:schemas.NAME_MAX],
|
||||||
|
memory=str(payload.get("memory") or ""),
|
||||||
|
authors_note=str(payload.get("authorsNote") or ""),
|
||||||
|
ai_instructions=str(payload.get("aiInstructions") or ""),
|
||||||
|
story_summary=str(payload.get("storySummary") or ""),
|
||||||
|
script_state=payload.get("scriptState") or {},
|
||||||
|
world_state=payload.get("worldState") or {},
|
||||||
|
auto_summarize=bool(payload.get("autoSummarize", False)),
|
||||||
|
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
|
||||||
|
)
|
||||||
|
db.add(adventure)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for card in payload.get("storyCards") or []:
|
||||||
|
if isinstance(card, dict):
|
||||||
|
db.add(models.StoryCard(
|
||||||
|
adventure_id=adventure.id,
|
||||||
|
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
|
||||||
|
name=str(card.get("name") or "")[:schemas.NAME_MAX],
|
||||||
|
keys=str(card.get("keys") or ""),
|
||||||
|
entry=str(card.get("entry") or ""),
|
||||||
|
notes=str(card.get("notes") or ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
for i, item in enumerate(payload.get("scripts") or []):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
db.add(models.AdventureScript(
|
||||||
|
adventure_id=adventure.id,
|
||||||
|
position=int(item.get("position", i)),
|
||||||
|
enabled=bool(item.get("enabled", True)),
|
||||||
|
name=str(item.get("name") or "Imported Script")[:schemas.NAME_MAX],
|
||||||
|
description=str(item.get("description") or ""),
|
||||||
|
library_js=str(item.get("library") or ""),
|
||||||
|
input_js=str(item.get("input") or ""),
|
||||||
|
context_js=str(item.get("context") or ""),
|
||||||
|
output_js=str(item.get("output") or ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
write(db, adventure, story)
|
||||||
|
db.flush()
|
||||||
|
db.expire(adventure, ["actions"])
|
||||||
|
settle(db, adventure, story)
|
||||||
|
return adventure
|
||||||
|
|
||||||
# ------------------------------------------------------------------ reading
|
# ------------------------------------------------------------------ reading
|
||||||
# Small coercions. A raw-dict import bypasses the schemas, so every value from a
|
# Small coercions. A raw-dict import bypasses the schemas, so every value from a
|
||||||
# bundle is whatever the JSON held.
|
# bundle is whatever the JSON held.
|
||||||
|
|||||||
@@ -1824,56 +1824,7 @@ def import_adventure(
|
|||||||
branches=story["branches"],
|
branches=story["branches"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# A raw-dict import bypasses the schemas, so truncate strings bound for
|
adventure = bundle.materialize(db, payload, story, user.id)
|
||||||
# VARCHAR columns. Postgres enforces the widths. See `schemas.py`.
|
|
||||||
adventure = models.Adventure(
|
|
||||||
user_id=user.id,
|
|
||||||
title=str(payload.get("title") or "Imported Adventure")[:schemas.NAME_MAX],
|
|
||||||
memory=str(payload.get("memory") or ""),
|
|
||||||
authors_note=str(payload.get("authorsNote") or ""),
|
|
||||||
ai_instructions=str(payload.get("aiInstructions") or ""),
|
|
||||||
story_summary=str(payload.get("storySummary") or ""),
|
|
||||||
script_state=payload.get("scriptState") or {},
|
|
||||||
world_state=payload.get("worldState") or {},
|
|
||||||
auto_summarize=bool(payload.get("autoSummarize", False)),
|
|
||||||
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
|
|
||||||
)
|
|
||||||
db.add(adventure)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
for card in payload.get("storyCards") or []:
|
|
||||||
if isinstance(card, dict):
|
|
||||||
db.add(models.StoryCard(
|
|
||||||
adventure_id=adventure.id,
|
|
||||||
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
|
|
||||||
name=str(card.get("name") or "")[:schemas.NAME_MAX],
|
|
||||||
keys=str(card.get("keys") or ""),
|
|
||||||
entry=str(card.get("entry") or ""),
|
|
||||||
notes=str(card.get("notes") or ""),
|
|
||||||
))
|
|
||||||
|
|
||||||
for i, s in enumerate(payload.get("scripts") or []):
|
|
||||||
if isinstance(s, dict):
|
|
||||||
db.add(models.AdventureScript(
|
|
||||||
adventure_id=adventure.id,
|
|
||||||
position=int(s.get("position", i)),
|
|
||||||
enabled=bool(s.get("enabled", True)),
|
|
||||||
name=str(s.get("name") or "Imported Script")[:schemas.NAME_MAX],
|
|
||||||
description=str(s.get("description") or ""),
|
|
||||||
library_js=str(s.get("library") or ""),
|
|
||||||
input_js=str(s.get("input") or ""),
|
|
||||||
context_js=str(s.get("context") or ""),
|
|
||||||
output_js=str(s.get("output") or ""),
|
|
||||||
))
|
|
||||||
|
|
||||||
bundle.write(db, adventure, story)
|
|
||||||
|
|
||||||
# The anchors and the legacy counts describe the same boundary in two
|
|
||||||
# coordinate systems. Aligning them requires the actions to be queryable,
|
|
||||||
# and this is the only point where both exist.
|
|
||||||
db.flush()
|
|
||||||
db.expire(adventure, ["actions"])
|
|
||||||
bundle.settle(db, adventure, story)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(adventure)
|
db.refresh(adventure)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import re
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import accesslog, analytics, auth, cleanup, limits, models, schemas, security
|
from .. import (accesslog, analytics, auth, cleanup, limits, models, schemas,
|
||||||
|
security, starter)
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from .settings import get_settings
|
from .settings import get_settings
|
||||||
|
|
||||||
@@ -70,6 +71,10 @@ def me(request: Request, response: Response, db: Session = Depends(get_db)):
|
|||||||
user = models.User(is_guest=True)
|
user = models.User(is_guest=True)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# The guest is committed first, so a failure while copying the
|
||||||
|
# starter adventure still leaves them with an account.
|
||||||
|
starter.give(db, user)
|
||||||
|
db.commit()
|
||||||
_set_session_cookie(response, user.id)
|
_set_session_cookie(response, user.id)
|
||||||
# This endpoint is the SPA's bootstrap call, so it is where a session first
|
# This endpoint is the SPA's bootstrap call, so it is where a session first
|
||||||
# shows itself; accesslog thins the rows down to one per day per address.
|
# shows itself; accesslog thins the rows down to one per day per address.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Gives every new guest a short adventure that is already played.
|
||||||
|
|
||||||
|
A guest arriving on an empty account has nothing to look at, and the demo turns
|
||||||
|
are limited, so learning what the app does used to cost one of them. This copies
|
||||||
|
a small pre-played adventure into the new account instead. It opens on a story
|
||||||
|
with real turns in it, and the turn summaries show what the world-state engine
|
||||||
|
records: the changes it applied, and the changes it refused.
|
||||||
|
|
||||||
|
The file in `starter_data/` is an ordinary export bundle, produced by
|
||||||
|
`GET /api/adventures/{id}/export` and trimmed to its first two exchanges. To
|
||||||
|
replace it, play a new adventure, export it, and overwrite the file. Nothing
|
||||||
|
else here knows what the story contains.
|
||||||
|
|
||||||
|
The copy is the guest's own from the first moment: they can edit it, branch it,
|
||||||
|
delete it, or export it, and nothing links it back to the file. A returning
|
||||||
|
guest is not given a second one, because this runs only where a guest row is
|
||||||
|
created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from . import bundle, models
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STARTER_FILE = Path(__file__).resolve().parent / "starter_data" / "pokemon-league.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict | None:
|
||||||
|
"""Returns the starter bundle, or None when the file is missing or invalid.
|
||||||
|
|
||||||
|
The file ships with the server, so a failure here is a packaging mistake
|
||||||
|
rather than bad input. It is still tolerated: a guest with no starter
|
||||||
|
adventure can play, and a guest who cannot be created cannot.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return json.loads(STARTER_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
logger.exception("Starter adventure is missing or unreadable; skipping it.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def give(db: Session, user: models.User) -> models.Adventure | None:
|
||||||
|
"""Copies the starter adventure into `user`, and returns it.
|
||||||
|
|
||||||
|
The caller commits. This writes rows and does not commit them, so a guest
|
||||||
|
and their first adventure land in one transaction: an account is never left
|
||||||
|
half-populated.
|
||||||
|
|
||||||
|
Failures are logged and swallowed. The starter is a convenience, and losing
|
||||||
|
it must not cost the visitor their session.
|
||||||
|
"""
|
||||||
|
payload = _load()
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# A savepoint, so a failure halfway through writing the tree discards
|
||||||
|
# only the starter's rows. Without it the caller's next commit would
|
||||||
|
# flush whatever part of the adventure the session still held.
|
||||||
|
with db.begin_nested():
|
||||||
|
story = bundle.plan(payload, bundle.check_format(payload))
|
||||||
|
return bundle.materialize(db, payload, story, user.id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not give user %s the starter adventure.", user.id)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
{
|
||||||
|
"format": "ai-dnd-adventure-v2",
|
||||||
|
"title": "Pokemon League Championship: Round One",
|
||||||
|
"memory": "The player is a Trainer in Round One of an eight-round League Championship. The Round One opponent is Milo, a Rock-type specialist with three Pokémon: Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). The player's team is Pidgeotto (Normal/Flying), Ivysaur (Grass/Poison), Wartortle (Water), Machoke (Fighting), and Pikachu (Electric). Pidgeotto is out first. The player carries Hyper Potions, which restore HP and can be swapped for a status heal when needed. A battle is lost when every Pokémon on one side has fainted.",
|
||||||
|
"authorsNote": "Play it like a real battle. Type matchups decide damage, status conditions stick around and cost the afflicted Pokémon turns, and switching costs the player a turn while the opponent attacks freely. Milo is competent: he switches to punish bad matchups and targets whatever is weakest.",
|
||||||
|
"aiInstructions": "Write in second person, present tense. Narrate the battle move by move, naming the active Pokémon on each side and the move each one uses. End every reply at a decision point where the player can choose a move, switch Pokémon, or use an item.\n\nReflect type advantage and disadvantage in the damage you narrate and in the HP numbers: a super-effective hit should take roughly double a neutral hit, a resisted hit roughly half. Ground and Rock moves hit Pikachu and Pidgeotto hard. Water, Grass, and Fighting moves are strong against Milo's Rock-types.\n\nEvery turn, update the state. Every number you send is a change to add to the current value, never the value you want it to end up at. The world state lines show you totals; the block you write carries the difference. A Pokémon at 96 HP that takes 30 damage is `-30`, not `66`.\n- Add 1 to `world.turn` in every reply, including this one. It counts the turns this battle has lasted, so it moves whether or not anything else does.\n- Send a negative number for HP lost and a positive number for HP restored on both sides (`npc.<name>.hp` for the player's own Pokémon, e.g. npc.pidgeotto.hp; `npc.milo.active_hp` for Milo's). Every Pokémon starts at its full HP, so a positive number on an undamaged one changes nothing.\n- Set a Pokémon's `status` stat (`npc.<name>.status`, or `npc.milo.active_status`) to the condition it just picked up: poisoned, burned, paralyzed, frozen, asleep, or none when it is cured or wakes up. Status matters. Poison and burn shave HP at the end of each turn, paralysis and freeze cost turns, sleep stops a Pokémon acting until it wakes.\n- When the player switches, set `player.active_pokemon` to the incoming Pokémon's name in full (not a delta).\n- When the player uses a potion, send `-1` for `player.potions` and a positive number for the healed Pokémon's HP. The count falls when a potion is spent. It never rises. Potions cannot be used once the count reaches 0.\n- When a Pokémon's HP hits 0 it faints, its status becomes `fainted`, and its trainer must send out a replacement (the player picks the next `player.active_pokemon`). A fainted Pokémon cannot be healed or switched back in.\n- Whenever Milo sends out a new Pokémon, set `npc.milo.active_pokemon` and `npc.milo.active_status` to their new values in full, and send `npc.milo.active_hp` as the positive change that carries it from where it stands to the newcomer's full HP. From a fainted Pokémon at 0 that is the newcomer's full HP: Graveler 98, Onix 90, Kabutops 88.\n\nWhen one of Milo's Pokémon faints, do both of these in the same reply: send `1` for `npc.milo.pokemon_fainted`, which adds one to the count rather than setting it, and set `npc.milo.active_pokemon` with a fresh `npc.milo.active_hp` and `npc.milo.active_status` for the one he sends out next. `npc.milo.pokemon_fainted` counts up from 0 and never goes down.\n\nMark each milestone as it happens, using the id shown beside the goal in the world state. Do not decide the whole battle in one reply; give the player a turn between every exchange.",
|
||||||
|
"storySummary": "",
|
||||||
|
"scriptState": {},
|
||||||
|
"worldState": {
|
||||||
|
"world": {
|
||||||
|
"round": 1,
|
||||||
|
"turn": 2
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"potions": 3,
|
||||||
|
"active_pokemon": "Wartortle"
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"pidgeotto": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"ivysaur": {
|
||||||
|
"hp": 96,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"wartortle": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"machoke": {
|
||||||
|
"hp": 104,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"pikachu": {
|
||||||
|
"hp": 76,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"milo": {
|
||||||
|
"active_pokemon": "Onix",
|
||||||
|
"active_hp": 90,
|
||||||
|
"active_status": "none",
|
||||||
|
"pokemon_fainted": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestones": {
|
||||||
|
"graveler_defeated": {
|
||||||
|
"reached": true,
|
||||||
|
"at": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"sandstorm_active": false
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"last_changed": {
|
||||||
|
"world.turn": 4,
|
||||||
|
"player.active_pokemon": 2,
|
||||||
|
"npc.wartortle.hp": 2,
|
||||||
|
"npc.milo.active_hp": 4,
|
||||||
|
"npc.milo.pokemon_fainted": 4,
|
||||||
|
"npc.milo.active_pokemon": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoSummarize": false,
|
||||||
|
"memoryBankEnabled": false,
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"parent": null,
|
||||||
|
"forkDepth": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headBranch": 0,
|
||||||
|
"memoryCursor": {
|
||||||
|
"branch": null,
|
||||||
|
"depth": -1
|
||||||
|
},
|
||||||
|
"summaryCursor": {
|
||||||
|
"branch": null,
|
||||||
|
"depth": -1
|
||||||
|
},
|
||||||
|
"memories": [],
|
||||||
|
"storyCards": [
|
||||||
|
{
|
||||||
|
"type": "location",
|
||||||
|
"name": "Championship Stadium",
|
||||||
|
"keys": "stadium, arena, battlefield, field, terrain",
|
||||||
|
"entry": "The League Championship's main battlefield. The floor is broken rock terrain, which gives Rock- and Ground-type moves solid footing and gives grounded Pokémon cover to duck behind. Eight rounds stand between any Trainer and the Champion's title, and this is Round One.",
|
||||||
|
"notes": "Rocky terrain. Rock and Ground moves get a slight edge. Boulders can be used as cover or thrown."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Trainer Milo",
|
||||||
|
"keys": "Milo, opponent, rock trainer",
|
||||||
|
"entry": "Milo is the Round One opponent, a Rock-type specialist who won his qualifiers on raw defense. His team is Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). He leads with Graveler, likes Sandstorm and Rock Slide, and switches out the moment a matchup turns against him.",
|
||||||
|
"notes": "Graveler and Onix are weak to Water, Grass, Fighting, Ground, Ice, and Steel, and are immune to Electric. Kabutops is weak to Grass, Fighting, Ground, and Electric. All three resist Normal and Flying."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item",
|
||||||
|
"name": "Hyper Potion",
|
||||||
|
"keys": "potion, hyper potion, heal, item, bag",
|
||||||
|
"entry": "A pressurized spray that closes wounds fast. One dose restores a large chunk of a Pokémon's HP, or clears a single status condition instead. Using one costs your turn, and the opponent attacks freely while you do it. You start the round with three and cannot restock mid-battle.",
|
||||||
|
"notes": "Decrement player.potions by 1 per use. A potion cannot revive a fainted Pokémon."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Pidgeotto",
|
||||||
|
"keys": "Pidgeotto",
|
||||||
|
"entry": "The player's own Pokémon. Normal/Flying. Fast, but Rock-type moves hit it for four times damage.",
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Ivysaur",
|
||||||
|
"keys": "Ivysaur",
|
||||||
|
"entry": "The player's own Pokémon. Grass/Poison. Super effective on Milo's Rock/Ground types; can inflict poison or sleep.",
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Wartortle",
|
||||||
|
"keys": "Wartortle",
|
||||||
|
"entry": "The player's own Pokémon. Water. Bulky, and super effective on the whole of Milo's team.",
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Machoke",
|
||||||
|
"keys": "Machoke",
|
||||||
|
"entry": "The player's own Pokémon. Fighting. Hits Rock-types hardest of the team but is slow and takes Ground moves badly.",
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Pikachu",
|
||||||
|
"keys": "Pikachu",
|
||||||
|
"entry": "The player's own Pokémon. Electric. Frail, and its Electric moves do nothing at all to Ground-types.",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"branch": 0,
|
||||||
|
"depth": 0,
|
||||||
|
"live": true,
|
||||||
|
"type": "start",
|
||||||
|
"text": "The floodlights come up over the championship field and the referee raises both flags. Round One of eight. Beat the trainer in front of you or go home.\n\nAcross the packed rock terrain, Trainer Milo tosses his first Poké Ball. Graveler lands hard enough to crack the stone under it, four arms flexing, and settles into a low stance.\n\n\"Rock-types all the way down,\" Milo calls over the noise. \"Hope you brought something that hits harder than it looks.\"\n\nYour Pidgeotto is already in the air, wings locked into a slow circle above the field. It is fast, but Rock-type moves will tear it out of the sky, and Graveler is built to take a hit. Three Hyper Potions sit in your belt pouch.\n\nGraveler grinds one fist into its palm. The referee drops the flag.\n\nWhat's your move?",
|
||||||
|
"createdAt": "2026-08-28T12:24:29.635984",
|
||||||
|
"stateAfter": {},
|
||||||
|
"worldStateAfter": {
|
||||||
|
"world": {
|
||||||
|
"round": 1,
|
||||||
|
"turn": 0
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"potions": 3,
|
||||||
|
"active_pokemon": "Pidgeotto"
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"pidgeotto": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"ivysaur": {
|
||||||
|
"hp": 96,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"wartortle": {
|
||||||
|
"hp": 92,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"machoke": {
|
||||||
|
"hp": 104,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"pikachu": {
|
||||||
|
"hp": 76,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"milo": {
|
||||||
|
"active_pokemon": "Graveler",
|
||||||
|
"active_hp": 98,
|
||||||
|
"active_status": "none",
|
||||||
|
"pokemon_fainted": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestones": {},
|
||||||
|
"flags": {
|
||||||
|
"sandstorm_active": false
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"last_changed": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": 0,
|
||||||
|
"depth": 1,
|
||||||
|
"live": true,
|
||||||
|
"type": "do",
|
||||||
|
"text": "> You Recall Pidgeotto and send out Wartortle, then hit Graveler with a full-power Water Gun.",
|
||||||
|
"createdAt": "2026-08-28T12:24:48.236580",
|
||||||
|
"stateAfter": {},
|
||||||
|
"worldStateAfter": {
|
||||||
|
"world": {
|
||||||
|
"round": 1,
|
||||||
|
"turn": 0
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"potions": 3,
|
||||||
|
"active_pokemon": "Pidgeotto"
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"pidgeotto": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"ivysaur": {
|
||||||
|
"hp": 96,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"wartortle": {
|
||||||
|
"hp": 92,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"machoke": {
|
||||||
|
"hp": 104,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"pikachu": {
|
||||||
|
"hp": 76,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"milo": {
|
||||||
|
"active_pokemon": "Graveler",
|
||||||
|
"active_hp": 98,
|
||||||
|
"active_status": "none",
|
||||||
|
"pokemon_fainted": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestones": {},
|
||||||
|
"flags": {
|
||||||
|
"sandstorm_active": false
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"last_changed": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": 0,
|
||||||
|
"depth": 2,
|
||||||
|
"live": true,
|
||||||
|
"type": "ai",
|
||||||
|
"text": "You call Pidgeotto back with a flash of red light, and it banks gratefully out of Graveler's range before the crack of a Rock Throw can find it. Wartortle drops onto the broken stone in its place, planting its feet, shell catching the floodlights.\n\n\"Smart,\" Milo grunts, \"but Graveler doesn't fold that easy.\" He points. \"Rock Throw!\"\n\nGraveler wrenches a chunk of the battlefield loose and hurls it in a flat, brutal arc. But Wartortle's already moving, ducking low behind a natural outcrop for a half-second of cover before the shard clips its shoulder — a glancing hit, more noise than damage.\n\n\"Wartortle, Water Gun — full power!\"\n\nWartortle plants both feet and unleashes a roaring jet of water straight into Graveler's chest. It's not a graceful hit, it's a drowning one — the stream slams Graveler backward, hissing against hot rock, and the four-armed boulder staggers, one knee grinding into the dirt before it forces itself back upright, dripping and furious.\n\n\"That's the matchup I didn't want to see,\" Milo mutters, though he's grinning like he half-expected it.\n\nGraveler shakes itself off, glaring, arms flexing back into a battle-ready stance. It's hurt — badly — but still standing, and still dangerous at close range.\n\nThe referee's flag stays down. It's still your move.",
|
||||||
|
"createdAt": "2026-08-28T12:25:00.242852",
|
||||||
|
"stateAfter": {},
|
||||||
|
"worldStateAfter": {
|
||||||
|
"world": {
|
||||||
|
"round": 1,
|
||||||
|
"turn": 1
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"potions": 3,
|
||||||
|
"active_pokemon": "Wartortle"
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"pidgeotto": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"ivysaur": {
|
||||||
|
"hp": 96,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"wartortle": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"machoke": {
|
||||||
|
"hp": 104,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"pikachu": {
|
||||||
|
"hp": 76,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"milo": {
|
||||||
|
"active_pokemon": "Graveler",
|
||||||
|
"active_hp": 52,
|
||||||
|
"active_status": "none",
|
||||||
|
"pokemon_fainted": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestones": {},
|
||||||
|
"flags": {
|
||||||
|
"sandstorm_active": false
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"last_changed": {
|
||||||
|
"world.turn": 2,
|
||||||
|
"player.active_pokemon": 2,
|
||||||
|
"npc.wartortle.hp": 2,
|
||||||
|
"npc.milo.active_hp": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worldDelta": {
|
||||||
|
"delta": {
|
||||||
|
"world.turn": 1,
|
||||||
|
"player.active_pokemon": "Wartortle",
|
||||||
|
"npc.wartortle.hp": -8,
|
||||||
|
"npc.milo.active_hp": -46,
|
||||||
|
"flags.type_advantage_used": true
|
||||||
|
},
|
||||||
|
"applied": [
|
||||||
|
{
|
||||||
|
"path": "world.turn",
|
||||||
|
"old": 0,
|
||||||
|
"new": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "player.active_pokemon",
|
||||||
|
"old": "Pidgeotto",
|
||||||
|
"new": "Wartortle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "npc.wartortle.hp",
|
||||||
|
"old": 92,
|
||||||
|
"new": 84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "npc.milo.active_hp",
|
||||||
|
"old": 98,
|
||||||
|
"new": 52
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clamped": [],
|
||||||
|
"rejected": [
|
||||||
|
{
|
||||||
|
"path": "flags.type_advantage_used",
|
||||||
|
"reason": "unknown flag",
|
||||||
|
"fix": "There is no flag `type_advantage_used`. The flags are: `sandstorm_active`."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": 0,
|
||||||
|
"depth": 3,
|
||||||
|
"live": true,
|
||||||
|
"type": "do",
|
||||||
|
"text": "> You Wartortle hits Graveler with everything it has left, a point-blank Hydro Pump to finish it.",
|
||||||
|
"createdAt": "2026-08-28T12:25:21.664571",
|
||||||
|
"stateAfter": {},
|
||||||
|
"worldStateAfter": {
|
||||||
|
"world": {
|
||||||
|
"round": 1,
|
||||||
|
"turn": 1
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"potions": 3,
|
||||||
|
"active_pokemon": "Wartortle"
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"pidgeotto": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"ivysaur": {
|
||||||
|
"hp": 96,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"wartortle": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"machoke": {
|
||||||
|
"hp": 104,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"pikachu": {
|
||||||
|
"hp": 76,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"milo": {
|
||||||
|
"active_pokemon": "Graveler",
|
||||||
|
"active_hp": 52,
|
||||||
|
"active_status": "none",
|
||||||
|
"pokemon_fainted": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestones": {},
|
||||||
|
"flags": {
|
||||||
|
"sandstorm_active": false
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"last_changed": {
|
||||||
|
"world.turn": 2,
|
||||||
|
"player.active_pokemon": 2,
|
||||||
|
"npc.wartortle.hp": 2,
|
||||||
|
"npc.milo.active_hp": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": 0,
|
||||||
|
"depth": 4,
|
||||||
|
"live": true,
|
||||||
|
"type": "ai",
|
||||||
|
"text": "Wartortle plants its stubby legs against the cracked stone, shell rattling as it draws breath for the biggest attack it has. \"Finish it — Hydro Pump!\" you shout.\n\nThe blast that follows isn't a stream, it's a cannon — a roaring column of pressurized water that closes the gap before Graveler can even brace. It catches the rock-type square in the chest and drives it backward across the broken terrain, gouging a furrow through the stone floor. Graveler's arms windmill once, twice, searching for purchase that isn't there, and then it simply goes down, crashing onto its back in a spreading puddle, swirl-eyed and still.\n\nMilo's jaw tightens, but he's already reaching for his second ball. \"Didn't want to see that either. Fine — you earned that one.\" He hurls it out. \"Onix, let's go!\"\n\nThe ground itself seems to shudder as Onix erupts from the earth in a towering column of jointed rock, easily three stories high, boulder head swinging around to fix its small eyes on Wartortle. Dust and loose gravel rain down from the impact of its arrival, pattering off your shoulders even from across the field.\n\n\"Rock/Ground,\" you murmur to yourself, but Wartortle doesn't flinch — Water still chews through that typing just as easily, and the referee's flag is already dropping to signal the next exchange.\n\nWartortle shakes water from its shell, tired from the Hydro Pump but still standing tall, eyeing the massive serpent of stone now looming over it.",
|
||||||
|
"createdAt": "2026-08-28T12:25:34.346927",
|
||||||
|
"stateAfter": {},
|
||||||
|
"worldStateAfter": {
|
||||||
|
"world": {
|
||||||
|
"round": 1,
|
||||||
|
"turn": 2
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"potions": 3,
|
||||||
|
"active_pokemon": "Wartortle"
|
||||||
|
},
|
||||||
|
"npc": {
|
||||||
|
"pidgeotto": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"ivysaur": {
|
||||||
|
"hp": 96,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"wartortle": {
|
||||||
|
"hp": 84,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"machoke": {
|
||||||
|
"hp": 104,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"pikachu": {
|
||||||
|
"hp": 76,
|
||||||
|
"status": "none"
|
||||||
|
},
|
||||||
|
"milo": {
|
||||||
|
"active_pokemon": "Onix",
|
||||||
|
"active_hp": 90,
|
||||||
|
"active_status": "none",
|
||||||
|
"pokemon_fainted": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestones": {
|
||||||
|
"graveler_defeated": {
|
||||||
|
"reached": true,
|
||||||
|
"at": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"sandstorm_active": false
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"last_changed": {
|
||||||
|
"world.turn": 4,
|
||||||
|
"player.active_pokemon": 2,
|
||||||
|
"npc.wartortle.hp": 2,
|
||||||
|
"npc.milo.active_hp": 4,
|
||||||
|
"npc.milo.pokemon_fainted": 4,
|
||||||
|
"npc.milo.active_pokemon": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worldDelta": {
|
||||||
|
"delta": {
|
||||||
|
"world.turn": 1,
|
||||||
|
"npc.milo.active_hp": -52,
|
||||||
|
"npc.milo.pokemon_fainted": 1,
|
||||||
|
"npc.milo.active_pokemon": "Onix",
|
||||||
|
"npc.milo.active_status": "none",
|
||||||
|
"milestones.graveler_defeated": true
|
||||||
|
},
|
||||||
|
"applied": [
|
||||||
|
{
|
||||||
|
"path": "world.turn",
|
||||||
|
"old": 1,
|
||||||
|
"new": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "npc.milo.active_hp",
|
||||||
|
"old": 52,
|
||||||
|
"new": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "npc.milo.pokemon_fainted",
|
||||||
|
"old": 0,
|
||||||
|
"new": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "npc.milo.active_pokemon",
|
||||||
|
"old": "Graveler",
|
||||||
|
"new": "Onix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "milestones.graveler_defeated",
|
||||||
|
"old": false,
|
||||||
|
"new": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clamped": [],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""The pre-played adventure every new guest is given: app/starter.py.
|
||||||
|
|
||||||
|
The starter is a shipped export bundle, so the two things that can break it are
|
||||||
|
the file and the import path. A bundle that no longer plans, or a story trimmed
|
||||||
|
to a state a player cannot continue from, both leave the guest worse off than an
|
||||||
|
empty account would.
|
||||||
|
|
||||||
|
python -m pytest tests/test_starter_adventure.py -v
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp.close()
|
||||||
|
os.environ["AIDND_DB_PATH"] = _tmp.name
|
||||||
|
os.environ.pop("AIDND_DATABASE_URL", None)
|
||||||
|
os.environ.pop("DATABASE_URL", None)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app import bundle, models, starter
|
||||||
|
from app.database import Base
|
||||||
|
from app.migrations import bootstrap
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db(tmp_path):
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}",
|
||||||
|
connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _fk(dbapi_connection, _record):
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
bootstrap(engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def guest(db):
|
||||||
|
user = models.User(is_guest=True)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def payload() -> dict:
|
||||||
|
return json.loads(starter.STARTER_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_file_is_a_bundle_this_build_can_import():
|
||||||
|
"""The file is written by an export, so a format change can strand it."""
|
||||||
|
data = payload()
|
||||||
|
version = bundle.check_format(data)
|
||||||
|
assert version == bundle.FORMAT
|
||||||
|
story = bundle.plan(data, version)
|
||||||
|
assert story["nodes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_guest_gets_the_adventure_and_owns_it(db, guest):
|
||||||
|
adventure = starter.give(db, guest)
|
||||||
|
db.commit()
|
||||||
|
assert adventure is not None
|
||||||
|
assert adventure.user_id == guest.id
|
||||||
|
assert "Pokemon" in adventure.title
|
||||||
|
assert len(adventure.actions) == len(payload()["actions"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_story_cards_come_with_it(db, guest):
|
||||||
|
"""A copy without the cards would drop out of character on the next turn."""
|
||||||
|
adventure = starter.give(db, guest)
|
||||||
|
db.commit()
|
||||||
|
assert {c.name for c in adventure.story_cards} == {
|
||||||
|
c["name"] for c in payload()["storyCards"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_turns_carry_what_the_engine_recorded(db, guest):
|
||||||
|
"""The point of shipping a played story: the summaries have chips in them.
|
||||||
|
|
||||||
|
An applied change, a refused one, and a milestone all appear in the first
|
||||||
|
two exchanges, which is what a visitor sees before spending a demo turn.
|
||||||
|
"""
|
||||||
|
adventure = starter.give(db, guest)
|
||||||
|
db.commit()
|
||||||
|
chips = [chip for action in adventure.actions for chip in action.world_changes]
|
||||||
|
kinds = {chip["kind"] for chip in chips}
|
||||||
|
assert "stat" in kinds
|
||||||
|
assert "rejected" in kinds
|
||||||
|
assert "milestone" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_state_left_behind_can_be_played_from(db, guest):
|
||||||
|
"""The opponent has to be on the field with HP, or every hit is refused.
|
||||||
|
|
||||||
|
A Pokemon sent in at 0 HP sits at the floor of `active_hp`, so the engine
|
||||||
|
refuses each later change and the story stops moving. That state reached a
|
||||||
|
playtest once, and it must not be what a guest inherits.
|
||||||
|
"""
|
||||||
|
adventure = starter.give(db, guest)
|
||||||
|
db.commit()
|
||||||
|
milo = adventure.world_state["npc"]["milo"]
|
||||||
|
assert milo["active_hp"] > 0
|
||||||
|
assert milo["active_pokemon"]
|
||||||
|
assert adventure.actions[-1].world_state_after["npc"]["milo"] == milo
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_file_costs_the_guest_nothing(db, guest, monkeypatch):
|
||||||
|
"""A packaging mistake must not stop an account from being created."""
|
||||||
|
monkeypatch.setattr(starter, "STARTER_FILE", starter.STARTER_FILE.with_name("gone.json"))
|
||||||
|
assert starter.give(db, guest) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_bundle_leaves_no_half_written_adventure(db, guest, monkeypatch):
|
||||||
|
"""The savepoint: a failure partway through discards the rows it wrote."""
|
||||||
|
monkeypatch.setattr(bundle, "materialize",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||||
|
assert starter.give(db, guest) is None
|
||||||
|
db.commit()
|
||||||
|
assert db.query(models.Adventure).count() == 0
|
||||||
Reference in New Issue
Block a user