2026 10 13 equipment rules - xopherdeep/do-it-for-the-xp GitHub Wiki

Equipment Rules 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: Make equipment in-game only, earned in temple order, used from the two hands, priced in an MP that costs something, with real-life bends moved to GM-priced Powers.

Architecture: One pure rules table (ItemRules.ts) gives every item its MP, temple use, fight effect and unlock hint; the fight (BattleEffects/BattleItems), temple (ItemUse) and portal screens read it. Five new fight effects join the shared effect table and recall is retired. Real-life Powers get an optional boardEffect that reuses the quest-schedule plumbing the old items used.

Tech Stack: Vue 3 + Ionic 7 + Pinia, TypeScript, Jest (tests/unit, component specs in __tests__), Cypress 8 e2e.

Spec: docs/superpowers/specs/2026-10-13-equipment-rules-design.md

Global Constraints

  • Rule zero: no item and no Power completes a chore or changes a quest's rewards.
  • Commands: chemx verify (chemx verify --build when a .vue changed), chemx test <spec> (-t "name" for one test), chemx typecheck, chemx lint, chemx read, chemx q. Never raw pnpm typecheck|lint|test|jest. After editing a page .ts whose .vue uses src="./X.ts", run npx jest --clearCache once before its spec (stale transform cache).
  • The checkout is shared by many sessions: never git add, git stash or git commit in the main repo. Commit only your paths with the private-index helper below. If git diff HEAD -- <path> shows edits that are not yours, stop and report instead of committing. In the docs submodule, git -C docs add <p> && git -C docs commit -m "<msg>" -- <p> is enough.
  • No data is removed and nothing is migrated: old profiles keep unlockedItems (or no list), old questEffects run out, a stored battleEffect: 'recall' reads as gust.
  • Costs and item text come from ItemRules by item name, never from a profile's stored desc.
  • Every fight effect is once per fight, per effect, whatever cast it.
  • Sounds only through existing play$fx / SFX keys. Toasts cssClass: "rpg-toast". Bottom nav tabs untouched.
  • Pages stay defineComponent with a sibling .ts; new components use <script setup lang="ts">; rules live in pure modules with jest specs.

Private-index commit helper (zsh or bash), used by every task's commit step:

commit_paths() { # commit_paths "<message>" <path>...
  local msg="$1"; shift
  local idx; idx=$(mktemp); GIT_INDEX_FILE=$idx git read-tree HEAD
  local p; for p in "$@"; do
    if [ -e "$p/.git" ]; then GIT_INDEX_FILE=$idx git update-index --add --cacheinfo "160000,$(git -C "$p" rev-parse HEAD),$p"
    elif [ -e "$p" ]; then GIT_INDEX_FILE=$idx git update-index --add --cacheinfo "100644,$(git hash-object -w "$p"),$p"
    else GIT_INDEX_FILE=$idx git update-index --force-remove "$p"; fi
  done
  local old; old=$(git rev-parse HEAD)
  local new; new=$(git commit-tree "$(GIT_INDEX_FILE=$idx git write-tree)" -p "$old" -m "$msg" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>")
  git update-ref refs/heads/master "$new" "$old"
  for p in "$@"; do
    if [ -e "$p/.git" ]; then git update-index --add --cacheinfo "160000,$(git rev-parse "HEAD:$p"),$p"
    elif [ -e "$p" ]; then git update-index --add --cacheinfo "100644,$(git rev-parse "HEAD:$p"),$p"
    else git update-index --force-remove "$p"; fi
  done
  rm -f "$idx"
}

Review Focus

  1. A hero's hand still holds an old stored copy (desc: "50MP | Freeze distractions..."): the Ice Wand must cost 10 MP in a fight and a temple. Test in Task 2.
  2. Using vanish while a beast's strike is already waiting to land: handleRun refuses, so the effect must refuse first ("A strike is already on its way."), charging nothing and not marking the effect used. Test in Task 1.
  3. Holding Bombs with an empty pack: fight and temple both say "Out of bombs." and spend nothing; buying a Bomb with a full bag charges no GP. Tests in Tasks 4 and 6.
  4. The bed across midnight: a rest stamped 23:59 allows another at 00:01 the next local day; a second rest the same day is refused even with HP and MP not full. Test in Task 3.
  5. Extra Time on a quest whose earlier extension has already passed is allowed again; one that has not passed is refused. Test in Task 5.

Task 1: Fight effects and engine verbs

Files:

  • Modify: src/lib/services/battle/BattleEffects.ts, src/lib/services/battle/BattleService.ts, src/lib/services/battle/battleService/strikeMath.ts, src/lib/services/battle/battleService/types.ts, src/lib/services/battle/battleService/dialogLines.ts, src/lib/services/battle/BattlePowers.ts, src/lib/services/battle/BattleItems.ts (the 'Silver Arrow' table line only), src/lib/services/abilities/AbilityPresets.ts (the Rewind battleEffect line only)
  • Test: tests/unit/BattleEffects.spec.ts, tests/unit/BattleService.spec.ts, tests/unit/BattleServiceStrikeMath.spec.ts, tests/unit/BattlePowers.spec.ts, tests/unit/BattleItems.spec.ts

Interfaces:

  • Produces:

    • type BattleEffectId = 'frost' | 'time-stop' | 'kindle' | 'gust' | 'stun' | 'pin' | 'weaken' | 'vanish' | 'charge' | 'moonlight' | 'heal'
    • BATTLE_EFFECT_IDS in exactly that order; every BATTLE_EFFECTS[id].oncePerFight === true
    • normalizeBattleEffect(value: unknown): BattleEffectId | null ('recall' → 'gust'; isBattleEffectId('recall') stays false)
    • BattleEffectOutcome.runsAway?: boolean (vanish sets it); needsTaskPick? and BattleEffectContext.taskId? stay optional and unused until Task 8 deletes them
    • BattleEffectEngine = Pick of 'extendClock' | 'freezeClock' | 'skipNextStrike' | 'weakenEnemy' | 'scaleCooldown' | 'refillClock' | 'vanishNextRun' | 'hasPendingDamage' | 'getCurrentEnemy' (postponeTask leaves)
    • BattleService.refillClock(): boolean, BattleService.vanishNextRun(): void; BattleService.postponeTask deleted
    • PartingDodge gains 'vanish'; PartingStrikeOwed.vanish: boolean
    • REASON_STRIKE_WAITING = 'A strike is already on its way.'
  • Step 1: Write the failing tests

In tests/unit/BattleEffects.spec.ts replace the recall cases and the id/label table, and add refillClock, vanishNextRun, hasPendingDamage (default false) to mockEngine, removing postponeTask:

it("lists the effects in dropdown order", () => {
  expect(BATTLE_EFFECT_IDS).toEqual(["frost", "time-stop", "kindle", "gust", "stun", "pin", "weaken", "vanish", "charge", "moonlight", "heal"]);
});
it.each([
  ["gust", "Refills the clock to full"],
  ["pin", "The beast misses its next two strikes"],
  ["vanish", "Run away with no parting strike"],
  ["charge", "Chores give back double MP"],
  ["moonlight", "Each chore heals 5% of max HP"],
  ["stun", "The beast misses its next strike"],
])("labels %s", (id, label) => expect(BATTLE_EFFECTS[id as BattleEffectId].label).toBe(label));
it("makes every effect once per fight", () => {
  expect(BATTLE_EFFECT_IDS.every((id) => BATTLE_EFFECTS[id].oncePerFight)).toBe(true);
});
it("reads a stored recall as gust", () => {
  expect(normalizeBattleEffect("recall")).toBe("gust");
  expect(normalizeBattleEffect("nope")).toBeNull();
  expect(isBattleEffectId("recall")).toBe(false);
});
it("gust refills the clock", () => {
  expect(applyBattleEffect("gust", ctx)).toEqual({ ok: true, line: "A gust blows the beast back: the clock is full again!", sound: SOUND_ITEM });
  expect(service.refillClock).toHaveBeenCalledTimes(1);
});
it("gust refuses a stopped clock and stays unspent", () => {
  service.refillClock.mockReturnValue(false);
  expect(applyBattleEffect("gust", ctx)).toEqual({ ok: false, line: "The clock is not running.", sound: SOUND_NO });
  expect(ctx.usedThisFight.has("gust")).toBe(false);
});
it("pin skips two strikes", () => {
  expect(applyBattleEffect("pin", ctx).line).toBe("Socktopus is pinned! It will miss its next two strikes.");
  expect(service.skipNextStrike).toHaveBeenCalledTimes(2);
});
it("vanish arms the getaway and asks the page to run", () => {
  const out = applyBattleEffect("vanish", ctx);
  expect(out).toEqual({ ok: true, line: "You vanish into the shadows...", sound: SOUND_ITEM, runsAway: true });
  expect(service.vanishNextRun).toHaveBeenCalledTimes(1);
});
it("vanish refuses while a strike is waiting to land", () => {
  service.hasPendingDamage.mockReturnValue(true);
  expect(applyBattleEffect("vanish", ctx)).toEqual({ ok: false, line: "A strike is already on its way.", sound: SOUND_NO });
  expect(service.vanishNextRun).not.toHaveBeenCalled();
  expect(ctx.usedThisFight.has("vanish")).toBe(false);
});
it.each(["charge", "moonlight"])("%s only marks the fight", (id) => {
  expect(applyBattleEffect(id as BattleEffectId, ctx).ok).toBe(true);
  expect(ctx.usedThisFight.has(id as BattleEffectId)).toBe(true);
});
it("refuses a second stun in the same fight", () => {
  applyBattleEffect("stun", ctx);
  expect(applyBattleEffect("stun", ctx)).toEqual({ ok: false, line: REASON_USED, sound: SOUND_NO });
});

The vanish line uses the hero's name when the store has one ("Max vanishes into the shadows..."), else "You vanish into the shadows..."; the fixture hero above has no nick or first name.

In tests/unit/BattleService.spec.ts delete the postponeTask (Silver Arrow) block and add:

describe("refillClock (Gale's Wind)", () => {
  it("restarts the running countdown at its full length", () => {
    // start a fight, let 30 s pass with fake timers, then:
    expect(s.refillClock()).toBe(true);
    expect(s.getTimeRemaining()).toBe(s.getAttackDuration());
  });
  it("refuses a stopped or frozen clock", () => {
    s.stopAttackTimer();
    expect(s.refillClock()).toBe(false);
  });
});
describe("vanishNextRun (X's Invisibility)", () => {
  it("dodges the parting strike once", () => {
    s.vanishNextRun();
    const result = s.handleBattleAction("run");
    expect(result.escape?.dodgedBy).toBe("vanish");
    expect(result.escape?.damage).toBe(0);
  });
});

In tests/unit/BattleServiceStrikeMath.spec.ts add: partingStrike({ ...input, owed: { shadow: false, frozen: false, hammer: false, vanish: true } }).dodgedBy is "vanish", and vanish wins over shadow.

In tests/unit/BattlePowers.spec.ts: the preset table has Rewind: "gust"; battleEffectOf({ name: "Old", battleEffect: "recall" as any }) is "gust"; the options list follows the new ids; delete the recall cast cases. In tests/unit/BattleItems.spec.ts change the Silver Arrow row to ["Silver Arrow", 25, "pin"] and delete the recall/postpone cases (needsTaskPick, postponeTask).

  • Step 2: Run the tests to see them fail

Run: chemx test tests/unit/BattleEffects.spec.ts (then the other four specs) Expected: FAIL (unknown effects gust, pin, vanish; missing normalizeBattleEffect, refillClock, vanishNextRun).

  • Step 3: Implement

  • BattleEffects.ts: the union, ids, labels (from the tests), oncePerFight: true everywhere. battleEffectBlocked adds: vanish with ctx.service.hasPendingDamage() → REASON_STRIKE_WAITING. applyBattleEffect cases: gust → refillClock() or refuse CLOCK_STOPPED; pin → skipNextStrike() twice, line ${beast} is pinned! It will miss its next two strikes.; vanish → vanishNextRun(), line ${heroName} vanishes into the shadows... / You vanish into the shadows..., runsAway: true; charge → Lightning crackles: every chore gives back double MP!; moonlight → Moonlight settles on you: every chore heals you!; stun's label becomes "The beast misses its next strike". Delete the recall case.

  • BattleService.ts: refillClock() returns false when attackTimerInterval === null or isClockFrozen(); otherwise attackDeadline = Date.now() + state.attackDuration, tick(), true. vanishNextRun() sets a private vanishOwed = true; takePartingStrike passes owed.vanish: this.vanishOwed; spendDodge('vanish') clears it. dispose() and a new fight clear it. Delete postponeTask.

  • strikeMath.ts: vanish is checked first in partingStrike. types.ts: PartingDodge gains 'vanish'. dialogLines.ts: PARTING_DODGE_LINES.vanish = (enemy) => \${enemy.name} swings at empty air.``.

  • BattlePowers.ts: PRESET_BATTLE_EFFECTS.Rewind = 'gust'; battleEffectOf returns normalizeBattleEffect(picked).

  • BattleItems.ts: 'Silver Arrow': { mp: 25, effect: 'pin' }. AbilityPresets.ts: Rewind battleEffect: 'gust', and its description becomes "Turn back the beast's clock: it refills to full."

  • Step 4: Run the tests to see them pass

Run: the five specs from Step 1, then chemx typecheck Expected: PASS, 0 type errors (BattleField.ts still compiles: needsTaskPick and taskId are optional).

  • Step 5: Commit
commit_paths "feat(battle): gust, pin, vanish, charge and moonlight; every effect once per fight; recall retired" src/lib/services/battle/BattleEffects.ts src/lib/services/battle/BattleService.ts src/lib/services/battle/battleService/strikeMath.ts src/lib/services/battle/battleService/types.ts src/lib/services/battle/battleService/dialogLines.ts src/lib/services/battle/BattlePowers.ts src/lib/services/battle/BattleItems.ts src/lib/services/abilities/AbilityPresets.ts tests/unit/BattleEffects.spec.ts tests/unit/BattleService.spec.ts tests/unit/BattleServiceStrikeMath.spec.ts tests/unit/BattlePowers.spec.ts tests/unit/BattleItems.spec.ts

Task 2: The item rules table, catalogue text and unlock sources

Files:

  • Create: src/lib/services/items/ItemRules.ts
  • Modify: src/app/Console/MyPortal/UserHud/components/equipment/EquipmentItems.ts, src/lib/services/quests/ItemEffects.ts (lock helpers only: ALWAYS_UNLOCKED, PEGASUS_INDEX, CHEST_ALIASES, CHEST_TEMPLE, unlockableItemNames, unlockHintOf), src/lib/services/temples/ChestLoot.ts (RELICS), src/lib/services/temples/ItemUse.ts (mpCostOf only), src/app/Console/MyPortal/UserHud/components/XpEquipmentModal.vue (the item handed to ItemInfoModal)
  • Test: tests/unit/ItemRules.spec.ts (new), tests/unit/ItemEffects.spec.ts, tests/unit/ChestLoot.spec.ts, tests/unit/ItemUse.spec.ts and tests/unit/BattleItems.spec.ts (their cost expectations follow the table: Ice Wand 10, Thor's Hammer 15, Chronos' Clock 20 (15 for the spec's Mage), Silver Arrow 25, Sol's Flare 20, Fire Wand 10, Hook Shot 5, Lantern 5, Bombs 0; the MP left after a use moves with them)

Interfaces:

  • Consumes: BattleEffectId (Task 1)
  • Produces:
    • type TempleUseId = 'map-compass' | 'bottle-spring' | 'blast-door' | 'reveal-secrets' | 'cross-pit' | 'slip-out' | 'freeze-trap' | 'moonlit-riddle'
    • interface ItemRule { mp: number; temple?: TempleUseId; fight?: BattleEffectId; container?: 'potions'; consumes?: 'Bomb'; templeLine?: string; fightLine?: string }
    • ITEM_RULES: Record<string, ItemRule> keyed by item name; itemRule(name: string | undefined): ItemRule | undefined
    • describeItem(name: string): string | null: "{mp}MP | Temple: {templeLine} Fight: {fightLine}", each part left out when absent, the "{mp}MP | " prefix left out at 0; null for a name with no rule
    • itemCardLine(name: string): string: "{name} ({mp} MP). Temple: … Fight: …" ("(uses a Bomb)" for Bombs), or "{name}: nothing happens here." with no rule
    • mpCostOf(item, definition?) (ItemUse.ts) returns itemRule(name).mp whenever a rule exists, even 0, before any description parsing

The table (MP, temple, fight; the lines are the card text):

Name mp temple fight templeLine fightLine
Lantern 5 map-compass — Shows this temple's map and compass. —
Bottles 0 bottle-spring (container potions) Bottles an unused spring as Spring Water or Silver Water. Opens your potions and food.
Bombs 0, consumes Bomb blast-door stun Blows open a locked door. The beast misses its next strike.
Gaia's Quake 10 reveal-secrets — Hidden rooms on this floor appear on your map. —
Hook Shot 5 cross-pit — Swing over a pit to the room beyond. —
X's Invisibility 15 slip-out vanish Slip out of a monster's room without its fight (not a quest's). Run away with no parting strike.
Fire Wand 10 — kindle — Every chore earns 1.5x the minutes.
Thor's Hammer 15 — stun — The beast misses its next strike.
Silver Arrow 25 — pin — The beast misses its next two strikes.
Sol's Flare 20 — weaken — Halves the beast's attack.
Ice Wand 10 freeze-trap frost Freezes the traps next to you. +10 minutes on the clock.
Tesla's Bolt 5 — charge — Chores give back double MP.
Gale's Wind 15 — gust — Refills the clock to full.
Night's Light 10 moonlit-riddle moonlight One wrong answer of a riddle fades. Each chore heals 5% of max HP.
Chronos' Clock 20 — time-stop — Pauses the clock for 15 minutes.

Unlock changes (ItemEffects.ts): ALWAYS_UNLOCKED gains "Bombs" (holding them is free; they need Bombs in the pack). CHEST_ALIASES gains "Trident of Tides": "Hook Shot", "Flame Shield": "Fire Wand", "Dawnbreaker Bow": "Silver Arrow", "Void Dagger": "X's Invisibility". CHEST_TEMPLE gains "Hook Shot": "Water Temple", "Fire Wand": "Fire Temple", "Silver Arrow": "Light Temple", "X's Invisibility": "Shadow Temple". PEGASUS_INDEX becomes { "Gale's Wind": 0, "Thor's Hammer": 1, "Sol's Flare": 2, "Pegasus Boots": 3, "Chronos' Clock": 8 }. unlockHintOf checks PEGASUS_INDEX before the registry event, so every Pegasus gift reads "Locked: free {Pegasus} the Pegasus to earn {item}.". unlockableItemNames() returns Object.keys(ITEM_RULES) minus ALWAYS_UNLOCKED. RELICS blurbs: 'Trident of Tides' → "A relic of the Water Temple. Equip it as the Hook Shot.", 'Flame Shield' → "A relic of the Fire Temple. Equip it as the Fire Wand.", 'Dawnbreaker Bow' → "A relic of the Light Temple. Equip it as the Silver Arrow.", 'Void Dagger' → "A relic of the Shadow Temple. Equip it as X's Invisibility."

  • Step 1: Write the failing tests

tests/unit/ItemRules.spec.ts:

it("gives every tool a temple use, a fight effect or a container", () => {
  Object.entries(ITEM_RULES).forEach(([name, rule]) =>
    expect([rule.temple, rule.fight, rule.container].some(Boolean)).toBe(true));
});
it("prices every tool between 0 and 25 MP", () => {
  Object.values(ITEM_RULES).forEach((rule) => {
    expect(rule.mp).toBeGreaterThanOrEqual(0);
    expect(rule.mp).toBeLessThanOrEqual(25);
  });
});
it("writes the card text", () => {
  expect(describeItem("Ice Wand")).toBe("10MP | Temple: Freezes the traps next to you. Fight: +10 minutes on the clock.");
  expect(describeItem("Fire Wand")).toBe("10MP | Fight: Every chore earns 1.5x the minutes.");
  expect(describeItem("My Quests")).toBeNull();
  expect(itemCardLine("Bombs")).toBe("Bombs (uses a Bomb). Temple: Blows open a locked door. Fight: The beast misses its next strike.");
});

tests/unit/ItemUse.spec.ts (Review Focus 1):

it("prices a stored old copy by the table, not its description", () => {
  expect(mpCostOf({ name: "Ice Wand", desc: "50MP | Freeze distractions in their tracks." })).toBe(10);
  expect(mpCostOf({ name: "Bombs", desc: "40MP | Blast through tough obstacles!" })).toBe(0);
});

tests/unit/ItemEffects.spec.ts: a Void Dagger chest unlocks ["Void Dagger", "X's Invisibility"]; a hero with stats.pegasi[1] = true and unlockedItems: [] has Thor's Hammer unlocked; unlockHintOf("Chronos' Clock") is "Locked: free Umbra the Pegasus to earn Chronos' Clock."; unlockHintOf("Silver Arrow") is "Locked: Silver Arrow waits in a chest in the Light Temple."; isItemUnlocked({ unlockedItems: [] }, "Bombs") is true. tests/unit/ChestLoot.spec.ts: the Flame Shield's blurb ends "Equip it as the Fire Wand."

  • Step 2: Run the tests to see them fail

Run: chemx test tests/unit/ItemRules.spec.ts, then tests/unit/ItemUse.spec.ts, tests/unit/ItemEffects.spec.ts, tests/unit/ChestLoot.spec.ts Expected: FAIL (module missing; old costs; old aliases).

  • Step 3: Implement

ItemRules.ts per the table. EquipmentItems.ts: each tool's desc is describeItem(name) and its mpCost is itemRule(name).mp; drop rechargeTime from the tools; the navigation and Pegasus items keep their text. ItemUse.mpCostOf: rule first. XpEquipmentModal.vue: the item passed to ItemInfoModal is { ...item, desc: describeItem(item.name) ?? item.desc, mpCost: itemRule(item.name)?.mp ?? item.mpCost }. The ItemEffects.ts lock changes listed above.

  • Step 4: Run the tests to see them pass

Run: the four specs, then chemx verify --build Expected: PASS.

  • Step 5: Commit
commit_paths "feat(items): one rules table; tools priced 5-25 MP; relics and Pegasus gifts unlock the rest" src/lib/services/items/ItemRules.ts src/app/Console/MyPortal/UserHud/components/equipment/EquipmentItems.ts src/lib/services/quests/ItemEffects.ts src/lib/services/temples/ChestLoot.ts src/lib/services/temples/ItemUse.ts src/app/Console/MyPortal/UserHud/components/XpEquipmentModal.vue tests/unit/ItemRules.spec.ts tests/unit/ItemEffects.spec.ts tests/unit/ChestLoot.spec.ts tests/unit/ItemUse.spec.ts tests/unit/BattleItems.spec.ts

Task 3: MP economy: the bed, the Hotel, chore gains

Files:

  • Create: src/lib/services/battle/choreGains.ts
  • Modify: src/lib/services/hometown/Lodging.ts, src/lib/services/hometown/hometownPrompts.ts (roomConfirmPrompt), src/lib/store/stores/user/profileFields.ts (setBedRestAt), src/lib/store/stores/user.ts (export it beside setBenchRestAt), src/app/Console/MyPortal/MyHome/MyHome.ts (restInBed), src/app/Console/MyPortal/HomeTown/HotelHub/HotelHub.ts (handleRentRoom)
  • Test: tests/unit/Lodging.spec.ts, tests/unit/choreGains.spec.ts (new)

Interfaces:

  • Produces:

    • ROOM_BASE_COST = 10, ROOM_COST_PER_LEVEL = 5, roomCost(level: number): number (10 + 5 × max(1, floor(level))); ROOM_COST stays exported, equal to ROOM_BASE_COST
    • quoteCare(kind, stats, level = 1): the room uses roomCost(level)
    • quoteBedRest(stats, bedRestAt: string | null | undefined, now = new Date()): { hpGain: number; mpGain: number; canRest: boolean; reason?: 'healthy' | 'rested' } (full HP and MP → healthy; same local day as bedRestAt → rested; the same day test as the bench)
    • user store setBedRestAt(userId: string, bedRestAt: string | null): void writing user.bedRestAt
    • roomConfirmPrompt(voice: Voice = 'adventure', cost: number = ROOM_COST)
    • CHORE_MP_SHARE = 0.1, MOONLIGHT_HP_SHARE = 0.05, choreGains(input: { maxHp: number; maxMp: number; questFight: boolean; charged: boolean; moonlit: boolean }): { hp: number; mp: number }
  • Step 1: Write the failing tests

tests/unit/choreGains.spec.ts:

it("gives 10% max MP back for a chore in a quest fight", () => {
  expect(choreGains({ maxHp: 40, maxMp: 42, questFight: true, charged: false, moonlit: false })).toEqual({ hp: 0, mp: 4 });
});
it("never less than 1 MP, doubled by charge", () => {
  expect(choreGains({ maxHp: 40, maxMp: 10, questFight: true, charged: true, moonlit: false }).mp).toBe(2);
});
it("heals 5% max HP per chore under moonlight", () => {
  expect(choreGains({ maxHp: 60, maxMp: 20, questFight: true, charged: false, moonlit: true }).hp).toBe(3);
});
it("gives nothing in a free fight", () => {
  expect(choreGains({ maxHp: 60, maxMp: 20, questFight: false, charged: true, moonlit: true })).toEqual({ hp: 0, mp: 0 });
});
it("gives no MP to a hero without an MP pool", () => {
  expect(choreGains({ maxHp: 60, maxMp: 0, questFight: true, charged: false, moonlit: false }).mp).toBe(0);
});

tests/unit/Lodging.spec.ts (Review Focus 4):

const tired = { hp: { now: 10, max: 40 }, mp: { now: 0, max: 30 } };
it("lets the hero sleep once a local day", () => {
  expect(quoteBedRest(tired, null)).toEqual({ hpGain: 30, mpGain: 30, canRest: true });
  const late = new Date(2026, 9, 13, 23, 59);
  expect(quoteBedRest(tired, late.toISOString(), new Date(2026, 9, 13, 23, 59, 30)).reason).toBe("rested");
  expect(quoteBedRest(tired, late.toISOString(), new Date(2026, 9, 14, 0, 1)).canRest).toBe(true);
});
it("says healthy before rested", () => {
  expect(quoteBedRest({ hp: { now: 40, max: 40 }, mp: { now: 30, max: 30 } }, new Date().toISOString()).reason).toBe("healthy");
});
it("prices the room by level", () => {
  expect(roomCost(1)).toBe(15);
  expect(roomCost(8)).toBe(50);
  expect(roomCost(0)).toBe(15);
  expect(quoteCare("room", { ...tired, gp: { wallet: 100 } }, 8).cost).toBe(50);
});

Update the existing room-cost expectations in Lodging.spec.ts to roomCost(1) (15) where they assumed ROOM_COST.

  • Step 2: Run the tests to see them fail

Run: chemx test tests/unit/choreGains.spec.ts, chemx test tests/unit/Lodging.spec.ts Expected: FAIL (missing choreGains, quoteBedRest, roomCost).

  • Step 3: Implement

The functions above. MyHome.restInBed: quoteBedRest(stats, user.value?.bedRestAt); healthy keeps the toast "You are already fully rested!"; rested toasts "You're not sleepy yet. Come back tomorrow."; on Rest, apply the gains, then setBedRestAt(userId, new Date().toISOString()). The alert copy loses "for free?": "Sleep in your cozy bed to restore all HP and MP? Once a day." HotelHub.handleRentRoom: quoteCare("room", stats, Number(this.user?.stats?.level) || 1) and roomConfirmPrompt(this.words, quote.cost).

  • Step 4: Run the tests to see them pass

Run: both specs, chemx test tests/unit/HometownPrompts.spec.ts, chemx test tests/unit/voicesPhase1.spec.ts (they read ROOM_COST, which stays the prompt's default), then npx jest --clearCache and chemx verify Expected: PASS.

  • Step 5: Commit
commit_paths "feat(mp): the bed refills once a day, the Hotel costs more by level, quest chores give MP back" src/lib/services/battle/choreGains.ts src/lib/services/hometown/Lodging.ts src/lib/services/hometown/hometownPrompts.ts src/lib/store/stores/user/profileFields.ts src/lib/store/stores/user.ts src/app/Console/MyPortal/MyHome/MyHome.ts src/app/Console/MyPortal/HomeTown/HotelHub/HotelHub.ts tests/unit/Lodging.spec.ts tests/unit/choreGains.spec.ts

Task 4: Temple uses and temple-shop Bombs

Files:

  • Modify: src/lib/services/temples/ItemUse.ts, src/lib/services/temples/RoomInteractions.ts (the puzzle case), src/lib/services/temples/roomInteractions/puzzle.ts, src/lib/services/temples/roomInteractions/vocabulary.ts (Riddle.faded?), src/lib/services/temples/roomInteractions/shop.ts, src/app/Console/TempleGrounds/templeGrounds/useRoomOutcomes.ts (riddle choices), src/lib/engine/core/dungeons/TempleSystem.ts, src/lib/engine/core/dungeons/templeSystem/runState.ts, src/lib/engine/core/dungeons/templeSystem/visibility.ts, src/lib/services/battle/BattlePotions.ts (two rows)
  • Test: tests/unit/ItemUse.spec.ts, tests/unit/RoomInteractions.spec.ts, tests/unit/BattlePotions.spec.ts

Interfaces:

  • Consumes: itemRule, ITEM_RULES, TempleUseId (Task 2); useUserStore().spendInventoryItem, addInventoryItem; inventoryOf
  • Produces:
    • TempleSystem.revealSecrets(templeId: string, level: string): number (count of secret rooms on that floor newly revealed; 0 when none or already revealed); TempleState.revealedSecretFloors?: Set<string> (not saved: lasts the visit)
    • TempleSystem.slipOut(templeId: string, level: string, position: [number, number]): void (calls unsealRoom; the room stays uncleared)
    • Riddle.faded?: number[]; publicRiddle(def, moonlit = false) fades the first wrong choice when moonlit
    • bombInPack(userId: string): InventoryEntry | null (in ItemUse.ts, the pack row whose name is "Bomb")
    • POTIONS['Spring Water'] = { hp: 10, effect: 'Restores 10 HP' }, POTIONS['Silver Water'] = { mp: 10, effect: 'Restores 10 MP' }
    • Ware { id: 'bomb', name: 'Bomb', price: shopPrice(20, hero), blurb: 'blows open a locked door' } after the Potion of Vigor

Outcomes (message, sound). MP is charged only on a success; nothing is remembered on a refusal.

Item Success Refusals
Ice Wand every open neighbour of kind trap not yet sprung gets rememberOnce(roomKey({ ...ctx, position: exit.position }, 'trap')): "Frost spreads into the next room: the trap is frozen." (SFX.secret) "No trap nearby to freeze."
Gaia's Quake "The ground shakes. Hidden rooms appear on your map." (SFX.secret, shake) "The ground shakes, but nothing is hidden on this floor." / "Hidden rooms on this floor are already on your map."
Night's Light unsolved puzzle room: rememberOnce(roomKey(ctx, 'moonlit')), "Moonlight settles on the glyphs: one answer fades." "The moonlight already rests here." / "No glyphs here to read."
X's Invisibility sealed (lockOnEnter, not cleared), not isQuestRoom: slipOut, "You slip away unseen. The doors open." quest room: "This beast holds a quest. It sees you." / "Nothing here to slip past."
Bottles unused health or mana spring: add { name: 'Spring Water' | 'Silver Water', kind: 'consumable', qty: 1, source: templeId }, rememberOnce(roomKey(ctx, 'spring')), "You bottle the spring: Spring Water is in your bag." (SFX.takeItem) full bag: "Your bag is full." (spring not used) / "No spring here to bottle."
Bombs as today, but spend one Bomb instead of MP "Out of bombs." (before anything else) plus today's refusals
a tool with no temple use "{name} is for fights." —
  • Step 1: Write the failing tests

In tests/unit/ItemUse.spec.ts (the templeFixtures helpers; the stored copies get rule names), one it per row above, for example:

it("freezes the trap next door so it never bites", () => {
  const id = hall({ T: { type: "trap", content: { damage: 5 } } }, [["E", "T"]]);
  const before = mp(HERO);
  expect(useItem(stored("Ice Wand", "wand-magic ice"), itemContext(id, [0, 0])).message).toBe("Frost spreads into the next room: the trap is frozen.");
  expect(mp(HERO)).toBe(before - 10);
  expect(onEnterRoom(dungeons.getRoom(id, [0, 1], "1")!, contextAt(id, [0, 1]))).toBeNull();
});
const lockedHall = () => hall({ E: { type: "entrance", locked: { east: true } } });
it("refuses Bombs with an empty pack and spends nothing", () => {
  const before = mp(HERO);
  expect(useItem(BOMBS, itemContext(lockedHall(), [0, 0])).message).toBe("Out of bombs.");
  expect(mp(HERO)).toBe(before);
});
it("spends one Bomb, not MP", () => {
  useUserStore().addInventoryItem(HERO, { name: "Bomb", kind: "consumable", qty: 2 });
  const before = mp(HERO);
  expect(useItem(BOMBS, itemContext(lockedHall(), [0, 0])).message).toBe("Boom! The east door blows open.");
  expect(mp(HERO)).toBe(before);
  expect(bombInPack(HERO)?.qty).toBe(1);
});
it("bottles a spring as Spring Water, once", () => {
  const id = hall({ S: { type: "health", content: { healthPoints: 10 } } }, [["E", "S"]]);
  enter(id, [0, 1]);
  expect(useItem(BOTTLES, itemContext(id, [0, 1])).message).toBe("You bottle the spring: Spring Water is in your bag.");
  expect(inventoryOf(useUserStore().getUserById(HERO)).find((e) => e.name === "Spring Water")?.qty).toBe(1);
  expect(useItem(BOTTLES, itemContext(id, [0, 1])).message).toBe("No spring here to bottle.");
});
it("will not slip past a quest beast", () => {
  const id = hall({ M: { type: "monster", content: { lockOnEnter: true, questId: "q1" } } }, [["E", "M"]]);
  enter(id, [0, 1]);
  expect(useItem(stored("X's Invisibility", "hat-wizard"), itemContext(id, [0, 1])).message).toBe("This beast holds a quest. It sees you.");
});
it("says a fight-only tool is for fights", () => {
  expect(useItem(stored("Sol's Flare", "flame"), itemContext(id, [0, 0])).message).toBe("Sol's Flare is for fights.");
});

In tests/unit/RoomInteractions.spec.ts: after Night's Light, the puzzle action's riddle has faded: [i] with i !== answer; after Gaia's Quake, TempleSystem.getRoomVisibility of an unvisited secret room (map held) is not NONE; the temple shop lists Bomb at 20 GP; buying it adds one Bomb; buying it with a full bag (20 stacks, see BAG_STACKS) answers "Your bag is full." and leaves the wallet unchanged (Review Focus 3). In tests/unit/BattlePotions.spec.ts: drinking Spring Water says "You drink a Spring Water: +10 HP".

Remove the old cases they replace: "Bottles heal once", "Bombs cost 15 MP", "wands refuse".

  • Step 2: Run the tests to see them fail

Run: chemx test tests/unit/ItemUse.spec.ts, chemx test tests/unit/RoomInteractions.spec.ts, chemx test tests/unit/BattlePotions.spec.ts Expected: FAIL.

  • Step 3: Implement

useItem switches on itemRule(name)?.temple instead of the name lists (BATTLE_ITEMS, SOON_ITEMS, DEFAULT_MP_COST go); navigation and Pegasus items keep running their click. Each temple use is one small function beside useLantern / useBombs / useHookShot. revealSecrets counts the floor's secret rooms through DungeonManager and stores the level in revealedSecretFloors; both isHiddenSecret checks in visibility.ts skip a revealed floor. useRoomOutcomes.ts builds the riddle buttons from the choices whose index is not in riddle.faded, keeping id: String(index). Temple shop: buyWare('bomb') checks the bag (addInventoryItem returns false) before charging.

  • Step 4: Run the tests to see them pass

Run: the three specs, chemx test tests/unit/RoomInteractionsModules.spec.ts, then chemx verify Expected: PASS.

  • Step 5: Commit
commit_paths "feat(temples): Ice Wand freezes traps, Gaia shows hidden rooms, moonlit riddles, slip past free fights, bottled springs, Bombs from the pack" src/lib/services/temples/ItemUse.ts src/lib/services/temples/RoomInteractions.ts src/lib/services/temples/roomInteractions/puzzle.ts src/lib/services/temples/roomInteractions/vocabulary.ts src/lib/services/temples/roomInteractions/shop.ts src/app/Console/TempleGrounds/templeGrounds/useRoomOutcomes.ts src/lib/engine/core/dungeons/TempleSystem.ts src/lib/engine/core/dungeons/templeSystem/runState.ts src/lib/engine/core/dungeons/templeSystem/visibility.ts src/lib/services/battle/BattlePotions.ts tests/unit/ItemUse.spec.ts tests/unit/RoomInteractions.spec.ts tests/unit/BattlePotions.spec.ts

Task 5: The quest board loses item effects; real-life Powers gain them

Files:

  • Create: src/lib/services/abilities/PowerBoardEffects.ts, src/hooks/quests/useQuestPicker.ts
  • Modify: src/types/abilities.ts, src/lib/services/abilities/AbilityPresets.ts (four new presets), src/lib/services/quests/ItemEffects.ts (board half), src/app/Console/MyPortal/MyPortal.ts (useHand, the picker moved out)
  • Test: tests/unit/PowerBoardEffects.spec.ts (new), tests/unit/ItemEffects.spec.ts

Interfaces:

  • Consumes: itemCardLine (Task 2); getQuestEngine().getQuestBoardForUser, effectiveDeadline, dayKeyOf, loadFamilySettings, useUserStore().setQuestEffects
  • Produces:
    • type PowerBoardEffectId = 'snooze' | 'extra-time' | 'quiet-day' | 'trusted-hour'; Ability.boardEffect?: PowerBoardEffectId | 'none'
    • POWER_BOARD_EFFECTS: Record<PowerBoardEffectId, { label: string; picksQuest: boolean }> (labels "Move one quest to tomorrow", "24 more hours on one quest's deadline", "No ambushes for 24 hours", "For one hour, claims skip approval"; picksQuest true for snooze and extra-time)
    • POWER_BOARD_EFFECT_OPTIONS: { value: PowerBoardEffectId | 'none'; label: string }[] (None first)
    • PRESET_BOARD_EFFECTS: Record<string, PowerBoardEffectId> (Snooze, Extra Time, Quiet Day, Trusted Hour)
    • boardEffectOfPower(ability: Pick<Ability, 'name' | 'type' | 'boardEffect'>): PowerBoardEffectId | null (real-life only; 'none' wins over the preset)
    • interface PowerBoardContext { userId: string; questId?: string; now?: Date }
    • powerBoardEffectBlocked(effect, ctx): Promise<string | null>; applyPowerBoardEffect(effect, ctx): Promise<{ ok: boolean; line: string; sound: string }> (it re-checks and changes nothing on a refusal; it never touches MP)
    • EXTRA_TIME_MS = 24h, QUIET_DAY_MS = 24h, TRUSTED_HOUR_MS = 1h
    • lanternOverdue(userId: string, now?: Date): Promise<{ ok: true; line: string; sound: string; quests: QuestBoardItem[] }> (ItemEffects.ts; today's Lantern lines)
    • useQuestPicker(userId: string): { openQuests(): Promise<QuestBoardItem[]>; selectQuest(choices: QuestBoardItem[], header: string): Promise<string | null> } (the action sheet moved from MyPortal.ts unchanged)
    • Kept in ItemEffects.ts: hasNoEncounters and the lock helpers. Removed: EFFECTS, BoardEffect*, applyBoardEffect, boardEffectOf, CHRONOS_PAUSE_MS, INVISIBILITY_MS, ICE_WAND_MS, plus cooldownLeft / cooldownLabel unless chemx q refs finds a caller

Lines: snooze "{title}: snoozed to tomorrow."; extra-time "{title}: now due {h:mma or EEE h:mma}."; quiet-day "Unseen by beasts until {when}."; trusted-hour "For 1 hour your claims need no approval.". Refusals: "Pick a quest first.", "That quest is not on your board.", "{title} is not open today.", "{title} is already snoozed.", "{title} already has extra time." (an extendedUntil that has not passed), "The Game Master has not allowed a trusted hour.".

Presets (AbilityType.RealLife, iconPrefix: 'fad', prerequisites: [], isPreset: true, frequency: TimePeriod.Weekly):

id name description mpCost AP (weekly) icon position boardEffect
irl-snooze Snooze Move one quest to tomorrow. 10 150 alarm-snooze {x: 100, y: 600} snooze
irl-extra-time Extra Time 24 more hours on one quest's deadline. 10 150 hourglass-half {x: 200, y: 600} extra-time
irl-quiet-day Quiet Day No ambushes for 24 hours. 15 200 eye-slash {x: 300, y: 600} quiet-day
irl-trusted-hour Trusted Hour For one hour, your claims skip the approval queue. 20 300 user-shield {x: 400, y: 600} trusted-hour
  • Step 1: Write the failing tests

tests/unit/PowerBoardEffects.spec.ts (mock the quest engine board the way ItemEffects.spec.ts does today; move its snooze/extend/trusted/no-encounter cases here):

it("snoozes one open quest, once a day", async () => {
  expect(await applyPowerBoardEffect("snooze", { userId: HERO, questId: "q1", now })).toEqual({ ok: true, line: "Dishes: snoozed to tomorrow.", sound: "useMP" });
  expect(await powerBoardEffectBlocked("snooze", { userId: HERO, questId: "q1", now })).toBe("Dishes is already snoozed.");
});
it("never stacks extra time, but allows it again once it passed", async () => {
  await applyPowerBoardEffect("extra-time", { userId: HERO, questId: "q1", now });
  expect(await powerBoardEffectBlocked("extra-time", { userId: HERO, questId: "q1", now })).toBe("Dishes already has extra time.");
  const later = new Date(now.getTime() + 3 * 24 * 3600_000);
  expect(await powerBoardEffectBlocked("extra-time", { userId: HERO, questId: "q1", now: later })).toBeNull();
});
it("refuses a trusted hour the family has not allowed and changes nothing", async () => {
  const before = JSON.stringify(user().questEffects ?? {});
  expect((await applyPowerBoardEffect("trusted-hour", { userId: HERO, now })).line).toBe("The Game Master has not allowed a trusted hour.");
  expect(JSON.stringify(user().questEffects ?? {})).toBe(before);
});
it("never touches MP", async () => {
  const mpBefore = user().stats.mp.now;
  await applyPowerBoardEffect("quiet-day", { userId: HERO, now });
  expect(user().stats.mp.now).toBe(mpBefore);
});
it("reads the effect only for real-life Powers, None winning over the preset", () => {
  expect(boardEffectOfPower({ name: "Snooze", type: AbilityType.RealLife })).toBe("snooze");
  expect(boardEffectOfPower({ name: "Snooze", type: AbilityType.RealLife, boardEffect: "none" })).toBeNull();
  expect(boardEffectOfPower({ name: "Snooze", type: AbilityType.InGame })).toBeNull();
});
it("ships the four presets", () => {
  expect(ABILITY_PRESETS.filter((p) => p.boardEffect).map((p) => p.name)).toEqual(["Snooze", "Extra Time", "Quiet Day", "Trusted Hour"]);
});

(Use whatever the presets export is named in AbilityPresets.ts.) In tests/unit/ItemEffects.spec.ts delete every applyBoardEffect case and add: lanternOverdue lists the overdue quest titles ("1 overdue: Dishes") and says "The lantern finds nothing overdue. Well done." on an empty board.

  • Step 2: Run the tests to see them fail

Run: chemx test tests/unit/PowerBoardEffects.spec.ts, chemx test tests/unit/ItemEffects.spec.ts Expected: FAIL.

  • Step 3: Implement

The module above, writing questEffects exactly as ItemEffects did (snoozedOn, extendedUntil from effectiveDeadline(...) ?? endOfDay(now) + 24 h, noEncountersUntil, trustedUntil). MyPortal.useHand: a live click runs; "Lantern" runs lanternOverdue and toasts its line; anything else toasts itemCardLine(name) with play$fx("select"). MyPortal.ts drops its local questChoices / pickQuest, and useQuestPicker takes their code.

  • Step 4: Run the tests to see them pass

Run: both specs, chemx test tests/unit/AbilityService.spec.ts, then npx jest --clearCache and chemx verify Expected: PASS.

  • Step 5: Commit
commit_paths "feat(powers): Snooze, Extra Time, Quiet Day and Trusted Hour as Powers; items leave the quest board" src/lib/services/abilities/PowerBoardEffects.ts src/hooks/quests/useQuestPicker.ts src/types/abilities.ts src/lib/services/abilities/AbilityPresets.ts src/lib/services/quests/ItemEffects.ts src/app/Console/MyPortal/MyPortal.ts tests/unit/PowerBoardEffects.spec.ts tests/unit/ItemEffects.spec.ts

Task 6: Fight items: hands only, table costs, Bombs from the pack

Files:

  • Modify: src/lib/services/battle/BattleItems.ts, src/app/Console/BattleField/BattleField.ts (the itemContext line only)
  • Test: tests/unit/BattleItems.spec.ts

Interfaces:

  • Consumes: ITEM_RULES, itemRule (Task 2); bombInPack (Task 4); the effects (Task 1)

  • Produces:

    • interface BattleItemContext extends BattleEffectContext { heldItems?: string[] } (absent = nothing held)
    • REASON_NOT_HELD = 'Hold it in a hand before a fight.', REASON_NO_BOMBS = 'Out of bombs.'
    • BATTLE_ITEMS derived from ITEM_RULES entries with a fight effect: { mp, effect }
    • reason order: locked → not held → once-per-fight / engine block → out of bombs → MP
    • battleBagRole unchanged in shape (Bottles, Goods → potions; Book Of Spells → powers; potions → potion; a rule with fight → effect)
  • Step 1: Write the failing tests

it("refuses a tool that is not in a hand", () => {
  expect(describeBattleItem(HAMMER, { ...ctx, heldItems: ["Ice Wand"] })?.reason).toBe("Hold it in a hand before a fight.");
  expect(useBattleItem(HAMMER, { ...ctx, heldItems: [] })).toEqual({ ok: false, line: "Hold it in a hand before a fight.", sound: SOUND_NO });
});
it("lets potions and the containers work from the bag", () => {
  expect(describeBattleItem(BOTTLES, { ...ctx, heldItems: [] })?.reason).toBeNull();
});
it("charges the table price, not the stored description", () => {
  const before = heroMp();
  useBattleItem({ name: "Ice Wand", desc: "50MP | old text" }, { ...ctx, heldItems: ["Ice Wand"] });
  expect(heroMp()).toBe(before - 10);
});
it("spends one Bomb and no MP", () => {
  useUserStore().addInventoryItem(HERO_ID, { name: "Bomb", kind: "consumable", qty: 1 });
  const before = heroMp();
  expect(useBattleItem(BOMBS, { ...ctx, heldItems: ["Bombs"] }).ok).toBe(true);
  expect(heroMp()).toBe(before);
  expect(bombInPack(HERO_ID)).toBeNull();
});
it("says out of bombs with an empty pack", () => {
  expect(describeBattleItem(BOMBS, { ...ctx, heldItems: ["Bombs"] })?.reason).toBe("Out of bombs.");
});
it("lists every fight tool", () => {
  expect(Object.keys(BATTLE_ITEMS).sort()).toEqual(["Bombs", "Chronos' Clock", "Fire Wand", "Gale's Wind", "Ice Wand", "Night's Light", "Silver Arrow", "Sol's Flare", "Tesla's Bolt", "Thor's Hammer", "X's Invisibility"]);
});

Existing cases that used a tool from the bag get heldItems: [<that tool>].

  • Step 2: Run the test to see it fail

Run: chemx test tests/unit/BattleItems.spec.ts Expected: FAIL.

  • Step 3: Implement

As above. On ok, Bombs call spendInventoryItem(userId, bombInPack(userId)!.id) and charge no MP. In BattleField.ts, itemContext adds heldItems: equipment.value.map((item) => String(item.name ?? "")).

  • Step 4: Run the tests to see them pass

Run: chemx test tests/unit/BattleItems.spec.ts, then npx jest --clearCache and chemx test src/app/Console/BattleField/__tests__/BattleField.spec.ts Expected: PASS. If a BattleField case used a tool it does not hold, give its fixture hero that tool in a hand.

  • Step 5: Commit
commit_paths "feat(battle): only the hands' tools work in a fight; Bombs spend from the pack" src/lib/services/battle/BattleItems.ts src/app/Console/BattleField/BattleField.ts tests/unit/BattleItems.spec.ts src/app/Console/BattleField/__tests__/BattleField.spec.ts

Task 7: Powers UI: the cast picks a quest, the Compendium select, family settings copy

Files:

  • Create: src/app/Console/MyPortal/UserHud/MyAbilities/boardCast.ts
  • Modify: src/app/Console/MyPortal/UserHud/MyAbilities/MyAbilities.ts (cast), src/app/Admin/XpCompendium/views/XpAbilities/components/XpCreateUpdateAbility.vue, src/app/SideMenu/XpSettings/components/FamilySettings.vue
  • Test: src/app/Console/MyPortal/UserHud/MyAbilities/__tests__/boardCast.spec.ts (new), tests/unit/XpCreateUpdateAbilityBoard.spec.ts (new, modelled on XpCreateUpdateAbilityBattle.spec.ts)

Interfaces:

  • Consumes: boardEffectOfPower, POWER_BOARD_EFFECTS, POWER_BOARD_EFFECT_OPTIONS, powerBoardEffectBlocked, applyPowerBoardEffect (Task 5); useQuestPicker (Task 5); castPower from useAbilitySystem
  • Produces: castWithBoardEffect(ability: Ability, userId: string, deps: { openQuests(): Promise<QuestBoardItem[]>; pickQuest(choices, header): Promise<string | null>; cast(abilityId: string, userId: string): Promise<{ ok: boolean }>; now?: Date }): Promise<{ status: 'cast' | 'cancelled' | 'refused' | 'failed'; line?: string; sound?: string }>

Order inside castWithBoardEffect: no board effect → cast (status cast, no line). Otherwise: picksQuest → openQuests(); empty → refused "No open quests on the board."; pickQuest(choices, "{name}: {label}"); null → cancelled. Then powerBoardEffectBlocked → refused with its line and sound no. Then cast; not ok → failed (the composable already toasted). Then applyPowerBoardEffect → cast with its line and sound.

  • Step 1: Write the failing tests
it("casts a plain Power without picking", async () => {
  const deps = fakeDeps();
  expect((await castWithBoardEffect(nightOwl, HERO, deps)).status).toBe("cast");
  expect(deps.pickQuest).not.toHaveBeenCalled();
});
it("charges nothing when the pick is cancelled", async () => {
  const deps = fakeDeps({ picked: null });
  expect((await castWithBoardEffect(snooze, HERO, deps)).status).toBe("cancelled");
  expect(deps.cast).not.toHaveBeenCalled();
});
it("charges nothing when the effect refuses", async () => {
  // the quest is already snoozed today
  const out = await castWithBoardEffect(snooze, HERO, deps);
  expect(out).toEqual({ status: "refused", line: "Dishes is already snoozed.", sound: "no" });
  expect(deps.cast).not.toHaveBeenCalled();
});
it("casts, then applies the effect", async () => {
  expect(await castWithBoardEffect(snooze, HERO, deps)).toEqual({ status: "cast", line: "Dishes: snoozed to tomorrow.", sound: "useMP" });
  expect(deps.cast).toHaveBeenCalledWith(snooze.id, HERO);
});

XpCreateUpdateAbilityBoard.spec.ts: [data-cy="ability-board-effect"] renders for a real-life Power with the five options (None, Snooze, Extra Time, Quiet Day, Trusted Hour); it is absent for an in-game Power; choosing Snooze sets ability.boardEffect = 'snooze'.

  • Step 2: Run the tests to see them fail

Run: chemx test src/app/Console/MyPortal/UserHud/MyAbilities/__tests__/boardCast.spec.ts, chemx test tests/unit/XpCreateUpdateAbilityBoard.spec.ts Expected: FAIL.

  • Step 3: Implement

boardCast.ts as specified. MyAbilities.cast calls it with useQuestPicker(heroId) and castPower; cancelled and failed say nothing; refused plays no and toasts the line; cast with a line toasts that line instead of the generic powers.toast.cast. In the editor, add the On the quest board item (data-cy="ability-board-effect", v-if real-life) beside In battle, which gets v-if in-game. FamilySettings.vue: the trusted hour's sub-line becomes "The Trusted Hour Power lets a hero's claims skip the approval queue for 1 hour"; the bedtime sub-line becomes "When the family's night begins and ends"; the Day-off broadcast item is removed from the template (its stored value stays).

  • Step 4: Run the tests to see them pass

Run: both specs, chemx test tests/unit/XpCreateUpdateAbilityBattle.spec.ts, chemx test src/app/Console/MyPortal/UserHud/MyAbilities/__tests__/powerRow.spec.ts, then npx jest --clearCache and chemx verify --build Expected: PASS.

  • Step 5: Commit
commit_paths "feat(powers): casting Snooze or Extra Time picks a quest first; the Compendium sets a Power's board effect" src/app/Console/MyPortal/UserHud/MyAbilities/boardCast.ts src/app/Console/MyPortal/UserHud/MyAbilities/MyAbilities.ts src/app/Admin/XpCompendium/views/XpAbilities/components/XpCreateUpdateAbility.vue src/app/SideMenu/XpSettings/components/FamilySettings.vue src/app/Console/MyPortal/UserHud/MyAbilities/__tests__/boardCast.spec.ts tests/unit/XpCreateUpdateAbilityBoard.spec.ts

Task 8: Battle page: vanish runs, chores give back, the postpone mode goes

Files:

  • Modify: src/app/Console/BattleField/BattleField.ts, src/app/Console/BattleField/BattleField.vue, src/app/Console/BattleField/components/XpBattleTaskMenu.vue, src/lib/services/battle/BattleEffects.ts (delete taskId and needsTaskPick), src/lib/services/battle/BattleItems.ts and src/lib/services/battle/BattlePowers.ts (their needsTaskPick checks)
  • Test: src/app/Console/BattleField/__tests__/BattleField.spec.ts, src/app/Console/BattleField/__tests__/XpBattleTaskMenu.spec.ts

Interfaces:

  • Consumes: runsAway (Task 1), choreGains (Task 3), heroPoints (BattleEffects), heldItems (Task 6)

  • Produces: no new exports. The page no longer returns taskMenuMode, recallBy or postponeTask; XpBattleTaskMenu loses its mode and recall props and its task-pick emit.

  • Step 1: Write the failing tests

In BattleField.spec.ts delete the postponeTask case and add:

it("runs away when X's Invisibility is used", async () => {
  // the hero holds X's Invisibility; Goods → its tile → use-item
  expect(engine.handleBattleAction).toHaveBeenCalledWith("run");
  expect(outcome.handleEscape).toHaveBeenCalled();
});
it("gives MP back for a chore in a quest fight", async () => {
  const before = heroMp();
  vm.finishCombatTask("t1");
  jest.advanceTimersByTime(SHEET_LEAVE_MS);
  expect(heroMp()).toBe(before + Math.max(1, Math.round(maxMp * 0.1)));
});
it("gives nothing back in a free fight", async () => { /* ctx.kind "free", no quest: MP unchanged */ });

In XpBattleTaskMenu.spec.ts delete the postpone-mode cases.

  • Step 2: Run the tests to see them fail

Run: npx jest --clearCache, then chemx test src/app/Console/BattleField/__tests__/BattleField.spec.ts Expected: FAIL.

  • Step 3: Implement

useBagItem and castPower: after sayItemResult(result), if (result.ok && result.runsAway) act("run"). finishCombatTask: right after service.value?.finishCombatTask(taskId), apply choreGains({ maxHp: heroPoints(ctx.userId, 'hp').max, maxMp: heroPoints(ctx.userId, 'mp').max, questFight: Boolean(encounter.value?.quest), charged: usedThisFight.has('charge'), moonlit: usedThisFight.has('moonlight') }) through updateUserMP / updateUserHP when above 0. Delete taskMenuMode, recallBy, arrowInFlight, powerInFlight, pickChoreToRecall, postponeTask and their template bindings; delete the needsTaskPick branches and the optional taskId / needsTaskPick fields.

  • Step 4: Run the tests to see them pass

Run: both specs, chemx test tests/unit/BattleItems.spec.ts, chemx test tests/unit/BattlePowers.spec.ts, then chemx verify --build Expected: PASS.

  • Step 5: Commit
commit_paths "feat(battle): vanish runs away, quest chores give MP back, the chore checklist has no postpone mode" src/app/Console/BattleField/BattleField.ts src/app/Console/BattleField/BattleField.vue src/app/Console/BattleField/components/XpBattleTaskMenu.vue src/lib/services/battle/BattleEffects.ts src/lib/services/battle/BattleItems.ts src/lib/services/battle/BattlePowers.ts src/app/Console/BattleField/__tests__/BattleField.spec.ts src/app/Console/BattleField/__tests__/XpBattleTaskMenu.spec.ts

Task 9: Contracts, docs and the e2e walk-through

Files:

  • Modify: docs/Items-And-Classes-Contract.md (sections A and B), docs/Equipment-Mechanics.md (whole file), docs/Battle-Field-Contract-v2.md (section H), docs/Temple-Interactions-Contract.md ("Equipment from the dungeon footer"), tests/e2e/fixtures/battle-backup.json, tests/e2e/specs/battle.spec.js

Interfaces:

  • Consumes: everything above.

  • Step 1: Update the e2e fixture and spec

Fixture hero: equipment.leftHand: [{ "name": "Ice Wand", "faIcon": "wand-magic ice" }] and rightHand: [{ "name": "Thor's Hammer", "faIcon": "hammer-war" }]; leave the profile without unlockedItems (everything unlocked). Spec: after Goods, tap the Ice Wand tile, item-use, expect "+10 minutes"; open Goods again, tap the Sol's Flare tile, expect [data-cy="item-reason"] to read "Hold it in a hand before a fight." and item-use disabled; then the Bottles, Health Potion and Stop steps as today. Screenshots 10-bag, 11-potions, 12-powers as today, plus 13-not-held.

  • Step 2: Run the e2e walk-through

Run (the shared server on :8100 must answer curl -s localhost:8100; never start a second one): pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/battle.spec.js Expected: all tests pass.

  • Step 3: Rewrite the docs

From the spec: A becomes hands only, the eleven effects and the item table's fight column; B becomes "items have no quest-board effect except the Lantern's overdue list; real-life bends are Powers (spec section 4)"; Equipment-Mechanics.md becomes the spec's item table with sources; battle contract H: hands only, the new effects, no recall or postpone mode; temple contract: the temple-use table from Task 4.

  • Step 4: Verify

Run: chemx verify --build Expected: green apart from the known project-wide AST gate (the critical/high counts must not rise).

  • Step 5: Commit the docs, then the pointer and the e2e
git -C docs add Items-And-Classes-Contract.md Equipment-Mechanics.md Battle-Field-Contract-v2.md Temple-Interactions-Contract.md && git -C docs commit -m "docs(equipment): hands only, in-game tools, the new fight effects and temple uses" -- Items-And-Classes-Contract.md Equipment-Mechanics.md Battle-Field-Contract-v2.md Temple-Interactions-Contract.md
commit_paths "test(e2e): the battle walk-through holds its tools; docs pointer for the equipment rules" docs tests/e2e/fixtures/battle-backup.json tests/e2e/specs/battle.spec.js

commit_paths writes docs as a gitlink (mode 160000) at the submodule's HEAD. That pointer also carries docs commits other sessions made since the last bump; they are already committed in the submodule, so that is expected.

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