2026 10 12 battle powers and bag design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-12. Status: approved design, ready for a plan.
The battle menu has Attack, Defend, Item and Run Away. Two things are missing from a normal RPG fight:
- 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.
- Abilities / magic, named Powers (the word the portal already teaches: "My Powers", AP earns them, MP casts them).
- 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.
-
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 isheroMpBill(class discount) of the item's price. The Silver Arrow answersneedsTaskPickand the page opens the checklist inpostponemode. -
BattleServiceverbs used by items:extendClock,freezeClock,skipNextStrike,postponeTask,weakenEnemy,scaleCooldown. -
XpEquipmentModal.vue(anion-modal):EquipmentGridofuseEquipmentItems(), theEquippedItemsCarddock,ItemInfoModalon tap. - Potions (Health Potion, Mana Potion, Ether, Elixir) live in
user.inventory(chests and temple shops add them;Inventory.tsreads them). Nothing can drink them yet, and the user store hasaddInventoryItembut 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.vuedraws a Power with a Cast button.AbilityService.useAbilityis the privilege ledger (cast log, Town Crier, adult notification). -
docs/Battle-Field-Contract-v2.mdbansion-modalin the arena.
| 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-itemtext (there is no per-buttondata-cy). -
Goods is the old Item: it opens the bag (section 3).
faIcon: "backpack". - The action ids stay
attack,defend,item,run-awayso the existing hooks and specs keep working;powersis 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 |
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.
Rules (BattleItems.ts):
-
BATTLE_ITEMSbecomesname -> { 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 arepotions; Book Of Spells ispowers(names compared case-insensitively); aBATTLE_ITEMSname iseffect; everything else isnone. - Any unlocked item in the bag can be used. It no longer has to be in a hand.
-
BattleItemCardkeeps 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." fornone, "Already used this fight" and "Not enough MP" (today's strings), andunlockHintOf(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 callsapplyBattleEffectand charges MP only when the outcome is ok and notneedsTaskPick. The Silver Arrow flow (task pick, second call withtaskId) is unchanged.
UI (XpEquipmentModal.vue, ItemInfoModal.vue, EquippedItemsCard.vue):
- New props on
XpEquipmentModal:mode?: 'equip' | 'battle'(defaultequip),battleCard?: (item: EquipmentItem) => BattleItemCard | nullandopensDirectly?: (item: EquipmentItem) => boolean(the page answers it withbattleBagRole, 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-itemstraight away, without opening a card. - Tapping anything else opens
ItemInfoModalin 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 emitsuse-itemand the bag closes. - Equip mode is unchanged everywhere else.
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 whosetoEntry(row).idmatches, decrements itsqty/quantity, removes the row when it reaches 0, syncs the current user and callspersistUser. - UI:
BattlePotionSheet.vue(new), built on a sharedBattleSheet.vueshell lifted fromBattleItemMenu.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", rowspotion-<id>.
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 notlocked). Real-life Powers never appear. - A fight cast does not go through
AbilityService.useAbility: no cast log, no Town Crier line, no adult notification, andlastUsedis 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
postponemode, exactly like the Silver Arrow. - UI:
BattlePowersSheet.vue(new): the sameBattleSheet.vueshell, holdingXpPowerCards.XpPowerCardgets an optionalbattle?: BattlePowerCardprop. 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".
-
XpCreateUpdateAbility.vue: when the type is in-game, an In battle select lists None plus the sevenBATTLE_EFFECTSlabels and savesbattleEffect. Real-life Powers don't show the field. -
AbilityPresets.ts: the in-game presets carry thePRESET_BATTLE_EFFECTSpicks. - 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.
- Replace
showItemMenuwith onesheetref:'task' | 'bag' | 'potions' | 'powers' | null.showMenu, the back button andcloseSheetread it. - Goods opens the bag (
XpEquipmentModal mode="battle",battleCardfromdescribeBattleItem). Onuse-item, the bag closes first. Then:-
potionsopens the potion sheet. -
powersopens the Powers sheet. -
effectrunsuseBattleItem;needsTaskPickgoes to the task pick. -
nonecan'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. -
usedThisFightbecomesSet<BattleEffectId>and is shared by items and Powers. - Victory, the hero downed, and leaving the view all close any open sheet and the bag.
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-cyhooks.
- 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
battleEffectis unknown (bad data) is treated as none and not listed. - A potion row with a missing or zero qty counts as 1 (the
Inventory.countrule). 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.
- 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. -
userstore:spendInventoryItem. -
BattlePowers.spec.ts: effect resolution (field, preset fallback, none), unlocked filter, real-life excluded, class discount, cast charges on ok, nouseAbilitycall.
-
- Component specs:
-
XpEquipmentModalbattle mode: Use button, disabled reason, container emits, read-only dock. -
BattlePotionSheet,BattlePowersSheet, theXpPowerCardbattleprop. -
BattleField.spec: the menu reads Tackle, Defend, Powers, Goods, Run Away in that order; Tackle wears the class weapon (andfist-raisedwith no class); Powers and Goods each open their sheet. -
battleMenuspec:tackleIconfor every class inJOB_CLASS_OPTIONSplus the fallback. -
XpCreateUpdateAbility: the In battle field.
-
- E2E,
tests/e2e/specs/battle.spec.js:- Fixture changes: the
battle-backup.jsonhero 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.
- Fixture changes: the
- Gate:
chemx verify.
- 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.tsstays as is). - Non-potion consumables in fights (Farore's Wind, keys).
- Powers Phase 2 (timers, caps).
| # | 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.