mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
security: fix underpayment exploit and make provably-fair real
Phase 0.1 — Underpayment confirmation bug (CRITICAL): Payment confirmation gated on `status.confirmed`, which checkPaymentStatus sets for ANY incoming tx regardless of amount, so sending a single atomic unit granted a full game/credits. Now gate on `status.complete` (received >= expected). Underpayers are warned once and the payment stays pending so a top-up can complete it. Persist `received_amount` for single-game and credits confirmations for reconciliation. Adds an underpayment regression test. Phase 0.2 — Provably-fair was decorative (CRITICAL): The committed seed had zero effect on the dungeon (created seededRNG was unused; generation used Math.random + a globally-seeded ROT.RNG). Thread a per-game seeded RNG derived from the committed seed through dungeon generation (reseed the Digger via seedToInt + replace all Math.random) and monster movement (seededShuffle). Add DungeonGenerator.regenerateFromSeed and layoutFingerprint; /api/verify now regenerates the dungeon from the seed and returns a fingerprint a player can independently reproduce. Adds a determinism test suite. Full suite: 11 suites / 113 tests passing. Plan and progress tracked in docs/REMEDIATION_PLAN.md.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# Wownerogue Remediation & Cleanup Plan
|
||||
|
||||
> Derived from the deep code review (May 2026). Goal: fix every BAD/UGLY finding, remove
|
||||
> the spaghetti, and reach a **real-funds launch gate**. Phases are ordered so launch-blockers
|
||||
> get surgical fixes first, then correctness hardening, then structural cleanup, then a
|
||||
> test/ops overhaul to lock it in.
|
||||
|
||||
## Guiding principles
|
||||
1. **Stabilize before restructuring.** Fix exploitable money bugs as minimal patches, ship, *then* refactor.
|
||||
2. **Single source of truth** for the three things that bite: money (one BigInt module), identity (one `user_id`), randomness (one per-game seeded RNG).
|
||||
3. **Defense in depth.** Every check at the DB *and* app level; every render escapes *and* the server escapes.
|
||||
4. **Every change ships with a test that fails before and passes after.**
|
||||
|
||||
Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker · 🟠 pre-launch · 🟡 post-launch
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Stop the bleeding (launch-blockers)
|
||||
|
||||
- [x] **0.1 🔴 S — Verify amount received ≥ expected before confirming.** ✅ Gate on `status.complete` in `paymentHandlers.js`; underpaid → stay pending + `payment_underpaid` warning (once). `received_amount` now recorded for single-game + credits. Regression test added (`payment_flow.integration.test.js`). _113 tests green._
|
||||
- [x] **0.2 🔴 M — Make provably-fair real.** ✅ Per-game seeded RNG (`seedToInt`) threaded through `dungeon.js` (Digger reseed + all `Math.random()` → `rng`), `monster.js` (`seededShuffle`), `game.js` (chase + generation). `DungeonGenerator.regenerateFromSeed` + `layoutFingerprint`; `/api/verify` regenerates & returns fingerprint. Determinism test suite added (`provablyFair.test.js`). _Verifier can now reproduce the exact dungeon from the seed._
|
||||
- [ ] **0.3 🔴 M — Fix batch-payout `tx_hash` collision + double-pay.** Stop writing one `tx_hash` to every row (`src/game/gameModeManager.js:1306`). Use per-destination hashes or a `batch_id`; ensure on-chain rows are never re-sent by retry. *Test: 3-dest batch → all completed, retry finds nothing.*
|
||||
- [ ] **0.4 🔴 S — Harden identity transport.** Move `anon_token` from query string to handshake `auth` (`src/network/sessionManager.js:24`); call `rotateToken` on resume.
|
||||
- [ ] **0.5 🟠 S — Single-game disconnect recovery.** Extend `recoverPendingPayments` (`src/network/sessionManager.js:158`) to handle `single_game`; stop silently deleting in-progress paid games (`src/network/socketHandlers.js:787`).
|
||||
- [ ] **0.6 🟠 S — Client + admin XSS and CSP.** Escape chat/admin fields (`html/js/network/socketHandlers.js:291,323`; `html/admin.html`); add CSP headers.
|
||||
|
||||
## Phase 1 — Money correctness hardening (🟠)
|
||||
- [ ] **1.1 M — `src/money/atomic.js` BigInt module** as the only money type; remove `Number()` casts in `gameModeManager.js` and float `reduce` in `walletRPCService.js:314`.
|
||||
- [ ] **1.2 S — Snapshot payout config at game start** (`gameModeManager.js:237`); pay from recorded multiplier/base.
|
||||
- [ ] **1.3 S — Close payout uniqueness holes.** Cover `processing` in the unique index; guard INSERT (`gameModeManager.js:1161`); add status/outcome/mode enum CHECKs.
|
||||
- [ ] **1.4 S — Fix retry stat guard + non-atomic tx_hash write** (`src/payments/payoutRetryService.js:121,174`).
|
||||
- [ ] **1.5 S — Remove dead refund send-path** (`src/index.js:795`).
|
||||
- [ ] **1.6 S — `credits` → BIGINT; make migration 010 idempotent** (`src/migrations/010_security_constraints.sql`); per-migration transactions in runner (`src/db/databaseManager.js:146`).
|
||||
|
||||
## Phase 2 — Identity, abuse & concurrency (🟠)
|
||||
- [ ] **2.1 L — Unify identity on `user_id`.** One `IdentityService.resolve(socket)`; kill `getOrCreateUser(socketId)` keying (`gameModeManager.js:926`); key `activeGames` by `user_id`.
|
||||
- [ ] **2.2 M — Reconnect-proof rate limiting.** Key on `user_id`/IP; IP-limit chat; set `trust proxy`; de-dup limit configs (`src/network/rateLimiter.js:80`).
|
||||
- [ ] **2.3 M — Input validation + leak fixes.** Validate socket payloads (`connectionHandler.js:90`); TTL-evict `mempoolNotified` (`socketHandlers.js:223`); evict `sessions` on disconnect; wire all `dispose()` into `shutdown()`; snapshot in `checkGamesTimeout` (`socketHandlers.js:1012`).
|
||||
|
||||
## Phase 3 — Game integrity (🟠)
|
||||
- [ ] **3.1 S — Uniform time budget** (elapsed/fixed-block, not "next block"); fix early-entry (`src/network/queueHandler.js:241`).
|
||||
- [ ] **3.2 S — Reachability guarantee** entrance→treasure→exit (`src/game/dungeon.js`).
|
||||
- [ ] **3.3 S — Swap/passthrough collision fix** (`src/game/game.js:181`).
|
||||
|
||||
## Phase 4 — De-spaghetti / structural cleanup (🟡)
|
||||
- [ ] **4.1 L — Break up `index.js`** into `app.js`, `routes/{public,user,admin,auth}.js`, `views/verify.js`, `server.js`.
|
||||
- [ ] **4.2 M — Kill duplicates.** Merge `rpcService`+`rpccalls`; delete dead `handlePlayerMove` (`socketHandlers.js:244`); unify `payout_address` widths; remove `QueryValidator` theater (`databaseManager.js:21`).
|
||||
- [ ] **4.3 M — Config as immutable snapshots;** stop mutating `process.env` (`gameModeManager.js:189`).
|
||||
- [ ] **4.4 M — Frontend de-spaghetti.** Shared `escapeHtml`/`dom.js`; kill double-declares + dead `_appendMessage`; explicit init ordering; upgrade/drop jQuery; gate broadcast logging (`broadcastManager.js:33`).
|
||||
- [ ] **4.5 S — Delete stale `test/` scratch files.**
|
||||
|
||||
## Phase 5 — Test & ops overhaul (🟠 — the launch gate)
|
||||
- [ ] **5.1 L — Real money-path tests** against Postgres (no stubbed `withTransaction`): under/overpay, double-confirm, batch dispatch, credit race, retry idempotency; property-test `money/atomic.js`.
|
||||
- [ ] **5.2 S — Run real `wownero-wallet-rpc` integration test** (open in TODO since day one).
|
||||
- [ ] **5.3 S — `environmentValidator` hard-stop** (`process.exit(1)`); add admin-key/mainnet/simulated-block checks (`src/index.js:33`).
|
||||
- [ ] **5.4 S — Reconcile docs** (README vs `.env.example` house odds); document real provably-fair.
|
||||
- [ ] **5.5 M — Durability:** DB/Redis-backed idempotency + rate limiter.
|
||||
- [ ] **5.6 S — TLS** via reverse proxy.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
Build `money/atomic.js` (1.1) and per-game RNG (0.2) **early** — Phases 1/3/4 depend on them.
|
||||
```
|
||||
Phase 0 (days) → freeze features, ship → Phase 1 (+5.1/5.2 in parallel)
|
||||
→ Phase 2 → Phase 3 → Phase 4 (safe now) → finish Phase 5 → LAUNCH GATE
|
||||
```
|
||||
|
||||
## Launch gate (real funds)
|
||||
- [ ] Under/overpayment correct + reconciled (0.1)
|
||||
- [ ] Provably-fair reproduces dungeon, or claim removed (0.2)
|
||||
- [ ] No double-payout under batch+retry, test-proven (0.3, 1.3, 1.4, 5.1)
|
||||
- [ ] All money math BigInt, property-tested (1.1)
|
||||
- [ ] One identity, impersonation closed (0.4, 2.1)
|
||||
- [ ] XSS closed + CSP (0.6)
|
||||
- [ ] Real wallet-rpc integration test executed (5.2)
|
||||
- [ ] Validator hard-stops on misconfig (5.3)
|
||||
- [ ] TLS in front (5.6)
|
||||
|
||||
_Estimate: ~3–4 weeks to launch gate; Phase 0 in days._
|
||||
+70
-7
@@ -1,5 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const ROT = require('./rot.js');
|
||||
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig');
|
||||
const { createSeededRNG, seedToInt } = require('./provablyFair');
|
||||
|
||||
// Environment-based console logging control
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
@@ -58,6 +60,55 @@ class DungeonGenerator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PROVABLY FAIR (Phase 0.2): Reproduce a dungeon purely from its committed seed.
|
||||
* Mirrors the standard generation path used by Game so a verifier can independently
|
||||
* regenerate the exact layout a player saw. Assumes standard generation options
|
||||
* (the difficulty preset for `cryptoType`); games started with custom dungeon
|
||||
* overrides would need those overrides passed in `gameOptions` to match.
|
||||
* @param {string} seed - The revealed game seed
|
||||
* @param {string|null} cryptoType - Currency, selects the difficulty preset
|
||||
* @param {object} gameOptions - Optional overrides matching the original game
|
||||
* @returns {object} The regenerated dungeon ({ map, rooms, entrance, exit, treasure, torches })
|
||||
*/
|
||||
static regenerateFromSeed(seed, cryptoType = null, gameOptions = {}) {
|
||||
const dungeonConfig = this.getConfig(cryptoType);
|
||||
const width = gameOptions.width || dungeonConfig.DEFAULT_WIDTH;
|
||||
const height = gameOptions.height || dungeonConfig.DEFAULT_HEIGHT;
|
||||
const gameConfig = {
|
||||
width,
|
||||
height,
|
||||
floorVariation: gameOptions.floorVariation !== undefined ? gameOptions.floorVariation : dungeonConfig.FLOOR_VARIATION,
|
||||
torchEnabled: gameOptions.torchEnabled !== undefined ? gameOptions.torchEnabled : dungeonConfig.TORCH_ENABLED,
|
||||
torchDensity: gameOptions.torchDensity !== undefined ? gameOptions.torchDensity : dungeonConfig.TORCH_DENSITY,
|
||||
primaryFloor: gameOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR,
|
||||
secondaryFloor: gameOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR,
|
||||
torchTile: gameOptions.torchTile || dungeonConfig.TORCH_TILE,
|
||||
...gameOptions
|
||||
};
|
||||
return this.generate(width, height, {
|
||||
...gameConfig,
|
||||
rng: createSeededRNG(seed),
|
||||
seedInt: seedToInt(seed)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic fingerprint of a dungeon's layout, used to compare a regenerated
|
||||
* dungeon against the one that was played.
|
||||
* @param {object} dungeon - A dungeon object from generate()/regenerateFromSeed()
|
||||
* @returns {string} SHA-256 hex of the layout-defining fields
|
||||
*/
|
||||
static layoutFingerprint(dungeon) {
|
||||
const payload = JSON.stringify({
|
||||
map: dungeon.map,
|
||||
entrance: dungeon.entrance,
|
||||
exit: dungeon.exit,
|
||||
treasure: dungeon.treasure
|
||||
});
|
||||
return crypto.createHash('sha256').update(payload).digest('hex');
|
||||
}
|
||||
|
||||
static generate(width, height, options = {}) {
|
||||
const defaultOptions = {
|
||||
floorVariation: DUNGEON_CONFIGS.FLOOR_VARIATION,
|
||||
@@ -73,6 +124,17 @@ class DungeonGenerator {
|
||||
console.log(`[DungeonGenerator] Generating dungeon with effective torchDensity: ${config.torchDensity}`);
|
||||
}
|
||||
|
||||
// PROVABLY FAIR (Phase 0.2): when a per-game seeded RNG is supplied, all
|
||||
// randomness in generation is derived from the committed seed so the dungeon
|
||||
// is reproducible for verification. `config.rng` drives floor/torch/treasure
|
||||
// placement; `config.seedInt` deterministically seeds ROT.RNG for the Digger.
|
||||
// Generation is fully synchronous, so seeding the global ROT.RNG here is safe
|
||||
// even with concurrent games (no other game can interleave mid-generation).
|
||||
const rng = (typeof config.rng === 'function') ? config.rng : Math.random;
|
||||
if (config.seedInt != null) {
|
||||
ROT.RNG.setSeed(config.seedInt);
|
||||
}
|
||||
|
||||
// Create dungeon using ROT.js Map.Digger
|
||||
const digger = new ROT.Map.Digger(width, height, {
|
||||
roomWidth: DUNGEON_CONFIGS.ROOM_WIDTH_RANGE,
|
||||
@@ -117,8 +179,8 @@ class DungeonGenerator {
|
||||
const treasureCenter = treasureRoom.getCenter();
|
||||
// Place treasure slightly off center to make it more interesting
|
||||
const treasure = [
|
||||
treasureCenter[0] + Math.floor(Math.random() * 3) - 1,
|
||||
treasureCenter[1] + Math.floor(Math.random() * 3) - 1
|
||||
treasureCenter[0] + Math.floor(rng() * 3) - 1,
|
||||
treasureCenter[1] + Math.floor(rng() * 3) - 1
|
||||
];
|
||||
|
||||
// Ensure the treasure is on a floor tile (check for both floor types)
|
||||
@@ -150,7 +212,8 @@ class DungeonGenerator {
|
||||
}
|
||||
|
||||
let placedTorchesCount = 0; // Counter for torches placed in this function
|
||||
|
||||
const rng = (typeof config.rng === 'function') ? config.rng : Math.random;
|
||||
|
||||
// Log the torchDensity being used for this map generation pass
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[DungeonGenerator.enhanceMapWithVariations] Starting enhancement with torchDensity: ${config.torchDensity}`);
|
||||
@@ -165,14 +228,14 @@ class DungeonGenerator {
|
||||
|
||||
if (cell === 0) {
|
||||
// Floor tile - randomly choose primary or secondary
|
||||
if (Math.random() < config.floorVariation) {
|
||||
if (rng() < config.floorVariation) {
|
||||
enhancedMap[y][x] = config.secondaryFloor;
|
||||
} else {
|
||||
enhancedMap[y][x] = config.primaryFloor;
|
||||
}
|
||||
} else if (cell === 1) {
|
||||
// Wall tile - maybe add a torch
|
||||
if (config.torchEnabled && this.shouldPlaceTorch(basicMap, x, y, config.torchDensity)) {
|
||||
if (config.torchEnabled && this.shouldPlaceTorch(basicMap, x, y, config.torchDensity, rng)) {
|
||||
enhancedMap[y][x] = config.torchTile;
|
||||
placedTorchesCount++; // Increment if a torch is placed
|
||||
} else {
|
||||
@@ -188,7 +251,7 @@ class DungeonGenerator {
|
||||
}
|
||||
|
||||
// Determine if a torch should be placed on this wall tile
|
||||
static shouldPlaceTorch(map, x, y, torchDensity) {
|
||||
static shouldPlaceTorch(map, x, y, torchDensity, rng = Math.random) {
|
||||
// Only place torches on walls that are adjacent to floors (for lighting logic)
|
||||
const height = map.length;
|
||||
const width = map[0].length;
|
||||
@@ -202,7 +265,7 @@ class DungeonGenerator {
|
||||
return nx >= 0 && nx < width && ny >= 0 && ny < height && map[ny][nx] === 0;
|
||||
});
|
||||
|
||||
const randomValue = Math.random();
|
||||
const randomValue = rng();
|
||||
const shouldPlace = adjacentToFloor && randomValue < torchDensity;
|
||||
|
||||
return shouldPlace;
|
||||
|
||||
+18
-7
@@ -5,7 +5,7 @@ const Monster = require('./monster.js');
|
||||
const DungeonGenerator = require('./dungeon.js');
|
||||
const LightingAndFov = require('./lightingAndFov.js');
|
||||
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig');
|
||||
const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG } = require('./provablyFair');
|
||||
const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG, seedToInt, seededShuffle } = require('./provablyFair');
|
||||
|
||||
// Environment-based console logging control
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
@@ -29,7 +29,11 @@ class Game {
|
||||
|
||||
// Provably fair: Generate game proof before anything else
|
||||
this.gameProof = createGameProof(this.id);
|
||||
// Per-game deterministic RNG derived from the committed seed. ALL game randomness
|
||||
// (dungeon generation + monster movement) flows through this so the game is
|
||||
// reproducible from the seed for provably-fair verification.
|
||||
this.seededRNG = createSeededRNG(this.gameProof.seed);
|
||||
this.seedInt = seedToInt(this.gameProof.seed);
|
||||
|
||||
// Get default configuration from DungeonGenerator
|
||||
const dungeonConfig = DungeonGenerator.getConfig();
|
||||
@@ -114,8 +118,14 @@ class Game {
|
||||
this.monsterMoveAccumulator = 0; // For fractional monster moves
|
||||
this.fee = 0;
|
||||
|
||||
// Generate dungeon with the provided dimensions and config
|
||||
this.dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, this.gameConfig);
|
||||
// Generate dungeon with the provided dimensions and config.
|
||||
// Pass the per-game seeded RNG + numeric seed so the layout is deterministic
|
||||
// and verifiable from the committed seed (provably fair).
|
||||
this.dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, {
|
||||
...this.gameConfig,
|
||||
rng: this.seededRNG,
|
||||
seedInt: this.seedInt
|
||||
});
|
||||
|
||||
// Place player at entrance
|
||||
if (this.dungeon.entrance) {
|
||||
@@ -236,12 +246,13 @@ class Game {
|
||||
while (this.monsterMoveAccumulator >= 1) {
|
||||
this.monsterMoveAccumulator -= 1;
|
||||
|
||||
// Apply chase aggressiveness (chance to move randomly instead of chasing)
|
||||
if (Math.random() < chaseAggressiveness) {
|
||||
this.monster.moveTowardPlayer(this.player, this.dungeon);
|
||||
// Apply chase aggressiveness (chance to move randomly instead of chasing).
|
||||
// Use the per-game seeded RNG so monster behaviour is deterministic from the seed.
|
||||
if (this.seededRNG() < chaseAggressiveness) {
|
||||
this.monster.moveTowardPlayer(this.player, this.dungeon, this.seededRNG);
|
||||
} else {
|
||||
// Random move - still try to move, just not toward player
|
||||
this.monster.moveTowardPlayer(null, this.dungeon);
|
||||
this.monster.moveTowardPlayer(null, this.dungeon, this.seededRNG);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ class GameModeManager {
|
||||
* @param {object} packageInfo - Package info (credits, bonus, etc.)
|
||||
* @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed
|
||||
*/
|
||||
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) {
|
||||
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) {
|
||||
try {
|
||||
// Look up user from payment record's user_id (stable across socket reconnects)
|
||||
// This avoids the bug where socket ID changes if user refreshes during confirmation
|
||||
@@ -301,10 +301,11 @@ class GameModeManager {
|
||||
UPDATE payments
|
||||
SET status = 'confirmed',
|
||||
credits_purchased = $1,
|
||||
confirmed_at = NOW()
|
||||
confirmed_at = NOW(),
|
||||
received_amount = COALESCE($3, received_amount)
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
RETURNING id
|
||||
`, [creditsToAdd, paymentId]);
|
||||
`, [creditsToAdd, paymentId, receivedAmount]);
|
||||
|
||||
if (paymentUpdateResult.rows.length === 0) {
|
||||
// Already processed by another instance - not an error
|
||||
|
||||
+12
-10
@@ -1,4 +1,5 @@
|
||||
const ROT = require('./rot.js');
|
||||
const { seededShuffle } = require('./provablyFair');
|
||||
|
||||
/**
|
||||
* Monster class - Manages monster state and AI with improved pathfinding
|
||||
@@ -104,7 +105,7 @@ class Monster {
|
||||
/**
|
||||
* Improved AI to move toward player using A* pathfinding with limited vision
|
||||
*/
|
||||
moveTowardPlayer(player, dungeon) {
|
||||
moveTowardPlayer(player, dungeon, rng = Math.random) {
|
||||
if (!dungeon || !dungeon.map) return;
|
||||
|
||||
// Check if a tile is passable (floor, stairs, treasure)
|
||||
@@ -115,7 +116,7 @@ class Monster {
|
||||
|
||||
// If no player target, wander randomly
|
||||
if (!player) {
|
||||
this._wander(dungeon, isPassable);
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ class Monster {
|
||||
}
|
||||
|
||||
// Fallback: direct move toward player
|
||||
this._moveDirectToward(player.x, player.y, dungeon, isPassable);
|
||||
this._moveDirectToward(player.x, player.y, dungeon, isPassable, rng);
|
||||
|
||||
} else if (this.lastKnownPlayerX !== null) {
|
||||
// Player not visible but we have last known position - patrol toward it
|
||||
@@ -153,7 +154,7 @@ class Monster {
|
||||
// Reached last known position, clear it and wander
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this._wander(dungeon, isPassable);
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
} else {
|
||||
// Path toward last known position
|
||||
const path = this.findPath(this.lastKnownPlayerX, this.lastKnownPlayerY, dungeon, isPassable);
|
||||
@@ -165,19 +166,19 @@ class Monster {
|
||||
// Can't reach last known position, clear and wander
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this._wander(dungeon, isPassable);
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No player info - wander randomly
|
||||
this._wander(dungeon, isPassable);
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple direct movement toward a target (fallback)
|
||||
*/
|
||||
_moveDirectToward(targetX, targetY, dungeon, isPassable) {
|
||||
_moveDirectToward(targetX, targetY, dungeon, isPassable, rng = Math.random) {
|
||||
const dx = Math.sign(targetX - this.x);
|
||||
const dy = Math.sign(targetY - this.y);
|
||||
|
||||
@@ -212,15 +213,16 @@ class Monster {
|
||||
}
|
||||
|
||||
// Blocked in preferred directions, try diagonals (A* backup)
|
||||
this._wander(dungeon, isPassable);
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random wandering behavior
|
||||
*/
|
||||
_wander(dungeon, isPassable) {
|
||||
_wander(dungeon, isPassable, rng = Math.random) {
|
||||
const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
|
||||
const shuffledDirs = ROT.RNG.shuffle(dirs.slice());
|
||||
// Deterministic shuffle from the per-game seeded RNG (provably fair).
|
||||
const shuffledDirs = seededShuffle(rng, dirs.slice());
|
||||
|
||||
for (const [dx, dy] of shuffledDirs) {
|
||||
const nx = this.x + dx;
|
||||
|
||||
@@ -79,6 +79,19 @@ function createSeededRNG(seed) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a positive integer from a hex seed, suitable for seeding ROT.RNG.
|
||||
* 13 hex chars = 52 bits, which stays within Number.MAX_SAFE_INTEGER so the
|
||||
* value is represented exactly. Always returns >= 1.
|
||||
* @param {string} seed - The game seed (hex)
|
||||
* @returns {number} A positive integer derived deterministically from the seed
|
||||
*/
|
||||
function seedToInt(seed) {
|
||||
const hex = String(seed).replace(/[^0-9a-fA-F]/g, '').slice(0, 13) || '1';
|
||||
const n = parseInt(hex, 16);
|
||||
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random integer in range [min, max] using seeded RNG
|
||||
* @param {function} rng - Seeded RNG function
|
||||
@@ -158,13 +171,12 @@ function getPostGameReveal(proof, gameResult = {}) {
|
||||
moves: gameResult.moves ?? 0,
|
||||
duration: gameResult.duration ?? 0
|
||||
},
|
||||
verificationUrl: `/verify/${proof.gameId}`,
|
||||
verificationUrl: `/verify/${proof.seed}`,
|
||||
instructions: [
|
||||
"To verify this game was fair:",
|
||||
`1. Compute SHA-256 of the seed: ${proof.seed}`,
|
||||
`2. Verify it matches the commitment: ${proof.commitment}`,
|
||||
"3. Use the seed to regenerate the dungeon layout",
|
||||
"4. Confirm the dungeon matches what you played"
|
||||
`1. Compute SHA-256 of the seed and confirm it equals the commitment: ${proof.commitment}`,
|
||||
"2. The seed deterministically generates the dungeon layout, treasure, and monster behaviour",
|
||||
`3. Open ${`/verify/${proof.seed}`} to regenerate the dungeon from the seed and compare its layout fingerprint`
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -196,6 +208,7 @@ module.exports = {
|
||||
hashSeed,
|
||||
verifySeed,
|
||||
createSeededRNG,
|
||||
seedToInt,
|
||||
seededRandomInt,
|
||||
seededShuffle,
|
||||
createGameProof,
|
||||
|
||||
@@ -1753,6 +1753,24 @@ app.get('/api/verify', (req, res) => {
|
||||
}
|
||||
|
||||
const result = verifyGame(seed, commitment);
|
||||
|
||||
// PROVABLY FAIR (Phase 0.2): when the seed matches the commitment, regenerate the
|
||||
// dungeon from the seed and return a layout fingerprint so the player can confirm
|
||||
// the layout is a deterministic function of the committed seed (not crafted after payment).
|
||||
if (result.valid) {
|
||||
try {
|
||||
const DungeonGenerator = require('./game/dungeon');
|
||||
const dungeon = DungeonGenerator.regenerateFromSeed(seed, process.env.CRYPTO_TYPE || 'WOW');
|
||||
result.layoutFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
|
||||
result.dungeonSize = { width: dungeon.map[0].length, height: dungeon.map.length };
|
||||
result.entrance = dungeon.entrance;
|
||||
result.exit = dungeon.exit;
|
||||
result.treasure = dungeon.treasure;
|
||||
} catch (e) {
|
||||
// Regeneration is best-effort; the hash check above is the primary verification.
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ class PaymentHandlers {
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.sessionManager = sessionManager;
|
||||
this.mempoolNotified = new Set();
|
||||
// Track underpaid payments we've already warned about (notify once, keep monitoring for top-up)
|
||||
this.underpaidNotified = new Set();
|
||||
this.paymentMonitors = new Map();
|
||||
// Track pending payment metadata per socket (may reuse existing)
|
||||
this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt }
|
||||
@@ -311,6 +313,34 @@ class PaymentHandlers {
|
||||
socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null });
|
||||
}
|
||||
} else if (status.confirmed) {
|
||||
// SECURITY (Phase 0.1): A confirmed transaction is NOT sufficient to grant a
|
||||
// game/credits — we must verify the RECEIVED amount covers what was expected.
|
||||
// checkPaymentStatus() sets `confirmed` for ANY incoming tx on the subaddress,
|
||||
// while `complete` means totalReceived >= required. Without this gate a player
|
||||
// could send a single atomic unit and play (or buy credits) for free.
|
||||
if (!status.complete) {
|
||||
// Underpaid. Keep monitoring so a later top-up to the same address can
|
||||
// complete it; warn the user once to avoid spamming on every 2s poll.
|
||||
if (!this.underpaidNotified.has(paymentRequest.id)) {
|
||||
this.underpaidNotified.add(paymentRequest.id);
|
||||
const required = status.required || 0;
|
||||
const received = status.amount || 0;
|
||||
const shortfall = Math.max(0, required - received);
|
||||
console.warn(`[PaymentHandlers] Underpaid payment ${paymentRequest.id}: received ${received}, required ${required} (shortfall ${shortfall})`);
|
||||
try {
|
||||
socket.emit('payment_underpaid', {
|
||||
paymentId: paymentRequest.id,
|
||||
received,
|
||||
required,
|
||||
shortfall
|
||||
});
|
||||
} catch (_) {}
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`⚠️ UNDERPAYMENT DETECTED\n\nReceived less than the required amount. Send the remaining balance to the SAME address to complete your payment, or wait for it to expire and try again.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Use in-memory Set as fast-path, but DB is source of truth
|
||||
// This prevents double-processing on server restart
|
||||
if (this.confirmedPayments.has(paymentRequest.id)) {
|
||||
@@ -331,7 +361,8 @@ class PaymentHandlers {
|
||||
const creditsResult = await this.gameModeManager.processCreditsPackageConfirmation(
|
||||
socket.id,
|
||||
paymentRequest.id,
|
||||
mapping.package
|
||||
mapping.package,
|
||||
Math.round(status.amount || 0)
|
||||
);
|
||||
if (creditsResult.success) {
|
||||
socket.emit('credits_update', { balance: creditsResult.newBalance });
|
||||
@@ -368,10 +399,11 @@ class PaymentHandlers {
|
||||
const updateResult = await this.gameModeManager.db.query(`
|
||||
UPDATE payments
|
||||
SET status = 'confirmed',
|
||||
confirmed_at = NOW()
|
||||
confirmed_at = NOW(),
|
||||
received_amount = $2
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id
|
||||
`, [paymentRequest.id]);
|
||||
`, [paymentRequest.id, Math.round(status.amount || 0)]);
|
||||
wasUpdated = updateResult.rows.length > 0;
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[PaymentHandlers] Payment ${paymentRequest.id} status update: ${wasUpdated ? 'success' : 'already confirmed'}`);
|
||||
@@ -464,6 +496,9 @@ class PaymentHandlers {
|
||||
if (mapping) {
|
||||
this.walletService.stopPaymentMonitoring(mapping.address);
|
||||
this.socketPaymentMap.delete(socketId);
|
||||
if (mapping.paymentId != null) {
|
||||
this.underpaidNotified.delete(mapping.paymentId);
|
||||
}
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
|
||||
}
|
||||
|
||||
@@ -2,139 +2,148 @@ const QueueManager = require('../src/network/queueManager');
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
|
||||
/**
|
||||
* Integration-style test to ensure a payment that confirms AFTER a block event
|
||||
* (where the entry was still unconfirmed) results in an immediate game start
|
||||
* without waiting for the next block.
|
||||
* Integration-style tests for the payment confirmation flow.
|
||||
*
|
||||
* 1. A payment that confirms AFTER a block event (entry still unconfirmed) results
|
||||
* in an immediate game start without waiting for the next block.
|
||||
* 2. (Phase 0.1 security gate) A payment that is confirmed but UNDERPAID
|
||||
* (received < expected) must NOT start a game or grant anything.
|
||||
*/
|
||||
|
||||
const blockHeight = 100;
|
||||
const socketId = 'sock-int';
|
||||
|
||||
/**
|
||||
* Build a fresh PaymentHandlers + QueueManager harness with controllable wallet
|
||||
* monitoring. Returns the pieces the tests need.
|
||||
*/
|
||||
function buildHarness() {
|
||||
const events = [];
|
||||
const activeGames = new Map();
|
||||
|
||||
const io = {
|
||||
to: () => ({ emit: (event, data) => events.push({ event, data }) })
|
||||
};
|
||||
const broadcastManager = {
|
||||
sendStatusUpdate: (id, type, message) => events.push({ event: 'status', data: { type, message } }),
|
||||
broadcastChatMessage: () => {}
|
||||
};
|
||||
const debugManager = {
|
||||
CONSOLE_LOGGING: false,
|
||||
getCurrentBlockHeight: () => blockHeight
|
||||
};
|
||||
|
||||
const userObj = { id: socketId, clientId: 'client-int', payout_address: 'wow1address' };
|
||||
const getUserBySocket = (id) => (id === socketId ? userObj : null);
|
||||
|
||||
const createGameForUser = (user) => {
|
||||
const game = {
|
||||
getState: () => ({ player: { x: 0, y: 0 }, lighting: {}, torches: [], started: true })
|
||||
};
|
||||
activeGames.set(user.id, game);
|
||||
return game;
|
||||
};
|
||||
|
||||
const gameModeManager = {
|
||||
gameMode: 'PAID_SINGLE',
|
||||
cryptoType: 'WOW',
|
||||
currencyDecimals: 11,
|
||||
singleGamePrice: 1000,
|
||||
creditsPayoutEnabled: false,
|
||||
formatAtomicHuman: () => '0.001',
|
||||
getOrCreateUser: async () => ({ id: socketId, payout_address: 'wow1address', clientId: 'client-int' }),
|
||||
createPaymentRequest: async () => ({
|
||||
id: 'pay-1',
|
||||
address: 'addr-1',
|
||||
amount: 1000,
|
||||
amountFormatted: '0.001',
|
||||
expiresAt: new Date(Date.now() + 60000)
|
||||
}),
|
||||
processGameStart: async () => ({ success: true, effectiveMode: 'PAID_SINGLE' }),
|
||||
db: {
|
||||
query: async (sql) => {
|
||||
if (sql && sql.includes('UPDATE payments') && sql.includes('RETURNING')) {
|
||||
return { rows: [{ id: 'pay-1' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let monitorCallback;
|
||||
const walletService = {
|
||||
startPaymentMonitoring: (address, cb) => { monitorCallback = cb; },
|
||||
stopPaymentMonitoring: () => {}
|
||||
};
|
||||
|
||||
const queueManager = new QueueManager({
|
||||
debugManager, broadcastManager, io, createGameForUser, getUserBySocket,
|
||||
activeGames, gameModeManager, consoleLogging: false
|
||||
});
|
||||
|
||||
const paymentHandlers = new PaymentHandlers({
|
||||
io, gameModeManager, walletService, debugManager, queueManager, broadcastManager
|
||||
});
|
||||
|
||||
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
|
||||
|
||||
return {
|
||||
events, queueManager, paymentHandlers, fakeSocket,
|
||||
getMonitorCallback: () => monitorCallback
|
||||
};
|
||||
}
|
||||
|
||||
describe('Payment flow integration: confirm-after-block immediate start', () => {
|
||||
test('unconfirmed at block tick then confirmed triggers immediate game', async () => {
|
||||
const events = [];
|
||||
const activeGames = new Map();
|
||||
const socketId = 'sock-int';
|
||||
const blockHeight = 100;
|
||||
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
|
||||
|
||||
// Mock IO emitter
|
||||
const io = {
|
||||
to: () => ({
|
||||
emit: (event, data) => {
|
||||
events.push({ event, data });
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Minimal broadcast manager stub
|
||||
const broadcastManager = {
|
||||
sendStatusUpdate: () => {},
|
||||
broadcastChatMessage: () => {}
|
||||
};
|
||||
|
||||
// Debug manager mock
|
||||
const debugManager = {
|
||||
CONSOLE_LOGGING: false,
|
||||
getCurrentBlockHeight: () => blockHeight
|
||||
};
|
||||
|
||||
// User lookup
|
||||
const userObj = { id: socketId, clientId: 'client-int', payout_address: 'wow1address' };
|
||||
const getUserBySocket = (id) => id === socketId ? userObj : null;
|
||||
|
||||
// createGameForUser mock (records active game & returns state)
|
||||
const createGameForUser = (user) => {
|
||||
const game = {
|
||||
getState: () => ({ player: { x:0, y:0 }, lighting: {}, torches: [], started: true })
|
||||
};
|
||||
activeGames.set(user.id, game);
|
||||
return game;
|
||||
};
|
||||
|
||||
// Game mode manager mock (paid single game mode)
|
||||
const gameModeManager = {
|
||||
gameMode: 'PAID_SINGLE',
|
||||
cryptoType: 'WOW',
|
||||
currencyDecimals: 11,
|
||||
singleGamePrice: 1000,
|
||||
creditsPayoutEnabled: false,
|
||||
formatAtomicHuman: () => '0.001',
|
||||
getOrCreateUser: async () => ({ id: socketId, payout_address: 'wow1address', clientId: 'client-int' }),
|
||||
createPaymentRequest: async () => ({
|
||||
id: 'pay-1',
|
||||
address: 'addr-1',
|
||||
amount: 1000,
|
||||
amountFormatted: '0.001',
|
||||
expiresAt: new Date(Date.now()+60000)
|
||||
}),
|
||||
// Mock processGameStart for QueueManager.startGameImmediately
|
||||
processGameStart: async () => ({ success: true, effectiveMode: 'PAID_SINGLE' }),
|
||||
// Mock db for PaymentHandlers DB update on confirmation
|
||||
// The security fix requires UPDATE to return rows to confirm the payment was updated
|
||||
db: {
|
||||
query: async (sql) => {
|
||||
// If this is the UPDATE payments query, return a row to indicate success
|
||||
if (sql && sql.includes('UPDATE payments') && sql.includes('RETURNING')) {
|
||||
return { rows: [{ id: 'pay-1' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wallet service mock with manual callback control
|
||||
let monitorCallback;
|
||||
const walletService = {
|
||||
startPaymentMonitoring: (address, cb) => { monitorCallback = cb; },
|
||||
stopPaymentMonitoring: () => {}
|
||||
};
|
||||
|
||||
// Instantiate real QueueManager & PaymentHandlers
|
||||
const queueManager = new QueueManager({
|
||||
debugManager,
|
||||
broadcastManager,
|
||||
io,
|
||||
createGameForUser,
|
||||
getUserBySocket,
|
||||
activeGames,
|
||||
gameModeManager,
|
||||
consoleLogging: false
|
||||
});
|
||||
|
||||
const paymentHandlers = new PaymentHandlers({
|
||||
io,
|
||||
gameModeManager,
|
||||
walletService,
|
||||
debugManager,
|
||||
queueManager,
|
||||
broadcastManager
|
||||
});
|
||||
|
||||
// Fake socket object with id
|
||||
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
|
||||
|
||||
// Create payment request (does NOT queue yet) and start monitoring
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
|
||||
|
||||
const monitorCallback = getMonitorCallback();
|
||||
expect(monitorCallback).toBeDefined();
|
||||
|
||||
// Simulate mempool detection first (callback is async, so we need to await it)
|
||||
// Simulate mempool detection first
|
||||
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1000, confirmations: 0 });
|
||||
// Should have queued the player but not started a game
|
||||
expect(queueManager.getQueueLength()).toBe(1);
|
||||
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
|
||||
|
||||
// Simulate block tick BEFORE confirmation (should skip unconfirmed)
|
||||
// Block tick BEFORE confirmation (should skip unconfirmed)
|
||||
await queueManager.startGamesForWaiting(blockHeight);
|
||||
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
|
||||
// Entry should still be in queue (re-queued)
|
||||
expect(queueManager.getQueueLength()).toBe(1);
|
||||
|
||||
// Now simulate confirmation after the block (await the async callback)
|
||||
await monitorCallback({ in_mempool: true, confirmed: true, amount: 1000, confirmations: 1 });
|
||||
// Confirmation after the block. `complete: true` means the received amount covers
|
||||
// the expected amount — required for a game to start (Phase 0.1 gate).
|
||||
await monitorCallback({ in_mempool: true, confirmed: true, complete: true, amount: 1000, required: 1000, confirmations: 1 });
|
||||
|
||||
// Immediate game start expected
|
||||
const gameStarts = events.filter(e => e.event === 'game_start');
|
||||
expect(gameStarts.length).toBe(1);
|
||||
expect(gameStarts[0].data.blockHeight).toBe(blockHeight);
|
||||
// Queue should now be empty
|
||||
expect(queueManager.getQueueLength()).toBe(0);
|
||||
});
|
||||
|
||||
test('confirmed but UNDERPAID does not start a game or grant anything', async () => {
|
||||
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
|
||||
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
|
||||
const monitorCallback = getMonitorCallback();
|
||||
expect(monitorCallback).toBeDefined();
|
||||
|
||||
// Mempool detection queues the player (queueing is gated on confirmation downstream)
|
||||
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1, confirmations: 0 });
|
||||
|
||||
// Confirmed on-chain, but only 1 of 1000 atomic units received → complete is false.
|
||||
await monitorCallback({ in_mempool: true, confirmed: true, complete: false, amount: 1, required: 1000, confirmations: 1 });
|
||||
|
||||
// No game must start, and the user must be warned about the underpayment.
|
||||
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
|
||||
expect(events.filter(e => e.event === 'payment_underpaid').length).toBe(1);
|
||||
const underpaid = events.find(e => e.event === 'payment_underpaid');
|
||||
expect(underpaid.data.received).toBe(1);
|
||||
expect(underpaid.data.required).toBe(1000);
|
||||
expect(underpaid.data.shortfall).toBe(999);
|
||||
|
||||
// Repeated underpaid polls must not spam the user (notify once).
|
||||
await monitorCallback({ in_mempool: true, confirmed: true, complete: false, amount: 1, required: 1000, confirmations: 2 });
|
||||
expect(events.filter(e => e.event === 'payment_underpaid').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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('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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user