Battle Field Contract v2 - xopherdeep/do-it-for-the-xp GitHub Wiki
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).
- 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.contextis shimmed (tests/jest/require-context.setup.js) and throws MODULE_NOT_FOUND on unknown keys like webpack. Neverawait new Promise(r => setTimeout(r))under fake timers; useawait nextTick()/flushPromises()from @vue/test-utils.pnpm typecheckalso 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 typecheckclean;pnpm test -- <your spec>green; lint viapnpm linthas 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.
- A beast is a chore. Its checklist items are the hero's attacks ("moves").
- Tackle (the old Attack) opens the checklist sheet; confirming a chore deals ceil(maxHealth / tasks) damage.
- 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.
- 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.
- 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.
- Free fights (random encounters, Test Fight) pay 1 AP per beast and no XP/GP: only real quests and temple rooms pay chore rewards.
- 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 ashospitalBill(the hero is grounded until it is paid at the Gold Bank). The arena says "You were carried to the Hospital.", then routes tohospitalwith?admitted=1. Thegame-oversplash and the 1 HP limp home are retired. - Every exit (victory, escape, game over, hardware back) returns to where the fight started (battleReturnRoute).
- 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".
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).
- 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:
attacksound + medium haptic, beastdamagedshake, "-{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 zeropreAttack+ 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 uialertsound 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, orlevelUpwhen 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)readsreport.emptied.
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".
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.
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.
- 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. - performEnemyTurn(): first
stopAttackTimer(); ifpendingPlayerDamage > 0return (a strike is already waiting). Readbraced = isDefendingto compute damage and rememberpendingStrikeBraced = bracedbut do NOT clear the brace here. FireonAttack('enemy'). Queue only${enemy.name} attacks!. Store pendingPlayerDamage. - 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(). - 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. - handleRun() (QR8): always succeeds.
escapeChanceAgainstand the 75/90/50/25 table are gone. stopAttackTimer, then, unlessalwaysEscapes(constructor flag orsetFreeFight(true): a free fight takes nothing), one parting strike:partingStrike()inbattleService/strikeMath.tstakes a fullstrikeDamage(maxHp, attack, softened, roll, braceFactor, strikeFactor), softened (the brace factor, once) when the hero was bracing (isDefending) or the class hasrunChanceset (Thief, Assassin, Ninja), and dodged when the Ninja'sskipFirstStrikeis still owed, the clock is frozen (Chronos), a Thor's Hammer skip is owed (it is used up), orMath.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 firesonDamage('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 saysemptied). Returns{ success: true, message: "Escaped successfully", escape: EscapeReport }with{ free, damage, dodgedBy, softener, emptied, carriesQuest };carriesQuestcomes from thecarriesQuestservice option (the page sets it when the fight holds a quest) and is the seam for the grudge mark. The onlysuccess: falseleft is a strike already on its way. - 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(), elseif (pendingPlayerDamage === 0) { stopAttackTimer(); startAttackTimer(); }`. - startPlayerTurn(): only setPlayerTurnState(true); no lines at all. endPlayerTurn(): setPlayerTurnState(false) then the 1 s timeout to performEnemyTurn (keep); remove setBattleMessage.
- Clock: startAttackTimer() computes
minutesPerChore = currentEnemy?.attackCooldown ?? 3,duration = max(1, remaining) * minutesPerChore * 60_000, setsattackDeadline = Date.now() + duration,attackDuration = duration, and SYNCHRONOUSLY sets timeRemaining = duration and enemyAttackTimer = 100; a 100 ms setInterval callstick(). Add publictick(): timeRemaining = max(0, deadline - Date.now()); enemyAttackTimer = timeRemaining / duration * 100; at 0 -> stopAttackTimer + performEnemyTurn. AddgetAttackDuration(). Use Date.now() only. - 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). - 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. - 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/setDevModehits in src/lib/store/stores/*.ts are different symbols). index.ts:export const createBattleService = (options?: { freeFight?: boolean }) => new BattleService(options); keep createBestiaryService. - 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/ProfileDbor 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 oneattacks!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. Runpnpm 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.
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.
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 (
heldItemsin 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 inItemRules: 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) andnone(everything else). Bottles, Goods and Book Of Spells open their sheet; any other row runs its effect. - Costs and text come from
ItemRulesby 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. Avanishruns 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.
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, stampslastFledAt),clearGrudgeMarks(keepslastAmbushAt, the ambush rule's own cooldown),grudgeLabel(marks). -
Strikes:
strikeDamage(maxHp, attack, braced, roll, braceFactor, strikeFactor, grudge = 1);grudgeis a sibling multiplier clamped to [1, 1.75] (BattlePassive.strikeFactoris 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.grudgeorsetGrudge(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.grudgeis the hero's factor for the quest being fought (1 for no marks; absent for a fight with no quest);enemies[0].grudgeMarksis the mark count for the stage. The page passesgrudge: encounter.grudgetocreateBattleService. -
Marking:
useBattleOutcome.handleEscape(report)first adds one mark (userStore.addQuestGrudge) whenreport.carriesQuestand the encounter holds a quest, before the Hospital branch (report.emptied). A free fight or a classic room marks nothing. -
Clearing:
settleQuestWinclears the winner's marks on the quest in every branch (paid, held, window already used);completeQuest(claim, pending or approved) andrecordBattleCompletionclear them throughQuestCompletionService, 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 byaddQuestGrudge(userId, questId, nowMs?)/clearQuestGrudge(userId, questId)and persisted withpersistUser. - 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.
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.