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

Battle: Tackle, Powers and the Goods bag

Date: 2026-10-12. Status: approved design, ready for a plan.

What the owner asked

The battle menu has Attack, Defend, Item and Run Away. Two things are missing from a normal RPG fight:

  1. Item (now Goods) should open the item bag the way it opens everywhere else in the game. Tapping an item uses it in the fight when it applies.
  2. Abilities / magic, named Powers (the word the portal already teaches: "My Powers", AP earns them, MP casts them).

Owner decisions (2026-10-12)

  • The bag is the hub: the Equipment grid already holds fight gear, Bottles (potions), Goods (the pack), Book of Spells (Powers) and non-fight things. Battle reuses it.
  • Battle spells come from the Compendium: the Game Master picks one fight effect per in-game Power. No built-in class spell table.
  • The battle command is called Powers. Only in-game Powers appear in a fight, never real-life privileges.

What exists today

  • src/app/Console/BattleField/components/BattleItemMenu.vue: a bottom sheet with only the two equipped hands.
  • src/lib/services/battle/BattleItems.ts: the fight rules for seven items (BATTLE_ITEMS), describeBattleItem, useBattleItem. MP cost is heroMpBill (class discount) of the item's price. The Silver Arrow answers needsTaskPick and the page opens the checklist in postpone mode.
  • BattleService verbs used by items: extendClock, freezeClock, skipNextStrike, postponeTask, weakenEnemy, scaleCooldown.
  • XpEquipmentModal.vue (an ion-modal): EquipmentGrid of useEquipmentItems(), the EquippedItemsCard dock, ItemInfoModal on tap.
  • Potions (Health Potion, Mana Potion, Ether, Elixir) live in user.inventory (chests and temple shops add them; Inventory.ts reads them). Nothing can drink them yet, and the user store has addInventoryItem but no way to spend one.
  • In-game Powers (AbilityType.InGame, presets Haste, Slow, Stop, Rewind, Debug, Quick Save, Overclock, System Restore, Quantum Computing) have no fight behaviour. XpPowerCard.vue draws a Power with a Cast button. AbilityService.useAbility is the privilege ledger (cast log, Town Crier, adult notification).
  • docs/Battle-Field-Contract-v2.md bans ion-modal in the arena.

Design

1. Battle menu (owner's layout, 2026-10-12)

Tackle Defend
Powers Goods
Run Away (full width)
  • Tackle is the old Attack: it opens the chore checklist, with the same rules. Its icon is the hero's class weapon (table below). The checklist sheet title changes from "Attack!" to "Tackle!".
  • Powers: faIcon: "book-spells". Menu buttons are found by their .menu-item text (there is no per-button data-cy).
  • Goods is the old Item: it opens the bag (section 3). faIcon: "backpack".
  • The action ids stay attack, defend, item, run-away so the existing hooks and specs keep working; powers is new.
  • The run confirm pair is unchanged.

Class weapons: CLASS_WEAPON_ICONS in battle/battleMenu.ts plus tackleIcon(jobClass). A hero with no class, or a class missing from the map, gets fist-raised (today's Attack icon). Every icon below exists in the bundled Font Awesome Pro 5.15.4.

Class Icon Class Icon Class Icon
Warrior axe Mage staff Thief dagger
Knight sword White Mage hand-holding-magic Assassin swords
Berserker axe-battle Black Mage wand-magic Ranger bow-arrow
Paladin mace Sage book-spells Ninja sickle
Monk fist-raised Martial Artist hand-rock Mystic hand-sparkles

2. Shared battle effects: src/lib/services/battle/BattleEffects.ts (new)

Items and spells point at one effect table, so a spell and its matching item behave the same.

export type BattleEffectId = 'frost' | 'time-stop' | 'stun' | 'recall' | 'weaken' | 'kindle' | 'heal';

export interface BattleEffectRule {
  id: BattleEffectId;
  /** The GM dropdown label and the card's effect line, e.g. "+10 minutes on the clock". */
  label: string;
  oncePerFight: boolean;
}

export const BATTLE_EFFECTS: Record<BattleEffectId, BattleEffectRule>;

export interface BattleEffectContext {
  userId: string;
  service: BattleService;
  /** Effect ids already spent this fight (once-per-fight is per effect, whatever cast it). */
  usedThisFight: Set<BattleEffectId>;
  taskId?: string;
}

/** Refusal reason when the effect cannot run right now (already used, HP full), else null. MP is not checked here. */
export function battleEffectBlocked(effect: BattleEffectId, ctx: BattleEffectContext): string | null;

/**
 * Run the effect on the engine. Never touches MP. On ok, marks once-per-fight effects used.
 * Recall without ctx.taskId answers { ok: true, needsTaskPick: true } and changes nothing.
 */
export function applyBattleEffect(effect: BattleEffectId, ctx: BattleEffectContext): BattleEffectOutcome;

export interface BattleEffectOutcome { ok: boolean; line: string; sound: string; needsTaskPick?: boolean }
Effect Label Engine call Once per fight Line on success
frost +10 minutes on the clock extendClock(ICE_WAND_MS) no Ice creeps over the clock: +10 minutes!
time-stop Pauses the clock for 15 minutes freezeClock(CHRONOS_FREEZE_MS) yes Time stands still for 15 minutes!
stun The beast skips its next strike skipNextStrike() no {Beast} is stunned! It will miss its next strike.
recall Sends one chore back to the board postponeTask(taskId) no The chore flies back to the board.
weaken Halves the beast's attack weakenEnemy(SOL_FLARE_FACTOR) yes The sun blazes! {Beast}'s attack is halved.
kindle Chores earn 1.5x the minutes scaleCooldown(FIRE_WAND_FACTOR) yes The fuse burns slow: every chore earns 1.5x the minutes!
heal Heals 25% of max HP updateUserHP +25% max (min 1) yes +{n} HP

Engine refusals keep today's wording ("The clock is not running.", "The last chore cannot fly away."). Heal refuses at full HP ("Your HP is full."). The constants (ICE_WAND_MS, CHRONOS_FREEZE_MS, SOL_FLARE_FACTOR, FIRE_WAND_FACTOR) move here; BattleItems.ts re-exports them so existing imports keep working.

Behaviour change: once-per-fight is now keyed by effect, not item name. Chronos' Clock and the Stop Power share one Time Stop per fight.

3. Goods: the bag in battle mode

Rules (BattleItems.ts):

  • BATTLE_ITEMS becomes name -> { mp, effect: BattleEffectId } for Ice Wand, Chronos' Clock, Thor's Hammer, Silver Arrow, Sol's Flare, Fire Wand. Bottles leaves the table.
  • battleBagRole(item): 'effect' | 'potions' | 'powers' | 'none'. Bottles and Goods are potions; Book Of Spells is powers (names compared case-insensitively); a BATTLE_ITEMS name is effect; everything else is none.
  • Any unlocked item in the bag can be used. It no longer has to be in a hand.
  • BattleItemCard keeps today's shape (name, faIcon, mp, effect, reason) so the bag UI (W3) can be built in parallel with this refactor.
  • describeBattleItem(item, ctx) returns the card: name, faIcon, mp (with the class discount), effect label, and the reason it can't be used: "Can't use that in a fight." for none, "Already used this fight" and "Not enough MP" (today's strings), and unlockHintOf(name) ("Locked: ...") for a locked item. Containers (potions, powers) return a card with mp 0, no reason, and an effect line ("Your potions", "Your Powers").
  • useBattleItem(item, ctx) checks the reason, then MP, then calls applyBattleEffect and charges MP only when the outcome is ok and not needsTaskPick. The Silver Arrow flow (task pick, second call with taskId) is unchanged.

UI (XpEquipmentModal.vue, ItemInfoModal.vue, EquippedItemsCard.vue):

  • New props on XpEquipmentModal: mode?: 'equip' | 'battle' (default equip), battleCard?: (item: EquipmentItem) => BattleItemCard | null and opensDirectly?: (item: EquipmentItem) => boolean (the page answers it with battleBagRole, so the bag needs no battle rules). New emit: use-item(item).
  • In battle mode the grid is the same and dragging is off. The dock is shown read-only: no drops and no change-class button. Tapping a hand opens its card like a grid tile does.
  • Tapping a container (Bottles, Goods, Book Of Spells) emits use-item straight away, without opening a card.
  • Tapping anything else opens ItemInfoModal in battle mode. It shows the name, the effect line, the MP cost, and a Use button (data-cy="item-use", min 48 px). When the card has a reason, the button is disabled and the reason shows under it. Use emits use-item and the bag closes.
  • Equip mode is unchanged everywhere else.

4. Potions: src/lib/services/battle/BattlePotions.ts (new)

export const POTIONS: Record<string, { hp?: number; mp?: number; full?: true; effect: string }>;
// 'Health Potion' { hp: 50 }, 'Mana Potion' { mp: 30 }, 'Ether' { mp: 50 }, 'Elixir' { full: true }

export interface PotionCard { id: string; name: string; qty: number; faIcon: string; effect: string; reason: string | null }

/** Potions in the hero's pack (Inventory.inventoryOf), in name order, with the reason one can't be drunk (HP or MP already full). */
export function potionShelf(userId: string): PotionCard[];

/** Drink one: heal and/or restore, spend one from the pack. No MP cost, no once-per-fight limit. */
export function drinkPotion(userId: string, entryId: string): BattleEffectOutcome;
  • Lines: "You drink a Health Potion: +50 HP", "+30 MP", Elixir "HP and MP fully restored!". Sound: the heal sound.
  • Refusals: "Your HP is full." / "Your MP is full." / "You are already at full strength." (Elixir). Nothing is spent on a refusal.
  • User store: spendInventoryItem(userId, entryId, qty = 1): boolean. It finds the first raw row whose toEntry(row).id matches, decrements its qty / quantity, removes the row when it reaches 0, syncs the current user and calls persistUser.
  • UI: BattlePotionSheet.vue (new), built on a shared BattleSheet.vue shell lifted from BattleItemMenu.vue, which is then retired. Each row shows the icon, name, x qty, effect and reason. Empty state: "Your bottles are empty. Temple shops brew potions." Back button. data-cy="potion-sheet", rows potion-<id>.

5. Powers in a fight: src/lib/services/battle/BattlePowers.ts (new)

Data: Ability.battleEffect?: BattleEffectId | 'none' in src/types/abilities.ts.

/** The Power's fight effect: its own field, else the preset default by name, else null. 'none' is null. */
export function battleEffectOf(ability: Ability): BattleEffectId | null;

export const PRESET_BATTLE_EFFECTS: Record<string, BattleEffectId>;
// Haste kindle, Slow frost, Stop time-stop, Rewind recall, Debug weaken, Overclock stun, System Restore heal
// (Quick Save, Quantum Computing: none)

export interface BattlePowerCard { mp: number; effect: string; reason: string | null }

/** The hero's (ctx.userId) unlocked in-game Powers that have a fight effect, with the battle card for each. */
export function battlePowers(abilities: Ability[], ctx: BattleEffectContext, now?: Date):
  { ability: Ability; card: BattlePowerCard }[];

/** Cast in a fight: reason check, MP check (heroMpBill of mpCost), applyBattleEffect, charge MP on ok. */
export function castBattlePower(ability: Ability, ctx: BattleEffectContext): BattleEffectOutcome;
  • Unlocked means the same as My Powers (computeHeroAbilityStatuses: status is not locked). Real-life Powers never appear.
  • A fight cast does not go through AbilityService.useAbility: no cast log, no Town Crier line, no adult notification, and lastUsed is not stamped. The Power's real-world frequency cooldown does not apply inside a fight; once-per-fight is the only limit.
  • The success line puts the caster first: "{Hero} casts Stop! Time stands still for 15 minutes!". Recall asks "Which chore flies back to the board?" and the page opens the checklist in postpone mode, exactly like the Silver Arrow.
  • UI: BattlePowersSheet.vue (new): the same BattleSheet.vue shell, holding XpPowerCards. XpPowerCard gets an optional battle?: BattlePowerCard prop. When it is set, the AP meter is hidden, the cost line reads "{mp} MP · {effect}", the state line is the reason, and Cast is enabled when there is no reason. Empty state: "No battle Powers yet. Earn AP to unlock them." data-cy="powers-sheet".

6. Compendium: the "In battle" field

  • XpCreateUpdateAbility.vue: when the type is in-game, an In battle select lists None plus the seven BATTLE_EFFECTS labels and saves battleEffect. Real-life Powers don't show the field.
  • AbilityPresets.ts: the in-game presets carry the PRESET_BATTLE_EFFECTS picks.
  • No migration. Saved Powers without the field fall back to the preset pick by name. A GM who picks None stores 'none', and that wins over the fallback.

7. Page wiring: BattleField.ts / .vue

  • Replace showItemMenu with one sheet ref: 'task' | 'bag' | 'potions' | 'powers' | null. showMenu, the back button and closeSheet read it.
  • Goods opens the bag (XpEquipmentModal mode="battle", battleCard from describeBattleItem). On use-item, the bag closes first. Then:
    • potions opens the potion sheet.
    • powers opens the Powers sheet.
    • effect runs useBattleItem; needsTaskPick goes to the task pick.
    • none can't happen, because the button is disabled.
  • Powers opens the Powers sheet. Abilities load once per fight from AbilityService.getAllAbilities(); unlocked ids come from the hero.
  • Every outcome goes through the existing sayItemResult (sound, haptic tap when ok, one auto line in the dialog). Using or casting keeps the hero's move, the same as items today.
  • usedThisFight becomes Set<BattleEffectId> and is shared by items and Powers.
  • Victory, the hero downed, and leaving the view all close any open sheet and the bag.

8. Contract

docs/Battle-Field-Contract-v2.md:

  • Line 16 gets an exception: "except the Equipment bag (XpEquipmentModal mode="battle"), which is the same bag as everywhere else".
  • Line 31 (menu rows) and line 65 (checklist title) follow section 1: Tackle | Defend, Powers | Goods, Run Away; title "Tackle!".
  • A new section "H. Goods and Powers" summarises sections 1-7 of this spec, with the data-cy hooks.

Edge cases

  • MP is checked before anything runs, and nothing is charged on a refusal or on needsTaskPick.
  • Recall or the Silver Arrow on the last open chore is refused, as today.
  • A Power whose battleEffect is unknown (bad data) is treated as none and not listed.
  • A potion row with a missing or zero qty counts as 1 (the Inventory.count rule). Spending the last one removes the row.
  • If the battle ends while the bag or a sheet is open, everything closes and no use is applied after the end.
  • Free fights and quest fights behave the same.

Testing

  • Jest (pure modules first):
    • BattleEffects.spec.ts: each effect hits its engine verb; once-per-fight is per effect; refusals change nothing.
    • BattleItems.spec.ts (updated): bag role, unlocked-not-held items usable, Bottles is a container, MP only charged on ok.
    • BattlePotions.spec.ts: shelf, drink, full refusals, Elixir, spend one.
    • user store: spendInventoryItem.
    • BattlePowers.spec.ts: effect resolution (field, preset fallback, none), unlocked filter, real-life excluded, class discount, cast charges on ok, no useAbility call.
  • Component specs:
    • XpEquipmentModal battle mode: Use button, disabled reason, container emits, read-only dock.
    • BattlePotionSheet, BattlePowersSheet, the XpPowerCard battle prop.
    • BattleField.spec: the menu reads Tackle, Defend, Powers, Goods, Run Away in that order; Tackle wears the class weapon (and fist-raised with no class); Powers and Goods each open their sheet.
    • battleMenu spec: tackleIcon for every class in JOB_CLASS_OPTIONS plus the fallback.
    • XpCreateUpdateAbility: the In battle field.
  • E2E, tests/e2e/specs/battle.spec.js:
    • Fixture changes: the battle-backup.json hero gets a Health Potion in the pack, an unlocked in-game Power with a battle effect, and enough MP.
    • Steps: Goods opens the bag, Ice Wand is used, Bottles opens the potion sheet, the hero drinks one; Powers opens the sheet, the hero casts one.
  • Gate: chemx verify.

Out of scope

  • The spell tree (lifetime-AP milestones, quick-draw slots): it's a later spec.
  • A shared "use context" for temple, overworld and battle (temple ItemUse.ts stays as is).
  • Non-potion consumables in fights (Farore's Wind, keys).
  • Powers Phase 2 (timers, caps).

Work units (for the board)

# Unit Effort Depends on Owns
W1 Effect table + BattleItems refactor + Ability.battleEffect type deep none BattleEffects.ts, BattleItems.ts, src/types/abilities.ts, their specs
W2 Potions rules + spendInventoryItem standard W1 BattlePotions.ts, user.ts (one function), specs
W3 Bag battle mode standard none (uses today's BattleItemCard) XpEquipmentModal.vue, ItemInfoModal.vue, EquippedItemsCard.vue, specs
W4 Powers rules + preset picks standard W1 BattlePowers.ts, AbilityPresets.ts, spec
W5 Compendium In battle field light W4 XpCreateUpdateAbility.vue (+ its .ts if any), spec
W6 Potion and Powers sheets + XpPowerCard battle prop standard W2, W4 BattlePotionSheet.vue, BattlePowersSheet.vue, XpPowerCard.vue, specs; deletes BattleItemMenu.vue + spec
W7 Page wiring (menu layout, Tackle weapon icon, sheet state, flows) deep W1-W6 BattleField.ts, BattleField.vue, _BattleField.scss, battle/battleMenu.ts, XpBattleTaskMenu.vue (title only), their specs
W8 Contract text + e2e + fixture light W7 Battle-Field-Contract-v2.md, battle.spec.js, battle-backup.json

Waves (W2 needs W1's heroPoints, so it waits): W1 + W3; then W2 + W4; then W5 + W6; then W7; then W8. The plan is docs/superpowers/plans/2026-10-12-battle-powers-and-bag.md.

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