Battle Field Contract v2 - xopherdeep/do-it-for-the-xp GitHub Wiki

Battle Field v2 โ€” the fabulous fight (contract v2, 2026-10-09, after two design critiques)

Repo: /home/xopher/www/x/do-it-for-the-xp. Vue 3 + Ionic 7 + Pinia + Capacitor 6, Vue CLI 5 / webpack, jest 27 (+ @vue/test-utils 2, vue3-jest, jest-transform-stub for assets), Cypress 8.7. Shell is zsh: quote globs (--include='*.ts'). Use pnpm, never npm/yarn. Board: every task below is a chemx team task (#25-#29). Claim it (chemx team task claim <id> --as=@<handle>), comment progress (chemx team task comment <id> "<text>"), close with chemx team task done <id> --as=@<handle> --force (the AST gate is red on this legacy repo, --force is expected).

Shared facts (read before coding)

  • Battle code lives in src/app/Console/BattleField/: page BattleField.vue/.ts/_BattleField.scss, battle/* (battleContext, encounterLoader, battleRewards, battleMenu, battleView), composables/, components/, hooks/useBattlePlayer.ts, hud/XpHpMpHud (to be deleted by F), tests/BattleField.spec.ts.
  • Engine: src/lib/services/battle/BattleService.ts (reactive per-battle state, Task-ATB clock, defend/run, pending damage) and BestiaryService.ts (Beast -> Enemy). Factory in src/lib/services/battle/index.ts.
  • SHARED TYPES (frozen, import from here, do not edit): src/app/Console/BattleField/battle/battleView.ts โ€” EnemyAnimation, EnemySize, HitPopup {id, amount, smash?}, RewardTally {xp,gp,ap}, VictoryCardState {title, subtitle, rewards, questName?, choresDone, elapsedMs, held, levelUp?}, DialogLine {text, auto?}, EngineMessage = string | DialogLine.
  • Theme: Earthbound. Fonts loaded (src/styles/_fonts.scss): "Apple Kid", "Year Is 199X", "StatusPlz", "Wisdom of the World", "Curtain Call", "Fourside", "Onett", "Twoson", "The Font Against Giygas", "Orange Kid". Never "Press Start 2P" (not loaded). Use the literal font-family strings (the SCSS var $eb-font-199x is "199X", wrong).
  • Box: @use "@/styles/themes/earthbound.scss" as *; then @include eb-box(); = dark purple #280828 box with 4 ring shadows that reach 16 px OUTSIDE the element (never put overflow:hidden on the eb-box element itself; give it >= 20 px margin inside overflow-hidden parents). Text cream = #e7e6b3 ($eb-color-cream); accent pale yellow = #f7e8a8 (= var(--ion-color-rpg)). Stat colours: var(--xp-color-xp) green, var(--xp-color-gp) yellow, var(--xp-color-ap) light blue, var(--xp-color-hp) red, var(--xp-color-mp) purple.
  • Sounds: import { play$fx } from "@/assets/fx"; play$fx(key) looks up ui.nintendo[key] then rpg.earthbound[key]. Valid: ui: select, yes, no, alert, options, openTask, closeTask, openPage, closePage, chooseUser, openShop, start, loading, intro, setup, noSelect. rpg: startBattle, attack, preAttack, damage (hero hit), enemyDamage (beast hit), enemyDie, win (local eb_win), levelUp (fanfare), die, text, cursor, restoreHP, countCoins, fillPoints, message, error, okdesuka. Unknown keys play nothing ("confirm"/"cancel" do not exist).
  • Battle BGM is started by the router (src/router/_Guards/bgm.guard.ts) for every battle-* route; components never start music.
  • Haptics: @capacitor/haptics 6.0.3; on the web it throws without navigator.vibrate; only the page (F) calls it, always in try/catch.
  • Jest: jest.useFakeTimers() is the modern implementation (fakes Date, timers, queueMicrotask). Asset imports resolve to "" via jest-transform-stub; require.context is shimmed (tests/jest/require-context.setup.js) and throws MODULE_NOT_FOUND on unknown keys like webpack. Never await new Promise(r => setTimeout(r)) under fake timers; use await nextTick() / flushPromises() from @vue/test-utils. pnpm typecheck also checks tests/**/*.ts, so specs must type-check. Component specs mount in isolation; no component may import "@ionic/vue" or "@/lib/mixins/ionic" (plain HTML + ), so they hydrate in jsdom.
  • Viewport: phones, portrait, 390x844 first; must also work at 360x640 and tablets (page content max-width 720 px). Players are kids (7+): short lines, tap targets >= 48 px, numbers animate, no native-looking dialogs in the arena (no alertController, ion-modal, ion-action-sheet, ion-item), except the Goods sheet (XpInventoryModal mode="battle"), the same My Goods sheet as everywhere else (section H).
  • Verification per task: pnpm typecheck clean; pnpm test -- <your spec> green; lint via pnpm lint has 0 errors (warnings OK). Do not touch files you do not own; name in your task comment any file you believe must change outside your ownership.

The loop (rules v2)

  1. A beast is a chore. Its checklist items are the hero's attacks ("moves").
  2. Tackle (the old Attack) opens the checklist sheet; confirming a chore deals ceil(maxHealth / tasks) damage.
  3. The attack clock: N minutes per REMAINING chore, N = enemy.attackCooldown ?? 3. When it empties the beast strikes. A strike costs about 12% of the hero's max HP (bosses bite harder); it never scales with time or chores left. The hit lands when the hero taps through "{Beast} attacks!", and the damage line types while HP rolls down.
  4. Defend = take a glancing hit now (half a strike, lands after 1 s) and get a fresh full clock. Run Away ALWAYS works (QR8, docs/Quest-Rooms-Spec.md 5.6): a run that could fail would trap a kid in a chore they cannot do right now. It costs one parting strike, except in a free fight, which costs nothing; the hero confirms first because finished chores would not count.
  5. Last chore = beast defeated. Rewards are credited immediately (or HELD as a pending quest-log entry when the Game Master requires approval), the outcome is recorded, HP is restored to full, then the celebration plays.
  6. Free fights (random encounters, Test Fight) pay 1 AP per beast and no XP/GP: only real quests and temple rooms pay chore rewards.
  7. A win keeps the HP the beast left. A defeat (or a parting strike that empties HP) admits the hero to the Hospital: full HP and MP at once, a flat bill (HOSPITAL_BILL, 100 GP) from the wallet, then savings, the rest owed as hospitalBill (the hero is grounded until it is paid at the Gold Bank). The arena says "You were carried to the Hospital.", then routes to hospital with ?admitted=1. The game-over splash and the 1 HP limp home are retired.
  8. Every exit (victory, escape, game over, hardware back) returns to where the fight started (battleReturnRoute).
  9. Finished chores are remembered for the day (per hero + quest/room/beast set); reopening the fight restores them and the beast is "still hurt from earlier".

Screen (portrait, top to bottom)

TOP โ€” BattleDialogBox (owner F): Earthbound message box, command menu inside it. The zone keeps a reserved height so the arena never jumps. Tap anywhere on the box to advance; one tap finishes the typing, the next moves on. Menu rows (>= 48 px, thumb-sized): Tackle | Defend on row 1, Powers | Goods on row 2, Run Away full-width on row 3. A prompt line can sit above the menu ("Do its chores to win!" until the first chore; "Run away? Your finished chores won't count." with [Keep fighting] [Run] when Run Away was tapped). MIDDLE โ€” BattleEnemyStage (A) over the animated earthbound-battle-backgrounds canvas (page). Tapping the stage also advances the dialog (page). BOTTOM โ€” BattleHeroCard (C). OVERLAYS (siblings of .battle-ui-container inside .battlefield, positioned absolute; the page owns the wrappers and their CSS): XpBattleTaskMenu bottom sheet (B, z 200), hit-flash vignette (F, z 150), BattleVictoryCard centered over the arena (E, z 300).

Flow and feel

  • Intro: beast appear + startBattle; line "From the dark forces of chaos comes {Beast}!!!" (tap-gated); the clock starts after the second tap. If chores were restored from earlier today, a second line "{Beast} is still hurt from earlier!".
  • Tackle -> sheet slides up. Row tap selects (cursor sound) and shows the confirm strip below the name: "Did you finish it?" [Not yet] [Done!]. Done! -> sheet slides away (250 ms), then: attack sound + medium haptic, beast damaged shake, "-{dmg}" popup (final blow: "SMAAAASH!!"), enemyDamage, a pip pops to a green check, the clock refills. The result line "{Hero} used {chore}! {Beast} took {dmg} damage!" types and auto-advances (no tap). Menu returns.
  • Clock under 10 s: bar pulses red, name plate tints red, float quickens; the page plays cursor + light haptic once per second; at zero preAttack + heavy haptic (the beast winds up).
  • Strike: "{Beast} attacks!" (tap-gated; the sheet closes if it was open). On dismiss the hit lands: HP written, screen shake + red flash + heavy haptic + damage, then "{Hero} takes {dmg} damage!" (or "{Hero} braced: only {dmg} damage!") types while the odometer rolls. Dismiss -> menu, clock already restarted (full). A pending untapped strike nags with the ui alert sound every 15 s, 3 times max.
  • Defend: "{Hero} braces!" -> 1 s -> "{Beast} hits the shield: only {dmg} damage!" flow as a strike at half damage; fresh full clock.
  • Run Away: prompt + [Keep fighting] [Run]. Run: "Running away..." (auto), then the parting strike line ("{Beast} strikes as {Hero} flees! {Hero} takes {dmg} damage!", or "braced: only {dmg}" / "slips most of it: only {dmg}" when softened, or the line saying why it missed; a free fight has none), then "Got away safely!" -> beast fades -> return. There is no failure branch. A fight that carries a quest also leaves one grudge mark on its beast for this hero, even when the strike was dodged (section I).
  • Victory: last blow -> pay/hold + record + HP restore at once (a quest's win also clears the hero's grudge marks on it) -> enemyDie + strobe 1000 ms -> arena collapse 750 ms -> victory-fadeout + 1000 ms -> fanfare (win, or levelUp when the hero levelled) + success haptic -> BattleVictoryCard counts up (title VICTORY! / QUEST TAMED!, "{Beast} was defeated!", "{n} chores in m:ss", LEVEL UP ribbon, locked tiles + "Waiting for the Game Master" when held) -> tap -> level-up stat lines in the dialog (append mode) when any -> return.
  • Defeat: die, hero card downed, "{Hero} has fallen!" "You blacked out..." -> admitToHospital (full HP and MP, the bill) -> "You were carried to the Hospital." -> hospital?admitted=1, where the nurse reads the bill. A parting strike that empties HP does the same after "...wakes up in the Hospital." (no "Got away safely!"); handleEscape(report) reads report.emptied.

A. components/BattleEnemyStage.vue (board #25, handle @sonnet-stage)

Pure presentational: no store, engine, router, Ionic or sounds. <script setup lang="ts">, scoped SCSS, import types from ../battle/battleView. Props: enemy: Enemy | null; avatarSrc: string; visible: boolean; animationClass: EnemyAnimation; sizeClass: EnemySize; choresTotal: number; choresLeft: number; timerVisible: boolean; timerPercent: number (0-100); timerLabel: string ("m:ss" or ""); timerCritical: boolean; lastHit: HitPopup | null. Emits: "tap" (any tap on the stage; the page advances the dialog). Layering (three elements, so animations never override each other): .enemy-container gets class appear for 600 ms whenever visible flips false->true OR enemy.id changes (a second beast in a multi-beast fight gets its own entrance); .enemy-sprite-container gets animationClass + sizeClass (monster 55% / miniboss 75% / boss 90% of the stage width, max 320 / 380 / 440 px; sprites are 480x480 PNGs, use object-fit: contain, max-height 60% of the stage); the .enemy-sprite img itself carries the idle float (3 s ease-in-out infinite, translateY +-4 px; 0.8 s while timerCritical). will-change: transform on the sprite container only. Grudge marks (section I): when enemy.grudgeMarks is 1-3 the stage draws that many red claw scratches (.grudge-marks > .grudge-mark, data-cy="grudge-marks") in the top corner of .enemy-sprite-container, so a hit shakes them and the victory fade takes them along. role="img", aria-label and title read "Holds a grudge: 2 marks, hits 50% harder". Nothing renders for none. Name plate .enemy-name: eb-box mini (padding 2px 12px), font "Apple Kid" 1.1rem letter-spacing 1px uppercase, cream; tinted red (#ff6b6b text, red ring) while timerCritical. Keep text = enemy.name exactly. Chore pips .chore-pips under the sprite: when choresTotal <= 12 one pip per chore (22 px, rounded square, 6 px gap, in authored order): remaining pips warm/glowing (orange-red gradient + glow), finished pips green with a check (fad fa-check) and a pip-pop animation when they flip (watch choresLeft). When choresTotal > 12, a plain bar instead. Label .chores-left-label text exactly "{n} chores left" / "1 chore left" / "0 chores left". Clock: .attack-timer row with the claw icon (fad fa-claw-marks), a 14 px bar whose fill width = timerPercent% (transition width 0.4s ease-out so a refill animates; colour green when timerPercent > 50, amber > 20, else red), caption .attack-timer-caption "strikes in" and label .attack-timer-label rendered with v-if="timerLabel" (so it does not exist before the intro is read). is-critical on the row when timerCritical: fill pulses (brightness/opacity keyframe). Whole row hidden when !timerVisible. Damage popups: watch lastHit (by id); push {id, text} into a list capped at 5, remove each after 1000 ms (setTimeout). Render absolutely over the sprite container, pointer-events: none, class .damage-popup, text "-{amount}" (font "Year Is 199X", 1.8rem, white with a 2 px dark text-shadow outline) or "SMAAAASH!!" when smash (2.4rem, #ffd84a). Keyframe: float up 60 px and fade over 1 s. Keyframes defined in this component's scoped block: enemy-appear, enemy-damaged (the existing shake + sepia flash), enemy-fadeout, victory-strobe (brightness/scale 0.2 s alternate infinite), victory-fadeout, idle-float, pip-pop, damage-pop, timer-pulse. The page SCSS no longer defines any enemy keyframes. Give the plate, pips and clock 20 px side margin (the middle zone clips). Root: data-cy="enemy-stage", @click="emit('tap')", full width, flex column centered, user-select: none. Reduced motion: @media (prefers-reduced-motion: reduce) disables the float, strobe, pulse and shake (keep opacity fades). Test: tests/BattleEnemyStage.spec.ts (mount with fake timers): renders name/label/clock text; label flips to "1 chore left"; .attack-timer-label absent when timerLabel is ""; a new lastHit renders one .damage-popup with "-50" that disappears after 1000 ms; smash renders "SMAAAASH!!"; appear class present right after visible flips and gone after 600 ms; click emits "tap".

B. components/XpBattleTaskMenu.vue (board #26, handle @sonnet-checklist)

Rewrite as a bottom sheet. <script setup lang="ts">, no any, scoped SCSS, plain <button type="button"> elements only (no ion-, no alertController). Props: tasks: CombatTask[] (from "@/lib/services/battle/BattleService": {id, name, isCompleted}); enemyName: string; damagePerTask: number. Emits: "cancel"; "attack-start"(task: CombatTask); "task-complete"(taskId: string); "attack-end"(payload: { success: true; taskId: string; taskName: string }). Root .xp-battle-task-menu data-cy="task-sheet": position: absolute; left: 0; right: 0; bottom: 0; margin: 0 auto; width: 100%; max-width: 600px; max-height: 70%; z-index: 200 (NOT fixed, NOT vh: .ion-page has contain, and the page's screen-shake animates transform). It renders a dimmed backdrop .sheet-backdrop behind it covering the page (position absolute inset 0, rgba(0,0,0,.45)); a tap on the backdrop emits cancel (plays closeTask). The sheet card .menu-container uses @include eb-box() with 20 px margin on the sides and bottom (rings), padding-bottom env(safe-area-inset-bottom). NO overflow:hidden on the eb-box; the list .menu-content scrolls (max-height ~50vh, @include eb-scrollbar()). Header: title "Tackle!" (Apple Kid 1.5rem) and subtitle .sheet-subtitle "Each chore hits {enemyName} for {damagePerTask}" (StatusPlz 0.95rem, 0.85 opacity). Rows in AUTHORED ORDER (never reorder): <button class="battle-task-item"> min-height 56 px, full width, claw icon, .task-name (Apple Kid 1.35rem, wraps, never truncates). Completed: .is-done dimmed (opacity .5), check icon instead of the claw, name struck through, disabled. Tapping an open row -> .is-selected (left accent bar, cream background 8%) and reveals, BELOW the row name (never under the finger), the confirm strip .task-confirm-strip: text "Did you finish it?" (StatusPlz) and two RPG buttons: "Not yet" (data-cy="task-not-yet", plays no, deselects) and "Done!" (data-cy="task-confirm", Apple Kid 1.3rem, cream on #4a2a4a, min 48 px). Ignore taps on Done! for 250 ms after the row was selected (double-tap guard). Tapping another open row moves the selection directly. Done! -> busy = true (ignore all further taps until unmounted), emit "attack-start"(task), "task-complete"(task.id), "attack-end"({success: true, taskId, taskName}) in that order. Footer: "Back" button data-cy="task-back" (plays closeTask, emits cancel). Sounds via import { play$fx } from "@/assets/fx": row select cursor, Not yet no, Back/backdrop closeTask. The parent plays the attack sounds. Empty state: "No chores on this beast!" when tasks is empty. The parent wraps the component in <transition name="sheet"> and owns that CSS; the component has no enter/leave styles of its own. Test: tests/XpBattleTaskMenu.spec.ts (fake timers): rows render in authored order with done rows dimmed; selecting shows one strip; Done! before 250 ms does nothing; after 300 ms Done! emits attack-start, task-complete, attack-end once each in order and ignores a second tap; Not yet deselects; Back and backdrop emit cancel; no ion- elements in the HTML.

C. components/BattleHeroCard.vue + hooks/useBattlePlayer.ts (board #27, handle @sonnet-hero)

Do NOT delete hud/XpHpMpHud or edit BattleField.ts (F swaps the import and deletes hud/ afterwards). <script setup lang="ts">, scoped SCSS, no Ionic imports, no mixins, no store writes. Props: userId: string; hitSignal: number (each increment = one 400 ms is-hit shake on the card); downed: boolean (grayscale + 60% opacity). useBattlePlayer(userId: Ref | string): everything derives reactively from useUserStore().users[userId]; NO loadUsers(), NO onMounted load, no deep watcher. hpNow = computed(users[id]?.stats?.hp?.now ?? 0); hpMax = users[id]?.stats?.hp?.max ?? calculatePlayerStats(...).hp.max (the store's max is the source of truth; the formula only a fallback); same for MP. Rolling display: one setInterval(step, 40) per stat started by a watch on the real value, +-1 per step toward the target, cancelled when reached and on unmount; the display INITIALISES AT the real value on first read (no roll-up from 0). Returns: player | null, isLoading (true only while the hero is missing), playerName (nick || first || "Hero"), level, avatarSrc (guarded: const a = player?.avatar; if (!a) return ""; try { const src = requireAvatar(./${a}.svg); return typeof src === "string" ? src : ""; } catch { return "" } with import { requireAvatar } from "@/lib/utils/requireImg"), hpDisplay/mpDisplay (rolling integers), formattedHp/formattedMp ({thou, hun, ten} digit strings, blank leading zeros), hpPercentage/mpPercentage clamped 0..100. Render (data-cy="hero-card", eb-box, width 100% up to 420 px, total height <= 140 px): row 1: avatar circle 40 px (<img> or the first letter of the nick on a purple disc), .hero-name (Apple Kid 1.35rem, cream), .hero-level badge "Lv {level}" (StatusPlz, pale-yellow ring). Row 2: "HP" label (font "Fourside", like today) + three rolling digit tiles .hp-digits (keep today's radial-gradient tile look, digit font "Orange Kid" ~34 px, black on white) + .hp-value element whose text is the rolling HP integer (may be visually hidden) + a 6 px .hp-bar under the tiles coloured var(--xp-color-hp) above 50%, amber (#ffb347) 25-50%, red with a pulse below 25%. Row 3 (small): .mp-line "MP {now}/{max}" (StatusPlz 0.9rem) with a 4 px purple bar; .mp-value text = rolling MP integer. Keyframes in the scoped block: card-hit (translateX shake 400 ms), hp-low-pulse. Reduced motion: no shake/pulse. Test: tests/BattleHeroCard.spec.ts (Pinia + fake timers; addUser a hero with stats.hp {now:30,max:30}, mp {now:10,max:10}, name {nick:"Max"}, level 2, no avatar): renders "Max", "Lv 2", .hp-value "30", .mp-value "10"; after updateUserHP({amount:-7}) and advanceTimersByTime(7*40+40) .hp-value is "23" and the bar width shrank; a hero with avatar "003-gamer" renders an (src "" in jest is fine) and a missing avatar renders the letter; hitSignal increment adds is-hit then removes it after 400 ms; downed adds is-downed.

D. src/lib/services/battle/BattleService.ts + index.ts + tests/unit/BattleService.spec.ts (board #28, handle @sonnet-engine)

Keep every public method the page uses: registerCallbacks, initMultiBattle, handleBattleAction('defend'|'run'), startPlayerTurn, finishCombatTask, startAttackTimer, stopAttackTimer, getEnemyAttackTimer, getTimeRemaining, applyPendingDamage, hasPendingDamage, getPendingDamage, isPlayerTurn, isBattleStarted, getCurrentEnemy, getAllEnemies, getCombatTasks, getTurnCounter. Keep callbacks: onDialogMessage, onEnemyHealthChange, onDefendStateChange, onPlayerTurnChange, onEnemyDefeated, onEnemyChange, onCombatTasksChange, onPlayerDefeated, onAttack, onDamage. Messages go through onDialogMessage(messages: EngineMessage[]) (import EngineMessage/DialogLine from "@/app/Console/BattleField/battle/battleView"); plain strings are tap-gated, { text, auto: true } lines auto-advance.

  1. Strike damage: add and export a pure strikeDamage(maxHp: number, attack: number, braced: boolean, roll = Math.random()): number = max(1, round(maxHp * 0.12 * ((attack || 10) / 10) * (0.9 + roll * 0.2))), halved (floor, min 1) when braced. performEnemyTurn uses it with the active hero's maxHp.
  2. performEnemyTurn(): first stopAttackTimer(); if pendingPlayerDamage > 0 return (a strike is already waiting). Read braced = isDefending to compute damage and remember pendingStrikeBraced = braced but do NOT clear the brace here. Fire onAttack('enemy'). Queue only ${enemy.name} attacks!. Store pendingPlayerDamage.
  3. applyPendingDamage(): write HP via userStore.updateUserHP(-damage); fire onDamage('player', damage); setDefendingState(false); then queue ${hero} takes ${damage} damage! or, when pendingStrikeBraced, ${hero} braced: only ${damage} damage!; if alive: setPlayerTurnState(true); turnCounter++; startAttackTimer() (the hero always regains the turn when the hit lands; this is the ONLY clock restart while a strike is pending). If dead: queue ${hero} has fallen!, You blacked out..., fire onPlayerDefeated. With no pending damage: just setPlayerTurnState(true) and startAttackTimer().
  4. handleDefend(): setDefendingState(true); stopAttackTimer(); queue ${hero} braces!; endPlayerTurn(); (the existing 1 s timeout strikes at half damage). No "Already braced" branch; if a strike is already pending return { success: false } silently.
  5. handleRun() (QR8): always succeeds. escapeChanceAgainst and the 75/90/50/25 table are gone. stopAttackTimer, then, unless alwaysEscapes (constructor flag or setFreeFight(true): a free fight takes nothing), one parting strike: partingStrike() in battleService/strikeMath.ts takes a full strikeDamage(maxHp, attack, softened, roll, braceFactor, strikeFactor), softened (the brace factor, once) when the hero was bracing (isDefending) or the class has runChance set (Thief, Assassin, Ninja), and dodged when the Ninja's skipFirstStrike is still owed, the clock is frozen (Chronos), a Thor's Hammer skip is owed (it is used up), or Math.random() < min(0.5, runChanceBonus) (trained agility, 1% a point; the 0.5 ceiling is tunable). Lines: runAwayLines(parting) = { text: "Running away...", auto: true }, the parting-strike or dodge line, Got away safely!. The hit is written to the store and fires onDamage('player', n). HP is floored at 1 for now (TODO: the Hospital rule, Quest-Rooms-Spec 5.8, admits the hero when the strike would empty HP; the report says emptied). Returns { success: true, message: "Escaped successfully", escape: EscapeReport } with { free, damage, dodgedBy, softener, emptied, carriesQuest }; carriesQuest comes from the carriesQuest service option (the page sets it when the fight holds a quest) and is the seam for the grudge mark. The only success: false left is a strike already on its way.
  6. finishCombatTask(taskId): mark done, damage = ceil(maxHealth / combatTasks.length), fire onDamage('enemy', damage) โ€” the page needs to know the final blow, so pass a third argument isFinalBlow: boolean (extend the callback type: onDamage(target, amount, isFinalBlow?)), queue ONE auto line { text: \${hero} used ${task.name}! ${enemy.name} took ${damage} damage!`, auto: true }, then if health <= 0 defeatEnemy(), else if (pendingPlayerDamage === 0) { stopAttackTimer(); startAttackTimer(); }`.
  7. startPlayerTurn(): only setPlayerTurnState(true); no lines at all. endPlayerTurn(): setPlayerTurnState(false) then the 1 s timeout to performEnemyTurn (keep); remove setBattleMessage.
  8. Clock: startAttackTimer() computes minutesPerChore = currentEnemy?.attackCooldown ?? 3, duration = max(1, remaining) * minutesPerChore * 60_000, sets attackDeadline = Date.now() + duration, attackDuration = duration, and SYNCHRONOUSLY sets timeRemaining = duration and enemyAttackTimer = 100; a 100 ms setInterval calls tick(). Add public tick(): timeRemaining = max(0, deadline - Date.now()); enemyAttackTimer = timeRemaining / duration * 100; at 0 -> stopAttackTimer + performEnemyTurn. Add getAttackDuration(). Use Date.now() only.
  9. Progress restore: add public restoreCompletedTasks(taskIds: string[]): number โ€” marks matching tasks done and lowers the current enemy's health by damage-per-task each, fires onCombatTasksChange and onEnemyHealthChange, queues nothing, returns how many were restored (0 if none matched). Never defeats the enemy (cap at health 1 if all would be restored; the page never passes all ids).
  10. Hero name: every hero line uses this.getActiveUser()?.name ?? "You"; delete getPlayerName() and getCurrentUserFromStore() (they read currentUser, which is not the route hero and is null in jest). getUserById reads stats.hp.max as maxHp.
  11. Dead code: remove initBattle, addEnemy, addUser, handleAttack and the 'attack' case (return { success: false, message: "Unhandled action" }), resetBattle, devDamageEnemy, devHealEnemy, devSkipTurn, setDevMode, isDevMode, the devMode constructor flag, getBattleMessage/setBattleMessage and onBattleMessageChange, enemyHealthColor + getEnemyHealthColor (the stage colours itself) โ€” after confirming with grep -rn "<name>" src tests --include='*.ts' --include='*.vue' that nothing outside this file uses them (beware: addUser/setDevMode hits in src/lib/store/stores/*.ts are different symbols). index.ts: export const createBattleService = (options?: { freeFight?: boolean }) => new BattleService(options); keep createBestiaryService.
  12. tests/unit/BattleService.spec.ts (fake timers; setActivePinia(createPinia()); useUserStore().addUser({ id: "hero-1", name: { nick: "Max" }, jobClass: "warrior", stats: { level: 1, xp: { now: 0, next_level: 283 }, hp: { now: 30, max: 30 }, mp: { now: 10, max: 10 }, gp: { wallet: 0, savings: 0, debt: 0, limit: 1000 } } }); mock @/lib/databases/ProfileDb or accept the caught persist error; enemy Socktopus maxHealth 100 with 2 tasks, attack 10): strikeDamage(30,10,false,0.5)=4 and braced=2, strikeDamage(150,10,false,0)=16, boss attack 15 on 30 HP = 5-6; a chore deals 50 and the clock resets to 1 chore * 3 min = 180000 ms (timeRemaining synchronous), tick after 90 s gives ~50%; expiry fires exactly one attacks! line and one pending damage even if endPlayerTurn's timeout also fires; defend -> brace line, one half strike after 1 s, isDefending true until applyPendingDamage, then false and a full clock with isPlayerTurn true; run failure (Math.random mocked 0.9) -> exactly one strike; run success in a free fight with random 0.9 (alwaysEscapes) -> escaped; applyPendingDamage lowers the store's hp.now, fires onDamage('player'), queues the damage line, restarts the clock, isPlayerTurn true; HP reaching 0 fires onPlayerDefeated with the two lines; finishCombatTask during a pending strike does not restart the clock; the last chore fires onDamage(..., true) and onEnemyDefeated; restoreCompletedTasks(["t1"]) marks it done, health 50, returns 1. Run pnpm typecheck, pnpm test -- tests/unit/BattleService.spec.ts. The page spec src/app/Console/BattleField/tests/BattleField.spec.ts WILL fail until F lands; do not edit it; list the broken expectations in your task comment.

E. components/BattleVictoryCard.vue (board #29, handle @sonnet-victory)

Pure presentational: no store, router, sounds. <script setup lang="ts">, scoped SCSS, import VictoryCardState from ../battle/battleView. Props: visible: boolean; card: VictoryCardState. Emits: "continue". Root [data-cy="victory-card"], role="button" tabindex="0", @click and @keyup.enter emit continue (a tap while tiles are still counting first completes every count; the next tap emits continue). eb-box card, max-width 360 px, centered by the PARENT (the page wraps it in a centering overlay); the card itself is just the box with 20 px margin. Content: title .victory-title (card.title) in "Year Is 199X" 2rem, pale yellow with a slow shimmer (background-clip text gradient animation, transform/opacity only); when card.levelUp a ribbon .victory-levelup "LEVEL UP! Lv {from} โ†’ {to}" (Onett 1.2rem, #ffd84a); subtitle .victory-subtitle (card.subtitle, StatusPlz 1rem); optional .victory-quest "Quest tamed: {questName}"; .victory-work "{choresDone} chores in {m:ss}" from elapsedMs ("1 chore" singular); three tiles .reward-tile[data-stat=xp|gp|ap] in a row: label (XP / GP / AP), icon (fad fa-star / fa-coins / fa-bolt), .reward-value[data-stat=...] big number (Orange Kid 2rem) coloured var(--xp-color-xp|gp|ap); a tile whose value is 0 renders at 40% opacity. Count-up: watch(() => props.visible, start, { immediate: true }); per tile setTimeout(delay 0 / 250 / 500) then a fixed 30-step setTimeout(step, 30) chain whose last step writes the exact target; reset to 0 when visible goes false; completeAll() writes every target at once. When card.held: tiles get .is-held (60% opacity, a small lock icon fad fa-lock) and a ribbon .victory-held "Waiting for the Game Master" (StatusPlz, amber). Twelve .victory-star โœฆ particles falling (pure CSS, transform/opacity only, nth-child stagger, 2.5 s loop, pointer-events none, inside the card). Footer .victory-footer "Tap to continue โ–ผ" bouncing. Reduced motion: @media (prefers-reduced-motion: reduce) { .victory-star, .victory-title, .victory-footer { animation: none } }. Test: tests/BattleVictoryCard.spec.ts (fake timers): mount visible with rewards {xp:50,gp:25,ap:1}, choresDone 2, elapsedMs 125000 -> after advanceTimersByTime(500 + 30*30 + 50) .reward-value[data-stat=xp] "50", gp "25", ap "1"; .victory-work "2 chores in 2:05"; a click during counting completes counts without emitting; a click after emits continue once; held renders the ribbon and .is-held tiles; levelUp renders the ribbon.

F. Page integration (owner: Fable; A-E must not edit these)

BattleField.vue/.ts, _BattleField.scss (layout, message box, transitions, screen-shake/hit-flash keyframes), battle/battleView.ts, battle/battleRewards.ts (free fights pay AP only), battle/battleProgress.ts (new: today's finished chores per hero + fight key, localStorage), battle/battleMenu.ts, composables/useAtbDisplay.ts, useBattleDialog.ts (DialogLine auto-advance), useBattleOutcome.ts (hold/pay, HP restore, victory sequencing with VICTORY_TIMINGS), useBattleAudio.ts, useBattleFeedback.ts, components/BattleDialogBox.vue (always-reserved zone, tap on the root, prompt line, the menu Tackle | Defend, Powers | Goods, Run Away full-width as in section H), src/components/atoms/TypingText/XpTypingText.vue (interactive prop), src/lib/services/quests/QuestCompletionService.ts (battle hold path), hud/XpHpMpHud (deleted), tests/BattleField.spec.ts (new assertions: no ion-alert; .chores-left-label; .attack-timer-label "6:00" then "3:00"; task sheet flow; .damage-popup "-50"; victory card values; strike + defend scenarios), public/index.html (viewport-fit=cover), docs.

G. E2E (owner: Fable, after F): tests/e2e/specs/battle.spec.js (already written against the hooks above), support/commands.js, fixtures/battle-backup.json, plugins (window size, autoplay policy), the dev-only window.__xp seam in src/main.ts.

H. Goods and Powers (2026-10-12, board #949; rewritten 2026-10-13 for the equipment rules; design: docs/superpowers/specs/2026-10-12-battle-powers-and-bag-design.md and docs/superpowers/specs/2026-10-13-equipment-rules-design.md)

The battle menu is Tackle | Defend, Powers | Goods, Run Away (.menu-item, found by text; the action ids stay attack, defend, item, run-away, plus the new powers). Tackle is the old Attack and opens the checklist; its icon is the hero's class weapon (tackleIcon(jobClass) in battle/battleMenu.ts, fist-raised with no class). The run confirm pair is unchanged. The checklist only ever confirms finished chores: it has no postpone mode, and no item or Power sends a chore back to the board.

Shared effects (src/lib/services/battle/BattleEffects.ts): items and Powers point at one table, so a spell and its matching item behave the same. There are eleven effects, every one once per fight, and once per fight is per effect, whoever cast it (Chronos' Clock and the Stop Power share one Time Stop; Bombs and Thor's Hammer share one Stun). An effect never touches MP; the caller charges it only on ok, never on a refusal.

Effect What it does Engine call Line on success
frost +10 minutes on the clock extendClock(ICE_WAND_MS) Ice creeps over the clock: +10 minutes!
time-stop Pauses the clock for 15 minutes freezeClock Time stands still for 15 minutes!
kindle Chores earn 1.5x the minutes scaleCooldown The fuse burns slow: every chore earns 1.5x the minutes!
gust Refills the clock to full refillClock(): the running countdown restarts at its full length A gust blows the beast back: the clock is full again!
stun The beast misses its next strike skipNextStrike() {Beast} is stunned! It will miss its next strike.
pin The beast misses its next two strikes skipNextStrike() twice {Beast} is pinned! It will miss its next two strikes.
weaken Halves the beast's attack weakenEnemy The sun blazes! {Beast}'s attack is halved.
vanish Run away with no parting strike vanishNextRun(), then the page runs Run Away; handleRun dodges the parting strike (PartingDodge 'vanish'); the grudge mark and the quest stay as for any run {Hero} vanishes into the shadows...
charge Chores give back double MP a flag the chore gains read Lightning crackles: every chore gives back double MP!
moonlight Each chore heals 5% of max HP a flag the chore gains read Moonlight settles on you: every chore heals you!
heal Heals 25% of max HP (Powers only) the user store +{n} HP

recall is retired: isBattleEffectId('recall') is false, and a stored recall (a saved Rewind) reads as gust. Engine refusals keep their wording: "The clock is not running." (frost, time-stop, gust), and a vanish refuses with "A strike is already on its way." while a strike waits, because Run Away would refuse too: it charges nothing and does not mark the effect used.

Chore gains (src/lib/services/battle/choreGains.ts, pure; choreGains({ maxHp, maxMp, questFight, charged, moonlit }) gives { hp, mp }): the page applies them as each chore completes. A chore in a fight that holds a quest gives back 10% of max MP (at least 1; charge doubles it for the rest of the fight) and, under moonlight, heals 5% of max HP (at least 1). A free fight gives nothing back: its moves are not real chores and could be farmed.

Goods opens My Goods in battle mode (XpInventoryModal mode="battle", an ion-modal, the one exception to the no-modal rule): one row for each thing in the pack and each tool in the hero's two hands, with its effect line, its MP chip (the class-discounted heroMpBill) and a Use button (.goods-use-btn; tapping the row does the same). When the item cannot be used the row is disabled and a red tag (.goods-reason) says why. There are no hand slots in the sheet: the hands are swapped before the fight.

  • Hands only. Only a tool held in a hand can be used (heldItems in the page's item context). An unlocked tool that is not held reads "Hold it in a hand before a fight."; a locked one shows its unlock hint. Reasons, in the order they are asked: locked, not held, "Already used this fight" (or the engine's wait), "Out of bombs.", "Not enough MP". A tool with no fight use (the Lantern, Hook Shot, Gaia's Quake, Pegasus Boots, Wind Whistle, the navigation items) reads "Can't use that in a fight."
  • battleBagRole(item) sorts the rows: effect (the fight tools in ItemRules: Ice Wand, Chronos' Clock, Thor's Hammer, Bombs, Silver Arrow, Sol's Flare, Fire Wand, Gale's Wind, X's Invisibility, Tesla's Bolt, Night's Light), potions (Bottles and Goods), powers (Book Of Spells), potion (a potion or food in the pack, drunk straight from its row) and none (everything else). Bottles, Goods and Book Of Spells open their sheet; any other row runs its effect.
  • Costs and text come from ItemRules by item name, never from the description a hero's old copy stored (a stored Ice Wand "50MP | ..." costs 10 MP). The Bombs cost one Bomb from the pack instead of MP ("Out of bombs." with an empty pack, nothing spent). Use closes the sheet, runs the effect, charges on ok and keeps the hero's move. A vanish runs Run Away at once, without the confirm.

Potions (BattlePotions.ts, BattlePotionSheet.vue, data-cy="potion-sheet", back button potion-sheet-back; opened by a held Bottles or Goods): the Potion (the bottle a temple chest drops), Health Potion, Mana Potion, Ether, Elixir and the two spring bottles in user.inventory, one row each, data-cy="potion-<id>" (the slug of the name, e.g. potion-health-potion) showing icon, name, x{qty}, effect and the reason when it can't be drunk. The Goods sheet drinks the same potions straight from their rows. A drink costs no MP and has no once-per-fight limit; it spends one from the pack (spendInventoryItem) and keeps the hero's move. Lines: "You drink a Health Potion: +50 HP" (the chest Potion "You drink a Potion: +50 HP", Mana Potion +30 MP, Ether +50 MP, Spring Water +10 HP, Silver Water +10 MP (the Bottles' temple use brings them home), Elixir "HP and MP fully restored!"). Refusals, nothing spent: "Your HP is full.", "Your MP is full.", "You are already at full strength." Empty pack: "Your bottles are empty. Temple shops brew potions."

Powers (BattlePowers.ts, BattlePowersSheet.vue, data-cy="powers-sheet", back button powers-sheet-back): the hero's unlocked in-game Powers that have a fight effect, as My Powers cards (XpPowerCard with a battle prop: no AP meter, "{mp} MP ยท {effect}", the reason as the state line) with a Cast button (.cast-btn). Real-life Powers never appear (they bend the quest board instead: docs/Items-And-Classes-Contract.md, B). Casting charges the class-discounted mpCost on ok and keeps the hero's move. The line puts the caster first: "{Hero} casts Stop! Time stands still for 15 minutes!". A fight cast skips AbilityService.useAbility: no cast log, no Town Crier line, no adult notification, no lastUsed; the Power's real-world frequency cooldown does not apply, once-per-fight is the only limit. Empty state: "No battle Powers yet. Earn AP to unlock them."

Compendium: an in-game Power carries battleEffect (BattleEffectId | 'none'), picked in the In battle select (data-cy="ability-battle-effect", in-game Powers only). Saved Powers without it fall back to the preset pick by name (Haste kindle, Slow frost, Stop time-stop, Rewind gust, Debug weaken, Overclock stun, System Restore heal); 'none' wins over the fallback; a stored recall reads as gust; any other unknown value is treated as none and not listed.

Page (BattleField.ts): one sheet ref ('task' | 'bag' | 'potions' | 'powers' | null). Victory, the hero downed and leaving the view close every sheet and the bag, and no use applies after the end. Every outcome goes through sayItemResult (sound, haptic tap when ok, one auto line in the dialog); a vanish then runs Run Away. Free fights and quest fights behave the same, except that only a quest fight pays chores back (choreGains).

E2E (tests/e2e/specs/battle.spec.js, fixture tests/e2e/fixtures/battle-backup.json): the hero has 20/42 HP, 300 MP, the Ice Wand in the left hand and Thor's Hammer in the right, two Health Potions and a Sol's Flare in the pack (in neither hand), and the in-game Power Stop (time-stop, 25 MP, 0 AP). The spec taps Goods and the Ice Wand row's Use ("+10 minutes"), Goods again and checks the Sol's Flare row reads "Hold it in a hand before a fight." with Use disabled, then drinks a Health Potion from its row ("You drink a Health Potion", HP 20 to 42), then Powers and Cast ("casts Stop!"); screenshots 10-bag, 13-not-held, 11-potions, 12-powers. Cypress 8's bundled Electron 93 cannot parse [email protected], so run it in Chrome: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/battle.spec.js.

I. Grudge marks (2026-10-12, board #1232; docs/Quest-Rooms-Spec.md 5.7)

Running from a fight that carries a quest leaves a grudge on that quest's beast for that hero. The owner's numbers: +25% strike damage per mark, three marks at the most (1.75x), completing the quest clears them, nothing grows by itself over time. Each hero's grudge is their own.

  • Rules (src/lib/services/battle/questGrudge.ts, pure, tolerant of a missing or old record): grudgeFactor(marks) = 1 + 0.25 x marks, clamped to [1, 1.75]; marksFor(record, questId), addGrudgeMark (cap 3, stamps lastFledAt), clearGrudgeMarks (keeps lastAmbushAt, the ambush rule's own cooldown), grudgeLabel(marks).
  • Strikes: strikeDamage(maxHp, attack, braced, roll, braceFactor, strikeFactor, grudge = 1); grudge is a sibling multiplier clamped to [1, 1.75] (BattlePassive.strikeFactor is the hero's trained-strength share and stays <= 1). Both multiply before the floor, then the brace applies. partingStrike({ ..., grudge }) passes it on.
  • Engine: BattleServiceOptions.grudge or setGrudge(f) sets the factor for the fight (getGrudge() reads it); it multiplies every clock strike, the strike a Defend provokes and the parting strike, in board battles, quest rooms and ambushes alike. A dodged parting strike stays dodged.
  • Loading: Encounter.grudge is the hero's factor for the quest being fought (1 for no marks; absent for a fight with no quest); enemies[0].grudgeMarks is the mark count for the stage. The page passes grudge: encounter.grudge to createBattleService.
  • Marking: useBattleOutcome.handleEscape(report) first adds one mark (userStore.addQuestGrudge) when report.carriesQuest and the encounter holds a quest, before the Hospital branch (report.emptied). A free fight or a classic room marks nothing.
  • Clearing: settleQuestWin clears the winner's marks on the quest in every branch (paid, held, window already used); completeQuest (claim, pending or approved) and recordBattleCompletion clear them through QuestCompletionService, and a shared quest (Team Effort, Rat Race) clears every assignee's. A later rejection does not bring them back.
  • Store: the hero profile gets questGrudges?: Record<questId, { marks, lastFledAt?, lastAmbushAt? }>, written by addQuestGrudge(userId, questId, nowMs?) / clearQuestGrudge(userId, questId) and persisted with persistUser.
  • Where it shows: the red scratches on the enemy stage (section A) and the lurking quest room sheet's line ("Wash the dishes ยท Due 6:00pm ยท Grudge x2").
  • Specs: tests/unit/questGrudge.spec.ts, BattleServiceStrikeMath.spec, BattleService.spec ("a grudge"), BattleOutcomePassives.spec ("grudge marks"), BattleOutcomeDefeat.spec, QuestCompletion.spec, encounterLoader.spec, userStoreModules.spec, QuestRoomSheet.spec, BattleEnemyStage.spec.

Definition of done

typecheck 0 errors; lint 0 errors; every jest suite green (4 new component specs + BattleService spec + updated page spec); Cypress battle walk-through green with screenshots reviewed by Fable; docs/Battle-Field-Architecture.md, docs/current_context.md, docs/Roadmap.md updated; committed.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ