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 sqlalchemy.orm import Session
from . import bundle, models from . import bundle, models, seed
logger = logging.getLogger(__name__) 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. # flush whatever part of the adventure the session still held.
with db.begin_nested(): with db.begin_nested():
story = bundle.plan(payload, bundle.check_format(payload)) 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: except Exception:
logger.exception("Could not give user %s the starter adventure.", user.id) logger.exception("Could not give user %s the starter adventure.", user.id)
return None 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", "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.", "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.", "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.", "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 import create_engine, event
from sqlalchemy.orm import sessionmaker 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.database import Base
from app.migrations import bootstrap 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 assert starter.give(db, guest) is None
db.commit() db.commit()
assert db.query(models.Adventure).count() == 0 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