diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 0fc9389..1b8321a 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-12T10:30:18.304Z +Generated: 2026-06-12T10:39:16.751Z Found: 4 circular chain(s) — these MUST be fixed before modifying involved files. 1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index b3c55b4..9e9805a 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-12T10:30:16.054Z", + "generated": "2026-06-12T10:39:14.588Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index f9bafc1..96bb02f 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -239,6 +239,7 @@ Mana-Loop/ │ │ │ │ ├── mana-utils.test.ts │ │ │ │ ├── melee-auto-attack.test.ts │ │ │ │ ├── melee-defense-bypass.test.ts +│ │ │ │ ├── non-combat-room-reward-guards.test.ts │ │ │ │ ├── pact-utils.test.ts │ │ │ │ ├── paused-conversion-dedup.test.ts │ │ │ │ ├── persistence.test.ts diff --git a/src/lib/game/__tests__/non-combat-room-reward-guards.test.ts b/src/lib/game/__tests__/non-combat-room-reward-guards.test.ts new file mode 100644 index 0000000..9a27bf6 --- /dev/null +++ b/src/lib/game/__tests__/non-combat-room-reward-guards.test.ts @@ -0,0 +1,354 @@ +// ─── Regression tests for Issue #382 ────────────────────────────────────────── +// Verifies that non-combat rooms (library, treasure) do NOT grant repeated +// rewards after completion, and that descent properly initializes non-combat rooms. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCombatStore } from '../stores/combatStore'; +import { useManaStore, makeInitialElements } from '../stores/manaStore'; +import { useDisciplineStore } from '../stores/discipline-slice'; +import { useUIStore } from '../stores/uiStore'; +import { useGameStore } from '../stores/gameStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { tickNonCombatRoom, onEnterLibraryRoom, onEnterTreasureRoom, onEnterRecoveryRoom } from '../stores/non-combat-room-actions'; +import { onEnterRoomDescend } from '../stores/combat-descent-actions'; +import type { FloorState } from '../types'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeInitialSpells() { + return { manaBolt: { id: 'manaBolt', unlocked: true, cooldown: 0 } }; +} + +function makeLibraryRoom(progress = 0, required = 10): FloorState { + return { + roomType: 'library', + enemies: [], + libraryProgress: progress, + libraryRequired: required, + libraryStayed: false, + }; +} + +function makeTreasureRoom(progress = 0, required = 10, loot = makeTestLoot()): FloorState { + return { + roomType: 'treasure', + enemies: [], + treasureProgress: progress, + treasureRequired: required, + treasureLoot: loot, + treasureLootClaimed: [], + }; +} + +function makeTestLoot(): FloorState['treasureLoot'] { + return [ + { id: 'gold1', name: 'Gold', type: 'gold' as const, amount: { min: 10, max: 20 } }, + { id: 'gold2', name: 'Gold', type: 'gold' as const, amount: { min: 10, max: 20 } }, + { id: 'gold3', name: 'Gold', type: 'gold' as const, amount: { min: 10, max: 20 } }, + { id: 'gold4', name: 'Gold', type: 'gold' as const, amount: { min: 10, max: 20 } }, + ]; +} + +function advanceSpy() { + const calls: Array<{ floor: number; room: number }> = []; + const fn = () => { + const s = useCombatStore.getState(); + calls.push({ floor: s.currentFloor, room: s.currentRoomIndex }); + }; + return { calls, fn }; +} + +function makeCombatStoreState(overrides: Partial> = {}) { + return { + currentFloor: 5, + floorHP: 0, + floorMaxHP: 0, + maxFloorReached: 5, + activeSpell: 'manaBolt', + currentAction: 'climb' as const, + castProgress: 0, + spireMode: true, + currentRoom: makeLibraryRoom(0, 10), + clearedFloors: {} as Record, + climbDirection: 'up' as const, + isDescending: false, + startFloor: 1, + exitFloor: 1, + currentRoomIndex: 0, + roomsPerFloor: 5, + descentPeak: null as { floor: number; roomIndex: number } | null, + roomResetState: {} as Record, + clearedRooms: {} as Record, + isDescentComplete: false, + golemancy: { activeGolems: [] as [], lastSummonFloor: 0, golemDesigns: {} as Record, golemLoadout: [] as [] }, + equipmentSpellStates: [] as [], + comboHitCount: 0, + floorHitCount: 0, + meleeSwordProgress: {} as Record, + weaponCastProgress: {} as Record, + spells: makeInitialSpells(), + activityLog: [] as [], + achievements: { unlocked: [] as string[], progress: {} as Record }, + totalSpellsCast: 0, + totalDamageDealt: 0, + totalCraftsCompleted: 0, + guardianShield: 0, + guardianShieldMax: 0, + guardianBarrier: 0, + guardianBarrierMax: 0, + ...overrides, + }; +} + +function resetAllStores() { + useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] }); + useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true }); + useManaStore.setState({ rawMana: 10000, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(500, {}) }); + usePrestigeStore.setState({ + loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0, + prestigeUpgrades: {}, pactSlots: 1, defeatedGuardians: [], + signedPacts: [], signedPactDetails: {}, + pactRitualFloor: null, pactRitualProgress: 0, + }); + useDisciplineStore.setState({ + disciplines: { + 'regen-transference': { + id: 'regen-transference', + defId: 'regen-transference', + xp: 100, + active: true, + paused: false, + xpAccruedThisLoop: 100, + lastPerkIndex: -1, + perksApplied: [], + }, + }, + activeIds: ['regen-transference'], + concurrentLimit: 1, + totalXP: 100, + processedPerks: [], + }); +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('Issue #382 — Non-combat room reward guards', () => { + beforeEach(() => { + resetAllStores(); + }); + + // ── Library Room ────────────────────────────────────────────────────────── + + describe('tickNonCombatRoom — library', () => { + it('should NOT grant XP when progress >= required (already complete)', () => { + const adv = advanceSpy(); + // Room is already complete: progress=10, required=10 + useCombatStore.setState(makeCombatStoreState({ + currentRoom: makeLibraryRoom(10, 10), + })); + + const xpBefore = useDisciplineStore.getState().totalXP; + + tickNonCombatRoom( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + 0.04, + adv.fn, + ); + + const xpAfter = useDisciplineStore.getState().totalXP; + expect(xpAfter).toBe(xpBefore); + }); + + it('should grant XP when progress < required (not yet complete)', () => { + const adv = advanceSpy(); + // Room is NOT complete: progress=0, required=10 + useCombatStore.setState(makeCombatStoreState({ + currentRoom: makeLibraryRoom(0, 10), + })); + + const xpBefore = useDisciplineStore.getState().totalXP; + + tickNonCombatRoom( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + 0.04, + adv.fn, + ); + + const xpAfter = useDisciplineStore.getState().totalXP; + expect(xpAfter).toBeGreaterThan(xpBefore); + }); + + it('should NOT grant XP on ticks after the room has been completed', () => { + const adv = advanceSpy(); + // Room starts at progress=0, required=10 + useCombatStore.setState(makeCombatStoreState({ + currentRoom: makeLibraryRoom(0, 10), + })); + + // Tick many times to complete the room + for (let i = 0; i < 300; i++) { + tickNonCombatRoom( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + 0.04, + adv.fn, + ); + } + + // Now the room should be complete; verify no more XP is granted + const xpAfterCompletion = useDisciplineStore.getState().totalXP; + + // Tick again — should NOT grant more XP + tickNonCombatRoom( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + 0.04, + adv.fn, + ); + + const xpAfterExtraTick = useDisciplineStore.getState().totalXP; + expect(xpAfterExtraTick).toBe(xpAfterCompletion); + }); + }); + + // ── Treasure Room ───────────────────────────────────────────────────────── + + describe('tickNonCombatRoom — treasure', () => { + it('should NOT process loot when progress >= required (already complete)', () => { + const adv = advanceSpy(); + // Room already complete + useCombatStore.setState(makeCombatStoreState({ + currentRoom: makeTreasureRoom(10, 10, makeTestLoot()), + })); + + const rawManaBefore = useManaStore.getState().rawMana; + + tickNonCombatRoom( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + 0.04, + adv.fn, + ); + + const rawManaAfter = useManaStore.getState().rawMana; + expect(rawManaAfter).toBe(rawManaBefore); + // advance should be called to move past the completed room + expect(adv.calls.length).toBe(1); + }); + + it('should NOT grant new loot on successive ticks after completion', () => { + const adv = advanceSpy(); + // Complete room with already-claimed loot + useCombatStore.setState(makeCombatStoreState({ + currentRoom: { + roomType: 'treasure', + enemies: [], + treasureProgress: 15, + treasureRequired: 10, + treasureLoot: makeTestLoot(), + treasureLootClaimed: [0, 1, 2, 3], + }, + })); + + const rawManaBefore = useManaStore.getState().rawMana; + + tickNonCombatRoom( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + 0.04, + adv.fn, + ); + + const rawManaAfter = useManaStore.getState().rawMana; + expect(rawManaAfter).toBe(rawManaBefore); + }); + }); + + // ── Descent: onEnterRoomDescend ─────────────────────────────────────────── + + describe('onEnterRoomDescend — non-combat room initialization', () => { + it('should initialize library room during descent', () => { + useCombatStore.setState({ + ...makeCombatStoreState({ + currentFloor: 10, + currentRoomIndex: 2, + roomsPerFloor: 5, + roomResetState: { '10:2': false }, + clearedRooms: { '10': true }, + currentRoom: makeLibraryRoom(0, 1), + climbDirection: 'down' as const, + isDescending: true, + descentPeak: { floor: 10, roomIndex: 4 }, + }), + // Override with full state + } as any); + + onEnterRoomDescend( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + ); + + const room = useCombatStore.getState().currentRoom; + expect(room.libraryProgress).toBe(0); + expect(room.libraryRequired).toBe(1); + }); + + it('should initialize treasure room during descent', () => { + useCombatStore.setState({ + ...makeCombatStoreState({ + currentFloor: 10, + currentRoomIndex: 2, + roomsPerFloor: 5, + roomResetState: { '10:2': false }, + clearedRooms: { '10': true }, + currentRoom: makeTreasureRoom(0, 1, makeTestLoot()), + climbDirection: 'down' as const, + isDescending: true, + descentPeak: { floor: 10, roomIndex: 4 }, + }), + } as any); + + onEnterRoomDescend( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + ); + + const room = useCombatStore.getState().currentRoom; + expect(room.treasureProgress).toBe(0); + expect(room.treasureRequired).toBe(1); + }); + + it('should initialize recovery room during descent', () => { + useCombatStore.setState({ + ...makeCombatStoreState({ + currentFloor: 10, + currentRoomIndex: 2, + roomsPerFloor: 5, + roomResetState: { '10:2': false }, + clearedRooms: { '10': true }, + currentRoom: { + roomType: 'recovery', + enemies: [], + recoveryProgress: 0, + recoveryRequired: 1, + recoveryStayed: false, + }, + climbDirection: 'down' as const, + isDescending: true, + descentPeak: { floor: 10, roomIndex: 4 }, + }), + } as any); + + onEnterRoomDescend( + () => useCombatStore.getState() as any, + (partial) => useCombatStore.setState(partial), + ); + + const room = useCombatStore.getState().currentRoom; + expect(room.recoveryProgress).toBe(0); + expect(room.recoveryRequired).toBe(1); + }); + }); +}); diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts index 9c39b62..575fd69 100644 --- a/src/lib/game/stores/combat-descent-actions.ts +++ b/src/lib/game/stores/combat-descent-actions.ts @@ -214,6 +214,17 @@ export function onEnterRoomDescend(get: GetFn, set: SetFn): void { }); } } + + // Initialize non-combat rooms during descent so they have proper progress tracking + if (s.currentRoom.roomType === 'library') { + onEnterLibraryRoom(get, set); + } else if (s.currentRoom.roomType === 'recovery') { + onEnterRecoveryRoom(get, set); + } else if (s.currentRoom.roomType === 'treasure') { + onEnterTreasureRoom(get, set); + } else if (s.currentRoom.roomType === 'puzzle') { + onEnterPuzzleRoom(get, set); + } return; } diff --git a/src/lib/game/stores/non-combat-room-actions.ts b/src/lib/game/stores/non-combat-room-actions.ts index 51b7ca7..67a1143 100644 --- a/src/lib/game/stores/non-combat-room-actions.ts +++ b/src/lib/game/stores/non-combat-room-actions.ts @@ -126,25 +126,27 @@ function tickLibraryRoom(get: GetFn, set: SetFn, hours: number, advance: Advance const progress = (room.libraryProgress || 0) + hours; const required = room.libraryRequired || 1; - const BASE_LIBRARY_XP_PER_HOUR = 50; - const xpGrant = Math.floor(BASE_LIBRARY_XP_PER_HOUR * 25 * hours); + if (progress < required) { + const BASE_LIBRARY_XP_PER_HOUR = 50; + const xpGrant = Math.floor(BASE_LIBRARY_XP_PER_HOUR * 25 * hours); - if (xpGrant > 0) { - const disciplineStore = useDisciplineStore.getState(); - const allDisciplines = disciplineStore.disciplines; - const entries = Object.entries(allDisciplines).filter(([, ds]) => ds && !ds.paused); + if (xpGrant > 0) { + const disciplineStore = useDisciplineStore.getState(); + const allDisciplines = disciplineStore.disciplines; + const entries = Object.entries(allDisciplines).filter(([, ds]) => ds && !ds.paused); - if (entries.length > 0) { - const [targetId, targetDs] = entries[Math.floor(Math.random() * entries.length)]; - useDisciplineStore.setState((prev) => ({ - disciplines: { - ...prev.disciplines, - [targetId]: { ...targetDs, xp: (targetDs?.xp || 0) + xpGrant }, - }, - totalXP: prev.totalXP + xpGrant, - })); - get().addActivityLog('special_effect', - `${targetDs?.id || targetId} gained ${xpGrant} XP from studying ancient tomes`); + if (entries.length > 0) { + const [targetId, targetDs] = entries[Math.floor(Math.random() * entries.length)]; + useDisciplineStore.setState((prev) => ({ + disciplines: { + ...prev.disciplines, + [targetId]: { ...targetDs, xp: (targetDs?.xp || 0) + xpGrant }, + }, + totalXP: prev.totalXP + xpGrant, + })); + get().addActivityLog('special_effect', + `${targetDs?.id || targetId} gained ${xpGrant} XP from studying ancient tomes`); + } } } @@ -180,6 +182,13 @@ function tickTreasureRoom(get: GetFn, set: SetFn, hours: number, advance: Advanc const loot = room.treasureLoot || []; const claimed = room.treasureLootClaimed || []; + if (progress >= required) { + set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: claimed } }); + get().addActivityLog('special_effect', `Treasure room looted — ${claimed.length} items recovered`); + advance(get, set); + return; + } + const thresholds = [0.10, 0.50, 0.95, 1.0]; const newlyClaimed: number[] = []; const claimedSet = new Set(claimed); @@ -228,14 +237,7 @@ function tickTreasureRoom(get: GetFn, set: SetFn, hours: number, advance: Advanc } } - if (progress >= required) { - const claimedCount = claimedSet.size; - set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } }); - get().addActivityLog('special_effect', `Treasure room looted — ${claimedCount} items recovered`); - advance(get, set); - } else { - set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } }); - } + set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } }); } function tickPuzzleRoom(get: GetFn, set: SetFn, hours: number, advance: AdvanceFn): void {