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();
+ 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);