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
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:
@@ -1,4 +1,4 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-05-27T09:16:38.657Z
|
||||
Generated: 2026-05-27T09:18:37.387Z
|
||||
|
||||
No circular dependencies found. ✅
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user