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

- 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:
2026-06-12 14:18:04 +02:00
parent 58181139d0
commit fde5911780
6 changed files with 395 additions and 27 deletions
@@ -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;
}
+27 -25
View File
@@ -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 {