From c2d3f0d8b9ff565941c921e34525ddc40ba6a970 Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Fri, 28 Aug 2026 19:19:23 +0530 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF --- backend/app/starter.py | 27 ++++++++++++++-- backend/app/starter_data/pokemon-league.json | 3 +- backend/tests/test_starter_adventure.py | 33 +++++++++++++++++++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/backend/app/starter.py b/backend/app/starter.py index 577ddf4..3b123dd 100644 --- a/backend/app/starter.py +++ b/backend/app/starter.py @@ -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 diff --git a/backend/app/starter_data/pokemon-league.json b/backend/app/starter_data/pokemon-league.json index 12b513f..97399d3 100644 --- a/backend/app/starter_data/pokemon-league.json +++ b/backend/app/starter_data/pokemon-league.json @@ -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..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..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.", diff --git a/backend/tests/test_starter_adventure.py b/backend/tests/test_starter_adventure.py index 97dd741..38e478a 100644 --- a/backend/tests/test_starter_adventure.py +++ b/backend/tests/test_starter_adventure.py @@ -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