From a8fab1eb86ef00bfacd2aad3018857d3fdb9dbb8 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 27 May 2026 11:26:28 +0200 Subject: [PATCH] fix: make guardian names deterministic per floor instead of using Math.random() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + src/lib/game/__tests__/guardian-names.test.ts | 137 ++++++++++++++++++ src/lib/game/data/guardian-encounters.ts | 18 +-- 5 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 src/lib/game/__tests__/guardian-names.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 4b1ffd8..93f4b64 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-27T09:16:38.657Z +Generated: 2026-05-27T09:18:37.387Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 1d7f53d..1772f39 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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 4b6be3f..768038f 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -213,6 +213,7 @@ Mana-Loop/ │ │ │ ├── floor-utils.test.ts │ │ │ ├── floor-utils.upgraded.test.ts │ │ │ ├── formatting.test.ts +│ │ │ ├── guardian-names.test.ts │ │ │ ├── mana-utils.test.ts │ │ │ ├── pact-utils.test.ts │ │ │ ├── regression-fixes.test.ts diff --git a/src/lib/game/__tests__/guardian-names.test.ts b/src/lib/game/__tests__/guardian-names.test.ts new file mode 100644 index 0000000..512a4ab --- /dev/null +++ b/src/lib/game/__tests__/guardian-names.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 " 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); + }); +}); diff --git a/src/lib/game/data/guardian-encounters.ts b/src/lib/game/data/guardian-encounters.ts index e44e170..9152852 100644 --- a/src/lib/game/data/guardian-encounters.ts +++ b/src/lib/game/data/guardian-encounters.ts @@ -36,19 +36,19 @@ const GUARDIAN_TITLES: string[] = [ '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 prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; - const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)]; + const prefix = prefixes[floor % prefixes.length]; + const title = GUARDIAN_TITLES[Math.floor(floor / 10) % GUARDIAN_TITLES.length]; return `${prefix} the ${title}`; } -export function generateComboGuardianName(elements: string[]): string { - const parts = elements.map((el) => { +export function generateComboGuardianName(elements: string[], floor: number = 0): string { + const parts = elements.map((el, i) => { 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}`; } @@ -115,7 +115,7 @@ export function getExtendedGuardian(floor: number): GuardianDef | null { const g = getComboGuardian(floor); if (!g.name) { const elements = g.element.split('+'); - g.name = generateComboGuardianName(elements); + g.name = generateComboGuardianName(elements, floor); } return g; } @@ -129,7 +129,7 @@ export function getExtendedGuardian(floor: number): GuardianDef | null { export function getGuardianForFloor(floor: number): GuardianDef | null { if (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 getExtendedGuardian(floor);