fix: make guardian names deterministic per floor instead of using Math.random()
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m37s

- generateGuardianName() now takes a floor parameter and uses floor % prefixes.length for deterministic prefix selection
- generateComboGuardianName() now takes a floor parameter and uses (floor + i) % prefixes.length for each element
- getGuardianForFloor() passes floor to generateGuardianName for static guardians with empty names
- getExtendedGuardian() passes floor to generateComboGuardianName for combo guardians
- Removes dependency on Math.random() → names are stable across ticks/refreshes

Fixes #161
This commit is contained in:
2026-05-27 11:26:28 +02:00
parent 8df3be5628
commit a8fab1eb86
5 changed files with 149 additions and 11 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-27T09:16:38.657Z Generated: 2026-05-27T09:18:37.387Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-27T09:16:36.915Z", "generated": "2026-05-27T09:18:35.464Z",
"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."
}, },
+1
View File
@@ -213,6 +213,7 @@ Mana-Loop/
│ │ │ ├── floor-utils.test.ts │ │ │ ├── floor-utils.test.ts
│ │ │ ├── floor-utils.upgraded.test.ts │ │ │ ├── floor-utils.upgraded.test.ts
│ │ │ ├── formatting.test.ts │ │ │ ├── formatting.test.ts
│ │ │ ├── guardian-names.test.ts
│ │ │ ├── mana-utils.test.ts │ │ │ ├── mana-utils.test.ts
│ │ │ ├── pact-utils.test.ts │ │ │ ├── pact-utils.test.ts
│ │ │ ├── regression-fixes.test.ts │ │ │ ├── regression-fixes.test.ts
@@ -0,0 +1,137 @@
// ─── Guardian Name Determinism Test ───────────────────────────────────────────
// Regression test for #161: Guardian names must be deterministic per floor.
// Names derived from Math.random() would change on every call.
import { describe, it, expect } from 'vitest';
import {
generateGuardianName,
generateComboGuardianName,
getGuardianForFloor,
getExtendedGuardian,
} from '../data/guardian-encounters';
describe('generateGuardianName', () => {
it('should return the same name for the same element and floor', () => {
const name1 = generateGuardianName('fire', 90);
const name2 = generateGuardianName('fire', 90);
expect(name1).toBe(name2);
});
it('should return different names for different elements', () => {
const nameFire = generateGuardianName('fire', 90);
const nameWater = generateGuardianName('water', 90);
expect(nameFire).not.toBe(nameWater);
});
it('should return different names for different floors', () => {
const name90 = generateGuardianName('fire', 90);
const name100 = generateGuardianName('fire', 100);
expect(name90).not.toBe(name100);
});
it('should produce non-empty names', () => {
const name = generateGuardianName('metal', 90);
expect(name).toBeTruthy();
expect(name.length).toBeGreaterThan(0);
});
it('should follow the "Prefix the Title" pattern', () => {
const name = generateGuardianName('crystal', 120);
expect(name).toMatch(/^[\w]+ the [\w]+$/);
});
it('should produce "Unknown the <title>" for unknown element', () => {
const name = generateGuardianName('nonexistent', 90);
expect(name).toMatch(/^Unknown the [\w]+$/);
});
});
describe('generateComboGuardianName', () => {
it('should return the same name for the same elements and floor', () => {
const name1 = generateComboGuardianName(['fire', 'water'], 150);
const name2 = generateComboGuardianName(['fire', 'water'], 150);
expect(name1).toBe(name2);
});
it('should return different names for different floors', () => {
const name150 = generateComboGuardianName(['fire', 'water'], 150);
const name160 = generateComboGuardianName(['fire', 'water'], 160);
expect(name150).not.toBe(name160);
});
it('should combine both element prefixes with hyphen', () => {
const name = generateComboGuardianName(['fire', 'water'], 150);
expect(name).toMatch(/^[\w]+-[\w]+ the [\w]+$/);
});
});
describe('getGuardianForFloor', () => {
it('should return stable names for compound guardians (floors 90-110)', () => {
const g90a = getGuardianForFloor(90);
const g90b = getGuardianForFloor(90);
expect(g90a).not.toBeNull();
expect(g90b).not.toBeNull();
expect(g90a!.name).toBe(g90b!.name);
const g100a = getGuardianForFloor(100);
const g100b = getGuardianForFloor(100);
expect(g100a!.name).toBe(g100b!.name);
const g110a = getGuardianForFloor(110);
const g110b = getGuardianForFloor(110);
expect(g110a!.name).toBe(g110b!.name);
});
it('should return stable names for exotic guardians (floors 120-140)', () => {
for (const floor of [120, 130, 140]) {
const a = getGuardianForFloor(floor);
const b = getGuardianForFloor(floor);
expect(a).not.toBeNull();
expect(a!.name).toBe(b!.name);
}
});
it('should preserve existing names for base guardians', () => {
const g10 = getGuardianForFloor(10);
expect(g10!.name).toBe('Ignis Prime');
});
it('should return different names for different floors', () => {
const g90 = getGuardianForFloor(90);
const g120 = getGuardianForFloor(120);
expect(g90!.name).not.toBe(g120!.name);
});
});
describe('getExtendedGuardian', () => {
it('should return stable names for combo guardians (floor 150+)', () => {
const a = getExtendedGuardian(150);
const b = getExtendedGuardian(150);
expect(a).not.toBeNull();
expect(b).not.toBeNull();
expect(a!.name).toBe(b!.name);
});
it('should return different names for different combo floors', () => {
const g150 = getExtendedGuardian(150);
const g160 = getExtendedGuardian(160);
expect(g150).not.toBeNull();
expect(g160).not.toBeNull();
expect(g150!.name).not.toBe(g160!.name);
});
it('should return null for non-guardian floors', () => {
expect(getExtendedGuardian(151)).toBeNull();
expect(getExtendedGuardian(155)).toBeNull();
});
it('should produce unique names across many repeated calls', () => {
const names = new Set<string>();
for (let i = 0; i < 100; i++) {
const g = getGuardianForFloor(90);
names.add(g!.name);
}
// All 100 calls should produce exactly 1 unique name
expect(names.size).toBe(1);
});
});
+9 -9
View File
@@ -36,19 +36,19 @@ const GUARDIAN_TITLES: string[] = [
'Guardian', 'Sentinel', 'Champion', 'Overlord', 'Archon', 'Guardian', 'Sentinel', 'Champion', 'Overlord', 'Archon',
]; ];
export function generateGuardianName(element: string): string { export function generateGuardianName(element: string, floor: number = 0): string {
const prefixes = GUARDIAN_PREFIXES[element] || ['Unknown']; const prefixes = GUARDIAN_PREFIXES[element] || ['Unknown'];
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; const prefix = prefixes[floor % prefixes.length];
const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)]; const title = GUARDIAN_TITLES[Math.floor(floor / 10) % GUARDIAN_TITLES.length];
return `${prefix} the ${title}`; return `${prefix} the ${title}`;
} }
export function generateComboGuardianName(elements: string[]): string { export function generateComboGuardianName(elements: string[], floor: number = 0): string {
const parts = elements.map((el) => { const parts = elements.map((el, i) => {
const prefixes = GUARDIAN_PREFIXES[el] || ['Unknown']; const prefixes = GUARDIAN_PREFIXES[el] || ['Unknown'];
return prefixes[Math.floor(Math.random() * prefixes.length)]; return prefixes[(floor + i) % prefixes.length];
}); });
const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)]; const title = GUARDIAN_TITLES[Math.floor(floor / 10) % GUARDIAN_TITLES.length];
return `${parts.join('-')} the ${title}`; return `${parts.join('-')} the ${title}`;
} }
@@ -115,7 +115,7 @@ export function getExtendedGuardian(floor: number): GuardianDef | null {
const g = getComboGuardian(floor); const g = getComboGuardian(floor);
if (!g.name) { if (!g.name) {
const elements = g.element.split('+'); const elements = g.element.split('+');
g.name = generateComboGuardianName(elements); g.name = generateComboGuardianName(elements, floor);
} }
return g; return g;
} }
@@ -129,7 +129,7 @@ export function getExtendedGuardian(floor: number): GuardianDef | null {
export function getGuardianForFloor(floor: number): GuardianDef | null { export function getGuardianForFloor(floor: number): GuardianDef | null {
if (BASE_GUARDIANS[floor]) { if (BASE_GUARDIANS[floor]) {
const g = { ...BASE_GUARDIANS[floor] }; const g = { ...BASE_GUARDIANS[floor] };
if (!g.name) g.name = generateGuardianName(g.element); if (!g.name) g.name = generateGuardianName(g.element, floor);
return g; return g;
} }
return getExtendedGuardian(floor); return getExtendedGuardian(floor);