Show the starter adventure the artwork of the demo it came from

An adventure has no cover art of its own and inherits its scenario's, and a
bundle carries no scenario id, because an id means nothing in another database.
The starter card therefore fell back to a monogram tile while the demo beside
it showed the Pokeball.

The starter file names its source under `scenarioTitle`, and the copy is linked
to the seeded scenario with that title. If no seed answers to the name, the
adventure keeps a NULL `scenario_id`, which is the state every imported bundle
is in and costs only the artwork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
This commit is contained in:
parththakkar106
2026-08-28 19:19:23 +05:30
co-authored by Claude Opus 5
parent ef4da7fda2
commit c2d3f0d8b9
3 changed files with 59 additions and 4 deletions
+25 -2
View File
@@ -23,7 +23,7 @@ from pathlib import Path
from sqlalchemy.orm import Session
from . import bundle, models
from . import bundle, models, seed
logger = logging.getLogger(__name__)
@@ -63,7 +63,30 @@ def give(db: Session, user: models.User) -> models.Adventure | None:
# 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)
adventure = bundle.materialize(db, payload, story, user.id)
_link_scenario(db, adventure, payload)
return adventure
except Exception:
logger.exception("Could not give user %s the starter adventure.", user.id)
return None
def _link_scenario(db: Session, adventure: models.Adventure, payload: dict) -> None:
"""Points the copy at the demo scenario it came from, when that seed exists.
A bundle does not carry a scenario id, because the id is local to one
database and an export outlives it. The link matters anyway: an adventure
has no cover art of its own, and inherits the scenario's. Without it the
starter shows a monogram tile while the demo it came from shows its own
artwork.
The name is read from `scenarioTitle` in the file. If no seeded scenario
answers to it, the adventure keeps a NULL `scenario_id`, which is the same
state an imported bundle is in and costs only the artwork.
"""
title = str(payload.get("scenarioTitle") or "").strip()
if not title:
return
scenario = seed.find_seeded(db, title)
if scenario is not None:
adventure.scenario_id = scenario.id
+2 -1
View File
@@ -1,6 +1,7 @@
{
"format": "ai-dnd-adventure-v2",
"title": "Pokemon League Championship: Round One",
"scenarioTitle": "[Demo] Pokemon League Championship: Round One",
"format": "ai-dnd-adventure-v2",
"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.",
+32 -1
View File
@@ -21,7 +21,7 @@ import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app import bundle, models, starter
from app import bundle, models, seed, starter
from app.database import Base
from app.migrations import bootstrap
@@ -126,3 +126,34 @@ def test_a_broken_bundle_leaves_no_half_written_adventure(db, guest, monkeypatch
assert starter.give(db, guest) is None
db.commit()
assert db.query(models.Adventure).count() == 0
def test_the_copy_inherits_the_demo_scenario_art(db, guest):
"""An adventure has no cover art of its own; it inherits the scenario's.
A bundle carries no scenario id, so the link is made by title. Without it
the starter card shows a monogram while the demo it came from shows its
artwork.
"""
title = payload()["scenarioTitle"]
data = json.loads((seed.SEED_DIR / "05-league-championship.json").read_text(encoding="utf-8"))
# The demo the copy came from, as the seeder would have written it. The
# seeder itself opens its own session against the app's engine, so it
# cannot be pointed at this fixture's database.
assert data["title"] == title, "the starter names a scenario no seed file ships"
db.add(models.Scenario(user_id=None, is_public=True, title=title, image=data["image"]))
db.commit()
adventure = starter.give(db, guest)
db.commit()
scenario = seed.find_seeded(db, title)
assert scenario is not None and scenario.image
assert adventure.scenario_id == scenario.id
def test_a_missing_demo_scenario_only_costs_the_art(db, guest):
"""Nothing seeds the scenarios in this fixture, so the link finds nothing."""
adventure = starter.give(db, guest)
db.commit()
assert adventure is not None
assert adventure.scenario_id is None