fix: prevent conversion system from auto-unlocking all mana types on first tick
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m30s

- Removed auto-unlock lines in gameStore.ts element regen loop and recovery room boost loop
- Added unlocked guard to skip locked elements during conversion processing
- Added regression test (bug-377) verifying only transference stays unlocked

Fixes #377
This commit is contained in:
2026-06-12 12:14:45 +02:00
parent b68cc948a3
commit 280847a231
5 changed files with 71 additions and 6 deletions
@@ -0,0 +1,66 @@
/**
* Regression test for Bug #377:
* "All 22 mana types auto-unlocked on first tick via conversion system"
*
* Expected: Only 'transference' should be unlocked at game start.
* The conversion system must NOT auto-unlock elements that have non-zero rates.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useManaStore } from '../stores/manaStore';
import { resetAllStores, tickN } from './cross-module-helpers';
import { BASE_UNLOCKED_ELEMENTS } from '../constants/elements';
function getUnlockedElements(): string[] {
const elements = useManaStore.getState().elements;
return Object.entries(elements)
.filter(([, v]) => v.unlocked)
.map(([k]) => k);
}
describe('Bug #377 — mana types must not auto-unlock on first tick', () => {
beforeEach(() => {
resetAllStores();
});
it('only transference is unlocked at game start', () => {
const unlocked = getUnlockedElements();
expect(unlocked).toEqual(BASE_UNLOCKED_ELEMENTS);
expect(unlocked).toHaveLength(1);
expect(unlocked).toContain('transference');
});
it('running one tick does not unlock any new elements', () => {
const before = getUnlockedElements();
expect(before).toEqual(['transference']);
// Run a single game tick
tickN(1);
const after = getUnlockedElements();
expect(after).toEqual(['transference']);
expect(after).toHaveLength(1);
});
it('running multiple ticks does not unlock exotic elements', () => {
// Run 20 ticks (simulating ~4 seconds of game time)
tickN(20);
const unlocked = getUnlockedElements();
expect(unlocked).toEqual(['transference']);
// Explicitly verify exotic elements remain locked
const elements = useManaStore.getState().elements;
const exoticElements = ['crystal', 'stellar', 'void', 'soul', 'time', 'plasma'];
for (const elem of exoticElements) {
expect(elements[elem]?.unlocked).toBe(false);
}
});
it('conversion rates for locked elements do not cause unlock after many ticks', () => {
// Run 50 ticks
tickN(50);
const unlocked = getUnlockedElements();
expect(unlocked).toEqual(['transference']);
});
});
+2 -4
View File
@@ -207,8 +207,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
}
// Apply net element regen to pools: produced - drained (component consumption)
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused || !elements[elem]) continue;
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
if (entry.paused || !elements[elem] || !elements[elem].unlocked) continue;
const netRate = elementRegen[elem];
if (netRate === 0) continue;
const delta = netRate * HOURS_PER_TICK;
@@ -320,8 +319,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
const regenDelta = Math.max(0, netBoostedRegen - netRawRegen);
rawMana = Math.min(rawMana + regenDelta * HOURS_PER_TICK, maxMana);
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
if (entry.paused || entry.finalRate <= 0 || !elements[elem] || !elements[elem].unlocked) continue;
// Normal conversion already applied above; add only the 9× delta
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * 9 * HOURS_PER_TICK) };
}