fix: prevent non-combat room repeated rewards during descent (Issue #382)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
- Wrap library XP grant in progress < required guard so completed rooms don't grant XP every tick - Add early return in treasure room tick when progress >= required to skip loot processing - Initialize non-combat rooms (library, treasure, recovery) during descent in onEnterRoomDescend - Add regression test file: non-combat-room-reward-guards.test.ts (8 tests)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ReturnType<typeof useCombatStore.getState>> = {}) {
|
||||
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<number, boolean>,
|
||||
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<string, boolean>,
|
||||
clearedRooms: {} as Record<string, boolean>,
|
||||
isDescentComplete: false,
|
||||
golemancy: { activeGolems: [] as [], lastSummonFloor: 0, golemDesigns: {} as Record<string, unknown>, golemLoadout: [] as [] },
|
||||
equipmentSpellStates: [] as [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
meleeSwordProgress: {} as Record<string, number>,
|
||||
weaponCastProgress: {} as Record<string, number>,
|
||||
spells: makeInitialSpells(),
|
||||
activityLog: [] as [],
|
||||
achievements: { unlocked: [] as string[], progress: {} as Record<string, number> },
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ function tickLibraryRoom(get: GetFn, set: SetFn, hours: number, advance: Advance
|
||||
const progress = (room.libraryProgress || 0) + hours;
|
||||
const required = room.libraryRequired || 1;
|
||||
|
||||
if (progress < required) {
|
||||
const BASE_LIBRARY_XP_PER_HOUR = 50;
|
||||
const xpGrant = Math.floor(BASE_LIBRARY_XP_PER_HOUR * 25 * hours);
|
||||
|
||||
@@ -147,6 +148,7 @@ function tickLibraryRoom(get: GetFn, set: SetFn, hours: number, advance: Advance
|
||||
`${targetDs?.id || targetId} gained ${xpGrant} XP from studying ancient tomes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (progress >= required) {
|
||||
set({ currentRoom: { ...room, libraryProgress: progress } });
|
||||
@@ -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) } });
|
||||
}
|
||||
}
|
||||
|
||||
function tickPuzzleRoom(get: GetFn, set: SetFn, hours: number, advance: AdvanceFn): void {
|
||||
|
||||
Reference in New Issue
Block a user