Files
wownerogue/test/serverSideFogOfWar.test.js
T
jwinterm dacbf356ed fix: close pre-launch review findings and light the premium renderers
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.

CRITICAL — unauthenticated remote kill switch
  Socket.IO does not wrap listener invocation, and this process treats
  uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
  listener exits the server. `socket.emit('debug_ping')` with no argument did
  exactly that, and systemd restarts straight back into it. Every registration
  in handleConnection now goes through a safe-dispatch wrapper that contains
  sync throws and rejected promises to the offending socket; handleDebugPing
  also stops trusting its payload.

HIGH
  - NIP-98 login now binds the signed event to this deployment's host. The path
    check alone let an event signed for any other origin authenticate here, and
    linking ADOPTS the account owning that pubkey — full account takeover.
  - Fog of war is enforced server-side. getState() shipped absolute
    entrance/exit/treasure coordinates and the monster's exact position in every
    frame, with concealment implemented only in the browser. Discovery is sticky;
    the monster is reported only while in view.
  - NODE_ENV must now be explicit and recognized. Unset, every production gate
    read false while DebugManager reported production and silenced logging.
  - Tavern chat applies the lobby's ban list, rate limiter and user_id
    attribution before entering global chat.
  - userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
    by socket.client.id, so nothing was ever freed).

MEDIUM / LOW
  - Payouts: a legitimately split transfer_split kept its transaction hashes
    instead of quarantining already-broadcast funds with tx_hash NULL, and
    provably pre-broadcast failures return to 'pending' rather than needs_review.
  - RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
    never reset, because ids contain colons.
  - Enter no longer submits a paid entry from behind a modal.
  - Lobby chat no longer double-escapes server-escaped text.
  - Rate limits for identity:get / address:prompt / early_entry.
  - Spectators released when a game ends by disconnect, not just settlement.
  - movementManager / addressManager per-socket maps cleaned on disconnect.

Renderers
  Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
  light pools, crevice AO, embers, cold memory wash, and it stops reallocating
  the whole canvas every animation frame.
  3D: instanced level geometry (3 draw calls), procedural stone/flagstone
  materials, torch lights + shadows, real meshes for stairs/chest/monster
  (they were humanoid capsules), dust motes, wall cutaway, and lighting
  rebalanced now that fog is measured from the camera.

Tavern
  Block height and hash fragments drift around the stage edge and ripple when a
  block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
  Speech bubbles work again (they keyed off a socketId the server stopped
  sending).

126 suites / 1005 tests pass.
2026-07-26 07:29:47 -04:00

100 lines
4.1 KiB
JavaScript

/**
* Server-side fog of war.
*
* `Game.getState()` used to put the absolute entrance/exit/treasure coordinates AND the monster's
* exact position into every `game_start` / `game_update` frame, relying entirely on the browser
* renderer to decline to draw them. Anyone reading the socket payload in devtools could walk
* straight to the treasure and the exit from move 0 — in a paid mode, a direct attack on the payout,
* and the same frame is forwarded verbatim to spectators.
*
* Concealment is now enforced where it is authoritative: the server. Discovery is STICKY so a
* feature you have found stays on your map after you walk away, matching the client's notion of
* explored memory.
*/
const Game = require('../src/game/game');
const KEYS = ['DUNGEON_LEVELS', 'DIFFICULTY_PRESET', 'CRYPTO_TYPE'];
const SAVE = {};
beforeEach(() => {
KEYS.forEach(k => { SAVE[k] = process.env[k]; delete process.env[k]; });
process.env.DIFFICULTY_PRESET = 'normal';
});
afterEach(() => {
KEYS.forEach(k => { if (SAVE[k] === undefined) delete process.env[k]; else process.env[k] = SAVE[k]; });
});
const user = () => ({ id: 0, username: 'test', endGame() {} });
/** Force a point into (or out of) the player's current FOV. */
function setVisible(game, point, visible) {
const [x, y] = Array.isArray(point) ? point : [point.x, point.y];
if (visible) {
game.visibleTiles[y] = game.visibleTiles[y] || {};
game.visibleTiles[y][x] = '.';
} else if (game.visibleTiles[y]) {
delete game.visibleTiles[y][x];
}
}
describe('server-side fog of war', () => {
test('undiscovered exit and treasure are withheld from the state frame', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
// Nothing but the spawn room is in view at game start.
setVisible(game, game.dungeon.exit, false);
if (game.dungeon.treasure) setVisible(game, game.dungeon.treasure, false);
const state = game.getState();
expect(state.exit).toBeNull();
expect(state.treasure).toBeNull();
// The dungeon itself still knows — only the wire frame is redacted.
expect(game.dungeon.exit).toEqual(expect.any(Array));
});
test('a feature is disclosed once seen, and STAYS disclosed after it leaves view', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
const exit = game.dungeon.exit;
expect(game.getState().exit).toBeNull();
setVisible(game, exit, true);
expect(game.getState().exit).toEqual(exit);
// Walk away: explored memory must persist, or a found staircase would vanish off the map.
setVisible(game, exit, false);
expect(game.getState().exit).toEqual(exit);
});
test('the monster is only reported while it is actually in view', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
expect(game.monster).toBeTruthy();
setVisible(game, game.monster, false);
expect(game.getState().monster).toBeNull();
setVisible(game, game.monster, true);
const seen = game.getState().monster;
expect(seen).toBeTruthy();
expect(seen.x).toBe(game.monster.x);
expect(seen.y).toBe(game.monster.y);
// Unlike features, the monster moves — concealment is NOT sticky, or you could track the
// hunter through walls for the rest of the run.
setVisible(game, game.monster, false);
expect(game.getState().monster).toBeNull();
});
test('discovery survives repeated getState calls without re-checking visibility', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
setVisible(game, game.dungeon.entrance, true);
expect(game.getState().entrance).toEqual(game.dungeon.entrance);
setVisible(game, game.dungeon.entrance, false);
expect(game.getState().entrance).toEqual(game.dungeon.entrance);
expect(game.getState().entrance).toEqual(game.dungeon.entrance);
});
});