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
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# 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.
|
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) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_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.",
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── melee-auto-attack.test.ts
|
│ │ │ │ ├── melee-auto-attack.test.ts
|
||||||
│ │ │ │ ├── melee-defense-bypass.test.ts
|
│ │ │ │ ├── melee-defense-bypass.test.ts
|
||||||
│ │ │ │ ├── pact-utils.test.ts
|
│ │ │ │ ├── pact-utils.test.ts
|
||||||
|
│ │ │ │ ├── paused-conversion-dedup.test.ts
|
||||||
│ │ │ │ ├── persistence.test.ts
|
│ │ │ │ ├── persistence.test.ts
|
||||||
│ │ │ │ ├── regression-fixes.test.ts
|
│ │ │ │ ├── regression-fixes.test.ts
|
||||||
│ │ │ │ ├── room-utils-floor-state.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,9 @@ import type { GameCoordinatorState } from './gameStore.types';
|
|||||||
import type { EnemyState } from '../types';
|
import type { EnemyState } from '../types';
|
||||||
import { applyEnemyDefenses as applyEnemyDefensesFromPipeline } from './pipelines/combat-tick';
|
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 {
|
export interface GameCoordinatorStore extends GameCoordinatorState {
|
||||||
tick: () => void;
|
tick: () => void;
|
||||||
resetGame: () => void;
|
resetGame: () => void;
|
||||||
@@ -177,10 +180,20 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
let rawMana = ctx.mana.rawMana;
|
let rawMana = ctx.mana.rawMana;
|
||||||
let elements = { ...ctx.mana.elements };
|
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)) {
|
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||||
if (entry.paused && entry.pauseReason) {
|
if (entry.paused && entry.pauseReason) {
|
||||||
addLog(`⚠️ PAUSED: ${elem} conversion — ${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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user