2026 10 12 battle powers and bag - xopherdeep/do-it-for-the-xp GitHub Wiki
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: The battle menu becomes Tackle | Defend, Powers | Goods, Run Away. Goods opens the real Equipment bag in a battle mode, Bottles/Goods open a potion sheet, and Powers casts in-game Powers through a shared effect table the Game Master picks from.
Architecture: Pure rule modules under src/lib/services/battle/ (BattleEffects, BattleItems, BattlePotions, BattlePowers) answer { ok, line, sound, needsTaskPick? } and never touch the screen. The bag reuses XpEquipmentModal with a mode="battle" prop. The two new sheets share one sheet shell. BattleField.ts routes taps to the rules and types the line. A single sheet ref replaces the menu booleans.
Tech Stack: Vue 3 + Ionic 7 + Pinia, TypeScript, Jest (@vue/test-utils), Cypress 8. Font Awesome Pro 5.15.4 duotone (fad).
Spec: docs/superpowers/specs/2026-10-12-battle-powers-and-bag-design.md (read it with this plan).
- Use
chemxfor reads, search and checks. Never run rawpnpm test,pnpm jest,pnpm typecheckorpnpm lint. To run one spec:chemx test <spec path>. Types:chemx typecheck. - Builders do not commit, because the tree carries other people's uncommitted edits. Lock every file you touch first with
chemx team lock. Touch only the files your task lists. The lead reviewsgit diff -- <owned files>and commits per task with explicit paths. - New components use
<script setup lang="ts">. Pages staydefineComponentwith a sibling.ts. - Sounds are
play$fxkeys fromsrc/assets/fx/index.ts:select,cursor,no,closeTask,restoreHP. - Kids read this: short lines, tap targets at least 48 px, no
alertController/ion-action-sheetin the arena. The Equipment bag is the oneion-modalallowed. - Copy is exact as written in this plan and the spec (apostrophes and periods included).
- Existing reason strings stay:
REASON_NO_MP = 'Not enough MP',REASON_USED = 'Already used this fight'.REASON_NOTHINGbecomes"Can't use that in a fight.". - Menu action ids stay
attack,defend,item,run-away;powersis new. E2E finds buttons by.menu-itemtext.
- The beast strikes while the bag or a sheet is open. The hero must see the hit: every sheet and the bag close, and anything in flight (Silver Arrow, Recall) is dropped with no MP charged. Test: Task 7, "a strike closes the bag and drops a Recall in flight".
- Recall cast, then the chore picker is cancelled. No MP is spent, Recall is not marked used, and the next tap starts fresh. Tests: Task 4, "Recall without a chore asks for one and charges nothing"; Task 7, "cancelling the chore pick after Recall charges nothing".
- Hardware back with the bag or a sheet open. It closes what is open and never opens the run confirm. Test: Task 7, "back closes the bag before it asks to run".
-
Class names stored in any case or spacing ("warrior", "White Mage", "white-mage"). They all map to the class weapon. Test: Task 7
battleMenu.spec, "tackleIcon ignores case and spacing". - Double tap on Use, Drink or Cast. It acts once: one use, one MP charge. Tests: Task 3, "Use emits once on a double tap"; Task 6, "a double tap drinks once" and "a double tap casts once".
| Wave | Tasks (parallel) | Board --needs
|
Model |
|---|---|---|---|
| 1 | Task 1, Task 3 | deep, standard | opus, sonnet |
| 2 | Task 2, Task 4 | standard, standard | sonnet, sonnet |
| 3 | Task 5, Task 6 | light, standard | haiku, sonnet |
| 4 | Task 7 | deep | opus |
| 5 | Task 8 | standard | sonnet |
The lead runs chemx verify at the end of each wave, reviews each task's diff, and commits each task separately.
Files:
- Create:
src/lib/services/battle/BattleEffects.ts - Modify:
src/lib/services/battle/BattleItems.ts - Modify:
src/types/abilities.ts(AbilitygetsbattleEffect?) - Modify:
src/app/Console/BattleField/BattleField.ts(only the twousedThisFightsites becomenew Set<BattleEffectId>(), plus the type import; Task 7 owns the rest) - Test:
tests/unit/BattleEffects.spec.ts(new),tests/unit/BattleItems.spec.ts(update)
Interfaces:
-
Produces (
BattleEffects.ts):type BattleEffectId = 'frost' | 'time-stop' | 'stun' | 'recall' | 'weaken' | 'kindle' | 'heal'-
const BATTLE_EFFECT_IDS: BattleEffectId[], in that order. -
interface BattleEffectRule { id: BattleEffectId; label: string; oncePerFight: boolean }andconst BATTLE_EFFECTS: Record<BattleEffectId, BattleEffectRule>. type BattleEffectEngine = Pick<BattleService, 'extendClock' | 'freezeClock' | 'skipNextStrike' | 'postponeTask' | 'weakenEnemy' | 'scaleCooldown' | 'getCurrentEnemy'>interface BattleEffectContext { userId: string; service: BattleEffectEngine; usedThisFight: Set<BattleEffectId>; taskId?: string }interface BattleEffectOutcome { ok: boolean; line: string; sound: string; needsTaskPick?: boolean }function isBattleEffectId(value: unknown): value is BattleEffectId-
function heroPoints(userId: string, kind: 'hp' | 'mp'): { now: number; max: number }(moved fromBattleItems.points). function battleEffectBlocked(effect: BattleEffectId, ctx: BattleEffectContext): string | nullfunction applyBattleEffect(effect: BattleEffectId, ctx: BattleEffectContext): BattleEffectOutcome- Constants moved here:
ICE_WAND_MS,CHRONOS_FREEZE_MS,SOL_FLARE_FACTOR,FIRE_WAND_FACTOR,REASON_NO_MP,REASON_USED,SOUND_ITEM,SOUND_HEAL,SOUND_NO. New:HEAL_SHARE = 0.25,REASON_HP_FULL = 'Your HP is full.'.
-
Produces (
BattleItems.ts):- Re-exports all the constants above.
BattleItemEngine = BattleEffectEngine,BattleItemContext = BattleEffectContext,BattleItemOutcome = BattleEffectOutcome.BattleItemCardkeeps its shape. -
BATTLE_ITEMS: Record<string, { mp: number; effect: BattleEffectId }>for Ice Wand frost 50, Chronos' Clock time-stop 100, Thor's Hammer stun 60, Silver Arrow recall 25, Sol's Flare weaken 100, Fire Wand kindle 50. Bottles is removed, and so isBOTTLES_HEAL_SHARE. -
type BattleBagRole = 'effect' | 'potions' | 'powers' | 'none'andfunction battleBagRole(item: BattleItemLike | null | undefined): BattleBagRole.bottlesandgoodsmap topotions,book of spellstopowers, both case-insensitive. -
describeBattleItem(item, ctx): BattleItemCard | nullanduseBattleItem(item, ctx): BattleItemOutcome, with the same names as today.
- Re-exports all the constants above.
-
Produces (
src/types/abilities.ts):Ability.battleEffect?: BattleEffectId | 'none', using a type-only import fromBattleEffects. -
Step 1: Write the failing effect tests in
tests/unit/BattleEffects.spec.ts. Reuse the mocks,mockEngine()andaddHero()fromBattleItems.spec.ts(copy the header).-
it("frost adds ten minutes and can be used again"):oktrue,service.extendClockcalled with600000, line"Ice creeps over the clock: +10 minutes!",usedThisFightdoes not contain'frost'. -
it("time-stop pauses fifteen minutes once per fight"):freezeClock(900000). A second call is refused withREASON_USED, andfreezeClockwas called once. -
it("stun names the beast"): line"Socktopus is stunned! It will miss its next strike." -
it("recall without a chore asks for one and changes nothing"):{ ok: true, needsTaskPick: true, line: "Which chore flies back to the board?" }, andpostponeTaskwas not called. -
it("recall with a chore sends it back"):postponeTask("t1"), line"The chore flies back to the board.". WhenpostponeTaskreturns null, it refuses with"The last chore cannot fly away." -
it("weaken halves the attack once per fight"):weakenEnemy(0.5), line"The sun blazes! Socktopus's attack is halved." -
it("kindle scales the clock once per fight"):scaleCooldown(1.5), line"The fuse burns slow: every chore earns 1.5x the minutes!" -
it("heal restores a quarter of max HP and refuses at full"): with HP 10/100, the outcome is +25 HP, line"+25 HP", soundSOUND_HEAL. At 100/100,battleEffectBlockedreturns"Your HP is full."and the HP is unchanged. -
it("a stopped clock refuses frost and time-stop"):extendClockreturns false, line"The clock is not running.", and the effect is not marked used. -
it("applyBattleEffect never touches MP"): MP is unchanged after every effect.
-
-
Step 2: Run
chemx test tests/unit/BattleEffects.spec.ts. Expected: FAIL (module not found). -
Step 3: Implement
BattleEffects.tsto the interfaces above. Labels, from the spec table:- frost:
"+10 minutes on the clock" - time-stop:
"Pauses the clock for 15 minutes" - stun:
"The beast skips its next strike" - recall:
"Sends one chore back to the board" - weaken:
"Halves the beast's attack" - kindle:
"Chores earn 1.5x the minutes" - heal:
"Heals 25% of max HP"
The heal gain is
max(1, min(round(max * 0.25), max - now)). On ok, add the effect tousedThisFightonly when it isoncePerFight. - frost:
-
Step 4: Run
chemx test tests/unit/BattleEffects.spec.ts. Expected: PASS. -
Step 5: Update
tests/unit/BattleItems.spec.ts:- Change
usedThisFighttonew Set<BattleEffectId>(). - Delete the Bottles heal block.
- Change "everything else" to expect
REASON_NOTHING === "Can't use that in a fight.". - Add these tests:
-
it("battleBagRole sorts the bag"): Ice Wand iseffect, Bottles ispotions, Goods ispotions, Book Of Spells ispowers, Lantern isnone, null isnone. -
it("a locked item says how to earn it"): the hero hasunlockedItems: [].describeBattleItem(HAMMER)givesreason === unlockHintOf("Thor's Hammer"). -
it("containers carry no reason and no MP"): Bottles gives{ mp: 0, effect: "Your potions", reason: null }, and Book Of Spells giveseffect: "Your Powers". -
it("Chronos' Clock and Time Stop share one use"): withusedThisFight={'time-stop'}, Chronos givesreason === REASON_USED. -
it("MP is charged only on success"): Silver Arrow withouttaskIdleaves MP unchanged; withtaskIdit costs 25.
-
- Change
-
Step 6: Run
chemx test tests/unit/BattleItems.spec.ts. Expected: FAIL. -
Step 7: Refactor
BattleItems.tsto callbattleEffectBlockedandapplyBattleEffect. MP is checked after the block reason and before the effect, and charged withupdateUserMPonly whenok && !needsTaskPick. Locked is checked withisItemUnlocked(user, name), and its reason isunlockHintOf(name). AddbattleEffect?: BattleEffectId | 'none'toAbility. -
Step 8: Run
chemx test tests/unit/BattleItems.spec.ts,chemx test tests/unit/BattleEffects.spec.tsandchemx typecheck. Expected: PASS, and no new type errors. Also runchemx test src/app/Console/BattleField/__tests__/BattleField.spec.ts; it must stay green (its Ice Wand and Silver Arrow test still drives the old hands sheet). -
Step 9: Report to the lead with
chemx team task done <id>. The lead commits:feat(battle): one effect table for items and spells; any unlocked bag item works in a fight
Files:
- Create:
src/lib/services/battle/BattlePotions.ts - Modify:
src/lib/store/stores/user.ts(one function, plus the return block) - Test:
tests/unit/BattlePotions.spec.ts(new)
Interfaces:
-
Consumes:
heroPoints,BattleEffectOutcome,SOUND_HEAL,SOUND_NOfromBattleEffects.ts;inventoryOffromsrc/lib/services/inventory/Inventory.ts. -
Produces:
-
interface PotionRule { hp?: number; mp?: number; full?: true; effect: string }andconst POTIONS: Record<string, PotionRule>:-
'Health Potion':{ hp: 50, effect: 'Restores 50 HP' } -
'Mana Potion':{ mp: 30, effect: 'Restores 30 MP' } -
Ether:{ mp: 50, effect: 'Restores 50 MP' } -
Elixir:{ full: true, effect: 'Fully restores HP and MP' }
-
interface PotionCard { id: string; name: string; qty: number; faIcon: string; effect: string; reason: string | null }-
function potionShelf(userId: string): PotionCard[]: pack entries whosenameis aPOTIONSkey, ininventoryOforder.faIcondefaults to'flask-potion'. function drinkPotion(userId: string, entryId: string): BattleEffectOutcome- User store:
spendInventoryItem(userId: string, entryId: string, qty = 1): boolean
-
-
Step 1: Write the failing tests in
tests/unit/BattlePotions.spec.ts. Use the same Pinia and ProfileDb mocks asBattleItems.spec.ts; the hero has HP 10/100 and MP 0/300.-
it("the shelf lists only potions, merged, with counts"): the inventory is[{name:"Health Potion"}, {name:"Health Potion"}, {name:"Small Key"}, {name:"Ether", qty: 2}]. The shelf is Ether x2, then Health Potion x2. No key. -
it("a Health Potion heals 50 and is spent"): line"You drink a Health Potion: +50 HP", soundrestoreHP, HP 60, shelf qty 1. -
it("an Ether restores 50 MP"): line"You drink an Ether: +50 MP". -
it("gains stop at max"): with HP 90/100, the Health Potion line is"+10 HP". -
it("full HP refuses a Health Potion and spends nothing"): line"Your HP is full.", soundno, qty unchanged. A Mana Potion at full MP gives"Your MP is full.". -
it("an Elixir fills both and refuses only when both are full"): line"You drink an Elixir: HP and MP fully restored!". Both full gives"You are already at full strength.". -
it("the shelf greys a potion that would do nothing"): at full HP, the Health Potion card hasreason === "Your HP is full.". -
it("an unknown or empty entry refuses"):drinkPotion(id, "nope")gives line"You don't have that." -
it("spendInventoryItem removes the last one"): from[{name:"Ether", qty: 1}], spending gives[]and returns true. From[{name:"Ether", quantity: 3}]the quantity becomes 2. An unknown id returns false.
-
-
Step 2: Run
chemx test tests/unit/BattlePotions.spec.ts. Expected: FAIL. -
Step 3: Implement
spendInventoryItemnext toaddInventoryIteminuser.ts, with the same sync and persist calls. It matches rows bytoEntry(row)?.id. Then implementBattlePotions.ts. The article isanbefore a vowel. Nothing is spent on a refusal. -
Step 4: Run
chemx test tests/unit/BattlePotions.spec.tsandchemx typecheck. Expected: PASS. -
Step 5: Report done. The lead commits:
feat(battle): potions from the pack heal and restore in a fight
Files:
- Modify:
src/app/Console/MyPortal/UserHud/components/XpEquipmentModal.vue - Modify:
src/app/Console/MyPortal/UserHud/components/equipment/ItemInfoModal.vue - Modify:
src/app/Console/MyPortal/UserHud/components/equipment/EquippedItemsCard.vue - Modify:
src/app/Console/MyPortal/UserHud/components/equipment/EquipmentGrid.vue - Test:
tests/unit/XpEquipmentModalBattle.spec.ts(new)
Interfaces:
-
Consumes: the
BattleItemCardtype fromBattleItems.ts(today's shape:name, faIcon, mp, effect, reason). -
Produces:
-
XpEquipmentModalgets these new props:-
mode: 'equip' | 'battle'(default'equip') -
battleCard: (item: EquipmentItem) => BattleItemCard | null(defaultnull) -
opensDirectly: (item: EquipmentItem) => boolean(default() => false)
It also gets a new emit,
use-item(item: EquipmentItem). -
-
ItemInfoModalgets these new props:-
mode: 'equip' | 'battle'(default'equip') -
battleCard: BattleItemCard | null(defaultnull)
It also gets a new emit,
use. -
-
EquippedItemsCardgets a new prop,readonly: boolean(defaultfalse). -
EquipmentGridgets a new prop,noDrag: boolean(defaultfalse).
-
-
Step 1: Write the failing tests. Mount
XpEquipmentModalwithisOpentrue. Stubion-modal/ion-*the waysrc/app/Console/BattleField/__tests__/BattleField.spec.tsmocks@ionic/vue. Pass a fixedbattleCardmock.-
it("battle mode: a fight item's card shows MP and effect, and Use emits use-item"): tap the Ice Wand tile.[data-cy=item-use]is enabled, the card text contains"50 MP"and"+10 minutes on the clock", and a click emitsuse-itemwith the Ice Wand. -
it("battle mode: a card with a reason disables Use and shows why"): the mock returnsreason: "Not enough MP". Use is disabled, and[data-cy=item-reason]contains it. -
it("battle mode: Bottles goes straight to use-item without a card"):opensDirectlyis true for Bottles. Tapping the tile emitsuse-item, andItemInfoModalis not open. -
it("battle mode: the dock is read-only"): there is no change-class button, and a dockequipevent does not calluserStoreequip. -
it("Use emits once on a double tap"): two clicks give oneuse-item. -
it("equip mode is unchanged"): there is no[data-cy=item-use], and the hand slot buttons are present.
-
-
Step 2: Run
chemx test tests/unit/XpEquipmentModalBattle.spec.ts. Expected: FAIL. -
Step 3: Implement.
-
In battle mode, the grid passes
noDrag, and the dock passesreadonly, which hides the change-class button and ignores drops. -
A tile tap first checks
opensDirectly(item) && !locked, and if so emitsuse-item. Otherwise it opensItemInfoModal. -
ItemInfoModalin battle mode replaces theslot-selection-rowwith:- the effect line
- an MP pill when
mp > 0 - the
[data-cy=item-reason]line when there is a reason - a
[data-cy=item-use]button readingUse, at least 48 px, disabled when there is a reason, with a busy latch that resets whenitemchanges
Use emits
use. The bag then emitsuse-item(selectedItem)and closes the info card.
-
-
Step 4: Run
chemx test tests/unit/XpEquipmentModalBattle.spec.tsandchemx typecheck. Expected: PASS. Also run the existing specs that mount these components, if any:chemx q XpEquipmentModallists them. They must stay green. -
Step 5: Report done. The lead commits:
feat(bag): battle mode for the Equipment bag: Use instead of Equip, read-only hands
Files:
- Create:
src/lib/services/battle/BattlePowers.ts - Modify:
src/lib/services/abilities/AbilityPresets.ts - Test:
tests/unit/BattlePowers.spec.ts(new)
Interfaces:
-
Consumes: from Task 1,
BattleEffectId,BattleEffectContext,BattleEffectOutcome,BATTLE_EFFECTS,BATTLE_EFFECT_IDS,isBattleEffectId,battleEffectBlocked,applyBattleEffect,heroPointsandREASON_NO_MP. AlsoheroMpBill(StatEffects.ts), andcomputeHeroAbilityStatuses(heroAbilityStatus.ts). -
Produces:
-
const PRESET_BATTLE_EFFECTS: Record<string, BattleEffectId>: Haste kindle, Slow frost, Stop time-stop, Rewind recall, Debug weaken, Overclock stun, System Restore heal. -
function battleEffectOf(ability: Pick<Ability, 'name' | 'battleEffect'>): BattleEffectId | null. The field wins, so'none'gives null. A missing field falls back to the preset by exact name. Garbage gives null. -
const BATTLE_EFFECT_OPTIONS: { value: BattleEffectId | 'none'; label: string }[]:{none, "None"}first, then the seven inBATTLE_EFFECT_IDSorder, each with itsBATTLE_EFFECTSlabel. -
interface BattlePowerCard { mp: number; effect: string; reason: string | null }andinterface BattlePowerEntry { ability: Ability; card: BattlePowerCard }. function battlePowers(abilities: Ability[], ctx: BattleEffectContext, now?: Date): BattlePowerEntry[]function castBattlePower(ability: Ability, ctx: BattleEffectContext): BattleEffectOutcome
-
-
Step 1: Write the failing tests. The hero "Max" (warrior, MP 100) uses the existing
addHeropattern. Abilities are built withAbilityPresets'createAbilitypartial helper.-
it("battleEffectOf: field, preset fallback, none, garbage"):{name:"Stop"}givestime-stop.{name:"Stop", battleEffect:"none"}gives null.{name:"Mine", battleEffect:"stun"}givesstun.{name:"Quick Save"}gives null.{name:"X", battleEffect:"lava"}gives null. -
it("lists only unlocked in-game Powers with a fight effect"): from Stop (in-game, 0 AP), Night Owl (real-life), Quick Save (in-game, no effect) and Rewind (in-game, needs 999 AP), only Stop is listed. -
it("the card carries the class-discounted MP and the effect label"):card.mp === heroMpBill(25, hero)andcard.effect === "Pauses the clock for 15 minutes". -
it("the card says why it can't be cast"): with MP 0 the reason is"Not enough MP". WithusedThisFight={'time-stop'}it is"Already used this fight". -
it("a cast names the caster, runs the effect and charges MP"): Stop gives line"Max casts Stop! Time stands still for 15 minutes!",freezeClockis called, and MP drops bycard.mp. -
it("Recall without a chore asks for one and charges nothing"): line"Which chore flies back to the board?",needsTaskPicktrue, MP unchanged. ThentaskIdcharges the MP. -
it("a refused cast charges nothing"): when the clock is stopped, MP is unchanged. -
it("never calls AbilityService.useAbility"):jest.mock@/lib/services/abilities/AbilityServiceand expectuseAbilitynot to have been called. -
it("every in-game preset carries its pick"): eachAbilityType.InGamepreset hasbattleEffectequal toPRESET_BATTLE_EFFECTS[name] ?? 'none'.
-
-
Step 2: Run
chemx test tests/unit/BattlePowers.spec.ts. Expected: FAIL. -
Step 3: Implement
BattlePowers.tsand addbattleEffectto the nine in-game presets. Quick Save and Quantum Computing get'none'. The caster's name isname.nick || name.first || "You". "Unlocked" meanscomputeHeroAbilityStatuses(abilities, hero, [], now).statuses[id] !== 'locked'. -
Step 4: Run
chemx test tests/unit/BattlePowers.spec.ts,chemx test tests/unit/AbilityService.spec.tsandchemx typecheck. Expected: PASS. -
Step 5: Report done. The lead commits:
feat(powers): in-game Powers cast in a fight through the shared effect table
Files:
- Modify:
src/app/Admin/XpCompendium/views/XpAbilities/components/XpCreateUpdateAbility.vue - Test:
tests/unit/XpCreateUpdateAbilityBattle.spec.ts(new)
Interfaces:
-
Consumes:
BATTLE_EFFECT_OPTIONSandbattleEffectOffrom Task 4. -
Step 1: Write the failing tests. Mount the form with
@ionic/vuemocked the wayBattleField.spec.tsdoes it.-
it("shows In battle only for in-game Powers"):[data-cy=ability-battle-effect]is present when the type is in-game and absent when it is real-life. -
it("starts on the preset pick and saves the GM's choice"): editing{name:"Stop", type: in-game}without the field showstime-stop. Choosing None makes the saved ability carrybattleEffect: 'none'.
-
-
Step 2: Run
chemx test tests/unit/XpCreateUpdateAbilityBattle.spec.ts. Expected: FAIL. -
Step 3: Implement. Add an
ion-itemwithdata-cy="ability-battle-effect", labelledIn battle, inside the existingv-if="ability.type === AbilityType.InGame"list. Itsion-select interface="popover"takes its options fromBATTLE_EFFECT_OPTIONS. Initial value:battleEffectOf(ability) ?? 'none', written back toability.battleEffecton change. -
Step 4: Run
chemx test tests/unit/XpCreateUpdateAbilityBattle.spec.ts. Expected: PASS. -
Step 5: Report done. The lead commits:
feat(compendium): the GM picks what an in-game Power does in battle
Files:
- Create:
src/app/Console/BattleField/components/BattleSheet.vue(the shell) - Create:
src/app/Console/BattleField/components/BattlePotionSheet.vue - Create:
src/app/Console/BattleField/components/BattlePowersSheet.vue - Modify:
src/app/Console/MyPortal/UserHud/MyAbilities/components/XpPowerCard.vue - Test:
src/app/Console/BattleField/__tests__/BattlePotionSheet.spec.ts,src/app/Console/BattleField/__tests__/BattlePowersSheet.spec.ts,tests/unit/XpPowerCard.spec.ts(add)
Interfaces:
-
Consumes:
PotionCard(Task 2);BattlePowerEntryandBattlePowerCard(Task 4);HeroAbilitySnapshot(heroAbilityStatus.ts). -
Produces:
-
BattleSheettakes props{ title: string; faIcon: string; subtitle: string; busy: boolean; cy: string }. It has a default slot (the list) and emitsback. The backdrop and the Back button (data-cy="${cy}-back", playscloseTask) both emitback. The markup and styles move fromBattleItemMenu.vue(sheet, backdrop, container, header, footer,.rpg-button). -
BattlePotionSheettakes props{ potions: PotionCard[] }and emitscancelanddrink(id: string). Itsdata-cyispotion-sheet, and each row ispotion-<id>. -
BattlePowersSheettakes props{ powers: BattlePowerEntry[]; snapshot: HeroAbilitySnapshot }and emitscancelandcast(abilityId: string). Itsdata-cyispowers-sheet. -
XpPowerCardgets a new optional prop,battle?: BattlePowerCard.
-
-
Step 1: Write the failing tests.
- Potion sheet:
-
it("lists potions with counts and greys the useless ones"): a row shows"Health Potion","x2"and"Restores 50 HP". A row with a reason is disabled and shows the reason. -
it("shows the empty line"):"Your bottles are empty. Temple shops brew potions." -
it("a double tap drinks once"): two clicks give onedrink. -
it("Back cancels").
-
- Powers sheet:
it("renders a battle XpPowerCard per Power")-
it("shows the empty line"):"No battle Powers yet. Earn AP to unlock them." it("a double tap casts once")
- XpPowerCard:
-
it("battle prop hides the AP meter and shows MP and effect"): there is no.power-ap, and.power-costis"25 MP · Pauses the clock for 15 minutes". -
it("battle reason disables Cast"):.power-stateis"Not enough MP", and the button is disabled.
-
- Potion sheet:
-
Step 2: Run the three specs with
chemx test. Expected: FAIL. -
Step 3: Implement. Sheet titles and subtitles:
- Potions: title
Bottles, iconflask-potion, subtitleDrinking one keeps your move. - Powers: title
Powers, iconbook-spells, subtitleCasting one keeps your move.
BattlePowersSheetpassesstatus="available"and:unlocked="true"to each card, withbattleset to the card.XpPowerCardwithbattleset:- hides
.power-ap - sets
costLineto${mp} MP · ${effect} - sets
stateLinetoreason ?? "" - sets
canCastto!reason && !casting
- Potions: title
-
Step 4: Run the three specs and
chemx typecheck. Expected: PASS. -
Step 5: Report done. The lead commits:
feat(battle): potion and Powers sheets on one sheet shell
Files:
- Modify:
src/app/Console/BattleField/battle/battleMenu.ts - Modify:
src/app/Console/BattleField/BattleField.ts - Modify:
src/app/Console/BattleField/BattleField.vue - Modify:
src/app/Console/BattleField/_BattleField.scss - Modify:
src/app/Console/BattleField/components/XpBattleTaskMenu.vue(title only) - Delete:
src/app/Console/BattleField/components/BattleItemMenu.vue,src/app/Console/BattleField/__tests__/BattleItemMenu.spec.ts - Test:
tests/unit/battleMenu.spec.ts(new),src/app/Console/BattleField/__tests__/BattleField.spec.ts,src/app/Console/BattleField/__tests__/XpBattleTaskMenu.spec.ts
Interfaces:
-
Consumes: everything from Tasks 1-6, plus
getAbilityService().getAllAbilities()andheroAbilitySnapshot. -
Produces (
battleMenu.ts):-
const CLASS_WEAPON_ICONS: Record<string, string>, keyed byclassKey(name):- warrior axe, knight sword, berserker axe-battle, paladin mace
- mage staff, whitemage hand-holding-magic, blackmage wand-magic, sage book-spells
- thief dagger, assassin swords, ranger bow-arrow, ninja sickle
- monk fist-raised, martialartist hand-rock, mystic hand-sparkles
const DEFAULT_TACKLE_ICON = 'fist-raised'function tackleIcon(jobClass: unknown): string
-
-
Step 1: Write the failing tests.
-
battleMenu.spec:it("every class in JOB_CLASS_OPTIONS has a weapon")-
it("tackleIcon ignores case and spacing"):"warrior","White Mage"and"white-mage"all map to their icons. it("no class or an unknown class tackles with a fist")
-
BattleField.spec:- Update
"Item shows the hands..."into"Goods opens the bag; the Ice Wand buys ten minutes and the Silver Arrow sends a chore back unfinished". StubXpEquipmentModal, and drive it by emittinguse-itemwith the stored Ice Wand and Silver Arrow. - Add these tests:
-
it("the menu reads Tackle, Defend, Powers, Goods, Run Away"): the.menu-itemtexts are in that order, and Tackle's icon class hasfa-axefor the warrior fixture. it("Bottles in the bag opens the potion sheet and a drink heals")-
it("Powers opens the sheet and a cast freezes the clock"):getAllAbilitiesis mocked with Stop. it("cancelling the chore pick after Recall charges nothing")it("a strike closes the bag and drops a Recall in flight")it("back closes the bag before it asks to run")
-
- Update
-
XpBattleTaskMenu.spec: the title is"Tackle!".
-
-
Step 2: Run the three specs. Expected: FAIL.
-
Step 3: Implement.
-
Replace
showTaskMenu/showItemMenuwithconst sheet = ref<'task' | 'bag' | 'potions' | 'powers' | null>(null).showMenu,closeSheet(which also clearsarrowInFlightandpowerInFlight) and the back handler all read it. -
Menu order: attack (
label: "Tackle",faIcon: tackleIcon(hero.jobClass)), defend, powers ("Powers",book-spells), item ("Goods",backpack), run-away (fullWidth). No row is full width except Run Away. -
Render
XpEquipmentModalwith these props::is-open="sheet === 'bag'"mode="battle"-
:battle-card=describeBattleItem -
:opens-directly= role is potions or powers -
equipmentanduserpassed the wayTempleGrounds.tspasses them
Closing the modal sets
sheetto null. -
use-itemrouting:-
potionsopenssheet = 'potions' -
powersopenssheet = 'powers' -
effectrunsuseBattleItem; onneedsTaskPickit setsarrowInFlightand opens the chore pick (sheet = 'task',taskMenuMode = 'postpone')
-
-
Powers load once in
beginBattle(a failure gives[]).castPower(id)follows the same pattern withpowerInFlight, andpostponeTaskserves both in-flight kinds. -
Every outcome goes through
sayItemResult.usedThisFightis aSet<BattleEffectId>, reset per fight. -
Delete
BattleItemMenu.vueand its spec. -
Change the
XpBattleTaskMenuheader from"Attack!"to"Tackle!".
-
-
Step 4: Run the three specs,
chemx typecheckandchemx lint. Expected: PASS, with no lint errors. -
Step 5: Report done. The lead runs
chemx verifyand commits:feat(battle): Tackle | Defend, Powers | Goods, Run Away; the bag, potions and Powers in a fight
Files:
-
Modify:
docs/Battle-Field-Contract-v2.md(docs submodule) -
Modify:
tests/e2e/fixtures/battle-backup.json -
Modify:
tests/e2e/specs/battle.spec.js -
Step 1: Update the fixture. The hero (Max) gets:
inventory: [{ "name": "Health Potion", "kind": "consumable", "qty": 2 }]- HP below max (for example 20/30)
- MP of 300
-
Ice WandinunlockedItems, if the fixture has that list
The backup's abilities gain one in-game Power:
Stop, 0 AP,mpCost25,battleEffect"time-stop". -
Step 2: Update
battle.spec.js.-
.menu-itemhas length 5, and"Attack"becomes"Tackle". - Add the test
it("Goods: the Ice Wand buys time and a potion heals"):- Goods opens
.xp-equipment-modal. - Tap the Ice Wand tile, then
[data-cy=item-use]; the dialog contains"+10 minutes". - Goods again, tap the Bottles tile;
[data-cy=potion-sheet]is visible. - Tap
[data-cy^=potion-]; the dialog contains"You drink a Health Potion".
- Goods opens
- Add the test
it("Powers: Stop freezes the clock"):- Powers opens
[data-cy=powers-sheet]. - Cast; the dialog contains
"casts Stop!".
- Powers opens
Screenshot each sheet (
10-bag.png,11-potions.png,12-powers.png). -
-
Step 3: Run against the owner's dev server on :8100. Check it is up first with
lsof -ti:8100; if it is not up, report and stop, and don't start it.pnpm vue-cli-service test:e2e --headless --url http://localhost:8100 --spec tests/e2e/specs/battle.spec.jsExpected: all tests pass, and the screenshots land intests/e2e/screenshots/battle.spec.js/. -
Step 4: Amend the contract.
- Line 16 adds:
except the Equipment bag (XpEquipmentModal mode="battle"), the same bag as everywhere else. - Lines 31 and 65 follow the new menu and the
"Tackle!"title. - Add
## H. Goods and Powers: a summary of spec sections 1-7 with thedata-cyhooks (item-use,item-reason,potion-sheet,potion-<id>,powers-sheet,ability-battle-effect).
- Line 16 adds:
-
Step 5: Report done with the screenshot paths. The lead reviews the screenshots and commits the e2e/fixture in the main repo and the contract in the docs submodule:
test(e2e): Goods and Powers in battle/docs(battle): contract H, Goods and Powers