Files
wownerogue/test/fairnessOffer.test.js
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

252 lines
10 KiB
JavaScript

const FairnessOfferManager = require('../src/game/fairnessOfferManager');
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
const {
hashSeed,
deriveSeed,
createGameProof,
getPreGameCommitment,
getPostGameReveal,
verifyGameProof
} = require('../src/game/provablyFair');
const SERVER_SEED = 'a'.repeat(64);
const CLIENT_SEED = '12'.repeat(32);
describe('two-phase solo fairness offers', () => {
test('publishes a commitment before client seed and consumes it exactly once', () => {
let now = 1000;
const offers = new FairnessOfferManager({
now: () => now,
seedFactory: () => SERVER_SEED,
idFactory: () => 'offer-1',
ttlMs: 60000
});
const published = offers.ensureOffer('socket-a');
expect(published).toEqual(expect.objectContaining({
offerId: 'offer-1',
commitment: hashSeed(SERVER_SEED),
issuedAt: 1000,
proofVersion: 2
}));
now = 2000; // client contribution arrives strictly after publication
const consumed = offers.consume('socket-a', {
offerId: published.offerId,
clientSeed: CLIENT_SEED.toUpperCase()
});
expect(consumed.success).toBe(true);
expect(consumed.proofInput.offerIssuedAt).toBeLessThan(now);
expect(consumed.proofInput.clientSeed).toBe(CLIENT_SEED);
expect(consumed.proofInput.commitment).toBe(published.commitment);
expect(offers.consume('socket-a', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
})).toEqual(expect.objectContaining({ success: false, code: 'OFFER_REPLAYED' }));
});
test('cannot steal, swap, or attach a client seed without echoing the published offer', () => {
const offers = new FairnessOfferManager({
seedFactory: () => SERVER_SEED,
idFactory: () => 'offer-owner'
});
const published = offers.ensureOffer('owner');
expect(offers.consume('attacker', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
}).code).toBe('OFFER_OWNER_MISMATCH');
expect(offers.consume('owner', { clientSeed: CLIENT_SEED }).code).toBe('OFFER_REQUIRED');
const consumed = offers.consume('owner', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
});
expect(consumed.success).toBe(true);
expect(() => createGameProof('game', CLIENT_SEED, {
...consumed.proofInput,
commitment: 'b'.repeat(64)
})).toThrow(/commitment does not match/i);
});
test('legacy clients may consume the already-published current offer with an empty seed', () => {
const offers = new FairnessOfferManager({
seedFactory: () => SERVER_SEED,
idFactory: () => 'legacy-offer'
});
offers.ensureOffer('legacy');
const consumed = offers.consume('legacy');
expect(consumed.success).toBe(true);
expect(consumed.proofInput.clientSeed).toBe('');
});
});
describe('v2 proof binds the accepted client seed to the played dungeon', () => {
function precommit(clientSeed, offerId) {
return {
proofVersion: 2,
offerId,
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed
};
}
test('Game derives its RNG before layout generation and persists reproducible context', () => {
const game = new Game('socket', { id: 1 }, { fairnessProof: precommit(CLIENT_SEED, 'offer-a') });
expect(game.gameProof.seed).toBe(deriveSeed(SERVER_SEED, CLIENT_SEED));
expect(game.gameProof.clientSeed).toBe(CLIENT_SEED);
expect(game.gameProof.offerId).toBe('offer-a');
const context = game.gameProof.context;
const regenerated = DungeonGenerator.regenerateFromSeed(
game.gameProof.seed,
context.cryptoType,
context.generationOptions
);
if (context.maxDepth > 1) regenerated.treasure = null;
expect(DungeonGenerator.layoutFingerprint(regenerated)).toBe(game.gameProof.layoutFingerprint);
});
test('changing the client seed changes both effective seed and dungeon', () => {
const one = new Game('one', { id: 1 }, { fairnessProof: precommit('01', 'offer-1') });
const two = new Game('two', { id: 2 }, { fairnessProof: precommit('02', 'offer-2') });
expect(one.gameProof.seed).not.toBe(two.gameProof.seed);
expect(one.gameProof.layoutFingerprint).not.toBe(two.gameProof.layoutFingerprint);
});
test('pre-game data hides server seed, reveal links by game id, and tampering fails', () => {
const proof = createGameProof('game-uuid', CLIENT_SEED, precommit(CLIENT_SEED, 'offer-z'));
proof.layoutFingerprint = 'f'.repeat(64);
const before = getPreGameCommitment(proof);
expect(before.clientSeed).toBe(CLIENT_SEED);
expect(before.offerId).toBe('offer-z');
expect(before.serverSeed).toBeUndefined();
expect(before.seed).toBeUndefined();
const reveal = getPostGameReveal(proof);
expect(reveal.verificationUrl).toBe('/verify/game-uuid');
expect(verifyGameProof(reveal).valid).toBe(true);
expect(verifyGameProof({ ...reveal, clientSeed: '03' }).valid).toBe(false);
expect(verifyGameProof({ ...reveal, effectiveSeed: 'b'.repeat(64), seed: 'b'.repeat(64) }).valid).toBe(false);
expect(verifyGameProof({ ...reveal, serverSeed: 'b'.repeat(64) }).valid).toBe(false);
});
test('post-game reveal preserves the terminal result after endGame changes runtime state', () => {
const game = new Game('result-socket', { id: 3, endGame() {} }, {
fairnessProof: precommit(CLIENT_SEED, 'result-offer')
});
game.endGame('won', { score: 10 });
expect(game.gameState).toBe('ended');
expect(game.getProofReveal().gameResult.won).toBe(true);
});
});
describe('all-depth versioned verification', () => {
let savedLevels;
let savedCrypto;
beforeEach(() => {
savedLevels = process.env.DUNGEON_LEVELS;
savedCrypto = process.env.CRYPTO_TYPE;
process.env.DUNGEON_LEVELS = '3';
process.env.CRYPTO_TYPE = 'WOW';
});
afterEach(() => {
if (savedLevels === undefined) delete process.env.DUNGEON_LEVELS;
else process.env.DUNGEON_LEVELS = savedLevels;
if (savedCrypto === undefined) delete process.env.CRYPTO_TYPE;
else process.env.CRYPTO_TYPE = savedCrypto;
});
test('persists and deterministically verifies every advertised depth', () => {
const fairnessProof = {
proofVersion: 2,
offerId: 'all-depth-offer',
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed: CLIENT_SEED
};
const game = new Game('depth-socket', { id: 7 }, { fairnessProof });
const reveal = game.getProofReveal();
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameProof.layoutFingerprints).toHaveLength(3);
expect(game.gameProof.layoutFingerprints.map(item => item.depth)).toEqual([1, 2, 3]);
const result = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprint: reveal.layoutFingerprint,
expectedFingerprints: reveal.layoutFingerprints,
generatorVersion: reveal.generatorVersion
});
expect(result.valid).toBe(true);
expect(result.verificationScope).toBe('all_depths');
expect(result.verifiedDepths).toEqual([1, 2, 3]);
expect(result.levels.every(level => level.matches)).toBe(true);
});
test('fails if any depth is tampered with or omitted', () => {
const game = new Game('tamper-socket', { id: 8 }, {
fairnessProof: {
proofVersion: 2,
offerId: 'tamper-offer',
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed: CLIENT_SEED
}
});
const reveal = game.getProofReveal();
const tampered = reveal.layoutFingerprints.map(item => ({ ...item }));
tampered[1].fingerprint = '0'.repeat(64);
const mismatch = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprints: tampered,
generatorVersion: reveal.generatorVersion
});
expect(mismatch.valid).toBe(false);
expect(mismatch.levels[1].matches).toBe(false);
const incomplete = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprints: reveal.layoutFingerprints.slice(0, 2),
generatorVersion: reveal.generatorVersion
});
expect(incomplete.valid).toBe(false);
expect(incomplete.regenerationError).toBe('incomplete_layout_manifest');
});
});
describe('browser contribution helper', () => {
test('generates a fresh 256-bit client seed after selecting the offer', () => {
const oldWindow = global.window;
const oldSocket = global.socket;
global.window = {
crypto: { getRandomValues: (bytes) => { for (let i = 0; i < bytes.length; i++) bytes[i] = i; return bytes; } }
};
global.socket = { emit: jest.fn() };
jest.resetModules();
const BrowserHandlers = require('../html/js/network/socketHandlers.js');
BrowserHandlers._fairnessOffer = { offerId: 'browser-offer', commitment: hashSeed(SERVER_SEED) };
const payload = BrowserHandlers.fairnessAttempt({ free: true });
expect(payload.free).toBe(true);
expect(payload.fairnessOfferId).toBe('browser-offer');
expect(payload.clientSeed).toMatch(/^[0-9a-f]{64}$/);
expect(BrowserHandlers._pendingFairnessAttempt.commitment).toBe(hashSeed(SERVER_SEED));
global.window = oldWindow;
global.socket = oldSocket;
});
});