fix: deduplicate PAUSED conversion log messages in tick pipeline (bug #337)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s

This commit is contained in:
2026-06-09 15:31:14 +02:00
parent 87f30b9544
commit 4a282a2121
5 changed files with 239 additions and 4 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies
Generated: 2026-06-09T10:03:41.836Z
Generated: 2026-06-09T12:49:08.595Z
Found: 2 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-09T10:03:39.823Z",
"generated": "2026-06-09T12:49:06.615Z",
"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."
},
+1
View File
@@ -228,6 +228,7 @@ Mana-Loop/
│ │ │ │ ├── melee-auto-attack.test.ts
│ │ │ │ ├── melee-defense-bypass.test.ts
│ │ │ │ ├── pact-utils.test.ts
│ │ │ │ ├── paused-conversion-dedup.test.ts
│ │ │ │ ├── persistence.test.ts
│ │ │ │ ├── regression-fixes.test.ts
│ │ │ │ ├── room-utils-floor-state.test.ts
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useGameStore } from '../stores/gameStore';
import { useManaStore, makeInitialElements } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useUIStore } from '../stores/uiStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { useAttunementStore } from '../stores/attunementStore';
import { useCraftingStore } from '../stores/craftingStore';
import { getFloorMaxHP } from '../utils';
import { ELEMENTS } from '../constants';
// ─── Helpers ──────────────────────────────────────────────────────────────────
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: 100,
meditateTicks: 0,
totalManaGathered: 0,
elements: makeInitialElements(50, {}),
});
useCombatStore.setState({
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
maxFloorReached: 1,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spireMode: false,
currentRoom: { roomType: 'combat', enemies: [] },
clearedFloors: {},
climbDirection: null,
isDescending: false,
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
activityLog: [],
achievements: { unlocked: [], progress: {} },
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
});
usePrestigeStore.setState({
loopCount: 0,
insight: 0,
totalInsight: 0,
loopInsight: 0,
prestigeUpgrades: {},
pactSlots: 1,
defeatedGuardians: [],
signedPacts: [],
signedPactDetails: {},
pactRitualFloor: null,
pactRitualProgress: 0,
});
useDisciplineStore.setState({
disciplines: {},
activeIds: [],
concurrentLimit: 1,
totalXP: 0,
processedPerks: [],
});
useAttunementStore.setState({
attunements: {},
});
useCraftingStore.setState({
designProgress: null,
designProgress2: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
enchantmentDesigns: [],
unlockedEffects: [],
equippedInstances: {},
equipmentInstances: {},
lootInventory: {
materials: {},
blueprints: [],
},
enchantmentSelection: {
selectedEquipmentType: null,
selectedEffects: [],
designName: '',
selectedDesign: null,
selectedEquipmentInstance: null,
},
lastError: null,
});
}
function unlockAllElements() {
const mana = useManaStore.getState();
for (const elem of Object.keys(ELEMENTS)) {
if (!mana.elements[elem]?.unlocked) {
// Directly set unlocked (simulating debug "Unlock All")
useManaStore.setState({
elements: {
...useManaStore.getState().elements,
[elem]: { ...useManaStore.getState().elements[elem], unlocked: true, current: 0 },
},
});
}
}
}
function countPausedLogs(logs: string[]): number {
return logs.filter(l => l.includes('⚠️ PAUSED')).length;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Paused conversion log deduplication (bug #337)', () => {
beforeEach(() => {
resetAllStores();
});
it('should log each paused conversion only once across many ticks', () => {
unlockAllElements();
// Run 50 ticks (10 seconds of game time at 200ms/tick)
for (let i = 0; i < 50; i++) {
useGameStore.getState().tick();
}
const logs = useUIStore.getState().logs;
const pausedCount = countPausedLogs(logs);
// With 22 elements unlocked but only transference having regen,
// many conversions will be paused. Before the fix, this would
// produce 50+ duplicate messages per tick (1000+ total).
// After the fix, each unique paused conversion should appear at most once.
// The exact count depends on how many elements have conversion rates,
// but it should be a small constant, not proportional to tick count.
expect(pausedCount).toBeLessThan(30);
});
it('should not produce more PAUSED logs after the first tick for the same state', () => {
unlockAllElements();
// First tick: should log paused conversions
useGameStore.getState().tick();
const logsAfterFirst = useUIStore.getState().logs;
const pausedAfterFirst = countPausedLogs(logsAfterFirst);
// Run 99 more ticks with unchanged state
for (let i = 0; i < 99; i++) {
useGameStore.getState().tick();
}
const logsAfter100 = useUIStore.getState().logs;
const pausedAfter100 = countPausedLogs(logsAfter100);
// The count should be identical — no new PAUSED messages after tick 1
expect(pausedAfter100).toBe(pausedAfterFirst);
});
it('should re-log a conversion if it un-pauses and re-pauses', () => {
unlockAllElements();
// First tick: logs paused conversions
useGameStore.getState().tick();
const pausedAfterFirst = countPausedLogs(useUIStore.getState().logs);
// Give enough raw regen to un-pause some conversions
useManaStore.setState({
rawMana: 999999,
});
// Run a tick — conversions should un-pause (loggedPausedConversions cleans up)
useGameStore.getState().tick();
// Now set raw regen low again by draining raw mana to 0
// (conversions will pause again due to insufficient regen)
useManaStore.setState({
rawMana: 0,
});
// Run another tick — conversions should re-pause and re-log
useGameStore.getState().tick();
const pausedAfterRepause = countPausedLogs(useUIStore.getState().logs);
// Should have at least as many as before (re-paused conversions re-log)
expect(pausedAfterRepause).toBeGreaterThanOrEqual(pausedAfterFirst);
});
it('should produce zero PAUSED logs when all conversions are active', () => {
// Only transference unlocked (default), with enough raw regen
useManaStore.setState({
rawMana: 999999,
});
for (let i = 0; i < 20; i++) {
useGameStore.getState().tick();
}
const pausedCount = countPausedLogs(useUIStore.getState().logs);
expect(pausedCount).toBe(0);
});
});
+14 -1
View File
@@ -33,6 +33,9 @@ import type { GameCoordinatorState } from './gameStore.types';
import type { EnemyState } from '../types';
import { applyEnemyDefenses as applyEnemyDefensesFromPipeline } from './pipelines/combat-tick';
// Track paused conversions already logged to avoid flooding the activity log every tick
const loggedPausedConversions = new Set<string>();
export interface GameCoordinatorStore extends GameCoordinatorState {
tick: () => void;
resetGame: () => void;
@@ -177,12 +180,22 @@ export const useGameStore = create<GameCoordinatorStore>()(
let rawMana = ctx.mana.rawMana;
let elements = { ...ctx.mana.elements };
// Log paused conversions
// Log paused conversions (only on state change to avoid flooding the log)
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused && entry.pauseReason) {
if (!loggedPausedConversions.has(elem)) {
loggedPausedConversions.add(elem);
addLog(`⚠️ PAUSED: ${elem} conversion — ${entry.pauseReason}`);
}
}
}
// Clean up entries for conversions that are no longer paused
for (const elem of loggedPausedConversions) {
const rateEntry = conversionResult.rates[elem];
if (!rateEntry || !rateEntry.paused) {
loggedPausedConversions.delete(elem);
}
}
// Compute per-element net regen: produced rate - drain from being used as component
const elementRegen: Record<string, number> = {};