2026 10 12 battle powers and bag - xopherdeep/do-it-for-the-xp GitHub Wiki

Battle: Tackle, Powers and the Goods bag Implementation Plan

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).

Global Constraints

  • Use chemx for reads, search and checks. Never run raw pnpm test, pnpm jest, pnpm typecheck or pnpm 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 reviews git diff -- <owned files> and commits per task with explicit paths.
  • New components use <script setup lang="ts">. Pages stay defineComponent with a sibling .ts.
  • Sounds are play$fx keys from src/assets/fx/index.ts: select, cursor, no, closeTask, restoreHP.
  • Kids read this: short lines, tap targets at least 48 px, no alertController / ion-action-sheet in the arena. The Equipment bag is the one ion-modal allowed.
  • 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_NOTHING becomes "Can't use that in a fight.".
  • Menu action ids stay attack, defend, item, run-away; powers is new. E2E finds buttons by .menu-item text.

Review Focus

  1. 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".
  2. 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".
  3. 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".
  4. 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".
  5. 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".

Waves and board mapping

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.


Task 1: Shared effect table and the BattleItems refactor (W1, deep)

Files:

  • Create: src/lib/services/battle/BattleEffects.ts
  • Modify: src/lib/services/battle/BattleItems.ts
  • Modify: src/types/abilities.ts (Ability gets battleEffect?)
  • Modify: src/app/Console/BattleField/BattleField.ts (only the two usedThisFight sites become new 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 } and const 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 from BattleItems.points).
    • function battleEffectBlocked(effect: BattleEffectId, ctx: BattleEffectContext): string | null
    • function 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. BattleItemCard keeps 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 is BOTTLES_HEAL_SHARE.
    • type BattleBagRole = 'effect' | 'potions' | 'powers' | 'none' and function battleBagRole(item: BattleItemLike | null | undefined): BattleBagRole. bottles and goods map to potions, book of spells to powers, both case-insensitive.
    • describeBattleItem(item, ctx): BattleItemCard | null and useBattleItem(item, ctx): BattleItemOutcome, with the same names as today.
  • Produces (src/types/abilities.ts): Ability.battleEffect?: BattleEffectId | 'none', using a type-only import from BattleEffects.

  • Step 1: Write the failing effect tests in tests/unit/BattleEffects.spec.ts. Reuse the mocks, mockEngine() and addHero() from BattleItems.spec.ts (copy the header).

    • it("frost adds ten minutes and can be used again"): ok true, service.extendClock called with 600000, line "Ice creeps over the clock: +10 minutes!", usedThisFight does not contain 'frost'.
    • it("time-stop pauses fifteen minutes once per fight"): freezeClock(900000). A second call is refused with REASON_USED, and freezeClock was 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?" }, and postponeTask was not called.
    • it("recall with a chore sends it back"): postponeTask("t1"), line "The chore flies back to the board.". When postponeTask returns 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", sound SOUND_HEAL. At 100/100, battleEffectBlocked returns "Your HP is full." and the HP is unchanged.
    • it("a stopped clock refuses frost and time-stop"): extendClock returns 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.ts to 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 to usedThisFight only when it is oncePerFight.

  • Step 4: Run chemx test tests/unit/BattleEffects.spec.ts. Expected: PASS.

  • Step 5: Update tests/unit/BattleItems.spec.ts:

    • Change usedThisFight to new 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 is effect, Bottles is potions, Goods is potions, Book Of Spells is powers, Lantern is none, null is none.
      • it("a locked item says how to earn it"): the hero has unlockedItems: []. describeBattleItem(HAMMER) gives reason === 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 gives effect: "Your Powers".
      • it("Chronos' Clock and Time Stop share one use"): with usedThisFight = {'time-stop'}, Chronos gives reason === REASON_USED.
      • it("MP is charged only on success"): Silver Arrow without taskId leaves MP unchanged; with taskId it costs 25.
  • Step 6: Run chemx test tests/unit/BattleItems.spec.ts. Expected: FAIL.

  • Step 7: Refactor BattleItems.ts to call battleEffectBlocked and applyBattleEffect. MP is checked after the block reason and before the effect, and charged with updateUserMP only when ok && !needsTaskPick. Locked is checked with isItemUnlocked(user, name), and its reason is unlockHintOf(name). Add battleEffect?: BattleEffectId | 'none' to Ability.

  • Step 8: Run chemx test tests/unit/BattleItems.spec.ts, chemx test tests/unit/BattleEffects.spec.ts and chemx typecheck. Expected: PASS, and no new type errors. Also run chemx 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

Task 2: Potions and spending from the pack (W2, standard)

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_NO from BattleEffects.ts; inventoryOf from src/lib/services/inventory/Inventory.ts.

  • Produces:

    • interface PotionRule { hp?: number; mp?: number; full?: true; effect: string } and const 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 whose name is a POTIONS key, in inventoryOf order. faIcon defaults 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 as BattleItems.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", sound restoreHP, 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.", sound no, 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 has reason === "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 spendInventoryItem next to addInventoryItem in user.ts, with the same sync and persist calls. It matches rows by toEntry(row)?.id. Then implement BattlePotions.ts. The article is an before a vowel. Nothing is spent on a refusal.

  • Step 4: Run chemx test tests/unit/BattlePotions.spec.ts and chemx typecheck. Expected: PASS.

  • Step 5: Report done. The lead commits: feat(battle): potions from the pack heal and restore in a fight

Task 3: The Equipment bag's battle mode (W3, standard)

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 BattleItemCard type from BattleItems.ts (today's shape: name, faIcon, mp, effect, reason).

  • Produces:

    • XpEquipmentModal gets these new props:

      • mode: 'equip' | 'battle' (default 'equip')
      • battleCard: (item: EquipmentItem) => BattleItemCard | null (default null)
      • opensDirectly: (item: EquipmentItem) => boolean (default () => false)

      It also gets a new emit, use-item(item: EquipmentItem).

    • ItemInfoModal gets these new props:

      • mode: 'equip' | 'battle' (default 'equip')
      • battleCard: BattleItemCard | null (default null)

      It also gets a new emit, use.

    • EquippedItemsCard gets a new prop, readonly: boolean (default false).

    • EquipmentGrid gets a new prop, noDrag: boolean (default false).

  • Step 1: Write the failing tests. Mount XpEquipmentModal with isOpen true. Stub ion-modal / ion-* the way src/app/Console/BattleField/__tests__/BattleField.spec.ts mocks @ionic/vue. Pass a fixed battleCard mock.

    • 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 emits use-item with the Ice Wand.
    • it("battle mode: a card with a reason disables Use and shows why"): the mock returns reason: "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"): opensDirectly is true for Bottles. Tapping the tile emits use-item, and ItemInfoModal is not open.
    • it("battle mode: the dock is read-only"): there is no change-class button, and a dock equip event does not call userStore equip.
    • it("Use emits once on a double tap"): two clicks give one use-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 passes readonly, which hides the change-class button and ignores drops.

    • A tile tap first checks opensDirectly(item) && !locked, and if so emits use-item. Otherwise it opens ItemInfoModal.

    • ItemInfoModal in battle mode replaces the slot-selection-row with:

      • 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 reading Use, at least 48 px, disabled when there is a reason, with a busy latch that resets when item changes

      Use emits use. The bag then emits use-item(selectedItem) and closes the info card.

  • Step 4: Run chemx test tests/unit/XpEquipmentModalBattle.spec.ts and chemx typecheck. Expected: PASS. Also run the existing specs that mount these components, if any: chemx q XpEquipmentModal lists 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

Task 4: Powers in a fight: rules and preset picks (W4, standard)

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, heroPoints and REASON_NO_MP. Also heroMpBill (StatEffects.ts), and computeHeroAbilityStatuses (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 in BATTLE_EFFECT_IDS order, each with its BATTLE_EFFECTS label.
    • interface BattlePowerCard { mp: number; effect: string; reason: string | null } and interface 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 addHero pattern. Abilities are built with AbilityPresets' createAbility partial helper.

    • it("battleEffectOf: field, preset fallback, none, garbage"): {name:"Stop"} gives time-stop. {name:"Stop", battleEffect:"none"} gives null. {name:"Mine", battleEffect:"stun"} gives stun. {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) and card.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". With usedThisFight = {'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!", freezeClock is called, and MP drops by card.mp.
    • it("Recall without a chore asks for one and charges nothing"): line "Which chore flies back to the board?", needsTaskPick true, MP unchanged. Then taskId charges 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/AbilityService and expect useAbility not to have been called.
    • it("every in-game preset carries its pick"): each AbilityType.InGame preset has battleEffect equal to PRESET_BATTLE_EFFECTS[name] ?? 'none'.
  • Step 2: Run chemx test tests/unit/BattlePowers.spec.ts. Expected: FAIL.

  • Step 3: Implement BattlePowers.ts and add battleEffect to the nine in-game presets. Quick Save and Quantum Computing get 'none'. The caster's name is name.nick || name.first || "You". "Unlocked" means computeHeroAbilityStatuses(abilities, hero, [], now).statuses[id] !== 'locked'.

  • Step 4: Run chemx test tests/unit/BattlePowers.spec.ts, chemx test tests/unit/AbilityService.spec.ts and chemx typecheck. Expected: PASS.

  • Step 5: Report done. The lead commits: feat(powers): in-game Powers cast in a fight through the shared effect table

Task 5: Compendium "In battle" field (W5, light)

Files:

  • Modify: src/app/Admin/XpCompendium/views/XpAbilities/components/XpCreateUpdateAbility.vue
  • Test: tests/unit/XpCreateUpdateAbilityBattle.spec.ts (new)

Interfaces:

  • Consumes: BATTLE_EFFECT_OPTIONS and battleEffectOf from Task 4.

  • Step 1: Write the failing tests. Mount the form with @ionic/vue mocked the way BattleField.spec.ts does 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 shows time-stop. Choosing None makes the saved ability carry battleEffect: 'none'.
  • Step 2: Run chemx test tests/unit/XpCreateUpdateAbilityBattle.spec.ts. Expected: FAIL.

  • Step 3: Implement. Add an ion-item with data-cy="ability-battle-effect", labelled In battle, inside the existing v-if="ability.type === AbilityType.InGame" list. Its ion-select interface="popover" takes its options from BATTLE_EFFECT_OPTIONS. Initial value: battleEffectOf(ability) ?? 'none', written back to ability.battleEffect on 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

Task 6: Potion and Powers sheets, and XpPowerCard's battle prop (W6, standard)

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); BattlePowerEntry and BattlePowerCard (Task 4); HeroAbilitySnapshot (heroAbilityStatus.ts).

  • Produces:

    • BattleSheet takes props { title: string; faIcon: string; subtitle: string; busy: boolean; cy: string }. It has a default slot (the list) and emits back. The backdrop and the Back button (data-cy="${cy}-back", plays closeTask) both emit back. The markup and styles move from BattleItemMenu.vue (sheet, backdrop, container, header, footer, .rpg-button).
    • BattlePotionSheet takes props { potions: PotionCard[] } and emits cancel and drink(id: string). Its data-cy is potion-sheet, and each row is potion-<id>.
    • BattlePowersSheet takes props { powers: BattlePowerEntry[]; snapshot: HeroAbilitySnapshot } and emits cancel and cast(abilityId: string). Its data-cy is powers-sheet.
    • XpPowerCard gets 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 one drink.
      • 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-cost is "25 MP · Pauses the clock for 15 minutes".
      • it("battle reason disables Cast"): .power-state is "Not enough MP", and the button is disabled.
  • Step 2: Run the three specs with chemx test. Expected: FAIL.

  • Step 3: Implement. Sheet titles and subtitles:

    • Potions: title Bottles, icon flask-potion, subtitle Drinking one keeps your move.
    • Powers: title Powers, icon book-spells, subtitle Casting one keeps your move.

    BattlePowersSheet passes status="available" and :unlocked="true" to each card, with battle set to the card. XpPowerCard with battle set:

    • hides .power-ap
    • sets costLine to ${mp} MP · ${effect}
    • sets stateLine to reason ?? ""
    • sets canCast to !reason && !casting
  • 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

Task 7: Page wiring: menu, sheets and flows (W7, deep)

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() and heroAbilitySnapshot.

  • Produces (battleMenu.ts):

    • const CLASS_WEAPON_ICONS: Record<string, string>, keyed by classKey(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". Stub XpEquipmentModal, and drive it by emitting use-item with the stored Ice Wand and Silver Arrow.
      • Add these tests:
        • it("the menu reads Tackle, Defend, Powers, Goods, Run Away"): the .menu-item texts are in that order, and Tackle's icon class has fa-axe for 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"): getAllAbilities is 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")
    • XpBattleTaskMenu.spec: the title is "Tackle!".
  • Step 2: Run the three specs. Expected: FAIL.

  • Step 3: Implement.

    • Replace showTaskMenu / showItemMenu with const sheet = ref<'task' | 'bag' | 'potions' | 'powers' | null>(null). showMenu, closeSheet (which also clears arrowInFlight and powerInFlight) 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 XpEquipmentModal with these props:

      • :is-open="sheet === 'bag'"
      • mode="battle"
      • :battle-card = describeBattleItem
      • :opens-directly = role is potions or powers
      • equipment and user passed the way TempleGrounds.ts passes them

      Closing the modal sets sheet to null.

    • use-item routing:

      • potions opens sheet = 'potions'
      • powers opens sheet = 'powers'
      • effect runs useBattleItem; on needsTaskPick it sets arrowInFlight and opens the chore pick (sheet = 'task', taskMenuMode = 'postpone')
    • Powers load once in beginBattle (a failure gives []). castPower(id) follows the same pattern with powerInFlight, and postponeTask serves both in-flight kinds.

    • Every outcome goes through sayItemResult. usedThisFight is a Set<BattleEffectId>, reset per fight.

    • Delete BattleItemMenu.vue and its spec.

    • Change the XpBattleTaskMenu header from "Attack!" to "Tackle!".

  • Step 4: Run the three specs, chemx typecheck and chemx lint. Expected: PASS, with no lint errors.

  • Step 5: Report done. The lead runs chemx verify and commits: feat(battle): Tackle | Defend, Powers | Goods, Run Away; the bag, potions and Powers in a fight

Task 8: Contract, fixture and e2e (W8, standard)

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 Wand in unlockedItems, if the fixture has that list

    The backup's abilities gain one in-game Power: Stop, 0 AP, mpCost 25, battleEffect "time-stop".

  • Step 2: Update battle.spec.js.

    • .menu-item has length 5, and "Attack" becomes "Tackle".
    • Add the test it("Goods: the Ice Wand buys time and a potion heals"):
      1. Goods opens .xp-equipment-modal.
      2. Tap the Ice Wand tile, then [data-cy=item-use]; the dialog contains "+10 minutes".
      3. Goods again, tap the Bottles tile; [data-cy=potion-sheet] is visible.
      4. Tap [data-cy^=potion-]; the dialog contains "You drink a Health Potion".
    • Add the test it("Powers: Stop freezes the clock"):
      1. Powers opens [data-cy=powers-sheet].
      2. Cast; the dialog contains "casts Stop!".

    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.js Expected: all tests pass, and the screenshots land in tests/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 the data-cy hooks (item-use, item-reason, potion-sheet, potion-<id>, powers-sheet, ability-battle-effect).
  • 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

⚠️ **GitHub.com Fallback** ⚠️