From 4a282a21214bb1c1de453ed7e04924be5d9e97a4 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Tue, 9 Jun 2026 15:31:14 +0200 Subject: [PATCH] fix: deduplicate PAUSED conversion log messages in tick pipeline (bug #337) --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + .../__tests__/paused-conversion-dedup.test.ts | 221 ++++++++++++++++++ src/lib/game/stores/gameStore.ts | 17 +- 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/lib/game/__tests__/paused-conversion-dedup.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 5e32400..ea33a95 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -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 diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index e10fb8b..fd8929d 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 0abd7e9..6fd8008 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -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 diff --git a/src/lib/game/__tests__/paused-conversion-dedup.test.ts b/src/lib/game/__tests__/paused-conversion-dedup.test.ts new file mode 100644 index 0000000..4017be7 --- /dev/null +++ b/src/lib/game/__tests__/paused-conversion-dedup.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); + }); +}); diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 4a6032a..da38298 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -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(); + export interface GameCoordinatorStore extends GameCoordinatorState { tick: () => void; resetGame: () => void; @@ -177,10 +180,20 @@ export const useGameStore = create()( 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) { - 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); } }