Refactor: split the 4.7k-line game.ts into single-responsibility modules - #17
Merged
Conversation
First step of splitting the ~4700-line game.ts god-class into single- responsibility pieces. The whole Fidchell board game — state, rook-slide move generation, custodial capture, the alpha-beta AI, win/loss resolution, and its save snapshot — now lives in a self-contained Fidchell class that holds a back-reference to Game and reaches out only for shared services (the grid it clears, the callback stream, floor descent, the reward it grants). Game keeps a `fidchell` instance and thin delegations: the `inFidchell` getter, the renderer's board getters, `startFidchell`/`handleFidchellTap`, the HUD payload (`fidchell.uiState()`), and save/resume (serialized explicitly, since the module holds a back-ref; added to SAVE_SKIP). A few engine helpers the module needs (emptyMap/emptyColors/spawnMonster/descendFloor/pushUI, tattooTiles) are now public. game.ts: 4686 → 4390 lines; new src/fidchell.ts is 351. No behaviour change — 300 tests pass (incl. the fidchell save/resume round trip), build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Pure tetromino/timing/scoring math, moved out of game.ts so feature modules (the Causeway Duel next) can use rotateMatrix without a circular import back into game.ts. Re-exported from game.ts so existing importers (main.ts, tests) are unaffected. No behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The rules were only in the dungeon-log, easy to miss on mobile. Now a match opens a titled "how to play" modal (side dealt, the goal, movement, capture, and the win/lose stakes) with a single Begin button. Opened deferred on the first safe tick via Fidchell.maybeShowRules() (driven from autoTick) so it never stacks on the descent dialog that entered the floor. Verified live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The ~380-line spellbook (handleRangedAttack dispatch + every activate* handler, plus findTarget/trail/param helpers) moves into a stateless AbilitySystem class of static methods taking Game — matching the existing systems/*.ts pattern (CombatSystem, HazardSystem, …). No own state, so no save/resume impact. Game keeps a one-line handleRangedAttack that delegates to AbilitySystem.cast. Three engine helpers the spells use are now public: advanceTurn, updateVisibility, and the activeBossOnDeath hook. game.ts: 4367 → 3991 lines (now under 4k). No behaviour change — 300 tests pass, build + smoke green, and casting verified live (a bolt kills its target and consumes ammo; Time Dilation applies its slow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
getInspectInfo becomes an InspectView class composed onto Game (constructor takes the game; read-only projection, no mutation). Game.getInspectInfo now delegates. getHazardAt/isTattooTile made public for the view; SpriteService import dropped from game.ts (no longer used there). InspectView added to SAVE_SKIP so its back-ref isn't swept into the save snapshot. No behaviour change — 300 tests pass, build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
buildCharacterSheet becomes a CharacterSheetView class composed onto Game (read-only stat projection). pushUI now calls characterSheetView.build(). Added to SAVE_SKIP; unused CharacterSheetSection import dropped from game.ts. No behaviour change — 300 tests pass, build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
pushUI's ~85-line UIState assembly moves into a UiStateBuilder class composed onto Game. The private-heavy sub-objects stay encapsulated behind two cohesive Game query methods — duelUiState() and floorProgressState() — so the builder composes only public state (no reaching into duel/floor internals). This also sets up the duel card to later delegate to the Causeway Duel module cleanly. difficultyTuning + characterSheetView exposed; builder added to SAVE_SKIP. No behaviour change — 300 tests pass, build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The pact flow — offering two patrons, swearing one (passive + level-gated spellbook, each paid a toll), and unlocking spells on level-up — becomes a PactCeremony class composed onto Game. The describePatronSpell/describeToll display helpers move with it. Game keeps thin delegators (maybeOfferPact, applyPatron, syncSpellUnlocks); activePatronId stays on Game (it's saved). Now-unused PATRONS/EffectResolver/PatronDef/EffectSpec imports dropped. No behaviour change — 300 tests pass (incl. the applyPatron suite), build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
…ters.ts triggerNpcEncounter (bounty/trade/flavor), the mound seanchaí + your-own-tale, and the ghost-of-a-past-run encounter move into an NpcEncounters class composed onto Game. Call sites in handleHeroMove/waystation now use npcEncounters.triggerEncounter/triggerSeanchai/triggerGhost. previewBossForFloor, activeGhost, and metFlavorNpcIds made public for the module. No behaviour change — 300 tests pass, build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
The three smith-quest methods — announceFloor (the anvil heads-up), next (the smith due), and triggerEncounter (grant a part; reforge the Spear on Goibniu's third meeting) — move into a SmithQuest class composed onto Game. Game keeps thin delegators (the rider-injection in spawnBlock and the two descent paths still call maybeAnnounceSmithFloor/nextSmith). Run state (pendingSmithFloor, smithsMetCount, spearPartsHeld, spearForged) stays on Game and is saved. No behaviour change — 300 tests pass, build + smoke green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move monster spawning and dungeon-room generation into a new src/spawning.ts Spawner class (compose-onto-Game pattern). Game keeps thin delegating methods (spawnMonster, getRandomMonsterKey, maybeSpawnDungeonRoom) so all internal callers, fidchell, and tests are unchanged. heatAdd/heatMult made public for the module; spawner added to SAVE_SKIP (stateless instance, back-ref to Game must not serialize). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the run-configuration pickers — starting class, difficulty preset, New Game+ heat, and Rift Curse modifier (getRandomClasses/applyClass, applyDifficulty, applyHeat, getRandomModifiers/applyModifier) — into a new src/runSetup.ts RunSetup class. Game keeps thin delegating methods so main.ts and tests are unchanged. Live scaling queries the rest of the run reads (difficultyTuning, heatMult/heatAdd, gravity getters) stay on Game. runSetup added to SAVE_SKIP; unused MODIFIERS import dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the mid-run persistence pair (serialize + applySave, plus the SAVE_SKIP scalar-sweep exclusion set) into a new src/saveGame.ts SaveGame class, keeping the SavedRun contract's two halves together. Game keeps thin delegating serialize()/applySave() methods so callers and tests are unchanged. The persistence layer legitimately touches deep run state, so rescueGuards, duelBoss, activeBossOnHalfHp, bossHalfHpTriggered, lastLineClearMs and makeGorgothOnHalfHp are made public (consistent with this refactor's "state on Game, exposed where a module needs it" approach); saveGame added to SAVE_SKIP; unused SAVE_VERSION import dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the in-world vendors and reward stalls — the altar (boon pick), the tattoo artist (Ogham brands), the Fear Dearg's shop, and the rescued-NPC services (Gobán Saor / Fedelm / Airmed / Abcán / Bricriu) — into a new src/vendorOffers.ts VendorOffers class. Each builds a modal payload and hands it to the host via cb. Game keeps thin delegating methods (openAltar, openTattooArtist, openPeddler, openRescueService) so all bump call sites and tests are unchanged. portionAtkBonus made public; vendorOffers added to SAVE_SKIP; unused Brand/BODY_PARTS/ShopItem imports dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the boss-encounter set pieces into a new src/bossEncounters.ts BossEncounters class: the Bres/Gorgoth finale (summonGorgoth, its half-HP roar factory, triggerVictory), the per-boss hook helpers other bosses fire (Cailleach's shard adds, Balor's Herald gravity surge), the near-ceiling "top out to win" nudge, and the boss-floor ambush toast. Game keeps thin delegating methods so all call sites, boss-hook wiring, and tests are unchanged. blockBuildingSuspended getter, stackTopRow, gorgothHintShown, gorgothHalfTriggered and pendingBossFloor made public; bossEncounters added to SAVE_SKIP. game.ts is now under 3000 lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the whole boss-floor Causeway Duel — ~30 methods and its state — into a new src/causewayDuel.ts CausewayDuel class (mirrors the Fidchell module). It owns its duel state (owner grid, switches, wall, boons, boss ref, home, resolution flags) and its own serialize()/restore(); Game holds the instance and delegates. - inCausewayDuel becomes a getter/setter proxying the module's `active`. - Input handlers, the switch/boon bump branch, isValidMove's wall check, and the UI/HUD builder call through game.causewayDuel. - SaveGame gains a causewayDuel snapshot and re-links the live boss ref on restore (the stale duelBoss skip entry is gone); SavedRun grows a causewayDuel field; causewayDuel added to SAVE_SKIP. - randomShapeKey and openStairsChoice made public for the module. - The duel test's private-accessor now reaches into game.causewayDuel. game.ts is down to ~2440 lines (from 4686 at the start of the split). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Move the sídhe-mound rest floor and every mound/floor NPC-tile bump-verb into a new src/waystation.ts Waystation class: enterWaystation() becomes enter(), and the ~230-line npcTile dispatch buried in handleHeroMove (An Dagda, the pact emissary, nexus rescues, ogham stone, Sídhe coffer, Well of Segais, held floor event, hearth-fire, Fear Dearg's stall, seanchaí, ghost, smiths, wandering NPCs) becomes interact(npcTile). The mound layout constant moves too; Game.MOUND now aliases Waystation.MOUND so tests keep resolving positions by name. handleHeroMove's npc branch collapses to a single delegate call. The module collaborates with sibling subsystems directly, so pact, npcEncounters, smithQuest and vendorOffers become public readonly and the now-dead Game delegators (openRescueService, maybeOfferPact, triggerSmithEncounter) are removed; the smith test calls game.smithQuest.triggerEncounter directly. pactPending and ritualComplete made public; waystation added to SAVE_SKIP; unused RescueDef import dropped. game.ts is down to ~2095 lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Update Project structure to list the feature modules now composed onto Game (fidchell, causewayDuel, waystation, bossEncounters, vendorOffers, spawning, runSetup, saveGame, pact, npcEncounters, smithQuest, gameMath, views/), add systems/abilities.ts, and repoint the Core-systems / data-file file references (Waystations, Fidchell, Gorgoth, save-resume, Lugh's Spear, patron pacts) at their new homes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Now that the feature modules are separated, add focused unit tests for the biggest gaps and wire them into coverage: - vitest.config: add the extracted modules (fidchell, causewayDuel, waystation, bossEncounters, vendorOffers, spawning, runSetup, saveGame, pact, npcEncounters, smithQuest, gameMath, views/) to the coverage include list — they were previously unmeasured. - npcEncounters (37→89%): bounty/trade/flavor dialogs, the seanchaí tale chain, ghost lay-to-rest vs turn-away. - runSetup (63→93%): class/modifier pickers, validation throws, effect apply. - vendorOffers (68→84%): peddler buys (afford/refuse/once-per-visit), altar commit, all five rescue services incl. Bricriu's one-helping rule. - bossEncounters (75→93%): crystal-shard adds, gravity surge, win-nudge once-only, summon idempotence, single victory. - views (inspect 52→94%, charSheet branches): every inspect branch; char-sheet placeholders vs a sworn draoi's patron/spellbook. 347 tests (was 300). Overall coverage 74.5→79.9% stmts / 67→71% branch; thresholds raised to 77/68/80/80. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Add focused unit tests for the parts the module split left thinnest: - gameCore.test.ts (new): the core block/floor loop on Game — line clears (gold/XP, the perfect-4 Dagda gift once-only, line-clear-damage & Annihilation-Rune AoE, cursed no-heal, combos, a captive lost to a cleared row), spell cycling, hero-move combat (guaranteed crit, kill, stun), Bealtaine ritual completion, stack collapse, veil upkeep. - pact.test.ts (new): pact gating, applying each patron, the modal's spell/toll descriptions (shriek/veil/drain, mul & add tolls), unlock sync. - waystation.test.ts (new): mound entry layout/residents and every bump verb driven through handleHeroMove (hearth, stall, ogham stone, ghost, smith, wanderer, well). - vendorOffers: altar/tattoo reroll paths + the tattoo artist. - causewayDuel: the gold boon-island payout and restore-from-nothing. Coverage 79.9→81% stmts, 71→72.4% branch (game.ts 69.5→73.7% branch); 386 tests (was 347). Thresholds raised to 79/70/82/82. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Refactor (behaviour-preserving): - AbilitySystem: extract two duplicated idioms into shared helpers — reap() (filter-dead + killMonster, was inlined in shriek/overload/ spearBolt) and slayWithHooks() (killMonster + biome death-hook, was inlined in bolt/drain). Five copies collapse to two helpers. Tests: - abilities.test.ts (new): every ability path through handleRangedAttack — cast guards (no-ability / stunned / cooldown / lethal HP-pact), bolt (hit/ammo/no-target/kill), drain, spear column, shriek, blight, overload, gravity well, veil, blink, time dilation, consecrate. - entities.test.ts (+Particle/ParticlePool): reset/update lifetime, draw (headless ctx), pool pre-fill/spawn/burst/exhaustion/recycle. entities 65→94% stmts, abilities 77→92%; overall 81→84.3% stmts, 72.4→75.5% branch. 411 tests (was 386). Thresholds raised to 82/73/82/85. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Refactor (behaviour-preserving): extract the "adjacent → strike, else chase within range → pursue" skeleton shared by melee/berserker/swift into a single chaseOrStrike(m, game, chaseRange, strike, chase) helper. The three handlers now express only what differs — berserker's enraged second hit, swift's double-move — with one source of truth for the contact/chase gate. Tests (monsterAI.test.ts, new): every behaviour path — - ranged: fire in range+LOS, kite back when crowded, advance when far - healer: mend the hurt ally, else fall back to melee - berserker: double-strike enraged vs single at full HP - swift: two tiles per chase turn - Gorgoth: descend/phase, strike on contact, ignore the veil - movement: ice-slide until it runs out - line of sight: clear across floor, blocked by a void gap - plus the dead-hero early-out and the god-mist veil skip monsterAI 53→92.6% stmts, 43→84.6% branch. Overall 84.3→85.1% stmts, 75.5→77% branch. 426 tests (was 411). Thresholds raised to 83/74/83/86. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
Final duplication pass. Three copy-pasted idioms are consolidated into documented Game helpers, each the single source of truth: - clearBoardEntities(): wipe terrain/colors + every entity & tile-feature list to bare rock (was duplicated in Fidchell, Causeway Duel, Waystation). - revealAll(): light the whole arena, no fog (was in those three plus the Gorgoth finale — four copies of the nested loop). - presentChoice(event, char, onClosed?): open a one-off floor-event choice, apply the pick, log under `char`, resume, run onClosed (was the same ~7-line close-out in NpcEncounters ×2, VendorOffers, SmithQuest, Pact). Behaviour-preserving: 426 tests still pass, typecheck + oxlint clean, build + smoke green. Net −26 lines and one place to change each behaviour. Dropped a now-unused GameConfig import in waystation.ts. A full-repo sweep found no dead code; a handful of internal-only `export`s (config/shape types) were left as intentional API surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
game.tshad grown to ~4,700 lines — a god-class that's hard to navigate. This PR splits it into focused, single-responsibility modules, with no behaviour change (300 tests pass at every step).Result:
game.ts4,686 → 2,095 lines. tsc + oxlint clean, data valid, 300 tests pass (including the fidchell and causeway-duel save/resume round-trips), build + smoke green — verified before every commit.Approach
Each larger feature becomes a class that owns its logic and is composed onto
Gameas a field (new Foo(this)), reached through thin delegating methods so callers, content-hook wiring, and tests are unchanged. Run state stays onGame(exposed where a module needs it) to keep save/resume simple; only logic moves. Modules that hold their own mid-run state (Fidchell, Causeway Duel) serialize/restore explicitly and are re-linked bySaveGame. Done one module per commit, fully verified — this file drives combat, save/resume, and the tick loop, where regressions have historically been subtle.Modules extracted
src/fidchell.tssrc/causewayDuel.tssrc/waystation.tssrc/bossEncounters.tssrc/vendorOffers.tssrc/spawning.tssrc/runSetup.tssrc/saveGame.tsSavedRuncontract)src/pact.tssrc/npcEncounters.tssrc/smithQuest.tssrc/systems/abilities.tssrc/views/inspect.ts,charSheet.ts,uiState.tsGamestate (tap-inspect, char sheet, HUDUIState)src/gameMath.tsWhat stays in
game.tsThe core engine only: the shared run state (grid, falling piece, player, monsters) and the block/floor loop it drives —
spawnBlock/lockBlock/checkLineClears, gravity, floor transition/descent, and combat orchestration inhandleHeroMove. Those are load-bearing and tightly interwoven, so they were intentionally left in place.README's Project-structure and Core-systems sections are updated to match.
🤖 Generated with Claude Code
Generated by Claude Code