2026 10 13 equipment rules - xopherdeep/do-it-for-the-xp GitHub Wiki
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 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
- Rule zero: no item and no Power completes a chore or changes a quest's rewards.
- Commands:
chemx verify(chemx verify --buildwhen a.vuechanged),chemx test <spec>(-t "name"for one test),chemx typecheck,chemx lint,chemx read,chemx q. Never rawpnpm typecheck|lint|test|jest. After editing a page.tswhose.vueusessrc="./X.ts", runnpx jest --clearCacheonce before its spec (stale transform cache). - The checkout is shared by many sessions: never
git add,git stashorgit commitin the main repo. Commit only your paths with the private-index helper below. Ifgit 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), oldquestEffectsrun out, a storedbattleEffect: 'recall'reads asgust. - Costs and item text come from
ItemRulesby item name, never from a profile's storeddesc. - Every fight effect is once per fight, per effect, whatever cast it.
- Sounds only through existing
play$fx/SFXkeys. ToastscssClass: "rpg-toast". Bottom nav tabs untouched. - Pages stay
defineComponentwith 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"
}- 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. - Using vanish while a beast's strike is already waiting to land:
handleRunrefuses, 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. - 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.
- 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.
- 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.
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 RewindbattleEffectline 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_IDSin exactly that order; everyBATTLE_EFFECTS[id].oncePerFight === true -
normalizeBattleEffect(value: unknown): BattleEffectId | null('recall'→'gust';isBattleEffectId('recall')stays false) -
BattleEffectOutcome.runsAway?: boolean(vanish sets it);needsTaskPick?andBattleEffectContext.taskId?stay optional and unused until Task 8 deletes them -
BattleEffectEngine= Pick of'extendClock' | 'freezeClock' | 'skipNextStrike' | 'weakenEnemy' | 'scaleCooldown' | 'refillClock' | 'vanishNextRun' | 'hasPendingDamage' | 'getCurrentEnemy'(postponeTaskleaves) -
BattleService.refillClock(): boolean,BattleService.vanishNextRun(): void;BattleService.postponeTaskdeleted -
PartingDodgegains'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: trueeverywhere.battleEffectBlockedadds:vanishwithctx.service.hasPendingDamage()→REASON_STRIKE_WAITING.applyBattleEffectcases:gust→refillClock()or refuseCLOCK_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 therecallcase. -
BattleService.ts:refillClock()returns false whenattackTimerInterval === nullorisClockFrozen(); otherwiseattackDeadline = Date.now() + state.attackDuration,tick(), true.vanishNextRun()sets a privatevanishOwed = true;takePartingStrikepassesowed.vanish: this.vanishOwed;spendDodge('vanish')clears it.dispose()and a new fight clear it. DeletepostponeTask. -
strikeMath.ts:vanishis checked first inpartingStrike.types.ts:PartingDodgegains'vanish'.dialogLines.ts:PARTING_DODGE_LINES.vanish = (enemy) => \${enemy.name} swings at empty air.``. -
BattlePowers.ts:PRESET_BATTLE_EFFECTS.Rewind = 'gust';battleEffectOfreturnsnormalizeBattleEffect(picked). -
BattleItems.ts:'Silver Arrow': { mp: 25, effect: 'pin' }.AbilityPresets.ts: RewindbattleEffect: '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.tsFiles:
- 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(mpCostOfonly),src/app/Console/MyPortal/UserHud/components/XpEquipmentModal.vue(the item handed toItemInfoModal) - Test:
tests/unit/ItemRules.spec.ts(new),tests/unit/ItemEffects.spec.ts,tests/unit/ChestLoot.spec.ts,tests/unit/ItemUse.spec.tsandtests/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) returnsitemRule(name).mpwhenever 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.tsFiles:
- 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 besidesetBenchRestAt),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_COSTstays exported, equal toROOM_BASE_COST -
quoteCare(kind, stats, level = 1): the room usesroomCost(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 asbedRestAt→rested; the same day test as the bench) - user store
setBedRestAt(userId: string, bedRestAt: string | null): voidwritinguser.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.tsFiles:
- 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(callsunsealRoom; the room stays uncleared) -
Riddle.faded?: number[];publicRiddle(def, moonlit = false)fades the first wrong choice when moonlit -
bombInPack(userId: string): InventoryEntry | null(inItemUse.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.tsFiles:
- 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";picksQuesttrue 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:hasNoEncountersand the lock helpers. Removed:EFFECTS,BoardEffect*,applyBoardEffect,boardEffectOf,CHRONOS_PAUSE_MS,INVISIBILITY_MS,ICE_WAND_MS, pluscooldownLeft/cooldownLabelunlesschemx q refsfinds 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.tsFiles:
- Modify:
src/lib/services/battle/BattleItems.ts,src/app/Console/BattleField/BattleField.ts(theitemContextline 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_ITEMSderived fromITEM_RULESentries with afighteffect:{ mp, effect } - reason order: locked → not held → once-per-fight / engine block → out of bombs → MP
-
battleBagRoleunchanged in shape (Bottles, Goods →potions; Book Of Spells →powers; potions →potion; a rule withfight→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.tsFiles:
- 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 onXpCreateUpdateAbilityBattle.spec.ts)
Interfaces:
- Consumes:
boardEffectOfPower,POWER_BOARD_EFFECTS,POWER_BOARD_EFFECT_OPTIONS,powerBoardEffectBlocked,applyPowerBoardEffect(Task 5);useQuestPicker(Task 5);castPowerfromuseAbilitySystem - 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.tsFiles:
- 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(deletetaskIdandneedsTaskPick),src/lib/services/battle/BattleItems.tsandsrc/lib/services/battle/BattlePowers.ts(theirneedsTaskPickchecks) - 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,recallByorpostponeTask;XpBattleTaskMenuloses itsmodeandrecallprops and itstask-pickemit. -
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.tsFiles:
- 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.jscommit_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.