mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
3.2 Reachability: a dungeon whose exit is unreachable from the entrance is paid-but-unwinnable. generate() now BFS-checks entrance->exit and regenerates if unreachable. Retries are deterministic (per-attempt ROT seed = seedInt + attempt; the seeded rng stream keeps advancing), so verify-regeneration replays the same attempts and reproduces the exact dungeon — the provably-fair determinism from Phase 0.2 is preserved. The single-pass body is extracted to _generateOnce and isReachable does the flood-fill. 3.3 Monster collision: with movesPerPlayerMove > 1 (the casino preset's 1.5x speed) the monster takes multiple steps per turn, but collision was only checked after the loop — so it could step onto the player's tile and off again between checks, phasing through and missing the catch (player-favorable, erodes the house edge). Collision is now checked after each sub-step. Adds reachability tests (provablyFair.test.js) and monsterCollision.test.js. Random block timing remains the untouched core mechanic. Full suite: 20 suites / 153 tests.
112 lines
4.4 KiB
JavaScript
112 lines
4.4 KiB
JavaScript
/**
|
|
* Provably-fair determinism tests (Phase 0.2).
|
|
*
|
|
* The committed seed must deterministically generate the dungeon and drive monster
|
|
* behaviour, so that:
|
|
* - the same seed always reproduces the exact same dungeon (verifiability), and
|
|
* - the server cannot craft a different/unfair dungeon after seeing the seed.
|
|
*/
|
|
|
|
const DungeonGenerator = require('../src/game/dungeon');
|
|
const Monster = require('../src/game/monster');
|
|
const { createSeededRNG, hashSeed, verifyGame } = require('../src/game/provablyFair');
|
|
|
|
const SEED_A = 'a'.repeat(64);
|
|
const SEED_B = 'b'.repeat(64);
|
|
|
|
describe('Provably fair: deterministic dungeon generation', () => {
|
|
test('same seed regenerates an identical dungeon layout', () => {
|
|
const d1 = DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW');
|
|
const d2 = DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW');
|
|
|
|
expect(DungeonGenerator.layoutFingerprint(d1))
|
|
.toBe(DungeonGenerator.layoutFingerprint(d2));
|
|
expect(d1.entrance).toEqual(d2.entrance);
|
|
expect(d1.exit).toEqual(d2.exit);
|
|
expect(d1.treasure).toEqual(d2.treasure);
|
|
expect(d1.map).toEqual(d2.map);
|
|
});
|
|
|
|
test('regeneration is stable even when interleaved with another seed (no global RNG bleed)', () => {
|
|
const a1 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
|
|
// Generate a different seed's dungeon in between — must not affect SEED_A's output.
|
|
DungeonGenerator.regenerateFromSeed(SEED_B, 'WOW');
|
|
const a2 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
|
|
expect(a1).toBe(a2);
|
|
});
|
|
|
|
test('different seeds produce different layouts', () => {
|
|
const fpA = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
|
|
const fpB = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_B, 'WOW'));
|
|
expect(fpA).not.toBe(fpB);
|
|
});
|
|
|
|
test('verifyGame confirms the seed/commitment relationship', () => {
|
|
const commitment = hashSeed(SEED_A);
|
|
expect(verifyGame(SEED_A, commitment).valid).toBe(true);
|
|
expect(verifyGame(SEED_B, commitment).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Dungeon reachability (Phase 3.2)', () => {
|
|
test('generated dungeons always have a reachable exit (many seeds)', () => {
|
|
const cfg = { primaryFloor: "'1", secondaryFloor: "'2" };
|
|
for (let i = 0; i < 25; i++) {
|
|
const seed = (i.toString(16).padStart(2, '0')).repeat(32);
|
|
const d = DungeonGenerator.regenerateFromSeed(seed, 'WOW');
|
|
expect(DungeonGenerator.isReachable(d.map, d.entrance, d.exit, cfg)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('isReachable returns false when the target is walled off', () => {
|
|
const cfg = { primaryFloor: "'1", secondaryFloor: "'2" };
|
|
// 3x3 all-floor map, but wall off the target corner.
|
|
const map = [
|
|
["'1", "'1", "'1"],
|
|
["'1", "'1", '#'],
|
|
["'1", '#', "'1"] // (2,2) is isolated by walls at (2,1) and (1,2)
|
|
];
|
|
expect(DungeonGenerator.isReachable(map, [0, 0], [2, 2], cfg)).toBe(false);
|
|
expect(DungeonGenerator.isReachable(map, [0, 0], [1, 0], cfg)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Provably fair: deterministic monster movement', () => {
|
|
// A small open room so the monster always has somewhere to wander.
|
|
function buildDungeon() {
|
|
const map = Array.from({ length: 7 }, () => Array(7).fill("'1"));
|
|
for (let i = 0; i < 7; i++) { map[0][i] = '#'; map[6][i] = '#'; map[i][0] = '#'; map[i][6] = '#'; }
|
|
return { map };
|
|
}
|
|
|
|
test('wandering is reproducible from the same seeded RNG sequence', () => {
|
|
const dungeon = buildDungeon();
|
|
const run = () => {
|
|
const rng = createSeededRNG(SEED_A);
|
|
const m = new Monster(3, 3, { visionRange: 5 });
|
|
const path = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
m.moveTowardPlayer(null, dungeon, rng); // no player => wander using rng
|
|
path.push([m.x, m.y]);
|
|
}
|
|
return path;
|
|
};
|
|
expect(run()).toEqual(run());
|
|
});
|
|
|
|
test('different seeds yield different wander paths', () => {
|
|
const dungeon = buildDungeon();
|
|
const walk = (seed) => {
|
|
const rng = createSeededRNG(seed);
|
|
const m = new Monster(3, 3, { visionRange: 5 });
|
|
const path = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
m.moveTowardPlayer(null, dungeon, rng);
|
|
path.push([m.x, m.y]);
|
|
}
|
|
return JSON.stringify(path);
|
|
};
|
|
expect(walk(SEED_A)).not.toBe(walk(SEED_B));
|
|
});
|
|
});
|