mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
fix: resolve security/correctness review + AAA graphics pass
Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater. Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests. Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles. Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+26
-21
@@ -69,6 +69,11 @@
|
||||
<script src="js/display/waitingScreenAnimator.js"></script>
|
||||
<script src="js/display/screenManager.js"></script>
|
||||
<script src="js/display/renderEngine.js"></script>
|
||||
<!-- Additive FX overlay (dependency-free window.FX; created by the fx-core track).
|
||||
Loaded BEFORE gameState.js / game.js / singlePlayerAvatar.js so their guarded
|
||||
`if (window.FX)` hooks can fire. All methods are no-ops when no overlay canvas
|
||||
is present, so match.html / tavern.html are unaffected. -->
|
||||
<script src="js/display/fxOverlay.js"></script>
|
||||
<script src="js/core/gameState.js"></script>
|
||||
<script src="js/core/game.js"></script>
|
||||
<script src="js/render/atlas.js"></script>
|
||||
@@ -110,41 +115,41 @@
|
||||
<div id="publicStats" style="display:none; margin-top:4px; font-size:11px; color:#9cf; font-family:monospace; line-height:1.4;"></div>
|
||||
<div id="liveCta" style="display:none; margin-top:6px; cursor:pointer; background:rgba(80,0,0,0.55); border:1px solid #f33; border-radius:4px; padding:5px 10px; font-size:12px; font-family:monospace; color:#ff8a8a; font-weight:bold;" title="Watch a live game"></div>
|
||||
<div id="creditsDisplay" style="display:none; margin-top:4px; font-size:13px; font-family:monospace; color:#0f0; background:#001a00; padding:4px 8px; border:1px solid #0a0; border-radius:4px; font-weight:bold;"></div>
|
||||
<button id="startButton" style="background: #0a5c0a; color: #ff0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; display: none;">
|
||||
<button id="startButton" class="ctrl-btn ctrl-btn--start" style="display: none;">
|
||||
🎮 <span class="lbl-full">START GAME</span><span class="lbl-short">START</span>
|
||||
</button>
|
||||
<button id="watchGamesButton" style="background: #053655; color: #0af; border: 2px solid #0af; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
<button id="watchGamesButton" class="ctrl-btn ctrl-btn--watch">
|
||||
👁️ <span class="lbl-full">WATCH GAMES</span><span class="lbl-short">WATCH</span>
|
||||
</button>
|
||||
<button id="tavernButton" style="background: #2a0a3a; color: #d8b4fe; border: 2px solid #a855f7; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;" onclick="window.location.href='/tavern'">
|
||||
<button id="tavernButton" class="ctrl-btn ctrl-btn--tavern" style="display: none;" onclick="window.location.href='/tavern'">
|
||||
🍺 <span class="lbl-full">TAVERN</span><span class="lbl-short">TAVERN</span>
|
||||
</button>
|
||||
<button id="characterButton" style="background: #163044; color: #a7d8ff; border: 2px solid #5aa7d8; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
<button id="characterButton" class="ctrl-btn ctrl-btn--character">
|
||||
🧍 <span id="characterButtonLabel" class="lbl-full">CHARACTER</span><span class="lbl-short">CHAR</span>
|
||||
</button>
|
||||
<button id="shopButton" style="background: #004400; color: #0f0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
<button id="shopButton" class="ctrl-btn ctrl-btn--shop" style="display: none;">
|
||||
🛒 SHOP
|
||||
</button>
|
||||
<button id="animationToggleButton" style="background: #5c5c0a; color: #ff0; border: 2px solid #aa0; padding: 6px 12px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
<button id="animationToggleButton" class="ctrl-btn ctrl-btn--anim" style="display: none;">
|
||||
🎬 DISABLE ANIMATION
|
||||
</button>
|
||||
<div style="margin-top:6px; font-size:11px; color:#ccc; display:flex; align-items:center; gap:4px;">
|
||||
<label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
|
||||
<input type="checkbox" id="audioAlertsToggle" style="cursor:pointer;"> Audio Alerts
|
||||
<div class="hud-toolbar">
|
||||
<label>
|
||||
<input type="checkbox" id="audioAlertsToggle"> Audio Alerts
|
||||
</label>
|
||||
<button id="helpButton" style="margin-left:auto; background:#2d2d00; color:#ffd700; border:1px solid #ffd700; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="How to play and payment information">❓ Help</button>
|
||||
<button id="historyButton" style="background:#1a1a4d; color:#c4b5fd; border:1px solid #8b5cf6; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View your payment and payout history">📜 History</button>
|
||||
<button id="leaderboardButton" style="background:#4a2800; color:#fbbf24; border:1px solid #f59e0b; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View high scores">🏆 Scores</button>
|
||||
<button id="manageAddressButton" style="background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
|
||||
<span id="sessionWarning" style="display:none; color:#f59e0b; font-size:10px; margin-left:6px;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
|
||||
<button id="helpButton" class="hud-chip hud-chip--help hud-chip--push" title="How to play and payment information">❓ Help</button>
|
||||
<button id="historyButton" class="hud-chip hud-chip--history" title="View your payment and payout history">📜 History</button>
|
||||
<button id="leaderboardButton" class="hud-chip hud-chip--scores" title="View high scores">🏆 Scores</button>
|
||||
<button id="manageAddressButton" class="hud-chip hud-chip--address" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
|
||||
<span id="sessionWarning" style="display:none;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment System UI -->
|
||||
<div id="payment-ui" class="payment-section" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2000; background: rgba(0,20,0,0.95); border: 2px solid #0f0; padding: 20px; color: #0f0; min-width: 400px; max-width: 500px; box-shadow: 0 0 20px rgba(0,255,0,0.2);">
|
||||
<div class="payment-header">
|
||||
<div id="payment-ui" class="payment-section dungeon-modal" style="display: none;">
|
||||
<div class="payment-header dm-head">
|
||||
<strong>🎮 Choose How to Play</strong>
|
||||
<button id="close-payment" style="float: right; background: #500; color: #f00; border: 1px solid #f00; padding: 2px 6px; cursor: pointer;">✕</button>
|
||||
<button id="close-payment" class="dm-close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic game mode options populated by PaymentUI.js -->
|
||||
@@ -173,10 +178,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Transaction History Panel -->
|
||||
<div id="history-panel" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2000; background: rgba(10,10,30,0.98); border: 2px solid #8b5cf6; padding: 20px; color: #e0e0e0; min-width: 500px; max-width: 600px; max-height: 80vh; overflow-y: auto; box-shadow: 0 0 20px rgba(139,92,246,0.3); border-radius: 8px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; border-bottom:1px solid #444; padding-bottom:10px;">
|
||||
<strong style="font-size:1.2em; color:#c4b5fd;">📜 Transaction History</strong>
|
||||
<button id="close-history" style="background: #500; color: #f00; border: 1px solid #f00; padding: 4px 8px; cursor: pointer; border-radius:3px;">✕</button>
|
||||
<div id="history-panel" class="dungeon-modal" style="display: none; max-width: 600px;">
|
||||
<div class="dm-head">
|
||||
<strong style="color:#c4b5fd;">📜 Transaction History</strong>
|
||||
<button id="close-history" class="dm-close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
|
||||
+190
-2
@@ -124,6 +124,11 @@ var Game = {
|
||||
}
|
||||
|
||||
this._drawGameScreen();
|
||||
|
||||
// JUICE: kick off the FX overlay (ambient embers + flicker + particle loop).
|
||||
// Purely additive; all calls are no-ops if the FX overlay module is absent.
|
||||
this._fxStart();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
@@ -144,12 +149,30 @@ var Game = {
|
||||
|
||||
// Update game state with new data from server
|
||||
updateGameState: function(data) {
|
||||
// JUICE: snapshot treasure/item count so we can detect a server-driven pickup below.
|
||||
const prevTreasureCount = (window.FX) ? this._fxTreasureCells().length : 0;
|
||||
|
||||
const needsRedraw = GameState.updateGameState(data);
|
||||
|
||||
|
||||
// Redraw the game screen if any relevant data changed
|
||||
if (needsRedraw) {
|
||||
this._drawGameScreen();
|
||||
}
|
||||
|
||||
// JUICE: react to server-driven changes (pickups, proximity) with FX.
|
||||
if (window.FX && needsRedraw) {
|
||||
try {
|
||||
const newTreasureCount = this._fxTreasureCells().length;
|
||||
if (newTreasureCount < prevTreasureCount) {
|
||||
// A treasure/item vanished near us -> gold burst (pickup).
|
||||
const pp = this._playerScreenPixel();
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 28);
|
||||
} else {
|
||||
// Otherwise run proximity effects for server-authoritative moves.
|
||||
this._fxOnPlayerMoved();
|
||||
}
|
||||
} catch (e) { /* FX is best-effort; never break gameplay */ }
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy alias (some handlers referenced updateGame previously)
|
||||
@@ -172,6 +195,29 @@ var Game = {
|
||||
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('other');
|
||||
}
|
||||
|
||||
// JUICE: celebratory or fatal FX on the outcome. Loop keeps running so the
|
||||
// burst/shake actually renders; it is torn down on return-to-title (below).
|
||||
if (window.FX) {
|
||||
try {
|
||||
const pp = this._playerScreenPixel();
|
||||
const escaped = !!(data && data.reason === 'escaped');
|
||||
if (escaped) {
|
||||
// Win: gold burst + brief bright flash.
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 40);
|
||||
window.FX.flash('rgba(255,215,0,1)', 0.35, 400);
|
||||
if (data && data.treasure) {
|
||||
window.FX.burst(pp.x, pp.y, '#fbbf24', 24);
|
||||
}
|
||||
} else {
|
||||
// Loss (monster / timeout / other): shake + red flash.
|
||||
window.FX.shake(12, 500);
|
||||
window.FX.flash('rgba(255,0,0,1)', 0.4, 450);
|
||||
}
|
||||
// Gameplay is over: quiet the ambient torch embers.
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
||||
// After short delay, allow user to return to title with Enter/start
|
||||
const score = data && typeof data.score === 'number' ? data.score : 0;
|
||||
const treasure = !!(data && data.treasure);
|
||||
@@ -208,6 +254,7 @@ var Game = {
|
||||
this._autoReturnTimer = setTimeout(() => {
|
||||
if (this._awaitingRestart) {
|
||||
this._awaitingRestart = false;
|
||||
this._fxStop(); // JUICE: tear down FX loop so it never leaks
|
||||
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
@@ -222,6 +269,7 @@ var Game = {
|
||||
if (e.key === 'Enter') {
|
||||
this._awaitingRestart = false;
|
||||
clearTimeout(this._autoReturnTimer);
|
||||
this._fxStop(); // JUICE: tear down FX loop so it never leaks
|
||||
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
@@ -242,8 +290,14 @@ var Game = {
|
||||
_drawGameScreen: function() {
|
||||
if (!this._ensureDisplay()) return;
|
||||
const gameState = GameState.getGameStateForRender();
|
||||
|
||||
|
||||
RenderEngine.drawGameScreen(gameState);
|
||||
|
||||
// JUICE: keep the FX overlay aligned to the base canvas and feed it the
|
||||
// player's screen-space position so its torch lighting tracks the camera.
|
||||
if (window.FX) {
|
||||
try { this._fxSyncLighting(); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
},
|
||||
|
||||
// Screen drawing methods - delegate to ScreenManager
|
||||
@@ -296,6 +350,140 @@ var Game = {
|
||||
const moved = GameState.movePlayer(dx, dy, this._screenWidth, this._screenHeight);
|
||||
if (moved) {
|
||||
this._drawGameScreen();
|
||||
// JUICE: sparkle near treasure, gold burst on pickup, red edge pulse near monsters.
|
||||
if (window.FX) {
|
||||
try { this._fxOnPlayerMoved(); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// JUICE / FX helpers. These ONLY call the window.FX overlay API (GFX1);
|
||||
// they never define it. Every call is guarded so the game stays fully
|
||||
// functional when the FX module is absent (match.html / tavern.html).
|
||||
// Coordinates are computed with the SAME camera-centered mapping the
|
||||
// renderer uses (the player is always drawn at the screen center).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The ROT.js display container is the base canvas FX layers above.
|
||||
_baseCanvas: function() {
|
||||
if (typeof DisplayManager === 'undefined' || !DisplayManager.getDisplay) return null;
|
||||
var display = DisplayManager.getDisplay();
|
||||
return (display && display.getContainer) ? display.getContainer() : null;
|
||||
},
|
||||
|
||||
// Convert a world cell -> center pixel in base-canvas space.
|
||||
_cellToScreenPixel: function(wx, wy) {
|
||||
var cell = (window.options && window.options.tileWidth) ? window.options.tileWidth : 32;
|
||||
var centerX = Math.floor(this._screenWidth / 2);
|
||||
var centerY = Math.floor(this._screenHeight / 2);
|
||||
var player = (GameState && GameState._player) ? GameState._player : { x: wx, y: wy };
|
||||
var sx = wx - player.x + centerX;
|
||||
var sy = wy - player.y + centerY;
|
||||
return { x: sx * cell + cell / 2, y: sy * cell + cell / 2, cell: cell };
|
||||
},
|
||||
|
||||
// The player is camera-centered, so this is always the center of the view.
|
||||
_playerScreenPixel: function() {
|
||||
var p = (GameState && GameState._player) ? GameState._player : { x: 0, y: 0 };
|
||||
return this._cellToScreenPixel(p.x, p.y);
|
||||
},
|
||||
|
||||
// Chebyshev (king-move) distance — matches the grid feel.
|
||||
_fxDist: function(ax, ay, bx, by) {
|
||||
return Math.max(Math.abs(ax - bx), Math.abs(ay - by));
|
||||
},
|
||||
|
||||
// Collect every treasure/item world cell currently known.
|
||||
_fxTreasureCells: function() {
|
||||
var cells = [];
|
||||
if (typeof GameState === 'undefined') return cells;
|
||||
var t = GameState._treasure;
|
||||
if (t && typeof t[0] === 'number' && typeof t[1] === 'number') {
|
||||
cells.push({ x: t[0], y: t[1] });
|
||||
}
|
||||
var items = GameState._items;
|
||||
if (items) {
|
||||
for (var k in items) {
|
||||
if (items.hasOwnProperty(k)) {
|
||||
var it = items[k];
|
||||
if (it && typeof it.x === 'number' && typeof it.y === 'number') {
|
||||
cells.push({ x: it.x, y: it.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
},
|
||||
|
||||
// Fire proximity/pickup FX for the player's current position.
|
||||
_fxOnPlayerMoved: function() {
|
||||
if (!window.FX) return;
|
||||
var player = (GameState && GameState._player) ? GameState._player : null;
|
||||
if (!player) return;
|
||||
|
||||
var pp = this._playerScreenPixel();
|
||||
|
||||
// Treasure proximity: on-cell => gold burst, nearby => sparkle.
|
||||
var cells = this._fxTreasureCells();
|
||||
var onTreasure = false, nearTreasure = false;
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
var d = this._fxDist(player.x, player.y, cells[i].x, cells[i].y);
|
||||
if (d === 0) onTreasure = true;
|
||||
else if (d <= 2) nearTreasure = true;
|
||||
}
|
||||
if (onTreasure && window.FX.burst) {
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 24);
|
||||
} else if (nearTreasure && window.FX.sparkle) {
|
||||
window.FX.sparkle(pp.x, pp.y, '#fbbf24');
|
||||
}
|
||||
|
||||
// Monster proximity: throttled red edge pulse, stronger the closer it is.
|
||||
var m = GameState._monster;
|
||||
if (m && typeof m.x === 'number' && typeof m.y === 'number' && window.FX.flash) {
|
||||
var md = this._fxDist(player.x, player.y, m.x, m.y);
|
||||
if (md <= 3) {
|
||||
var now = Date.now();
|
||||
if (!this._fxLastMonsterFlash || (now - this._fxLastMonsterFlash) > 550) {
|
||||
this._fxLastMonsterFlash = now;
|
||||
var alpha = (md <= 1) ? 0.22 : (md <= 2 ? 0.14 : 0.09);
|
||||
window.FX.flash('rgba(255,60,60,1)', alpha, 260);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Attach + start the FX loop for a fresh game.
|
||||
_fxStart: function() {
|
||||
if (!window.FX) return;
|
||||
this._fxLastMonsterFlash = 0;
|
||||
try {
|
||||
var base = this._baseCanvas();
|
||||
if (base && window.FX.attach) window.FX.attach(base);
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
||||
if (window.FX.start) window.FX.start();
|
||||
} catch (e) { /* best-effort */ }
|
||||
},
|
||||
|
||||
// Stop + clear the FX loop so requestAnimationFrame never leaks.
|
||||
_fxStop: function() {
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
if (window.FX.stop) window.FX.stop();
|
||||
if (window.FX.clear) window.FX.clear();
|
||||
} catch (e) { /* best-effort */ }
|
||||
},
|
||||
|
||||
// Keep the overlay aligned and feed it the camera-centered player position.
|
||||
_fxSyncLighting: function() {
|
||||
if (!window.FX) return;
|
||||
var base = this._baseCanvas();
|
||||
if (!base) return;
|
||||
if (window.FX.syncTo) window.FX.syncTo(base);
|
||||
if (window.FX.renderLighting && GameState && GameState._player) {
|
||||
var pp = this._playerScreenPixel();
|
||||
window.FX.renderLighting(pp.x, pp.y, pp.cell, { torches: GameState._torches || [] });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -177,12 +177,32 @@
|
||||
this.clearOverlay();
|
||||
var player = gameState.player;
|
||||
var facing = this._updateFacing(player);
|
||||
return root.RK.avatarVisuals.drawTopdownWorld(ctx, visual, {
|
||||
var drew = root.RK.avatarVisuals.drawTopdownWorld(ctx, visual, {
|
||||
id: 'single-player',
|
||||
x: player.x,
|
||||
y: player.y,
|
||||
facing: facing
|
||||
}, viewport, { onReady: requestRedraw });
|
||||
// After the avatar is drawn, sync the overlay FX layer to the same base
|
||||
// canvas and let its warm torch light track the player cell. Fully guarded:
|
||||
// no-op when the FX module is absent.
|
||||
this._syncFX(viewport);
|
||||
return drew;
|
||||
},
|
||||
|
||||
// Attach/sync the FX overlay to the same base canvas used by the avatar overlay
|
||||
// (mirrors _syncOverlay's sizing) and drive the torch light at the player cell.
|
||||
_syncFX: function (viewport) {
|
||||
if (!root.FX || !viewport) return;
|
||||
var base = this._baseCanvas();
|
||||
if (!base) return;
|
||||
try {
|
||||
root.FX.attach(base);
|
||||
var cell = viewport.cell || (root.options && root.options.tileWidth) || 32;
|
||||
var px = viewport.screenX * cell + cell / 2;
|
||||
var py = viewport.screenY * cell + cell / 2;
|
||||
root.FX.renderLighting(px, py, cell);
|
||||
} catch (_) { /* FX is purely additive — never let it break the avatar */ }
|
||||
},
|
||||
|
||||
drawLegendIcon: function (tileX, tileY, opts) {
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
// FXOverlay — dependency-free 2D-canvas FX layer for the FREE ROT.js dungeon (Pipeline A).
|
||||
// Ports the premium FancyRenderer look (warm flickering torch light, screen-edge vignette,
|
||||
// drifting-ember particle field, transient sparkles/bursts, full-screen color flash and a
|
||||
// decaying screen shake) using plain <canvas> radial gradients instead of Pixi.
|
||||
//
|
||||
// Exposes a single global `window.FX`. EVERY method is a no-op when there is no
|
||||
// #game-display / ROT base canvas present (so match.html & tavern.html — which never
|
||||
// include this file — and any early-boot call are safe). It draws onto its OWN
|
||||
// absolutely-positioned canvas layered just above the avatar overlay (z-index 7,
|
||||
// pointer-events:none) and reuses the same overlay-sync math as
|
||||
// SinglePlayerAvatar._syncOverlay. It runs its own requestAnimationFrame loop for
|
||||
// flicker/embers/particles, and self-suspends (cancelAnimationFrame — no leaks) once the
|
||||
// scene is idle; stop() hard-cancels it. Coordinates are in base-canvas pixel space.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
var doc = root.document;
|
||||
|
||||
function now() {
|
||||
return (root.performance && root.performance.now) ? root.performance.now() : Date.now();
|
||||
}
|
||||
|
||||
// A soft radial-gradient sprite baked onto an offscreen canvas (mirrors
|
||||
// FancyRenderer.radialTexture, but returns a <canvas> we blit with drawImage).
|
||||
function radialSprite(size, stops) {
|
||||
if (!doc) return null;
|
||||
var c = doc.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
var ctx = c.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
var g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
for (var i = 0; i < stops.length; i++) g.addColorStop(stops[i][0], stops[i][1]);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
return c;
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
var FX = {
|
||||
_base: null, // ROT base canvas (fallback if DisplayManager is unavailable)
|
||||
_host: null, // #game-display container
|
||||
_canvas: null, // our overlay canvas
|
||||
_ctx: null,
|
||||
_hostOrig: null, // saved inline transform of the host (for shake restore)
|
||||
|
||||
_running: false,
|
||||
_raf: 0,
|
||||
_lastT: 0,
|
||||
_t: 0, // seconds accumulator for flicker/drift
|
||||
|
||||
_active: false, // a dungeon torch is being driven (renderLighting called)
|
||||
_forceAmbient: false, // screens can force the ambient scene without a player torch
|
||||
_light: { x: 0, y: 0, cell: 32, radius: 6.5, color: null, intensity: 1 },
|
||||
|
||||
_embers: null, // ambient drifting-ember field
|
||||
_particles: [], // transient sparkles / bursts
|
||||
_flash: null, // { color, alpha, t, dur }
|
||||
_shake: null, // { mag, t, dur }
|
||||
|
||||
_texLight: null,
|
||||
_texEmber: null,
|
||||
_texVig: null,
|
||||
_vigKey: '',
|
||||
_sizeKey: '',
|
||||
_tint: {}, // css-color -> tinted mote canvas (cache)
|
||||
_frameBound: null,
|
||||
|
||||
// ---- texture bootstrap -------------------------------------------------
|
||||
_initTex: function () {
|
||||
if (this._texLight) return;
|
||||
this._texLight = radialSprite(160, [
|
||||
[0, 'rgba(255,205,130,0.95)'],
|
||||
[0.45, 'rgba(255,160,80,0.40)'],
|
||||
[1, 'rgba(255,150,70,0)']
|
||||
]);
|
||||
this._texEmber = radialSprite(48, [
|
||||
[0, 'rgba(255,228,182,0.95)'],
|
||||
[1, 'rgba(255,228,182,0)']
|
||||
]);
|
||||
},
|
||||
|
||||
_tintedMote: function (color) {
|
||||
color = color || 'rgba(255,228,182,1)';
|
||||
if (this._tint[color]) return this._tint[color];
|
||||
if (!doc) return null;
|
||||
var size = 64;
|
||||
var c = doc.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
var ctx = c.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
var g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
try {
|
||||
g.addColorStop(0, color);
|
||||
g.addColorStop(0.4, color);
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = g;
|
||||
} catch (_) {
|
||||
ctx.fillStyle = 'rgba(255,228,182,0.9)';
|
||||
}
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
this._tint[color] = c;
|
||||
return c;
|
||||
},
|
||||
|
||||
// ---- overlay sync (mirrors SinglePlayerAvatar._syncOverlay) -------------
|
||||
_resolveBase: function () {
|
||||
if (root.DisplayManager && root.DisplayManager.getDisplay) {
|
||||
var d = root.DisplayManager.getDisplay();
|
||||
if (d && d.getContainer) {
|
||||
var c = d.getContainer();
|
||||
if (c) return c;
|
||||
}
|
||||
}
|
||||
return this._base || null;
|
||||
},
|
||||
|
||||
// Returns the 2D context of a correctly sized/positioned overlay, or null
|
||||
// (which makes every public method a no-op — keeps other pages unaffected).
|
||||
_ensure: function () {
|
||||
if (!doc) return null;
|
||||
var base = this._resolveBase();
|
||||
var host = doc.getElementById('game-display');
|
||||
if (!base || !host) return null;
|
||||
this._host = host;
|
||||
|
||||
if (!this._canvas) {
|
||||
var cv = doc.createElement('canvas');
|
||||
cv.className = 'fx-overlay';
|
||||
cv.style.position = 'absolute';
|
||||
cv.style.pointerEvents = 'none';
|
||||
cv.style.zIndex = '7'; // just above the avatar overlay (z-index 6)
|
||||
cv.style.left = '0px';
|
||||
cv.style.top = '0px';
|
||||
// Override the retro `.rotdis canvas { image-rendering: pixelated }` rule so
|
||||
// the soft light/vignette gradients scale smoothly rather than blocky.
|
||||
cv.style.imageRendering = 'auto';
|
||||
this._canvas = cv;
|
||||
this._ctx = cv.getContext('2d');
|
||||
}
|
||||
// If a fresh ROT display replaced the host contents, re-attach.
|
||||
if (this._canvas.parentNode !== host) host.appendChild(this._canvas);
|
||||
if (!this._ctx) this._ctx = this._canvas.getContext('2d');
|
||||
if (!this._ctx) return null;
|
||||
|
||||
this._initTex();
|
||||
|
||||
var sizeKey = base.width + 'x' + base.height;
|
||||
if (sizeKey !== this._sizeKey) {
|
||||
if (base.width) this._canvas.width = base.width;
|
||||
if (base.height) this._canvas.height = base.height;
|
||||
this._sizeKey = sizeKey;
|
||||
this._buildVignette();
|
||||
this._seedEmbers();
|
||||
}
|
||||
this._canvas.style.left = base.offsetLeft + 'px';
|
||||
this._canvas.style.top = base.offsetTop + 'px';
|
||||
this._canvas.style.width = base.offsetWidth + 'px';
|
||||
this._canvas.style.height = base.offsetHeight + 'px';
|
||||
this._canvas.style.display = 'block';
|
||||
return this._ctx;
|
||||
},
|
||||
|
||||
_buildVignette: function () {
|
||||
if (!doc || !this._canvas) return;
|
||||
var w = this._canvas.width, h = this._canvas.height;
|
||||
if (!w || !h) { this._texVig = null; return; }
|
||||
var c = doc.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
var ctx = c.getContext('2d');
|
||||
if (!ctx) { this._texVig = null; return; }
|
||||
var r = Math.max(w, h) * 0.72;
|
||||
var g = ctx.createRadialGradient(w / 2, h / 2, r * 0.35, w / 2, h / 2, r);
|
||||
g.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
g.addColorStop(0.62, 'rgba(0,0,0,0)');
|
||||
g.addColorStop(1, 'rgba(0,0,0,0.62)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
this._texVig = c;
|
||||
},
|
||||
|
||||
_seedEmbers: function () {
|
||||
if (!this._canvas) return;
|
||||
var w = this._canvas.width, h = this._canvas.height;
|
||||
if (!w || !h) { this._embers = []; return; }
|
||||
var n = Math.round(clamp((w * h) / 16000, 10, 46));
|
||||
var list = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
list.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 8,
|
||||
vy: -(6 + Math.random() * 14),
|
||||
size: 3 + Math.random() * 6,
|
||||
ph: Math.random() * 6.28,
|
||||
baseA: 0.10 + Math.random() * 0.16
|
||||
});
|
||||
}
|
||||
this._embers = list;
|
||||
},
|
||||
|
||||
// ---- lifecycle ---------------------------------------------------------
|
||||
_gameActive: function () {
|
||||
var g = root.Game;
|
||||
if (!g) return true; // no host game object -> assume caller knows best
|
||||
try { return !!g._gameActive; } catch (_) { return true; }
|
||||
},
|
||||
|
||||
_sceneOn: function () {
|
||||
return (this._active && this._gameActive()) || this._forceAmbient;
|
||||
},
|
||||
|
||||
_busy: function () {
|
||||
return this._sceneOn() || this._particles.length > 0 || !!this._flash || !!this._shake;
|
||||
},
|
||||
|
||||
start: function () {
|
||||
if (!this._ensure()) return this;
|
||||
if (this._running) return this;
|
||||
if (!root.requestAnimationFrame) return this;
|
||||
this._running = true;
|
||||
this._lastT = 0;
|
||||
this._raf = root.requestAnimationFrame(this._frameBound);
|
||||
return this;
|
||||
},
|
||||
|
||||
stop: function () {
|
||||
this._running = false;
|
||||
if (this._raf && root.cancelAnimationFrame) root.cancelAnimationFrame(this._raf);
|
||||
this._raf = 0;
|
||||
this._active = false;
|
||||
this._particles = [];
|
||||
this._flash = null;
|
||||
this._shake = null;
|
||||
this._resetHostTransform();
|
||||
if (this._ctx && this._canvas) {
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
clear: function () {
|
||||
this._particles = [];
|
||||
this._flash = null;
|
||||
this._shake = null;
|
||||
this._resetHostTransform();
|
||||
if (this._ctx && this._canvas) {
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
// ---- public FX API -----------------------------------------------------
|
||||
attach: function (baseCanvas) {
|
||||
if (baseCanvas) this._base = baseCanvas;
|
||||
this._ensure();
|
||||
return this;
|
||||
},
|
||||
|
||||
syncTo: function (baseCanvas) {
|
||||
if (baseCanvas) this._base = baseCanvas;
|
||||
this._ensure();
|
||||
return this;
|
||||
},
|
||||
|
||||
// Warm torch light centered on the player cell (base-canvas pixel space).
|
||||
renderLighting: function (playerScreenX, playerScreenY, cellPx, opts) {
|
||||
if (typeof playerScreenX !== 'number' || typeof playerScreenY !== 'number') return this;
|
||||
if (!isFinite(playerScreenX) || !isFinite(playerScreenY)) return this;
|
||||
opts = opts || {};
|
||||
var L = this._light;
|
||||
L.x = playerScreenX;
|
||||
L.y = playerScreenY;
|
||||
L.cell = cellPx || L.cell || 32;
|
||||
L.radius = opts.radius || L.radius || 6.5;
|
||||
L.color = opts.color || null;
|
||||
L.intensity = (typeof opts.intensity === 'number') ? opts.intensity : 1;
|
||||
this._active = true;
|
||||
if (!this._ensure()) return this; // no overlay -> no-op
|
||||
this.start();
|
||||
return this;
|
||||
},
|
||||
|
||||
setAmbient: function (on) {
|
||||
this._forceAmbient = !!on;
|
||||
if (on) this.start();
|
||||
return this;
|
||||
},
|
||||
|
||||
sparkle: function (x, y, color) {
|
||||
if (typeof x !== 'number' || typeof y !== 'number') return this;
|
||||
if (!this._ensure()) return this;
|
||||
var n = 5;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var a = Math.random() * Math.PI * 2;
|
||||
var sp = 10 + Math.random() * 40;
|
||||
this._particles.push({
|
||||
x: x, y: y,
|
||||
vx: Math.cos(a) * sp,
|
||||
vy: Math.sin(a) * sp - 20,
|
||||
life: 0.5 + Math.random() * 0.4,
|
||||
max: 0.9,
|
||||
size: 6 + Math.random() * 8,
|
||||
color: color || '#ffd700',
|
||||
twinkle: true
|
||||
});
|
||||
}
|
||||
this._cap();
|
||||
this.start();
|
||||
return this;
|
||||
},
|
||||
|
||||
burst: function (x, y, color, count) {
|
||||
if (typeof x !== 'number' || typeof y !== 'number') return this;
|
||||
if (!this._ensure()) return this;
|
||||
var n = count || 14;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var a = Math.random() * Math.PI * 2;
|
||||
var sp = 40 + Math.random() * 140;
|
||||
this._particles.push({
|
||||
x: x, y: y,
|
||||
vx: Math.cos(a) * sp,
|
||||
vy: Math.sin(a) * sp,
|
||||
life: 0.45 + Math.random() * 0.55,
|
||||
max: 1.0,
|
||||
size: 8 + Math.random() * 12,
|
||||
color: color || '#fbbf24',
|
||||
twinkle: false
|
||||
});
|
||||
}
|
||||
this._cap();
|
||||
this.start();
|
||||
return this;
|
||||
},
|
||||
|
||||
flash: function (color, alpha, ms) {
|
||||
if (!this._ensure()) return this;
|
||||
this._flash = {
|
||||
color: color || '#ffffff',
|
||||
alpha: (typeof alpha === 'number') ? alpha : 0.5,
|
||||
t: 0,
|
||||
dur: Math.max(0.05, (ms || 220) / 1000)
|
||||
};
|
||||
this.start();
|
||||
return this;
|
||||
},
|
||||
|
||||
shake: function (intensity, ms) {
|
||||
if (!this._ensure()) return this;
|
||||
this._shake = {
|
||||
mag: intensity || 6,
|
||||
t: 0,
|
||||
dur: Math.max(0.05, (ms || 320) / 1000)
|
||||
};
|
||||
this.start();
|
||||
return this;
|
||||
},
|
||||
|
||||
_cap: function () {
|
||||
var maxP = 180;
|
||||
if (this._particles.length > maxP) {
|
||||
this._particles.splice(0, this._particles.length - maxP);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- shake host transform ---------------------------------------------
|
||||
_setHostTransform: function (dx, dy) {
|
||||
if (!this._host) return;
|
||||
if (this._hostOrig == null) this._hostOrig = this._host.style.transform || '';
|
||||
this._host.style.transform = (this._hostOrig + ' translate(' + dx.toFixed(2) + 'px,' + dy.toFixed(2) + 'px)').trim();
|
||||
},
|
||||
|
||||
_resetHostTransform: function () {
|
||||
if (this._host && this._hostOrig != null) {
|
||||
this._host.style.transform = this._hostOrig;
|
||||
}
|
||||
this._hostOrig = null;
|
||||
},
|
||||
|
||||
// ---- animation loop ----------------------------------------------------
|
||||
_frame: function () {
|
||||
if (!this._running) return;
|
||||
var ctx = this._ensure();
|
||||
if (!ctx) { this._running = false; this._raf = 0; return; }
|
||||
|
||||
var t = now();
|
||||
var dt = this._lastT ? (t - this._lastT) / 1000 : 0.016;
|
||||
if (dt > 0.05) dt = 0.05; // clamp tab-switch gaps
|
||||
this._lastT = t;
|
||||
this._t += dt;
|
||||
|
||||
this._update(dt);
|
||||
this._render(ctx);
|
||||
|
||||
if (this._busy()) {
|
||||
this._raf = root.requestAnimationFrame(this._frameBound);
|
||||
} else {
|
||||
// Idle (game over / title, nothing animating): suspend the loop
|
||||
// entirely — no lingering rAF, no leak. A future call restarts it.
|
||||
this._running = false;
|
||||
this._raf = 0;
|
||||
this._active = false;
|
||||
this._resetHostTransform();
|
||||
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
},
|
||||
|
||||
_update: function (dt) {
|
||||
var w = this._canvas.width, h = this._canvas.height;
|
||||
|
||||
if (this._embers) {
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var e = this._embers[i];
|
||||
e.x += e.vx * dt;
|
||||
e.y += e.vy * dt;
|
||||
if (e.y < -10) { e.y = h + 10; e.x = Math.random() * w; }
|
||||
if (e.x < -10) e.x = w + 10; else if (e.x > w + 10) e.x = -10;
|
||||
}
|
||||
}
|
||||
|
||||
for (var j = this._particles.length - 1; j >= 0; j--) {
|
||||
var p = this._particles[j];
|
||||
p.life -= dt;
|
||||
if (p.life <= 0) { this._particles.splice(j, 1); continue; }
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= (1 - 1.6 * dt); // drag
|
||||
p.vy += 60 * dt; // gentle gravity
|
||||
p.vy *= (1 - 1.2 * dt);
|
||||
}
|
||||
|
||||
if (this._flash) {
|
||||
this._flash.t += dt;
|
||||
if (this._flash.t >= this._flash.dur) this._flash = null;
|
||||
}
|
||||
|
||||
if (this._shake) {
|
||||
this._shake.t += dt;
|
||||
if (this._shake.t >= this._shake.dur) {
|
||||
this._shake = null;
|
||||
this._resetHostTransform();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_render: function (ctx) {
|
||||
var w = this._canvas.width, h = this._canvas.height;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Screen shake: translate the whole host (base ROT canvas + overlays move
|
||||
// together, stays perfectly aligned); decays to zero.
|
||||
if (this._shake) {
|
||||
var k = 1 - (this._shake.t / this._shake.dur);
|
||||
var mag = this._shake.mag * k;
|
||||
this._setHostTransform((Math.random() * 2 - 1) * mag, (Math.random() * 2 - 1) * mag);
|
||||
}
|
||||
|
||||
var scene = this._sceneOn();
|
||||
|
||||
if (scene) {
|
||||
// (a) warm torch light on the player cell, gently flickering
|
||||
if (this._active && this._gameActive() && this._texLight) {
|
||||
var L = this._light;
|
||||
var cell = L.cell || 32;
|
||||
var flick = 0.86 + Math.sin(this._t * 7.0) * 0.06 + Math.sin(this._t * 17.0) * 0.04 + Math.random() * 0.03;
|
||||
var rad = (L.radius || 6.5) * cell * flick;
|
||||
var a = clamp(0.42 * (L.intensity || 1) * flick, 0, 0.85);
|
||||
var tex = L.color ? this._tintedMote(L.color) : this._texLight;
|
||||
if (tex) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = a;
|
||||
ctx.drawImage(tex, L.x - rad, L.y - rad, rad * 2, rad * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// (c) ambient drifting-ember field (subtle, additive)
|
||||
if (this._embers && this._texEmber) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var e = this._embers[i];
|
||||
ctx.globalAlpha = e.baseA * (0.55 + 0.45 * Math.sin(this._t * 1.6 + e.ph));
|
||||
ctx.drawImage(this._texEmber, e.x - e.size, e.y - e.size, e.size * 2, e.size * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// (b) screen-edge vignette (normal blend, darkens toward the frame)
|
||||
if (this._texVig) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(this._texVig, 0, 0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
// (d) transient one-shot particles (sparkle / burst), additive glow
|
||||
if (this._particles.length) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (var j = 0; j < this._particles.length; j++) {
|
||||
var p = this._particles[j];
|
||||
var lk = clamp(p.life / p.max, 0, 1);
|
||||
var tw = p.twinkle ? (0.6 + 0.4 * Math.sin(this._t * 22 + p.x)) : 1;
|
||||
ctx.globalAlpha = lk * tw;
|
||||
var tex2 = this._tintedMote(p.color);
|
||||
if (tex2) {
|
||||
var s = p.size * (0.55 + 0.45 * lk);
|
||||
ctx.drawImage(tex2, p.x - s, p.y - s, s * 2, s * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (e) full-canvas color flash (normal blend, fades out)
|
||||
if (this._flash) {
|
||||
var fk = clamp(1 - (this._flash.t / this._flash.dur), 0, 1);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = clamp(this._flash.alpha * fk, 0, 1);
|
||||
ctx.fillStyle = this._flash.color;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
};
|
||||
|
||||
FX._frameBound = function () { FX._frame(); };
|
||||
|
||||
root.FX = FX;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = FX;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -272,6 +272,18 @@ var RenderEngine = {
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the overlay FX torch light on the player, who is always camera-centered
|
||||
// at (centerX, centerY). Coordinates are in base-canvas pixel space (tile * cell),
|
||||
// matching the avatar overlay convention. Fully guarded + additive only; the ROT
|
||||
// display draw above is untouched. Works whether or not the fancy avatar rendered.
|
||||
if (window.FX && window.FX.renderLighting) {
|
||||
try {
|
||||
var _cellW = (window.options && window.options.tileWidth) || 32;
|
||||
var _cellH = (window.options && window.options.tileHeight) || _cellW;
|
||||
window.FX.renderLighting(centerX * _cellW + _cellW / 2, centerY * _cellH + _cellH / 2, _cellW);
|
||||
} catch (_) { /* FX is purely cosmetic — never block the dungeon render */ }
|
||||
}
|
||||
|
||||
// Render message at bottom if present
|
||||
if (message) {
|
||||
ScreenManager.drawCenteredText(this._screenHeight - 1, message);
|
||||
|
||||
@@ -7,6 +7,7 @@ var ScreenManager = {
|
||||
_waitingForBlock: false,
|
||||
_isShowingWaitingScreen: false, // Track if waiting screen is active
|
||||
_cryptoType: 'WOW', // Default to WOW, updated on game mode info
|
||||
_ambientFXOn: false, // GFX3: FX overlay ambient state (guarded, no-op unless window.FX)
|
||||
|
||||
// Get the game title based on crypto type
|
||||
getGameTitle: function() {
|
||||
@@ -31,6 +32,7 @@ var ScreenManager = {
|
||||
this._isShowingWaitingScreen = false;
|
||||
this._updateInterval = null; // Initialize update interval tracker
|
||||
this._cryptoType = 'WOW';
|
||||
this._ambientFXOn = false; // GFX3: reset FX overlay ambient state
|
||||
|
||||
// Initialize the waiting screen animator
|
||||
if (typeof WaitingScreenAnimator !== 'undefined') {
|
||||
@@ -38,6 +40,53 @@ var ScreenManager = {
|
||||
}
|
||||
},
|
||||
|
||||
// ===== GFX3: FX overlay helpers (additive, always guarded) =====
|
||||
_fxBaseCanvas: function() {
|
||||
try {
|
||||
var d = (typeof DisplayManager !== 'undefined' && DisplayManager.getDisplay)
|
||||
? DisplayManager.getDisplay() : null;
|
||||
return (d && d.getContainer) ? d.getContainer() : null;
|
||||
} catch (e) { return null; }
|
||||
},
|
||||
|
||||
// Subtle vignette + slow ambient embers behind the teletype title.
|
||||
_startAmbientFX: function() {
|
||||
if (!window.FX) return;
|
||||
if (this._ambientFXOn) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (window.FX.attach) window.FX.attach(canvas);
|
||||
if (window.FX.syncTo) window.FX.syncTo(canvas);
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
||||
if (window.FX.start) window.FX.start();
|
||||
this._ambientFXOn = true;
|
||||
} catch (e) { /* additive: never break the screen */ }
|
||||
},
|
||||
|
||||
_stopAmbientFX: function() {
|
||||
this._ambientFXOn = false;
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
if (window.FX.stop) window.FX.stop();
|
||||
if (window.FX.clear) window.FX.clear();
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// One-shot celebratory/somber flash as an end screen appears.
|
||||
_screenFlash: function(color, alpha, ms) {
|
||||
if (!window.FX) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (window.FX.attach) window.FX.attach(canvas);
|
||||
if (window.FX.syncTo) window.FX.syncTo(canvas);
|
||||
if (window.FX.start) window.FX.start();
|
||||
if (window.FX.flash) window.FX.flash(color, alpha, ms);
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
drawText: function(x, y, text) {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
const display = DisplayManager.getDisplay();
|
||||
@@ -87,7 +136,10 @@ var ScreenManager = {
|
||||
|
||||
// Draw the border
|
||||
this.drawBorder();
|
||||
|
||||
|
||||
// GFX3: subtle vignette + slow ambient embers behind the teletype title
|
||||
this._startAmbientFX();
|
||||
|
||||
// Draw content - use dynamic title based on crypto type
|
||||
let y = 3;
|
||||
this.drawCenteredText(y, this.getGameTitle());
|
||||
@@ -242,6 +294,18 @@ var ScreenManager = {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
// GFX3: brief celebratory gold flash + burst as the win screen appears
|
||||
if (window.FX) {
|
||||
this._stopAmbientFX();
|
||||
this._screenFlash('#ffd700', 0.45, 700);
|
||||
var winCanvas = this._fxBaseCanvas();
|
||||
if (winCanvas && window.FX.burst) {
|
||||
try {
|
||||
window.FX.burst(winCanvas.width / 2, winCanvas.height / 2, '#ffd700', 24);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
let y = Math.floor(this._screenHeight / 3);
|
||||
this.drawCenteredText(y, "CONGRATULATIONS!");
|
||||
|
||||
@@ -257,6 +321,12 @@ var ScreenManager = {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
// GFX3: brief somber red flash as the lose screen appears
|
||||
if (window.FX) {
|
||||
this._stopAmbientFX();
|
||||
this._screenFlash('#ff2a2a', 0.4, 700);
|
||||
}
|
||||
|
||||
let y = Math.floor(this._screenHeight / 3);
|
||||
this.drawCenteredText(y, "YOU HAVE PERISHED");
|
||||
|
||||
@@ -364,6 +434,9 @@ var ScreenManager = {
|
||||
if (typeof WaitingScreenAnimator !== 'undefined') {
|
||||
WaitingScreenAnimator.stopAnimation();
|
||||
}
|
||||
// GFX3: waiting animator tears down the shared FX overlay; clear our flag
|
||||
// so ambient FX can restart when the welcome screen is shown again.
|
||||
this._ambientFXOn = false;
|
||||
this.hideAnimationButton();
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
@@ -3,6 +3,9 @@ var WaitingScreenAnimator = {
|
||||
_animationEnabled: true,
|
||||
_waitingAnimationInterval: null,
|
||||
_animating: false,
|
||||
// GFX3: FX overlay ambient state (additive; no-op unless window.FX exists)
|
||||
_fxAmbientOn: false,
|
||||
_fxTick: 0,
|
||||
_waitingAnimation: {
|
||||
frame: 0,
|
||||
playerX: 8,
|
||||
@@ -82,13 +85,74 @@ var WaitingScreenAnimator = {
|
||||
display.draw(startX + roomWidth - 1, roomY, GameTiles.getFloorTile(true), "white", "transparent"); // Floor variation
|
||||
},
|
||||
|
||||
// ===== GFX3: FX overlay helpers (additive, always guarded) =====
|
||||
_fxBaseCanvas: function() {
|
||||
try {
|
||||
var d = (typeof DisplayManager !== 'undefined' && DisplayManager.getDisplay)
|
||||
? DisplayManager.getDisplay() : null;
|
||||
return (d && d.getContainer) ? d.getContainer() : null;
|
||||
} catch (e) { return null; }
|
||||
},
|
||||
|
||||
// Turn on the torchlight gradient + drifting embers overlay behind the
|
||||
// scripted char-cell waiting animation. No-op unless window.FX is present.
|
||||
_startAmbientFX: function() {
|
||||
if (!window.FX) return;
|
||||
if (this._fxAmbientOn) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (window.FX.attach) window.FX.attach(canvas);
|
||||
if (window.FX.syncTo) window.FX.syncTo(canvas);
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
||||
if (window.FX.start) window.FX.start();
|
||||
this._fxAmbientOn = true;
|
||||
} catch (e) { /* additive: never break the waiting screen */ }
|
||||
},
|
||||
|
||||
_stopAmbientFX: function() {
|
||||
this._fxAmbientOn = false;
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
if (window.FX.stop) window.FX.stop();
|
||||
if (window.FX.clear) window.FX.clear();
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// Emit gentle amber embers rising from the scripted torch positions.
|
||||
// Throttled so we don't flood the FX particle system.
|
||||
_emitTorchEmbers: function(torchPositions, screenWidth, screenHeight) {
|
||||
if (!window.FX || !window.FX.sparkle) return;
|
||||
if (!torchPositions || !torchPositions.length) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas || !screenWidth || !screenHeight) return;
|
||||
this._fxTick = (this._fxTick + 1) % 1000000;
|
||||
// Only emit on every 3rd frame to keep the embers subtle.
|
||||
if (this._fxTick % 3 !== 0) return;
|
||||
var cellW = canvas.width / screenWidth;
|
||||
var cellH = canvas.height / screenHeight;
|
||||
// One torch per emit, cycling through the scripted positions.
|
||||
var t = torchPositions[this._fxTick % torchPositions.length];
|
||||
if (!t) return;
|
||||
if (t.x < 0 || t.x >= screenWidth || t.y < 0 || t.y >= screenHeight) return;
|
||||
var px = (t.x + 0.5) * cellW;
|
||||
var py = (t.y + 0.5) * cellH;
|
||||
try {
|
||||
window.FX.sparkle(px, py, 'rgba(255, 170, 60, 0.9)');
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
drawAnimatedWaitingScreen: function(screenWidth, screenHeight, drawBorderFn, drawCenteredTextFn) {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
const display = DisplayManager.getDisplay();
|
||||
|
||||
|
||||
// Draw border
|
||||
drawBorderFn();
|
||||
|
||||
|
||||
// GFX3: layer the torchlight + ember FX overlay on top of the ROT scene
|
||||
this._startAmbientFX();
|
||||
|
||||
// Check if we're in "Awaiting payment" mode - use treasure hunt animation
|
||||
const isAwaitingPayment = typeof Game !== 'undefined' && Game._awaitingPayment;
|
||||
|
||||
@@ -184,7 +248,10 @@ var WaitingScreenAnimator = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GFX3: drifting embers from the scripted torch positions
|
||||
this._emitTorchEmbers(torchPositions, screenWidth, screenHeight);
|
||||
|
||||
// Update character positions
|
||||
this.updateAnimation();
|
||||
const anim = this._waitingAnimation;
|
||||
@@ -395,7 +462,10 @@ var WaitingScreenAnimator = {
|
||||
display.draw(torch.x, torch.y, ["#", "torch"], [wallColor, flameColor], ["transparent", "transparent"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GFX3: drifting embers from the scripted torch positions
|
||||
this._emitTorchEmbers(torchPositions, screenWidth, screenHeight);
|
||||
|
||||
// Update animation state
|
||||
this.updatePaymentAnimation();
|
||||
|
||||
@@ -855,8 +925,10 @@ var WaitingScreenAnimator = {
|
||||
|
||||
if (!this._animationEnabled) {
|
||||
this.resetAnimation();
|
||||
// GFX3: tear down the FX overlay when animation is switched off
|
||||
this._stopAmbientFX();
|
||||
}
|
||||
|
||||
|
||||
return this._animationEnabled;
|
||||
},
|
||||
|
||||
@@ -887,6 +959,8 @@ var WaitingScreenAnimator = {
|
||||
this._animating = false;
|
||||
this.resetAnimation();
|
||||
this.resetPaymentAnimation();
|
||||
// GFX3: tear down the FX overlay when the waiting screen ends
|
||||
this._stopAmbientFX();
|
||||
},
|
||||
|
||||
isAnimationEnabled: function() {
|
||||
|
||||
@@ -347,10 +347,14 @@ const SocketHandlers = {
|
||||
|
||||
onChatBroadcast: function(data) {
|
||||
const msgElement = $('<li style="color: #aaa;">');
|
||||
if (data.socketId) {
|
||||
// Prefer non-sensitive attribution fields; the server no longer sends the raw full
|
||||
// socket.id (S1). Fall back to a short slice only for legacy payloads.
|
||||
const sender = data.username || data.playerId || data.publicId ||
|
||||
(data.socketId ? String(data.socketId).substring(0, 6) : null);
|
||||
if (sender) {
|
||||
// Escape both fields — chat content is attacker-controlled. Defense in depth:
|
||||
// the server also escapes, but the client must never trust that.
|
||||
msgElement.html('<strong>' + escapeHtml(String(data.socketId).substring(0, 6)) + ':</strong> ' + escapeHtml(data.message));
|
||||
msgElement.html('<strong>' + escapeHtml(String(sender)) + ':</strong> ' + escapeHtml(data.message));
|
||||
} else {
|
||||
msgElement.text(data.message);
|
||||
}
|
||||
@@ -372,7 +376,7 @@ const SocketHandlers = {
|
||||
if (msg.id) {
|
||||
msgElement.attr('data-msg-id', msg.id);
|
||||
}
|
||||
const username = msg.playerId || msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
const username = msg.playerId || msg.username || msg.publicId || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
const isSystem = msg.type === 'system' || msg.socketId === 'system';
|
||||
if (isSystem) {
|
||||
msgElement.addClass('status');
|
||||
|
||||
@@ -112,11 +112,23 @@
|
||||
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
|
||||
// Default tile picks from the sheet metadata ( roguelikeSheet_meta.json).
|
||||
// The tile-picker overlay can override these per-user via localStorage.
|
||||
// floor/wall are ARRAYS of [col,row] variants (sheet cols = 57): TileRenderer
|
||||
// picks one deterministically per cell so a wall of stone doesn't read as a
|
||||
// single flat texture. Single-pair kinds (bar/table/features) draw as-is.
|
||||
tiles: {
|
||||
floor: [5, 2], // tile 119 — floor_wood
|
||||
wall: [13, 12], // tile 697 — stone wall
|
||||
bar: [28, 0], // tile 28 — counter
|
||||
table: [20, 3] // tile 191 — table
|
||||
// floor: sheet 119/120 (wood/stone) + 982-984 (planks)
|
||||
floor: [[5, 2], [6, 2], [13, 17], [14, 17], [15, 17]],
|
||||
floor2: [[6, 2], [13, 17], [15, 17]],
|
||||
// wall: sheet 697-701 + 868/869/871-874 (stone masonry)
|
||||
wall: [[13, 12], [14, 12], [15, 12], [16, 12], [17, 12],
|
||||
[13, 15], [14, 15], [16, 15], [17, 15], [18, 15], [19, 15]],
|
||||
bar: [28, 0], // tile 28 — counter
|
||||
table: [20, 3], // tile 191 — table
|
||||
// Dungeon features drawn as real tiles instead of monospace glyphs.
|
||||
entrance: [37, 9], // tile 550 — door
|
||||
exit: [37, 9], // tile 550 — door
|
||||
treasure: [42, 15], // tile 897 — item
|
||||
torch: [50, 10] // tile 620 — rubble / prop
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+267
-30
@@ -1,5 +1,8 @@
|
||||
// TileRenderer — draws a Scene as coloured tiles + entity sprites on a 2D canvas.
|
||||
// The default tier. Uses programmatic tiles for now; a real tileset/atlas can slot in here.
|
||||
// TileRenderer — draws a Scene as tiled sprites + entity sprites on a 2D canvas.
|
||||
// The default tier. Pulls tiles from the theme atlas (Kenney roguelikeSheet) when loaded,
|
||||
// with a flat-colour fallback for any unmapped kind. Adds torch-lit dungeon atmosphere:
|
||||
// per-cell tile variation, drop shadows, a warm torch light on the player + edge vignette,
|
||||
// and a lightweight requestAnimationFrame flicker/ember loop composited over the last scene.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
@@ -10,6 +13,50 @@
|
||||
return { x: 0, y: 1 };
|
||||
}
|
||||
|
||||
// Deterministic per-cell hash so a given (x,y) always maps to the same tile variant.
|
||||
function cellHash(x, y) {
|
||||
var h = (x * 73856093) ^ (y * 19349663);
|
||||
h = h % 100000;
|
||||
return h < 0 ? h + 100000 : h;
|
||||
}
|
||||
|
||||
// A tile coord is either a single [col,row] or an ARRAY of [col,row] variants.
|
||||
// Resolve to one [col,row] for this cell.
|
||||
function pickVariant(coord, x, y) {
|
||||
if (!coord) return null;
|
||||
if (Array.isArray(coord[0])) return coord[cellHash(x, y) % coord.length];
|
||||
return coord;
|
||||
}
|
||||
|
||||
// Dungeon lighting: darken a cell based on the scene's light grid (1 = lit, 0 = dark).
|
||||
function applyCellLight(ctx, scene, x, y, px, py, cell) {
|
||||
if (!scene.lightGrid || !scene.lightGrid[y] || scene.lightGrid[y][x] == null) return;
|
||||
var brightness = scene.lightGrid[y][x];
|
||||
if (brightness < 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,' + (1 - brightness) + ')';
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
}
|
||||
}
|
||||
|
||||
// The player/you entity that carries the torch (for the light composite + embers).
|
||||
function findPlayer(scene) {
|
||||
if (!scene || !scene.entities) return null;
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
if (e.kind === 'player' || e.you) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Real-tile coord for a dungeon feature entity, keyed off its glyph.
|
||||
function featureCoord(tmap, e) {
|
||||
var ch = e.char;
|
||||
if (ch === '<') return tmap.entrance;
|
||||
if (ch === '>') return tmap.exit;
|
||||
if (ch === '$' || ch === '$W' || ch === '$M') return tmap.treasure;
|
||||
return null;
|
||||
}
|
||||
|
||||
function TileRenderer(host, opts) {
|
||||
opts = opts || {};
|
||||
this.name = 'tiles';
|
||||
@@ -19,10 +66,15 @@
|
||||
this.canvas.className = 'rk-canvas';
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
host.appendChild(this.canvas);
|
||||
this._snap = null; // offscreen snapshot of the base scene (tiles + entities)
|
||||
this._lastScene = null;
|
||||
this._embers = [];
|
||||
this._raf = null;
|
||||
this._t = 0;
|
||||
}
|
||||
|
||||
TileRenderer.prototype.render = function (scene) {
|
||||
if (!scene) return;
|
||||
if (!scene || !this.ctx) return;
|
||||
var cell = this.cell, ctx = this.ctx;
|
||||
this.canvas.width = scene.cols * cell;
|
||||
this.canvas.height = scene.rows * cell;
|
||||
@@ -30,21 +82,35 @@
|
||||
ctx.fillStyle = scene.background || '#000';
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
var atlas = (window.RK && RK.themeAtlas) ? RK.themeAtlas() : null;
|
||||
var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {};
|
||||
var useAtlas = !!(atlas && atlas.ready);
|
||||
|
||||
if (!scene.isDungeon && window.RK && RK.roomReady && RK.roomReady()) {
|
||||
// A designed room (imported .tmx) is loaded — draw its layered tiles.
|
||||
RK.drawRoomCanvas(ctx, cell);
|
||||
} else {
|
||||
// Theme atlas tile if mapped, else the programmatic coloured tile.
|
||||
var atlas = (window.RK && RK.themeAtlas) ? RK.themeAtlas() : null;
|
||||
var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {};
|
||||
var useAtlas = atlas && atlas.ready;
|
||||
for (var y = 0; y < scene.rows; y++) {
|
||||
for (var x = 0; x < scene.cols; x++) {
|
||||
var kind = scene.grid[y][x];
|
||||
var px = x * cell, py = y * cell;
|
||||
var coord = useAtlas ? tmap[kind] : null;
|
||||
if (coord) { atlas.draw(ctx, coord[0], coord[1], px, py, cell); continue; }
|
||||
var def = scene.legend[kind] || { color: '#333' };
|
||||
var coord = useAtlas ? pickVariant(tmap[kind], x, y) : null;
|
||||
if (coord) {
|
||||
atlas.draw(ctx, coord[0], coord[1], px, py, cell);
|
||||
// Dark bottom-edge strip grounds a solid wall against the floor below.
|
||||
if (def.solid) {
|
||||
var strip = Math.max(2, cell * 0.16);
|
||||
var sg = ctx.createLinearGradient(0, py + cell - strip, 0, py + cell);
|
||||
sg.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
sg.addColorStop(1, 'rgba(0,0,0,0.42)');
|
||||
ctx.fillStyle = sg;
|
||||
ctx.fillRect(px, py + cell - strip, cell, strip);
|
||||
}
|
||||
applyCellLight(ctx, scene, x, y, px, py, cell);
|
||||
continue;
|
||||
}
|
||||
// Flat-colour fallback for any unmapped kind (or atlas not yet loaded).
|
||||
ctx.fillStyle = def.color;
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
|
||||
@@ -53,15 +119,10 @@
|
||||
if (def.solid) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.fillRect(px, py, cell, Math.max(1, cell * 0.14));
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.fillRect(px, py + cell - Math.max(1, cell * 0.14), cell, Math.max(1, cell * 0.14));
|
||||
}
|
||||
// Dungeon lighting: darken tiles based on the light grid.
|
||||
if (scene.lightGrid && scene.lightGrid[y] && scene.lightGrid[y][x] != null) {
|
||||
var brightness = scene.lightGrid[y][x];
|
||||
if (brightness < 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,' + (1 - brightness) + ')';
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
}
|
||||
}
|
||||
applyCellLight(ctx, scene, x, y, px, py, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,29 +130,45 @@
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
// Dungeon features (entrance, exit, treasure) — draw as colored glyphs.
|
||||
// Dungeon features (entrance / exit / treasure) — real tiles, glyph fallback.
|
||||
if (e.kind === 'feature') {
|
||||
var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2;
|
||||
ctx.fillStyle = e.color || '#fff';
|
||||
ctx.font = (cell - 4) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(e.char || '?', cx, cy);
|
||||
var fpx = e.x * cell, fpy = e.y * cell;
|
||||
var fc = useAtlas ? pickVariant(featureCoord(tmap, e), e.x, e.y) : null;
|
||||
if (fc) {
|
||||
atlas.draw(ctx, fc[0], fc[1], fpx, fpy, cell);
|
||||
// Soft colour accent so entrance (green) / exit (amber) stay legible.
|
||||
if (e.color) {
|
||||
var acx = fpx + cell / 2, acy = fpy + cell / 2;
|
||||
var ag = ctx.createRadialGradient(acx, acy, 0, acx, acy, cell * 0.6);
|
||||
ag.addColorStop(0, hexToRgba(e.color, 0.32));
|
||||
ag.addColorStop(1, hexToRgba(e.color, 0));
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.fillStyle = ag;
|
||||
ctx.fillRect(fpx, fpy, cell, cell);
|
||||
ctx.restore();
|
||||
}
|
||||
} else {
|
||||
softGlyph(ctx, e.char || '?', e.color || '#fff', fpx + cell / 2, fpy + cell / 2, cell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Dungeon items.
|
||||
if (e.kind === 'item') {
|
||||
var ix = e.x * cell + cell / 2, iy = e.y * cell + cell / 2;
|
||||
ctx.fillStyle = e.color || '#fbbf24';
|
||||
ctx.font = (cell - 4) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('$', ix, iy);
|
||||
var ipx = e.x * cell, ipy = e.y * cell;
|
||||
var ic = useAtlas ? pickVariant(tmap.treasure, e.x, e.y) : null;
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, ipy + cell * 0.86, cell * 0.3, cell * 0.12);
|
||||
if (ic) {
|
||||
atlas.draw(ctx, ic[0], ic[1], ipx, ipy, cell);
|
||||
} else {
|
||||
softGlyph(ctx, '$', e.color || '#fbbf24', ipx + cell / 2, ipy + cell / 2, cell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Dungeon monster.
|
||||
if (e.kind === 'monster') {
|
||||
var mx = e.x * cell + cell / 2, my = e.y * cell + cell / 2, mr = cell * 0.34;
|
||||
drawEntityShadow(ctx, mx, e.y * cell + cell * 0.9, cell * 0.32, cell * 0.13);
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my, mr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = e.color || '#f85149';
|
||||
@@ -101,6 +178,8 @@
|
||||
ctx.stroke();
|
||||
continue;
|
||||
}
|
||||
// Character-like entity: soft ground shadow, then the sprite (or fallback circle).
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.92, cell * 0.34, cell * 0.14);
|
||||
// Roguelike character sprite, or premium animated skin; else the fallback circle.
|
||||
if (window.RK && RK.avatarVisuals && RK.avatarVisuals.drawTopdownWorld) {
|
||||
var visual = RK.avatarVisuals.resolve(e.appearance || { avatar: e.avatar }, {
|
||||
@@ -142,12 +221,170 @@
|
||||
ctx.fillText(e.label, cx, cy - cell * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the base scene, then composite the torch light + vignette on top, and keep
|
||||
// an animation loop running so the light flickers and embers drift between renders.
|
||||
this._lastScene = scene;
|
||||
this._snapshot();
|
||||
this._ensureEmbers(scene);
|
||||
this._composite(scene);
|
||||
this._startLoop();
|
||||
};
|
||||
|
||||
// Soft drop shadow (an ellipse) under an entity's feet.
|
||||
function drawEntityShadow(ctx, cx, cy, rx, ry) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.33)';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// A softened glyph fallback (used only when the atlas tile is unavailable).
|
||||
function softGlyph(ctx, ch, color, cx, cy, cell) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, cell * 0.3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold ' + Math.round(cell * 0.62) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(ch, cx, cy + 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
hex = String(hex).replace('#', '');
|
||||
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
var n = parseInt(hex, 16);
|
||||
if (isNaN(n)) return 'rgba(255,255,255,' + a + ')';
|
||||
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
|
||||
}
|
||||
|
||||
// Copy the freshly-drawn base scene into an offscreen canvas so the flicker loop can
|
||||
// restore it each frame without re-running the (expensive) tile + entity passes.
|
||||
TileRenderer.prototype._snapshot = function () {
|
||||
if (!this.canvas) return;
|
||||
if (!this._snap) this._snap = document.createElement('canvas');
|
||||
if (this._snap.width !== this.canvas.width) this._snap.width = this.canvas.width;
|
||||
if (this._snap.height !== this.canvas.height) this._snap.height = this.canvas.height;
|
||||
var sctx = this._snap.getContext('2d');
|
||||
sctx.clearRect(0, 0, this._snap.width, this._snap.height);
|
||||
sctx.drawImage(this.canvas, 0, 0);
|
||||
};
|
||||
|
||||
TileRenderer.prototype._ensureEmbers = function (scene) {
|
||||
var p = findPlayer(scene);
|
||||
if (!p) { this._embers = []; return; }
|
||||
if (this._embers.length) return;
|
||||
var cell = this.cell;
|
||||
var pcx = p.x * cell + cell / 2, pcy = p.y * cell + cell / 2;
|
||||
for (var i = 0; i < 10; i++) this._embers.push(this._spawnEmber(pcx, pcy, cell, true));
|
||||
};
|
||||
|
||||
TileRenderer.prototype._spawnEmber = function (pcx, pcy, cell, seed) {
|
||||
var max = 60 + Math.random() * 60;
|
||||
return {
|
||||
x: pcx + (Math.random() - 0.5) * cell * 2.2,
|
||||
y: pcy + (Math.random() - 0.5) * cell * 1.6,
|
||||
vx: (Math.random() - 0.5) * 0.25,
|
||||
vy: -(0.15 + Math.random() * 0.35),
|
||||
size: cell * (0.05 + Math.random() * 0.06),
|
||||
life: seed ? Math.random() * max : max,
|
||||
max: max
|
||||
};
|
||||
};
|
||||
|
||||
TileRenderer.prototype._advanceEmbers = function () {
|
||||
var scene = this._lastScene;
|
||||
var p = findPlayer(scene);
|
||||
if (!p) return;
|
||||
var cell = this.cell;
|
||||
var pcx = p.x * cell + cell / 2, pcy = p.y * cell + cell / 2;
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var m = this._embers[i];
|
||||
m.x += m.vx;
|
||||
m.y += m.vy;
|
||||
m.life -= 1;
|
||||
if (m.life <= 0) this._embers[i] = this._spawnEmber(pcx, pcy, cell, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Warm torch light on the player cell + drifting embers + an edge vignette. Ported from
|
||||
// the FancyRenderer recipe to flat 2D canvas (no Pixi, no shaders).
|
||||
TileRenderer.prototype._composite = function (scene) {
|
||||
var ctx = this.ctx;
|
||||
if (!ctx) return;
|
||||
var w = this.canvas.width, h = this.canvas.height, cell = this.cell;
|
||||
var p = findPlayer(scene);
|
||||
if (p) {
|
||||
var cx = p.x * cell + cell / 2, cy = p.y * cell + cell / 2;
|
||||
var flick = 1 + Math.sin(this._t * 0.18) * 0.08 + Math.sin(this._t * 0.5 + 1.3) * 0.04;
|
||||
var rad = cell * 4.4 * flick;
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
|
||||
g.addColorStop(0, 'rgba(255,190,110,0.42)');
|
||||
g.addColorStop(0.5, 'rgba(255,150,70,0.14)');
|
||||
g.addColorStop(1, 'rgba(255,150,70,0)');
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, rad, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Embers.
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var m = this._embers[i];
|
||||
var a = Math.max(0, Math.min(1, m.life / m.max)) * 0.6;
|
||||
var eg = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, m.size * 3);
|
||||
eg.addColorStop(0, 'rgba(255,220,150,' + a + ')');
|
||||
eg.addColorStop(1, 'rgba(255,150,60,0)');
|
||||
ctx.fillStyle = eg;
|
||||
ctx.beginPath();
|
||||
ctx.arc(m.x, m.y, m.size * 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Edge vignette (normal blend, darkens the borders).
|
||||
var vig = ctx.createRadialGradient(
|
||||
w / 2, h / 2, Math.min(w, h) * 0.34,
|
||||
w / 2, h / 2, Math.max(w, h) * 0.72
|
||||
);
|
||||
vig.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
vig.addColorStop(1, 'rgba(0,0,0,0.55)');
|
||||
ctx.save();
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
TileRenderer.prototype._startLoop = function () {
|
||||
if (this._raf != null) return;
|
||||
var self = this;
|
||||
function loop() {
|
||||
if (!self.ctx || !self._snap) { self._raf = null; return; }
|
||||
self._t += 1;
|
||||
self._advanceEmbers();
|
||||
self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height);
|
||||
self.ctx.drawImage(self._snap, 0, 0);
|
||||
self._composite(self._lastScene);
|
||||
self._raf = root.requestAnimationFrame(loop);
|
||||
}
|
||||
this._raf = root.requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
TileRenderer.prototype.destroy = function () {
|
||||
if (this._raf != null) { root.cancelAnimationFrame(this._raf); this._raf = null; }
|
||||
if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this._snap = null;
|
||||
this._lastScene = null;
|
||||
this._embers = [];
|
||||
};
|
||||
|
||||
root.RK = root.RK || {};
|
||||
|
||||
@@ -83,14 +83,22 @@ const TransactionHistory = {
|
||||
});
|
||||
},
|
||||
|
||||
// These reads are now session-ownership gated (contract C2), so send the session token
|
||||
// (users.anon_token, persisted as localStorage['wownerogue_token']) as 'X-Session-Token'.
|
||||
_sessionHeaders: function() {
|
||||
var token = null;
|
||||
try { token = localStorage.getItem('wownerogue_token'); } catch (e) {}
|
||||
return token ? { 'X-Session-Token': token } : {};
|
||||
},
|
||||
|
||||
_fetchPayments: function(socketId) {
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payments?limit=50`)
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payments?limit=50`, { headers: this._sessionHeaders() })
|
||||
.then(res => res.json())
|
||||
.catch(() => ({ payments: [], total: 0 }));
|
||||
},
|
||||
|
||||
|
||||
_fetchPayouts: function(socketId) {
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payouts?limit=50`)
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payouts?limit=50`, { headers: this._sessionHeaders() })
|
||||
.then(res => res.json())
|
||||
.catch(() => ({ payouts: [], total: 0, totalReceived: 0 }));
|
||||
},
|
||||
|
||||
+410
-38
@@ -13,6 +13,52 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
DESIGN TOKENS — "dungeon console" palette
|
||||
Single source of truth for the torch-lit dungeon aesthetic. Hard-coded hex
|
||||
throughout the sheet is refactored to reference these incrementally.
|
||||
========================================================================= */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg: #111; /* deepest chrome (banner / footer) */
|
||||
--bg-panel: #222; /* side panels (status / chat) */
|
||||
--bg-deep: #0a0a0f;/* modal interior */
|
||||
--bg-input: #444; /* form fields */
|
||||
|
||||
/* Ink */
|
||||
--ink: #e0e0e0;/* default body text */
|
||||
--ink-dim: #888; /* muted / secondary text */
|
||||
|
||||
/* Accents */
|
||||
--neon: #0f0; /* neon-green primary */
|
||||
--good: #4ade80;/* success green */
|
||||
--cyan: #0ff; /* cyan */
|
||||
--gold: #fbbf24;/* torch gold */
|
||||
--gold-bright: #ffd700;/* bright gold highlight */
|
||||
--purple: #8b5cf6;/* arcane purple */
|
||||
--purple-soft: #c4b5fd;/* purple text */
|
||||
--torch: #f59e0b;/* torch-amber (frame glow) */
|
||||
|
||||
/* Structure */
|
||||
--rule: #444; /* hairline borders / dividers */
|
||||
--danger: #ef4444;/* danger / monster */
|
||||
--danger-hot: #f00; /* hot red (close buttons) */
|
||||
|
||||
/* Reusable glows */
|
||||
--glow-neon: rgba(0, 255, 0, 0.28);
|
||||
--glow-cyan: rgba(0, 255, 255, 0.28);
|
||||
--glow-purple: rgba(139, 92, 246, 0.30);
|
||||
--glow-torch: rgba(245, 158, 11, 0.30);
|
||||
}
|
||||
|
||||
/* Global console shell: PixelOperator everywhere with a monospace fallback. */
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
}
|
||||
|
||||
/* Updated layout with 5-row structure */
|
||||
.container {
|
||||
display: grid;
|
||||
@@ -29,41 +75,224 @@
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
background-color: #222;
|
||||
background-color: var(--bg-panel);
|
||||
}
|
||||
|
||||
/* Header area */
|
||||
.banner {
|
||||
grid-area: header;
|
||||
background-color: #111;
|
||||
background-color: var(--bg);
|
||||
padding: 5px;
|
||||
color: white;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #444;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
box-shadow: inset 0 -2px 12px rgba(245, 158, 11, 0.06);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Status area for block height display */
|
||||
/* Status area for block height display — the "control console" column */
|
||||
.status {
|
||||
grid-area: status;
|
||||
background-color: #222;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 158, 11, 0.04), transparent 42%),
|
||||
var(--bg-panel);
|
||||
padding: 10px;
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
color: var(--neon);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
border-right: 1px solid #444;
|
||||
border-right: 1px solid var(--rule);
|
||||
box-shadow: inset 0 0 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Torch-lit accent bar running down the console's leading edge. */
|
||||
.status::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, var(--torch), transparent 65%);
|
||||
box-shadow: 0 0 10px var(--torch);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
animation: torch-flicker 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#blockHeight, #connectionStatus {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 0 6px rgba(0, 255, 0, 0.25);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
CONTROL BAR — primary action buttons (#startButton, #watchGamesButton,
|
||||
#tavernButton, #characterButton, #shopButton, #animationToggleButton).
|
||||
Presentation lives here; ids + click handlers stay untouched in the HTML.
|
||||
Each variant only sets three custom props (accent / panel / label).
|
||||
========================================================================= */
|
||||
.ctrl-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 6px;
|
||||
padding: 8px 16px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
color: var(--btn-label, var(--ink));
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.25)),
|
||||
var(--btn-panel, #1a1a1a);
|
||||
border: 2px solid var(--btn-accent, var(--rule));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
|
||||
transition: box-shadow 0.18s ease, transform 0.08s ease,
|
||||
border-color 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 0 14px var(--btn-glow, var(--glow-torch)),
|
||||
inset 0 0 10px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ctrl-btn:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.ctrl-btn:focus-visible {
|
||||
outline: 2px solid var(--btn-label, var(--ink));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Variants — colours preserved from the original inline styles. */
|
||||
.ctrl-btn--start { --btn-panel: #0a5c0a; --btn-accent: var(--neon); --btn-label: #ff0; --btn-glow: var(--glow-neon); }
|
||||
.ctrl-btn--watch { --btn-panel: #053655; --btn-accent: #0af; --btn-label: #0af; --btn-glow: var(--glow-cyan); }
|
||||
.ctrl-btn--tavern { --btn-panel: #2a0a3a; --btn-accent: #a855f7; --btn-label: #d8b4fe; --btn-glow: var(--glow-purple); }
|
||||
.ctrl-btn--character{ --btn-panel: #163044; --btn-accent: #5aa7d8; --btn-label: #a7d8ff; --btn-glow: rgba(90, 167, 216, 0.32); }
|
||||
.ctrl-btn--shop { --btn-panel: #004400; --btn-accent: var(--neon); --btn-label: var(--neon); --btn-glow: var(--glow-neon); }
|
||||
.ctrl-btn--anim { --btn-panel: #5c5c0a; --btn-accent: #aa0; --btn-label: #ff0; --btn-glow: var(--glow-torch); padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
/* The primary CTA gently pulses like a torch while idle (paused on hover). */
|
||||
.ctrl-btn--start { animation: torch-glow 3.4s ease-in-out infinite; }
|
||||
.ctrl-btn--start:hover { animation: none; }
|
||||
|
||||
/* =========================================================================
|
||||
HUD TOOLBAR — secondary "chip" controls (audio toggle + help / history /
|
||||
scores / manage-address). Small, bordered, glow-on-hover.
|
||||
========================================================================= */
|
||||
.hud-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--rule);
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.hud-toolbar label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hud-chip {
|
||||
font-family: 'PixelOperator', monospace;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
color: var(--chip-label, var(--ink));
|
||||
background: var(--chip-panel, #1a1a1a);
|
||||
border: 1px solid var(--chip-accent, var(--rule));
|
||||
transition: box-shadow 0.18s ease, filter 0.18s ease, transform 0.08s ease;
|
||||
}
|
||||
|
||||
.hud-chip:hover {
|
||||
filter: brightness(1.2);
|
||||
box-shadow: 0 0 9px var(--chip-glow, var(--glow-torch));
|
||||
}
|
||||
|
||||
.hud-chip:active { transform: translateY(1px); }
|
||||
|
||||
.hud-chip:focus-visible {
|
||||
outline: 2px solid var(--chip-label, var(--ink));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hud-chip--push { margin-left: auto; }
|
||||
|
||||
.hud-chip--help { --chip-panel: #2d2d00; --chip-accent: var(--gold-bright); --chip-label: var(--gold-bright); --chip-glow: var(--glow-torch); }
|
||||
.hud-chip--history { --chip-panel: #1a1a4d; --chip-accent: var(--purple); --chip-label: var(--purple-soft); --chip-glow: var(--glow-purple); }
|
||||
.hud-chip--scores { --chip-panel: #4a2800; --chip-accent: var(--torch); --chip-label: var(--gold); --chip-glow: var(--glow-torch); }
|
||||
.hud-chip--address { --chip-panel: #053655; --chip-accent: var(--cyan); --chip-label: var(--cyan); --chip-glow: var(--glow-cyan); }
|
||||
|
||||
/* Info readouts inside the console */
|
||||
#creditsDisplay {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
color: var(--neon);
|
||||
background: #001a00;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #0a0;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#publicStats {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #9cf;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#liveCta {
|
||||
margin-top: 6px;
|
||||
cursor: pointer;
|
||||
background: rgba(80, 0, 0, 0.55);
|
||||
border: 1px solid #f33;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
color: #ff8a8a;
|
||||
font-weight: bold;
|
||||
transition: box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
#liveCta:hover {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 0 12px rgba(255, 51, 51, 0.4);
|
||||
}
|
||||
|
||||
#sessionWarning {
|
||||
color: var(--torch);
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Make sure rotdis is properly visible across all browsers */
|
||||
.rotdis {
|
||||
grid-area: game;
|
||||
background-color: #000;
|
||||
border: 2px solid #444;
|
||||
border: 2px solid var(--rule);
|
||||
display: flex !important; /* Using flex to center canvas */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -89,10 +318,10 @@
|
||||
/* Chat messages area */
|
||||
.chat-container {
|
||||
grid-area: chat;
|
||||
background-color: #222;
|
||||
background-color: var(--bg-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #444;
|
||||
border-right: 1px solid var(--rule);
|
||||
overflow: hidden; /* Prevents chat from causing scrolling */
|
||||
}
|
||||
|
||||
@@ -122,8 +351,8 @@
|
||||
grid-area: input;
|
||||
background-color: #333;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #444;
|
||||
border-bottom: 1px solid #444;
|
||||
border-top: 1px solid var(--rule);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
#chatForm {
|
||||
@@ -135,9 +364,10 @@
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background-color: #444;
|
||||
background-color: var(--bg-input);
|
||||
color: #fff;
|
||||
border-radius: 3px 0 0 3px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
}
|
||||
|
||||
#chatForm button {
|
||||
@@ -147,6 +377,12 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0 3px 3px 0;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
transition: background-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
#chatForm button:hover {
|
||||
background-color: #0a5c0a;
|
||||
box-shadow: 0 0 10px var(--glow-neon);
|
||||
}
|
||||
|
||||
/* Add this to your stylesheet to use the font in other places */
|
||||
@@ -159,17 +395,18 @@
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
background-color: #111;
|
||||
color: #888;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink-dim);
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
font-family: 'PixelOperator', sans-serif;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* Game messages styling */
|
||||
li.game-start {
|
||||
color: #0f0;
|
||||
color: var(--neon);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 5px 0;
|
||||
@@ -178,20 +415,20 @@ li.game-start {
|
||||
}
|
||||
|
||||
li.error {
|
||||
color: #f00;
|
||||
color: var(--danger-hot);
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
padding: 2px 5px;
|
||||
background: rgba(255,0,0,0.1);
|
||||
border-left: 2px solid #f00;
|
||||
border-left: 2px solid var(--danger-hot);
|
||||
}
|
||||
|
||||
li.debug {
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #0ff;
|
||||
border-left: 2px solid var(--cyan);
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
@@ -199,7 +436,16 @@ li.debug {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
/* =========================================================================
|
||||
SHARED PANEL FRAME
|
||||
The overlay-centred modals (#addressModal / #helpModal via .modal-content)
|
||||
and the fixed-position panels (#payment-ui / #history-panel via
|
||||
.dungeon-modal, plus the JS-built #leaderboard-panel) all share one framed,
|
||||
torch-lit, inner-glowing look. Each panel keeps its signature accent colour
|
||||
through the --accent / --accent-glow / --panel-bg custom properties.
|
||||
========================================================================= */
|
||||
|
||||
/* -- Overlay-centred modals (address / help) -- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -212,15 +458,19 @@ li.debug {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #0a0a0f;
|
||||
border: 2px solid #0ff;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 45%),
|
||||
var(--bg-deep);
|
||||
border: 2px solid var(--cyan);
|
||||
border-radius: 8px;
|
||||
width: min(480px, 90vw);
|
||||
padding: 20px;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
position: relative;
|
||||
box-shadow: 0 0 18px rgba(0, 255, 255, 0.2);
|
||||
box-shadow: 0 0 18px var(--glow-cyan),
|
||||
inset 0 0 42px rgba(0, 0, 0, 0.55);
|
||||
animation: mc-pop 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
@@ -247,9 +497,9 @@ li.debug {
|
||||
#addressModalInput {
|
||||
width: 100%;
|
||||
background: #020205;
|
||||
border: 1px solid #0ff;
|
||||
border: 1px solid var(--cyan);
|
||||
border-radius: 4px;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
@@ -267,11 +517,12 @@ li.debug {
|
||||
.modal-actions button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #0ff;
|
||||
border: 1px solid var(--cyan);
|
||||
background: #031c28;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.modal-actions button#addressModalSave {
|
||||
@@ -281,7 +532,8 @@ li.debug {
|
||||
}
|
||||
|
||||
.modal-actions button:hover {
|
||||
box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 0 10px var(--glow-cyan);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
@@ -290,10 +542,12 @@ li.debug {
|
||||
right: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.modal-close:hover { color: #fff; transform: scale(1.15); }
|
||||
|
||||
.modal-feedback {
|
||||
margin-top: 12px;
|
||||
@@ -313,6 +567,77 @@ li.debug {
|
||||
color: #9cf6ff;
|
||||
}
|
||||
|
||||
/* -- Fixed-position framed panels (payment / history) -- */
|
||||
.dungeon-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2000;
|
||||
min-width: 400px;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border: 2px solid var(--accent, var(--torch));
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 45%),
|
||||
var(--panel-bg, rgba(12, 12, 20, 0.97));
|
||||
box-shadow: 0 0 24px var(--accent-glow, var(--glow-torch)),
|
||||
inset 0 0 44px rgba(0, 0, 0, 0.55);
|
||||
animation: dm-fade 0.22s ease-out;
|
||||
}
|
||||
|
||||
#payment-ui.dungeon-modal {
|
||||
--accent: var(--neon);
|
||||
--accent-glow: var(--glow-neon);
|
||||
--panel-bg: rgba(0, 20, 0, 0.96);
|
||||
color: var(--neon);
|
||||
}
|
||||
|
||||
#history-panel.dungeon-modal {
|
||||
--accent: var(--purple);
|
||||
--accent-glow: var(--glow-purple);
|
||||
--panel-bg: rgba(10, 10, 30, 0.98);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Shared header bar for framed panels */
|
||||
.dm-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.dm-head strong { font-size: 1.15em; }
|
||||
|
||||
/* Shared close control for framed panels */
|
||||
.dm-close {
|
||||
background: #500;
|
||||
color: var(--danger-hot);
|
||||
border: 1px solid var(--danger-hot);
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
transition: box-shadow 0.15s ease, filter 0.15s ease;
|
||||
}
|
||||
.dm-close:hover {
|
||||
filter: brightness(1.25);
|
||||
box-shadow: 0 0 10px rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* The JS-built leaderboard panel keeps its inline amber frame; give it the
|
||||
same soft entrance + inner glow so it reads as part of the same system.
|
||||
(Opacity-only animation avoids clobbering its translate() centring.) */
|
||||
#leaderboard-panel {
|
||||
animation: dm-fade 0.22s ease-out;
|
||||
box-shadow: 0 0 22px var(--glow-torch), inset 0 0 40px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Win feed toasts (global "someone escaped" announcements)
|
||||
@@ -330,8 +655,8 @@ li.debug {
|
||||
}
|
||||
.win-toast {
|
||||
background: rgba(0, 30, 0, 0.92);
|
||||
border: 1px solid #0f0;
|
||||
border-left: 4px solid #4ade80;
|
||||
border: 1px solid var(--neon);
|
||||
border-left: 4px solid var(--good);
|
||||
border-radius: 6px;
|
||||
color: #cffccf;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
@@ -343,7 +668,52 @@ li.debug {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
.win-toast.show { opacity: 1; transform: translateX(0); }
|
||||
.win-toast strong { color: #4ade80; }
|
||||
.win-toast strong { color: var(--good); }
|
||||
|
||||
/* =========================================================================
|
||||
Motion — tasteful transitions & keyframes (all respect reduced-motion)
|
||||
========================================================================= */
|
||||
@keyframes torch-flicker {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
18% { opacity: 0.55; }
|
||||
36% { opacity: 0.85; }
|
||||
58% { opacity: 0.5; }
|
||||
77% { opacity: 0.78; }
|
||||
}
|
||||
|
||||
@keyframes torch-glow {
|
||||
0%, 100% { box-shadow: 0 0 6px var(--btn-glow, var(--glow-torch)); }
|
||||
50% { box-shadow: 0 0 16px var(--btn-glow, var(--glow-torch)); }
|
||||
}
|
||||
|
||||
@keyframes dm-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mc-pop {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status::before,
|
||||
.ctrl-btn,
|
||||
.ctrl-btn--start,
|
||||
.hud-chip,
|
||||
.modal-content,
|
||||
.dungeon-modal,
|
||||
#leaderboard-panel,
|
||||
.win-toast,
|
||||
#liveCta,
|
||||
.modal-actions button,
|
||||
.modal-close,
|
||||
.dm-close,
|
||||
#chatForm button {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Mobile / touch support
|
||||
@@ -394,7 +764,7 @@ li.debug {
|
||||
#staticbanner { font-size: 15px !important; }
|
||||
.status {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #444;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
@@ -418,6 +788,8 @@ li.debug {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Toolbar spans the full row when the console wraps. */
|
||||
.hud-toolbar { flex-basis: 100%; margin-top: 6px; }
|
||||
|
||||
/* Fixed-width modals would overflow tiny screens — make them fit */
|
||||
#payment-ui, #history-panel, #leaderboard-panel {
|
||||
@@ -449,7 +821,7 @@ body.touch #touch-dpad { display: grid; }
|
||||
width: 44px; height: 44px;
|
||||
font-size: 17px;
|
||||
background: rgba(0,40,0,0.5);
|
||||
color: #0f0;
|
||||
color: var(--neon);
|
||||
border: 2px solid rgba(0,255,0,0.7);
|
||||
border-radius: 9px;
|
||||
padding: 0;
|
||||
|
||||
@@ -11,13 +11,6 @@ const path = require('path');
|
||||
* Query validator to detect potential SQL injection
|
||||
*/
|
||||
class QueryValidator {
|
||||
constructor() {
|
||||
this.dangerousKeywords = [
|
||||
'DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'REPLACE',
|
||||
'EXEC', 'EXECUTE', 'SCRIPT', '--', '/*', '*/', 'XP_', 'SP_'
|
||||
];
|
||||
}
|
||||
|
||||
isSafe(text, params) {
|
||||
// Check if using parameterized query properly
|
||||
// Count unique placeholders ($1, $2, etc.) — PostgreSQL allows reusing $1 multiple times
|
||||
@@ -29,29 +22,6 @@ class QueryValidator {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for dangerous patterns in the query text itself
|
||||
for (const keyword of this.dangerousKeywords) {
|
||||
// Use regex to avoid false positives (e.g., "DESCRIPTION" containing "SCRIPT")
|
||||
// Alphanumeric keywords use word boundaries; others use literal match
|
||||
let regex;
|
||||
if (/^[A-Z0-9_]+$/i.test(keyword)) {
|
||||
regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
||||
} else {
|
||||
// Escape special characters for literal match
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
regex = new RegExp(escaped, 'i');
|
||||
}
|
||||
|
||||
if (regex.test(text)) {
|
||||
// Allow legitimate DDL in migrations only
|
||||
const stack = new Error().stack;
|
||||
if (!stack.includes('migrations')) {
|
||||
console.warn(`⚠️ Potentially dangerous keyword "${keyword}" in query`);
|
||||
// Log but don't block - some keywords might be legitimate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+23
-8
@@ -110,13 +110,24 @@ class DungeonGenerator {
|
||||
}
|
||||
|
||||
static generate(width, height, options = {}) {
|
||||
// G4: resolve the difficulty-preset overrides so the digger and treasure
|
||||
// placement honour them (getConfig applies preset + env-var overrides on top
|
||||
// of DUNGEON_CONFIGS). These become defaults that explicit `options` can override.
|
||||
const presetConfig = this.getConfig(options.cryptoType || null);
|
||||
const defaultOptions = {
|
||||
floorVariation: DUNGEON_CONFIGS.FLOOR_VARIATION,
|
||||
torchEnabled: DUNGEON_CONFIGS.TORCH_ENABLED,
|
||||
torchDensity: DUNGEON_CONFIGS.TORCH_DENSITY,
|
||||
primaryFloor: DUNGEON_CONFIGS.PRIMARY_FLOOR,
|
||||
secondaryFloor: DUNGEON_CONFIGS.SECONDARY_FLOOR,
|
||||
torchTile: DUNGEON_CONFIGS.TORCH_TILE
|
||||
torchTile: DUNGEON_CONFIGS.TORCH_TILE,
|
||||
// Preset-derived digger parameters (difficulty overrides)
|
||||
roomWidthRange: presetConfig.ROOM_WIDTH_RANGE,
|
||||
roomHeightRange: presetConfig.ROOM_HEIGHT_RANGE,
|
||||
corridorLengthRange: presetConfig.CORRIDOR_LENGTH_RANGE,
|
||||
dugPercentage: presetConfig.DUG_PERCENTAGE,
|
||||
// Preset-derived treasure room placement ratio
|
||||
roomPositionRatio: presetConfig.difficulty.treasure.roomPositionRatio
|
||||
};
|
||||
|
||||
const config = { ...defaultOptions, ...options };
|
||||
@@ -160,12 +171,14 @@ class DungeonGenerator {
|
||||
ROT.RNG.setSeed(seedInt);
|
||||
}
|
||||
|
||||
// Create dungeon using ROT.js Map.Digger
|
||||
// Create dungeon using ROT.js Map.Digger.
|
||||
// G4: honour the difficulty-preset overrides carried on config (falling back to
|
||||
// the module constants) instead of always using DUNGEON_CONFIGS.
|
||||
const digger = new ROT.Map.Digger(width, height, {
|
||||
roomWidth: DUNGEON_CONFIGS.ROOM_WIDTH_RANGE,
|
||||
roomHeight: DUNGEON_CONFIGS.ROOM_HEIGHT_RANGE,
|
||||
corridorLength: DUNGEON_CONFIGS.CORRIDOR_LENGTH_RANGE,
|
||||
dugPercentage: DUNGEON_CONFIGS.DUG_PERCENTAGE
|
||||
roomWidth: config.roomWidthRange || DUNGEON_CONFIGS.ROOM_WIDTH_RANGE,
|
||||
roomHeight: config.roomHeightRange || DUNGEON_CONFIGS.ROOM_HEIGHT_RANGE,
|
||||
corridorLength: config.corridorLengthRange || DUNGEON_CONFIGS.CORRIDOR_LENGTH_RANGE,
|
||||
dugPercentage: (config.dugPercentage !== undefined) ? config.dugPercentage : DUNGEON_CONFIGS.DUG_PERCENTAGE
|
||||
});
|
||||
|
||||
// Initialize the empty map with walls (1)
|
||||
@@ -193,10 +206,12 @@ class DungeonGenerator {
|
||||
const exitCenter = exitRoom.getCenter();
|
||||
const exit = [exitCenter[0], exitCenter[1]];
|
||||
|
||||
// Place treasure in a middle room (not first or last)
|
||||
// Place treasure in a middle room (not first or last).
|
||||
// G4: use the difficulty-aware treasure room index instead of a hardcoded midpoint.
|
||||
let treasureRoom;
|
||||
if (rooms.length > 2) {
|
||||
treasureRoom = rooms[Math.floor(rooms.length / 2)];
|
||||
const treasureIndex = getTreasureRoomIndex(rooms, config.roomPositionRatio);
|
||||
treasureRoom = rooms[treasureIndex];
|
||||
} else {
|
||||
// Fallback if we have fewer than 3 rooms
|
||||
treasureRoom = rooms[0];
|
||||
|
||||
+20
-1
@@ -319,9 +319,28 @@ class GameManager {
|
||||
const result = await db.query(`
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at)
|
||||
VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW())
|
||||
RETURNING id
|
||||
RETURNING id, user_id
|
||||
`, [socketId, socketId, gameMode, blockHeight, game.id]);
|
||||
game.dbId = result.rows[0]?.id || null;
|
||||
|
||||
// Stamp the stable DB users.id onto the in-memory game object so
|
||||
// suspend/restore and completeGame can key on it WITHOUT any socket_id
|
||||
// lookup (socket ids are volatile across reconnects). game.userId is the
|
||||
// canonical field; game.dbUserId is kept as an alias for the disconnect/
|
||||
// suspend path that already reads it.
|
||||
const stableUserId = result.rows[0]?.user_id ?? null;
|
||||
if (stableUserId != null) {
|
||||
game.userId = stableUserId;
|
||||
if (game.dbUserId == null) game.dbUserId = stableUserId;
|
||||
}
|
||||
|
||||
// Entry block height captured at creation so the suspend path can persist
|
||||
// it (blockRec) and the reconnect timeout logic stays consistent.
|
||||
game.blockRec = blockHeight;
|
||||
|
||||
// Reference to the DB pool so restoreGame can keep games.socket_id aligned
|
||||
// to the reconnecting socket without needing its own DB handle.
|
||||
game.db = db;
|
||||
} catch (err) {
|
||||
console.error('Game insert failed:', err.message);
|
||||
game.dbId = null;
|
||||
|
||||
+177
-46
@@ -803,9 +803,24 @@ class GameModeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process game start (deduct credits or link payment)
|
||||
* Process game start (deduct credits or link payment).
|
||||
*
|
||||
* C7: `options` is the optional trailing opts bag and may carry a `clientSeed`. The
|
||||
* dungeon's effective RNG seed is derived at game CREATION via
|
||||
* provablyFair.deriveSeed(serverSeed, clientSeed); this start entry point accepts and
|
||||
* forwards the (already-validated) client seed so the flow stays in sync and the value
|
||||
* is surfaced on the start result. Non-breaking: clientSeed is optional (defaults to '').
|
||||
*/
|
||||
async processGameStart(socketId, gameId, options = {}) {
|
||||
const clientSeed = typeof options.clientSeed === 'string' ? options.clientSeed : '';
|
||||
const result = await this._dispatchGameStart(socketId, gameId, options);
|
||||
if (result && typeof result === 'object' && clientSeed && result.clientSeed === undefined) {
|
||||
result.clientSeed = clientSeed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async _dispatchGameStart(socketId, gameId, options = {}) {
|
||||
try {
|
||||
const user = await this.getOrCreateUser(socketId);
|
||||
|
||||
@@ -979,29 +994,63 @@ class GameModeManager {
|
||||
|
||||
async _processGameStartWithPayment(user, payment, gameId) {
|
||||
const snap = this._computePayoutSnapshot('PAID_SINGLE');
|
||||
await this.db.withTransaction(async (client) => {
|
||||
await client.query(`
|
||||
UPDATE games
|
||||
SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3,
|
||||
payout_escape_amount = $4, payout_treasure_amount = $5,
|
||||
payout_escape_mult = $6, payout_treasure_mult = $7
|
||||
WHERE dungeon_seed = $2
|
||||
`, [payment.id, gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]);
|
||||
// M3: claiming a confirmed single_game payment must be atomic. Re-verify the payment
|
||||
// is still unclaimed and lock it FOR UPDATE inside the transaction, so two concurrent
|
||||
// starts can't both consume the same payment. The unique index on games.payment_id
|
||||
// (migration 023) is the final backstop; a 23505 there is treated as "already consumed".
|
||||
try {
|
||||
const result = await this.db.withTransaction(async (client) => {
|
||||
const claim = await client.query(`
|
||||
SELECT id FROM payments
|
||||
WHERE id = $1 AND status = 'confirmed' AND payment_type = 'single_game'
|
||||
AND NOT EXISTS (SELECT 1 FROM games WHERE games.payment_id = payments.id)
|
||||
FOR UPDATE
|
||||
`, [payment.id]);
|
||||
|
||||
await client.query(`
|
||||
UPDATE users
|
||||
SET total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, [user.id]);
|
||||
});
|
||||
if (claim.rows.length === 0) {
|
||||
// Locked+re-checked: a concurrent start already linked this payment (or it is
|
||||
// no longer claimable). Abort WITHOUT linking or creating a duplicate game.
|
||||
return { alreadyConsumed: true };
|
||||
}
|
||||
|
||||
console.log(`Linked game ${gameId} to payment ${payment.id}`);
|
||||
return {
|
||||
success: true,
|
||||
paymentId: payment.id,
|
||||
effectiveMode: 'PAID_SINGLE'
|
||||
};
|
||||
await client.query(`
|
||||
UPDATE games
|
||||
SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3,
|
||||
payout_escape_amount = $4, payout_treasure_amount = $5,
|
||||
payout_escape_mult = $6, payout_treasure_mult = $7
|
||||
WHERE dungeon_seed = $2
|
||||
`, [payment.id, gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]);
|
||||
|
||||
await client.query(`
|
||||
UPDATE users
|
||||
SET total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, [user.id]);
|
||||
|
||||
return { linked: true };
|
||||
});
|
||||
|
||||
if (result.alreadyConsumed) {
|
||||
console.warn(`⚠️ Payment ${payment.id} already consumed by a concurrent start; aborting duplicate for game ${gameId}.`);
|
||||
return { success: false, reason: 'Payment already consumed', alreadyConsumed: true };
|
||||
}
|
||||
|
||||
console.log(`Linked game ${gameId} to payment ${payment.id}`);
|
||||
return {
|
||||
success: true,
|
||||
paymentId: payment.id,
|
||||
effectiveMode: 'PAID_SINGLE'
|
||||
};
|
||||
} catch (error) {
|
||||
// 23505 = unique_violation on games.payment_id: a concurrent start linked this
|
||||
// payment first. Treat as already-consumed, never an uncaught throw / duplicate.
|
||||
if (error && error.code === '23505') {
|
||||
console.warn(`⚠️ payment_id unique violation linking game ${gameId} to payment ${payment.id}; treating as already consumed.`);
|
||||
return { success: false, reason: 'Payment already consumed', alreadyConsumed: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// processGameCompletion() removed — dead code, never called.
|
||||
@@ -1171,7 +1220,7 @@ class GameModeManager {
|
||||
* with no live session) — `socket_id` is mutable and non-unique and must never be the
|
||||
* primary money key.
|
||||
*/
|
||||
async getOrCreateUser(socketId, ipAddress = null) {
|
||||
async getOrCreateUser(socketId, { create = true } = {}) {
|
||||
try {
|
||||
// 1. Prefer the stable identity established by SessionManager (anon_token -> id).
|
||||
if (this.sessionManager && typeof this.sessionManager.getBySocket === 'function') {
|
||||
@@ -1205,6 +1254,12 @@ class GameModeManager {
|
||||
return userResult.rows[0];
|
||||
}
|
||||
|
||||
// Read-only callers (REST reads) pass { create: false }: never mint an orphan
|
||||
// row for a socket that has no user yet — return null and let them 404/handle it.
|
||||
if (!create) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Last resort: create a new user. This only fires when no session was
|
||||
// established for the socket (abnormal in normal flow, since every connection
|
||||
// creates a session) — log it so orphan-row creation is visible.
|
||||
@@ -1212,7 +1267,7 @@ class GameModeManager {
|
||||
INSERT INTO users (socket_id, ip_address)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *
|
||||
`, [socketId, ipAddress]);
|
||||
`, [socketId, null]);
|
||||
|
||||
console.warn(`👤 Created new user without a session (socket ${socketId}) — no anon_token identity resolved.`);
|
||||
return userResult.rows[0];
|
||||
@@ -1374,6 +1429,29 @@ class GameModeManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Fire-and-forget operator alert (no-op when no alertService is wired). */
|
||||
_alert(type, subject, html) {
|
||||
if (this.alertService && typeof this.alertService.sendAlert === 'function') {
|
||||
this.alertService.sendAlert(type, { subject, html }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the configured payout min/max (atomic BigInt) for a recorded game mode from
|
||||
* the runtime config snapshot, or null when unavailable (older config / no snapshot).
|
||||
*/
|
||||
_payoutBoundsForMode(recordedPaymentMode) {
|
||||
const rules = this.configSnapshot?.payouts?.rules;
|
||||
if (!rules) return null;
|
||||
const rule = recordedPaymentMode === 'PAID_CREDITS' ? rules.credits : rules.direct;
|
||||
if (!rule) return null;
|
||||
const toBigOrNull = (v) => {
|
||||
if (v === undefined || v === null) return null;
|
||||
try { return money.toBig(v); } catch { return null; }
|
||||
};
|
||||
return { min: toBigOrNull(rule.minPayout), max: toBigOrNull(rule.maxPayout) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a game (called from socket handlers when game ends)
|
||||
* @param {string} socketId - Player's socket ID
|
||||
@@ -1389,22 +1467,27 @@ class GameModeManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve the game record to get the actual payment_mode used at game start
|
||||
// Retrieve the game record to get the actual payment_mode used at game start.
|
||||
// M4/C4: match by dungeon_seed ALONE (the UUID is unique) — never by the mutable
|
||||
// socket_id — and carry the stable users.id (games.user_id) so the paying user is
|
||||
// resolved by identity, not by the socket that happened to finish the game.
|
||||
let recordedPaymentMode = this.gameMode; // fallback to global
|
||||
let gameDbId = null;
|
||||
let gameUserId = null; // stable users.id carried on the game row
|
||||
let lockedPayoutAddress = null; // address locked at game start
|
||||
let payoutSnapshot = null; // payout terms snapshotted at game start
|
||||
try {
|
||||
const gameRecord = await this.db.query(`
|
||||
SELECT id, game_mode, payout_address,
|
||||
SELECT id, user_id, game_mode, payout_address,
|
||||
payout_escape_amount, payout_treasure_amount,
|
||||
payout_escape_mult, payout_treasure_mult
|
||||
FROM games
|
||||
WHERE dungeon_seed = $1 AND socket_id = $2
|
||||
WHERE dungeon_seed = $1
|
||||
LIMIT 1
|
||||
`, [gameId, socketId]);
|
||||
`, [gameId]);
|
||||
if (gameRecord.rows.length > 0) {
|
||||
gameDbId = gameRecord.rows[0].id;
|
||||
gameUserId = gameRecord.rows[0].user_id ?? null;
|
||||
recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase();
|
||||
lockedPayoutAddress = gameRecord.rows[0].payout_address || null;
|
||||
const r = gameRecord.rows[0];
|
||||
@@ -1428,14 +1511,13 @@ class GameModeManager {
|
||||
try {
|
||||
await this.db.query(`
|
||||
UPDATE games SET status = $1, treasure_found = $2, moves_made = COALESCE($3, moves_made), duration_seconds = COALESCE($4, duration_seconds), completed_at = NOW()
|
||||
WHERE dungeon_seed = $5 AND socket_id = $6
|
||||
WHERE dungeon_seed = $5
|
||||
`, [
|
||||
won ? 'won' : 'lost',
|
||||
treasureFound,
|
||||
metrics.moves ?? null,
|
||||
metrics.durationSeconds ?? null,
|
||||
gameId,
|
||||
socketId
|
||||
gameId
|
||||
]);
|
||||
} catch (e) {
|
||||
// Non-fatal if games table differs during early dev.
|
||||
@@ -1450,6 +1532,14 @@ class GameModeManager {
|
||||
const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE' && this.directPayoutEnabled)
|
||||
|| (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
|
||||
if (payoutEligibleStartMode && won) {
|
||||
// M4/C4: never insert a payout without a resolved game row — a null game_id
|
||||
// would defeat the payouts dedup (game_id) guard and could double-pay.
|
||||
if (!gameDbId) {
|
||||
console.error(`❌ completeGame: no game row for dungeon_seed ${gameId}; refusing to insert a payout.`);
|
||||
this._alert('payout_unresolved_game', '⚠️ Payout skipped — game row not found', `<p>completeGame could not resolve a games row for dungeon_seed <b>${gameId}</b> (socket ${socketId}). No payout was inserted.</p>`);
|
||||
return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Game row not resolved', score: metrics.score ?? null };
|
||||
}
|
||||
|
||||
// Use the payout terms snapshotted at game start (so a mid-game config change
|
||||
// can't alter an in-flight payout). Fall back to live calculation only for
|
||||
// older games that predate the snapshot columns.
|
||||
@@ -1464,21 +1554,51 @@ class GameModeManager {
|
||||
multiplier = live.multiplier;
|
||||
}
|
||||
|
||||
// Check if payout was already processed for this game (prevent duplicates)
|
||||
if (gameDbId) {
|
||||
const existingPayout = await this.db.query(`
|
||||
SELECT id FROM payouts WHERE game_id = $1 AND status IN ('pending', 'processing', 'completed')
|
||||
`, [gameDbId]);
|
||||
if (existingPayout.rows.length > 0) {
|
||||
console.log(`⚠️ Payout already exists for game ${gameId}, skipping duplicate`);
|
||||
return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Payout already processed', score: metrics.score ?? null };
|
||||
// M5: clamp/validate the payout against the configured min/max for this mode.
|
||||
// Over-max -> cap + alert (never send more than policy allows). Under-min ->
|
||||
// skip entirely (dust not worth an on-chain fee). Bounds are atomic BigInt.
|
||||
const bounds = this._payoutBoundsForMode(recordedPaymentMode);
|
||||
if (bounds) {
|
||||
let amtBig = null;
|
||||
try { amtBig = money.toBig(payoutAmount); } catch { amtBig = null; }
|
||||
if (amtBig !== null) {
|
||||
if (bounds.max !== null && amtBig > bounds.max) {
|
||||
console.warn(`⚠️ Payout ${amtBig} for game ${gameId} exceeds configured max ${bounds.max}; capping.`);
|
||||
this._alert('payout_over_max', '⚠️ Payout capped at configured maximum', `<p>Computed payout <b>${amtBig}</b> for game <b>${gameId}</b> exceeded the configured max <b>${bounds.max}</b> and was capped. Mode: ${recordedPaymentMode}.</p>`);
|
||||
amtBig = bounds.max;
|
||||
payoutAmount = amtBig.toString();
|
||||
}
|
||||
if (bounds.min !== null && amtBig < bounds.min) {
|
||||
console.log(`Payout ${amtBig} for game ${gameId} below configured min ${bounds.min}; skipping payout.`);
|
||||
return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Below minimum payout', score: metrics.score ?? null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use payout address locked at game start, fall back to current user address
|
||||
const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]);
|
||||
const userRow = userResult.rows[0];
|
||||
const payoutAddress = lockedPayoutAddress || (userRow && userRow.payout_address);
|
||||
// Check if payout was already processed for this game (prevent duplicates)
|
||||
const existingPayout = await this.db.query(`
|
||||
SELECT id FROM payouts WHERE game_id = $1 AND status IN ('pending', 'processing', 'completed')
|
||||
`, [gameDbId]);
|
||||
if (existingPayout.rows.length > 0) {
|
||||
console.log(`⚠️ Payout already exists for game ${gameId}, skipping duplicate`);
|
||||
return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Payout already processed', score: metrics.score ?? null };
|
||||
}
|
||||
|
||||
// M4/C4: resolve the paying user by the STABLE users.id carried on the game
|
||||
// row (games.user_id), never by the mutable socket that finished the game.
|
||||
let userRow = null;
|
||||
if (gameUserId != null) {
|
||||
const userResult = await this.db.query(`SELECT * FROM users WHERE id = $1 LIMIT 1`, [gameUserId]);
|
||||
userRow = userResult.rows[0] || null;
|
||||
}
|
||||
if (!userRow) {
|
||||
console.error(`❌ completeGame: could not resolve paying user (game user_id=${gameUserId}) for game ${gameId}; refusing payout.`);
|
||||
this._alert('payout_unresolved_user', '⚠️ Payout skipped — paying user not resolved', `<p>completeGame could not resolve the paying user (games.user_id=<b>${gameUserId}</b>) for game <b>${gameId}</b>. No payout was inserted.</p>`);
|
||||
return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Paying user not resolved', score: metrics.score ?? null };
|
||||
}
|
||||
|
||||
// Use payout address locked at game start, fall back to the user's address
|
||||
const payoutAddress = lockedPayoutAddress || userRow.payout_address;
|
||||
if (userRow && payoutAddress) {
|
||||
// Create pending payout record, then schedule batch processing.
|
||||
// Multiple wins within 5 seconds are batched into one transfer_split call,
|
||||
@@ -1643,14 +1763,25 @@ class GameModeManager {
|
||||
console.log(`💸 Single payout ${p.id} completed: ${result.txHash}`);
|
||||
} catch (err) {
|
||||
// Insufficient-unlocked-funds is a pre-broadcast error -> retry on the next
|
||||
// batch run (revert to pending). Other failures -> 'failed' (retry service,
|
||||
// which re-checks the chain before re-sending).
|
||||
const status = isInsufficientFundsError(err.message) ? 'pending' : 'failed';
|
||||
// batch run (revert to pending). ANY OTHER error is ambiguous: processPayout
|
||||
// (transfer_split) may have broadcast on-chain even though the RPC response
|
||||
// errored, and this row has a null tx_hash so the retry service's blockchain
|
||||
// guard can't protect it — an auto-retry could DOUBLE-PAY. Mark 'needs_review'
|
||||
// (retry service skips it) + alert, mirroring the batch path below.
|
||||
const fundsIssue = isInsufficientFundsError(err.message);
|
||||
const status = fundsIssue ? 'pending' : 'needs_review';
|
||||
console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message);
|
||||
await this.db.query(
|
||||
`UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = $3`,
|
||||
[status, err.message, p.id]
|
||||
).catch(() => {});
|
||||
if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') {
|
||||
this.alertService.sendAlert('single_payout_failed', {
|
||||
subject: '⚠️ Payout failed — manual review required',
|
||||
html: `<p>Single payout <b>${p.id}</b> failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
|
||||
+ `<p>Amount: ${p.amount} → ${p.payout_address}</p><p>Error: ${err.message}</p>`
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multiple payouts — batch via transfer_split with multiple destinations
|
||||
|
||||
+45
-13
@@ -52,6 +52,24 @@ function verifySeed(seed, commitment) {
|
||||
return hashSeed(seed) === commitment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the effective RNG seed from a server seed and an (optional) client seed.
|
||||
* Uses HMAC-SHA256 with the server seed as key and the client seed as message so
|
||||
* that neither party can unilaterally control the outcome:
|
||||
* - the server commits to hash(serverSeed) BEFORE seeing the client seed, and
|
||||
* - the client seed still perturbs the final layout.
|
||||
* The commitment stays hash(serverSeed); a verifier with (serverSeed, clientSeed)
|
||||
* can reproduce this derived seed and therefore the entire dungeon.
|
||||
* @param {string} serverSeed - The secret server seed (revealed after the game)
|
||||
* @param {string} [clientSeed=''] - Optional client-supplied seed
|
||||
* @returns {string} 64-character hex string used as the effective RNG seed
|
||||
*/
|
||||
function deriveSeed(serverSeed, clientSeed = '') {
|
||||
return crypto.createHmac('sha256', String(serverSeed))
|
||||
.update(String(clientSeed ?? ''))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a seeded random number generator
|
||||
* Uses the seed to create deterministic "random" numbers
|
||||
@@ -73,9 +91,10 @@ function createSeededRNG(seed) {
|
||||
.digest();
|
||||
counter++;
|
||||
|
||||
// Convert first 4 bytes to a number between 0 and 1
|
||||
// Convert first 4 bytes to a number in [0, 1). Divide by 2^32 (not
|
||||
// 0xFFFFFFFF) so the maximum value stays strictly below 1.
|
||||
const num = hash.readUInt32BE(0);
|
||||
return num / 0xFFFFFFFF;
|
||||
return num / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,16 +140,21 @@ function seededShuffle(rng, array) {
|
||||
/**
|
||||
* Create a game proof object for a new game
|
||||
* @param {string} gameId - Unique game identifier
|
||||
* @returns {object} Proof object with seed, commitment, and creation time
|
||||
* @param {string} [clientSeed=''] - Optional client-supplied seed (non-breaking)
|
||||
* @returns {object} Proof object with seeds, commitment, and creation time
|
||||
*/
|
||||
function createGameProof(gameId) {
|
||||
const seed = generateSeed();
|
||||
const commitment = hashSeed(seed);
|
||||
function createGameProof(gameId, clientSeed = '') {
|
||||
const serverSeed = generateSeed();
|
||||
const commitment = hashSeed(serverSeed); // commits to serverSeed only
|
||||
// Effective RNG seed folds in the client seed so neither party controls it.
|
||||
const seed = deriveSeed(serverSeed, clientSeed);
|
||||
const timestamp = Date.now();
|
||||
|
||||
|
||||
return {
|
||||
gameId,
|
||||
seed, // Keep secret until game ends
|
||||
serverSeed, // Keep secret until game ends
|
||||
clientSeed, // Client contribution (may be '')
|
||||
seed, // Derived effective seed used to generate the dungeon
|
||||
commitment, // Show to player before game starts
|
||||
timestamp,
|
||||
verified: false
|
||||
@@ -160,9 +184,15 @@ function getPreGameCommitment(proof) {
|
||||
* @returns {object} Full verification data for client
|
||||
*/
|
||||
function getPostGameReveal(proof, gameResult = {}) {
|
||||
const serverSeed = proof.serverSeed ?? proof.seed;
|
||||
const clientSeed = proof.clientSeed ?? '';
|
||||
// Effective seed a verifier regenerates the dungeon from.
|
||||
const effectiveSeed = proof.seed ?? deriveSeed(serverSeed, clientSeed);
|
||||
return {
|
||||
gameId: proof.gameId,
|
||||
seed: proof.seed,
|
||||
serverSeed,
|
||||
clientSeed,
|
||||
seed: effectiveSeed,
|
||||
commitment: proof.commitment,
|
||||
timestamp: proof.timestamp,
|
||||
gameResult: {
|
||||
@@ -171,12 +201,13 @@ function getPostGameReveal(proof, gameResult = {}) {
|
||||
moves: gameResult.moves ?? 0,
|
||||
duration: gameResult.duration ?? 0
|
||||
},
|
||||
verificationUrl: `/verify/${proof.seed}`,
|
||||
verificationUrl: `/verify/${effectiveSeed}`,
|
||||
instructions: [
|
||||
"To verify this game was fair:",
|
||||
`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`
|
||||
`1. Compute SHA-256 of the serverSeed and confirm it equals the commitment: ${proof.commitment}`,
|
||||
"2. Compute HMAC-SHA256(key=serverSeed, msg=clientSeed) to derive the effective seed",
|
||||
"3. The effective seed deterministically generates the dungeon layout, treasure, and monster behaviour",
|
||||
`4. Open ${`/verify/${effectiveSeed}`} to regenerate the dungeon from the effective seed and compare its layout fingerprint`
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -207,6 +238,7 @@ module.exports = {
|
||||
generateSeed,
|
||||
hashSeed,
|
||||
verifySeed,
|
||||
deriveSeed,
|
||||
createSeededRNG,
|
||||
seedToInt,
|
||||
seededRandomInt,
|
||||
|
||||
+137
-16
@@ -153,7 +153,69 @@ app.get('/api/payment/status/:paymentId', asyncHandler(async (req, res) => {
|
||||
throw restNotImplemented();
|
||||
}));
|
||||
|
||||
app.get('/api/user/:socketId/credits', asyncHandler(async (req, res) => {
|
||||
// =============================================================================
|
||||
// /api/user/* protection (S1 / S2 / S3, contract C2)
|
||||
// =============================================================================
|
||||
|
||||
// S3: simple in-memory IP rate limiter for the /api/user/* REST surface. Each of these
|
||||
// endpoints hits the DB, so cap requests per IP over a short window. Best-effort only (the
|
||||
// socket layer has its own limiter and nginx can add another); the map is pruned lazily so
|
||||
// it can't grow unbounded.
|
||||
const _userApiHits = new Map(); // ip -> { count, windowStart }
|
||||
const USER_API_WINDOW_MS = 60 * 1000;
|
||||
const USER_API_MAX = Number(process.env.USER_API_RATE_MAX) || 120;
|
||||
function userApiRateLimit(req, res, next) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
const now = Date.now();
|
||||
let rec = _userApiHits.get(ip);
|
||||
if (!rec || (now - rec.windowStart) > USER_API_WINDOW_MS) {
|
||||
rec = { count: 0, windowStart: now };
|
||||
_userApiHits.set(ip, rec);
|
||||
}
|
||||
rec.count += 1;
|
||||
if (_userApiHits.size > 5000) {
|
||||
for (const [k, v] of _userApiHits) {
|
||||
if ((now - v.windowStart) > USER_API_WINDOW_MS) _userApiHits.delete(k);
|
||||
}
|
||||
}
|
||||
if (rec.count > USER_API_MAX) {
|
||||
return res.status(429).json({ error: 'Too many requests' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
app.use('/api/user', userApiRateLimit);
|
||||
|
||||
// C2: session-ownership guard for /api/user/:socketId/*. The caller must present the session
|
||||
// token (users.anon_token — emitted to the client as the 'session_token'/'session_resumed'
|
||||
// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header
|
||||
// (fallback ?t=). We only proceed for the row whose socket_id AND anon_token both match, so
|
||||
// one client can't read or mutate another player's payments/payouts/address. failStatus is
|
||||
// 403 for mutations, 401 for reads.
|
||||
function requireSessionOwnership(failStatus) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const { socketId } = req.params;
|
||||
const token = req.get('X-Session-Token') || req.query.t;
|
||||
if (!socketId || !token) {
|
||||
return res.status(failStatus).json({ error: 'Session token required' });
|
||||
}
|
||||
const result = await databaseManager.query(
|
||||
'SELECT id FROM users WHERE socket_id = $1 AND anon_token = $2',
|
||||
[socketId, token]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(failStatus).json({ error: 'Session ownership verification failed' });
|
||||
}
|
||||
req.sessionUserId = result.rows[0].id;
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[requireSessionOwnership] verification error:', err.message);
|
||||
return res.status(failStatus).json({ error: 'Session verification failed' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/user/:socketId/credits', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
@@ -162,7 +224,11 @@ app.get('/api/user/:socketId/credits', asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
// C1: read-only route — never mint a user row for a stranger's socketId.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({});
|
||||
}
|
||||
res.json({
|
||||
socketId,
|
||||
credits: user.credits || 0,
|
||||
@@ -177,7 +243,7 @@ app.get('/api/user/:socketId/credits', asyncHandler(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
app.get('/api/user/:socketId/mode', asyncHandler(async (req, res) => {
|
||||
app.get('/api/user/:socketId/mode', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
@@ -186,7 +252,11 @@ app.get('/api/user/:socketId/mode', asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
// C1: read-only route — do not create a user row here.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({});
|
||||
}
|
||||
res.json({
|
||||
socketId,
|
||||
preferredPaymentMode: user.preferred_payment_mode || 'direct',
|
||||
@@ -204,7 +274,7 @@ app.get('/api/user/:socketId/mode', asyncHandler(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/api/user/:socketId/address', asyncHandler(async (req, res) => {
|
||||
app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const { address } = req.body || {};
|
||||
|
||||
@@ -336,7 +406,7 @@ app.get('/health', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get payment options for a user (mixed mode support)
|
||||
app.get('/api/user/:socketId/payment-options', asyncHandler(async (req, res) => {
|
||||
app.get('/api/user/:socketId/payment-options', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
@@ -357,11 +427,11 @@ app.get('/api/user/:socketId/payment-options', asyncHandler(async (req, res) =>
|
||||
}));
|
||||
|
||||
// Get payment history for a user
|
||||
app.get('/api/user/:socketId/payments', asyncHandler(async (req, res) => {
|
||||
app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
|
||||
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId parameter is required.'
|
||||
@@ -369,7 +439,11 @@ app.get('/api/user/:socketId/payments', asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
// C1: read-only route — do not create a user row here.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({ payments: [], total: 0, totalPaid: 0, limit, offset });
|
||||
}
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
id,
|
||||
@@ -424,11 +498,11 @@ app.get('/api/user/:socketId/payments', asyncHandler(async (req, res) => {
|
||||
}));
|
||||
|
||||
// Get payout history for a user
|
||||
app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
|
||||
app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
|
||||
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId parameter is required.'
|
||||
@@ -436,7 +510,11 @@ app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
// C1: read-only route — do not create a user row here.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({ payouts: [], total: 0, totalReceived: 0, limit, offset });
|
||||
}
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
p.id,
|
||||
@@ -824,16 +902,32 @@ async function startServer() {
|
||||
try {
|
||||
// Initialize payment system first
|
||||
const paymentSystemReady = await initializePaymentSystem();
|
||||
|
||||
|
||||
// C6: right after payment/recovery init at startup, finalize/refund any games left
|
||||
// status='active' by a previous crash or restart. recoverOrphanedGames() is idempotent
|
||||
// so it is safe to run on every boot; failures here must not block server startup.
|
||||
try {
|
||||
const sm = socketHandlers.sessionManager;
|
||||
if (sm && typeof sm.recoverOrphanedGames === 'function') {
|
||||
await sm.recoverOrphanedGames();
|
||||
}
|
||||
} catch (recoverErr) {
|
||||
console.error('❌ recoverOrphanedGames failed at startup:', recoverErr.message);
|
||||
}
|
||||
|
||||
// Socket.io connection handler - ONLY after payment system is ready
|
||||
io.on('connection', function(socket) {
|
||||
socketHandlers.handleConnection(socket);
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
// Start HTTP server.
|
||||
// C8: bind to loopback by default (nginx terminates TLS and reverse-proxies to us),
|
||||
// so the Node process is not directly reachable on a public interface. Override with
|
||||
// HOST=0.0.0.0 only for a deployment that intentionally exposes the port directly.
|
||||
const PORT = process.env.PORT || 3000;
|
||||
http.listen(PORT, function() {
|
||||
console.log(`🚀 Wownerogue server listening on *:${PORT}`);
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
http.listen(PORT, HOST, function() {
|
||||
console.log(`🚀 Wownerogue server listening on ${HOST}:${PORT}`);
|
||||
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
|
||||
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
|
||||
const summary = paymentConfigManager.summarize();
|
||||
@@ -969,5 +1063,32 @@ async function gracefulShutdown(signal) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Last-resort process guards. A single bad socket event or an unhandled promise rejection
|
||||
// must NOT crash this process, because it holds the live wallet RPC session and in-flight
|
||||
// payout/batch state — being killed mid-payout is a far worse failure than leaking one
|
||||
// broken request. TRADEOFF: standard Node guidance is to exit on 'uncaughtException' (process
|
||||
// state may be corrupt); here we deliberately stay up so payouts can settle and sockets stay
|
||||
// alive, and we log (and alert, if the alert service is up) so nothing fails silently.
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('❌ uncaughtException (kept alive):', err && err.stack ? err.stack : err);
|
||||
try {
|
||||
alertService?.sendAlert?.('process_uncaught_exception', {
|
||||
subject: 'uncaughtException (server kept alive)',
|
||||
body: String(err && err.stack ? err.stack : err),
|
||||
level: 'error'
|
||||
})?.catch?.(() => {});
|
||||
} catch (_) {}
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('❌ unhandledRejection (kept alive):', reason && reason.stack ? reason.stack : reason);
|
||||
try {
|
||||
alertService?.sendAlert?.('process_unhandled_rejection', {
|
||||
subject: 'unhandledRejection (server kept alive)',
|
||||
body: String(reason && reason.stack ? reason.stack : reason),
|
||||
level: 'error'
|
||||
})?.catch?.(() => {});
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration 023: Payment safety — one game per confirmed single_game payment
|
||||
--
|
||||
-- M3: claiming a confirmed single_game payment must be atomic. The application locks the
|
||||
-- payment row FOR UPDATE while linking the game, but this partial unique index is the
|
||||
-- final backstop: at most one games row may reference a given payment_id. A concurrent
|
||||
-- start that races past the lock hits a 23505 unique_violation (handled gracefully in
|
||||
-- gameModeManager._processGameStartWithPayment as "already consumed"), so a single paid
|
||||
-- entry can never spawn two games.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_games_payment_id
|
||||
ON games(payment_id)
|
||||
WHERE payment_id IS NOT NULL;
|
||||
@@ -530,6 +530,11 @@ class MatchRoom extends Room {
|
||||
if (this.status !== 'finished') return this.playerStates;
|
||||
|
||||
const exit = this.dungeon.exit;
|
||||
// A winner already designated by first-to-escape (_checkResolution) must remain the
|
||||
// winner: the first player out wins the race regardless of treasure/speed tiebreakers
|
||||
// among later finishers. When no one escaped (timeout / all-dead) preWinner is null and
|
||||
// ranking alone decides.
|
||||
const preWinner = this.winnerId;
|
||||
const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = this.occupants.get(id);
|
||||
const dist = exit && occ && state.alive ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
|
||||
@@ -537,6 +542,12 @@ class MatchRoom extends Room {
|
||||
});
|
||||
|
||||
ranked.sort((a, b) => {
|
||||
// Designated escape-winner always ranks first (placement #1 == crypto payout target).
|
||||
if (preWinner) {
|
||||
const aw = a.id === preWinner ? 1 : 0;
|
||||
const bw = b.id === preWinner ? 1 : 0;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
const finishedA = a.state.finished ? 1 : 0;
|
||||
const finishedB = b.state.finished ? 1 : 0;
|
||||
if (finishedA !== finishedB) return finishedB - finishedA;
|
||||
@@ -557,6 +568,12 @@ class MatchRoom extends Room {
|
||||
r.state.score = this._calculateScore(r.state, i + 1, r.dist);
|
||||
}
|
||||
|
||||
// Winner consistency across ALL end paths (escape / timeout / all-dead): the crypto
|
||||
// payout target (winnerId) and the leaderboard's placement #1 always name the SAME
|
||||
// player. finalize() is the single source of truth — it runs after expire() in every
|
||||
// path, so it reconciles any provisional winner expire() may have set.
|
||||
if (ranked.length > 0) this.winnerId = ranked[0].id;
|
||||
|
||||
return this.playerStates;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,18 +59,19 @@ class BroadcastManager {
|
||||
* @param {string} username - Username of the sender
|
||||
* @param {string} message - Chat message content
|
||||
* @param {number} timestamp - Message timestamp
|
||||
* @param {string} socketId - Socket ID of the sender
|
||||
* @param {string} publicId - Short, non-sensitive public id of the sender (NOT the raw
|
||||
* full socket.id — S1 defense in depth; the full socket.id must never leave the server)
|
||||
*/
|
||||
broadcastChatMessage(username, message, timestamp, socketId) {
|
||||
broadcastChatMessage(username, message, timestamp, publicId) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`💬 Broadcasting chat message from ${socketId}: "${message}"`);
|
||||
console.log(`💬 Broadcasting chat message from ${publicId}: "${message}"`);
|
||||
}
|
||||
|
||||
this.io.emit('chat_broadcast', {
|
||||
username: username,
|
||||
message: message,
|
||||
timestamp: timestamp,
|
||||
socketId: socketId
|
||||
publicId: publicId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ class SocketChatProvider extends ChatProvider {
|
||||
async publish(msg = {}) {
|
||||
const { scope = 'global', username, text, ts = Date.now(), socketId = null, userId = null } = msg;
|
||||
|
||||
// Defense in depth (S1): never broadcast the raw full socket.id to other clients — it is
|
||||
// a hijackable handle. Derive a short, non-sensitive public id for display/attribution.
|
||||
const publicId = userId != null ? String(userId) : (socketId ? String(socketId).substring(0, 6) : null);
|
||||
|
||||
if (scope === 'global') {
|
||||
if (this.history) {
|
||||
// Fire-and-forget persistence, exactly as before (must not block delivery).
|
||||
@@ -36,16 +40,16 @@ class SocketChatProvider extends ChatProvider {
|
||||
.catch(err => console.error('Failed to save chat message:', err.message));
|
||||
}
|
||||
if (this.broadcastManager) {
|
||||
this.broadcastManager.broadcastChatMessage(username, text, ts, socketId);
|
||||
this.broadcastManager.broadcastChatMessage(username, text, ts, publicId);
|
||||
} else {
|
||||
this.io.emit('chat_broadcast', { username, message: text, timestamp: ts, socketId });
|
||||
this.io.emit('chat_broadcast', { username, message: text, timestamp: ts, publicId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Room-scoped (e.g. tavern): delivered only to occupants of that room. The `scope`
|
||||
// field lets a room client filter these from any global broadcasts it also receives.
|
||||
this.io.to(scope).emit('chat_broadcast', { username, message: text, timestamp: ts, socketId, scope });
|
||||
this.io.to(scope).emit('chat_broadcast', { username, message: text, timestamp: ts, publicId, scope });
|
||||
}
|
||||
|
||||
async getHistory({ scope = 'global', limit = 50 } = {}) {
|
||||
|
||||
@@ -43,11 +43,13 @@ class MatchLeaderboard {
|
||||
|
||||
async _postFreeToPleb(room) {
|
||||
// Insert synthetic solo-style records so free races show up on the Pleb board.
|
||||
// Columns are the REAL games columns (treasure_found, moves_made); dungeon_seed is the
|
||||
// match UUID (36 chars, fits VARCHAR(50)) — never the 64-char seedHash which overflows.
|
||||
await this.db.withTransaction(async (client) => {
|
||||
for (const [socketId, state] of room.playerStates.entries()) {
|
||||
if (!state.userId || state.score <= 0) continue;
|
||||
await client.query(`
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, outcome, has_treasure, moves, duration_seconds, score, dungeon_seed, created_at, completed_at)
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, score, dungeon_seed, created_at, completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
`, [
|
||||
state.userId,
|
||||
@@ -59,7 +61,7 @@ class MatchLeaderboard {
|
||||
state.moves,
|
||||
Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
|
||||
state.score,
|
||||
room.seedHash
|
||||
room.id
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
@@ -78,12 +80,13 @@ class MatchLeaderboard {
|
||||
}
|
||||
|
||||
async _postCryptoRace(room) {
|
||||
// Insert synthetic paid records for the Hall of Champions board.
|
||||
// Insert synthetic paid records for the Hall of Champions board. Real columns
|
||||
// (treasure_found, moves_made); dungeon_seed is the match UUID, not the 64-char seedHash.
|
||||
await this.db.withTransaction(async (client) => {
|
||||
for (const [socketId, state] of room.playerStates.entries()) {
|
||||
if (!state.userId || state.score <= 0) continue;
|
||||
await client.query(`
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, outcome, has_treasure, moves, duration_seconds, score, dungeon_seed, created_at, completed_at)
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, score, dungeon_seed, created_at, completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
`, [
|
||||
state.userId,
|
||||
@@ -95,7 +98,7 @@ class MatchLeaderboard {
|
||||
state.moves,
|
||||
Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
|
||||
state.score,
|
||||
room.seedHash
|
||||
room.id
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
|
||||
+257
-136
@@ -1,44 +1,50 @@
|
||||
/**
|
||||
* MatchManager — Socket.IO transport and lifecycle for match mode.
|
||||
*
|
||||
* Owns active MatchRooms, their MatchEngines, socket-to-match mapping, and broadcasts.
|
||||
* Also handles reconnect grace (a disconnected player can rejoin the same match within a
|
||||
* short window).
|
||||
* Owns active MatchRooms, their MatchEngines, socket/user-to-match mappings, broadcasts, the
|
||||
* real pre-race countdown, and end-of-match finalization (payout, persistence, leaderboard,
|
||||
* broadcast). Also handles reconnect grace: a disconnected player can rejoin the same match
|
||||
* within a short window and is re-mapped across ALL room state.
|
||||
*
|
||||
* Server-authoritative throughout: every move is validated, occupancy is solid, and the room
|
||||
* stays in 'starting' (moves rejected) until the countdown elapses so an honest client and a
|
||||
* modified client always start together.
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const MatchRoom = require('../multiplayer/MatchRoom');
|
||||
const MatchEngine = require('../multiplayer/MatchEngine');
|
||||
const MatchState = require('../multiplayer/MatchState');
|
||||
const MatchPayoutService = require('./matchPayoutService');
|
||||
const MatchLeaderboard = require('./matchLeaderboard');
|
||||
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
const RECONNECT_GRACE_MS = 30000; // 30s to reconnect during an active match
|
||||
const RECONNECT_GRACE_MS = 30000; // 30s to reconnect during an active match before AFK-kill
|
||||
const DEFAULT_COUNTDOWN_MS = 3000;
|
||||
const FINALIZE_CLEANUP_MS = 30000; // keep mappings this long after finish for late reconnects
|
||||
|
||||
class MatchManager {
|
||||
constructor({ io, db, debugManager, identityService = null, chatProvider = null } = {}) {
|
||||
constructor({ io, db, debugManager, identityService = null, gameModeManager = null, chatProvider = null } = {}) {
|
||||
this.io = io;
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this.identityService = identityService;
|
||||
this.chatProvider = chatProvider;
|
||||
this.gameModeManager = gameModeManager; // socketHandlers injects this so payouts/entry-fee reads work
|
||||
this.enabled = process.env.MATCH_ENABLED === 'true';
|
||||
|
||||
this.rooms = new Map(); // matchId -> MatchRoom
|
||||
this.engines = new Map(); // matchId -> MatchEngine
|
||||
this.socketToMatch = new Map(); // socketId -> matchId
|
||||
this.userToMatch = new Map(); // userId -> matchId
|
||||
this.disconnectTimeouts = new Map(); // matchId:socketId -> timeout id
|
||||
this.userToMatch = new Map(); // userId -> matchId
|
||||
this.disconnectTimeouts = new Map(); // `${matchId}:${socketId}` -> timeout id
|
||||
this._pendingCleanups = new Set(); // grace-cleanup timers awaiting fire
|
||||
|
||||
this.matchLeaderboard = new MatchLeaderboard({ db: this.db, io: this.io, debugManager: this.debugManager });
|
||||
this.matchPayoutService = new MatchPayoutService({
|
||||
db: this.db,
|
||||
walletService: null,
|
||||
gameModeManager: null,
|
||||
walletService: this.gameModeManager?.walletService || null,
|
||||
gameModeManager: this.gameModeManager,
|
||||
debugManager: this.debugManager
|
||||
});
|
||||
this.gameModeManager = null; // set via attach options
|
||||
|
||||
// Last-known movement per socket for flood control.
|
||||
this._lastMoveAt = new Map();
|
||||
@@ -50,16 +56,31 @@ class MatchManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a MatchRoom that was just created by MatchScheduler. The entrants array
|
||||
* contains { userId, socketId, sessionToken } objects from the queue.
|
||||
* Optional init hook (socketHandlers calls it after MatchQueue.initialize()). In-memory
|
||||
* state is empty on boot; abandoned in-flight matches are recovered and refunded by
|
||||
* MatchQueue.initialize() (the money authority), so there is nothing to rebuild here.
|
||||
*/
|
||||
attach(room, entrants, { tickMs = 250, minDurationMs = 20000, hardCeilingMs = 240000, db = null, gameModeManager = null } = {}) {
|
||||
if (!this.enabled) return;
|
||||
initialize() { /* no-op: boot recovery lives in MatchQueue.initialize() */ }
|
||||
|
||||
/**
|
||||
* Attach a MatchRoom that MatchScheduler just created and (for crypto) already collected
|
||||
* the pot for. Notifies entrants, runs the real countdown, and owns all lifecycle timers.
|
||||
* @param {MatchRoom} room
|
||||
* @param {Array<{userId:number, socketId:string, sessionToken:string}>} entrants
|
||||
*/
|
||||
attach(room, entrants, { tickMs = 250, minDurationMs = 20000, hardCeilingMs = 240000, countdownMs = DEFAULT_COUNTDOWN_MS, db = null, gameModeManager = null } = {}) {
|
||||
if (!this.enabled || !room) return;
|
||||
if (db) this.db = db;
|
||||
this.gameModeManager = gameModeManager;
|
||||
if (gameModeManager && this.matchPayoutService) this.matchPayoutService.gameModeManager = gameModeManager;
|
||||
if (gameModeManager) this.gameModeManager = gameModeManager;
|
||||
// Keep the payout service pointed at the live db + gameModeManager.
|
||||
if (this.matchPayoutService) {
|
||||
if (this.db) this.matchPayoutService.db = this.db;
|
||||
if (this.gameModeManager) this.matchPayoutService.gameModeManager = this.gameModeManager;
|
||||
}
|
||||
if (this.matchLeaderboard && this.db) this.matchLeaderboard.db = this.db;
|
||||
|
||||
this.rooms.set(room.id, room);
|
||||
for (const e of entrants) {
|
||||
for (const e of (entrants || [])) {
|
||||
this.socketToMatch.set(e.socketId, room.id);
|
||||
this.userToMatch.set(e.userId, room.id);
|
||||
}
|
||||
@@ -67,11 +88,11 @@ class MatchManager {
|
||||
// Resolve display names and appearances from identity service (best-effort).
|
||||
this._hydrateEntrants(room, entrants).catch(err => this._log('[MatchManager] hydrate error', err.message));
|
||||
|
||||
this._log(`[MatchManager] attached match ${room.id} with ${entrants.length} entrants`);
|
||||
this._log(`[MatchManager] attached match ${room.id} with ${(entrants || []).length} entrants`);
|
||||
|
||||
// Notify players.
|
||||
for (const e of entrants) {
|
||||
const socket = this.io.sockets.sockets.get(e.socketId);
|
||||
// Notify players (they will render a countdown; the server enforces it authoritatively).
|
||||
for (const e of (entrants || [])) {
|
||||
const socket = this.io?.sockets?.sockets?.get(e.socketId);
|
||||
if (socket) {
|
||||
socket.join(this._channel(room.id));
|
||||
socket.emit('match_joined', {
|
||||
@@ -79,31 +100,37 @@ class MatchManager {
|
||||
economy: room.economy,
|
||||
seedHash: room.seedHash,
|
||||
players: Array.from(room.occupants.values()).map(o => o.getState()),
|
||||
countdownMs: 3000
|
||||
countdownMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown before first tick.
|
||||
setTimeout(() => {
|
||||
room.start();
|
||||
this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: room.seedHash });
|
||||
}, 3000);
|
||||
// MP-H6: REAL countdown. The room stays in 'starting' (moves rejected) until this
|
||||
// fires; only then does the engine begin ticking, so honest and modified clients start
|
||||
// together — no head-start cheat is possible.
|
||||
const startTimer = setTimeout(() => {
|
||||
const r = this.rooms.get(room.id);
|
||||
if (!r || r.status !== 'starting') return; // expired/finalized during the countdown
|
||||
const engine = this.engines.get(room.id);
|
||||
if (engine && typeof engine.start === 'function') engine.start(); // activates room + ticks
|
||||
else r.start();
|
||||
this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: r.seedHash });
|
||||
}, countdownMs);
|
||||
|
||||
// Minimum-duration watchdog: prevent next-block expiry before floor.
|
||||
const floorTimeout = setTimeout(() => {
|
||||
room._minDurationMet = true;
|
||||
}, minDurationMs);
|
||||
// Minimum-duration watchdog: prevents a next-block expiry before the floor.
|
||||
const floorTimeout = setTimeout(() => { const r = this.rooms.get(room.id); if (r) r._minDurationMet = true; }, minDurationMs);
|
||||
|
||||
// Hard ceiling watchdog.
|
||||
// Hard-ceiling watchdog: absolute max match length. Single owner (the scheduler no
|
||||
// longer sets its own ceiling timer), cleared by _finalize so it never fires on a
|
||||
// finished/removed room.
|
||||
const ceilingTimeout = setTimeout(() => this.expire(room.id, 'hard_ceiling'), hardCeilingMs);
|
||||
|
||||
room._watchdogs = [floorTimeout, ceilingTimeout];
|
||||
room._watchdogs = [startTimer, floorTimeout, ceilingTimeout];
|
||||
}
|
||||
|
||||
async _hydrateEntrants(room, entrants) {
|
||||
if (!this.identityService) return;
|
||||
for (const e of entrants) {
|
||||
for (const e of (entrants || [])) {
|
||||
try {
|
||||
const user = await this.identityService.userForId(e.userId);
|
||||
if (user) {
|
||||
@@ -153,21 +180,21 @@ class MatchManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a match ends naturally.
|
||||
* Called when a match ends naturally (engine detected finished).
|
||||
*/
|
||||
async onFinish(room) {
|
||||
await this._finalize(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-end a match (block deadline, hard ceiling, admin abort).
|
||||
* Force-end a match (block deadline, hard ceiling, admin abort). Idempotent.
|
||||
*/
|
||||
expire(matchId, reason) {
|
||||
const engine = this.engines.get(matchId);
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
if (!room || room._finalized) return;
|
||||
const engine = this.engines.get(matchId);
|
||||
if (engine) {
|
||||
engine.expire(reason);
|
||||
engine.expire(reason); // room.expire + finalize + onFinish -> _finalize
|
||||
} else {
|
||||
room.expire(reason);
|
||||
room.finalize();
|
||||
@@ -175,43 +202,73 @@ class MatchManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End a match immediately from a manager-side trigger (forfeit / AFK wipeout). Idempotent
|
||||
* via room._finalized.
|
||||
*/
|
||||
_endNow(room, reason) {
|
||||
if (!room || room._finalized) return;
|
||||
const engine = this.engines.get(room.id);
|
||||
if (room.status !== 'finished') room.expire(reason);
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
room.finalize();
|
||||
this._finalize(room).catch(err => this._log('[MatchManager] endNow finalize error', err.message));
|
||||
}
|
||||
|
||||
async _finalize(room) {
|
||||
if (room._finalized) return;
|
||||
if (!room || room._finalized) return;
|
||||
room._finalized = true;
|
||||
|
||||
// Clear watchdogs.
|
||||
if (room._watchdogs) room._watchdogs.forEach(id => clearTimeout(id));
|
||||
// Clear lifecycle watchdogs (countdown, floor, hard ceiling) so none fire post-finish.
|
||||
if (room._watchdogs) { room._watchdogs.forEach(id => clearTimeout(id)); room._watchdogs = null; }
|
||||
// Clear any pending AFK timers for this match.
|
||||
this._clearDisconnectTimers(room.id);
|
||||
// Stop the engine if it is still ticking.
|
||||
const engine = this.engines.get(room.id);
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
|
||||
// Persist results.
|
||||
if (this.db) {
|
||||
await this._persistFinish(room);
|
||||
}
|
||||
await this.matchLeaderboard.postMatch(room);
|
||||
// Each step is isolated in its OWN try/catch so a failure in one NEVER prevents the
|
||||
// winner payout or the match_end broadcast. The winner PAYOUT runs FIRST.
|
||||
// 1. Winner payout (crypto only).
|
||||
if (room.economy === 'crypto_race') {
|
||||
await this.matchPayoutService.payoutWinner(room);
|
||||
try { await this.matchPayoutService.payoutWinner(room); }
|
||||
catch (err) { this._log('[MatchManager] payout error', err.message); }
|
||||
}
|
||||
// 2. Persist match + entrant results.
|
||||
if (this.db) {
|
||||
try { await this._persistFinish(room); }
|
||||
catch (err) { this._log('[MatchManager] persist error', err.message); }
|
||||
}
|
||||
// 3. Leaderboard integration.
|
||||
try { await this.matchLeaderboard.postMatch(room); }
|
||||
catch (err) { this._log('[MatchManager] leaderboard error', err.message); }
|
||||
// 4. Broadcast the result (always attempted).
|
||||
try {
|
||||
this._broadcast(room.id, 'match_end', {
|
||||
matchId: room.id,
|
||||
reason: room.endReason,
|
||||
winnerId: room.winnerId,
|
||||
players: Array.from(room.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = room.occupants.get(id);
|
||||
return {
|
||||
id,
|
||||
name: occ?.name || null,
|
||||
placement: state.placement,
|
||||
escaped: state.escaped,
|
||||
hasTreasure: state.hasTreasure,
|
||||
score: state.score,
|
||||
killedBy: state.killedBy
|
||||
};
|
||||
})
|
||||
});
|
||||
} catch (err) { this._log('[MatchManager] broadcast error', err.message); }
|
||||
|
||||
// Notify clients.
|
||||
this._broadcast(room.id, 'match_end', {
|
||||
matchId: room.id,
|
||||
reason: room.endReason,
|
||||
winnerId: room.winnerId,
|
||||
players: Array.from(room.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = room.occupants.get(id);
|
||||
return {
|
||||
id,
|
||||
name: occ?.name || null,
|
||||
placement: state.placement,
|
||||
escaped: state.escaped,
|
||||
hasTreasure: state.hasTreasure,
|
||||
score: state.score,
|
||||
killedBy: state.killedBy
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
// Clean up mappings after a grace period so late reconnects still get the result.
|
||||
setTimeout(() => this._cleanup(room.id), 30000);
|
||||
// 5. Drop all mappings after a grace period so late reconnects still resolve the result.
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
this._pendingCleanups.delete(cleanupTimer);
|
||||
this._cleanup(room.id);
|
||||
}, FINALIZE_CLEANUP_MS);
|
||||
this._pendingCleanups.add(cleanupTimer);
|
||||
}
|
||||
|
||||
async _persistFinish(room) {
|
||||
@@ -257,28 +314,53 @@ class MatchManager {
|
||||
});
|
||||
}
|
||||
|
||||
_clearDisconnectTimers(matchId) {
|
||||
const prefix = `${matchId}:`;
|
||||
for (const [key, t] of this.disconnectTimeouts.entries()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
clearTimeout(t);
|
||||
this.disconnectTimeouts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every mapping owned by a finished match. Previously the userToMatch loop was a
|
||||
* no-op; it now actually deletes by userId, and socketToMatch / _lastMoveAt are cleared for
|
||||
* every occupant (including re-keyed ones).
|
||||
*/
|
||||
_cleanup(matchId) {
|
||||
const room = this.rooms.get(matchId);
|
||||
if (room) {
|
||||
for (const occ of room.occupants.values()) {
|
||||
this.socketToMatch.delete(occ.id);
|
||||
this._lastMoveAt.delete(occ.id);
|
||||
}
|
||||
for (const [uid] of room.playerStates.entries()) {
|
||||
// userToMatch is keyed by socketId today; cleanup is best-effort.
|
||||
for (const state of room.playerStates.values()) {
|
||||
if (state.userId != null && this.userToMatch.get(state.userId) === matchId) {
|
||||
this.userToMatch.delete(state.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Belt-and-suspenders: remove any lingering maps that still point at this match.
|
||||
for (const [sid, mid] of this.socketToMatch.entries()) {
|
||||
if (mid === matchId) this.socketToMatch.delete(sid);
|
||||
}
|
||||
for (const [uid, mid] of this.userToMatch.entries()) {
|
||||
if (mid === matchId) this.userToMatch.delete(uid);
|
||||
}
|
||||
this._clearDisconnectTimers(matchId);
|
||||
this.rooms.delete(matchId);
|
||||
this.engines.delete(matchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.IO handler: move request.
|
||||
* Socket.IO handler: move request. Null-safe; a benign no-op when match mode is disabled,
|
||||
* the socket isn't in a match, or the room hasn't reached 'active' yet (MP-H6 countdown).
|
||||
*/
|
||||
move(socket, data) {
|
||||
if (!this.enabled) return;
|
||||
if (db) this.db = db;
|
||||
this.gameModeManager = gameModeManager;
|
||||
if (gameModeManager && this.matchPayoutService) this.matchPayoutService.gameModeManager = gameModeManager;
|
||||
if (!this.enabled || !socket) return;
|
||||
|
||||
const now = Date.now();
|
||||
const last = this._lastMoveAt.get(socket.id) || 0;
|
||||
if (now - last < this.moveCooldownMs) return;
|
||||
@@ -288,9 +370,12 @@ class MatchManager {
|
||||
if (!matchId) return;
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
// Reject moves until the countdown elapses and the room is active (no head-start cheat).
|
||||
if (room.status !== 'active') return;
|
||||
|
||||
const dx = Math.sign(Number(data.dx) || 0);
|
||||
const dy = Math.sign(Number(data.dy) || 0);
|
||||
const d = (data && typeof data === 'object') ? data : {};
|
||||
const dx = Math.sign(Number(d.dx) || 0);
|
||||
const dy = Math.sign(Number(d.dy) || 0);
|
||||
if (Math.abs(dx) + Math.abs(dy) !== 1) return;
|
||||
|
||||
room.queueMove(socket.id, dx, dy);
|
||||
@@ -300,47 +385,57 @@ class MatchManager {
|
||||
* Socket.IO handler: leave an active match (forfeit).
|
||||
*/
|
||||
leave(socket) {
|
||||
if (!this.enabled || !socket) return;
|
||||
const matchId = this.socketToMatch.get(socket.id);
|
||||
if (!matchId) return;
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
|
||||
const state = room.playerStates.get(socket.id);
|
||||
if (!state || !state.alive || state.finished) return; // nothing to forfeit
|
||||
|
||||
room._killPlayer(socket.id, 'forfeit');
|
||||
this._broadcast(matchId, 'player_forfeit', { id: socket.id });
|
||||
|
||||
if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') {
|
||||
room.expire('all_forfeit');
|
||||
const engine = this.engines.get(matchId);
|
||||
if (engine) engine.stop();
|
||||
room.finalize();
|
||||
this._finalize(room).catch(err => this._log('[MatchManager] forfeit finalize error', err.message));
|
||||
// Clear any pending AFK timer for this socket.
|
||||
const key = `${matchId}:${socket.id}`;
|
||||
const t = this.disconnectTimeouts.get(key);
|
||||
if (t) { clearTimeout(t); this.disconnectTimeouts.delete(key); }
|
||||
|
||||
// _killPlayer flips the room to 'finished' when it was the last active player.
|
||||
if (room.status === 'finished') {
|
||||
this._endNow(room, room.endReason || 'all_forfeit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnect: start grace timer, then mark AFK-dead if not reconnected.
|
||||
* Handle disconnect: start a grace timer, then AFK-kill only if the player never reconnects.
|
||||
* Takes the live socket (identity is resolved from the connection, never the client).
|
||||
*/
|
||||
handleDisconnect(socketId, userId) {
|
||||
handleDisconnect(socket) {
|
||||
if (!this.enabled || !socket) return;
|
||||
const socketId = socket.id;
|
||||
const matchId = this.socketToMatch.get(socketId);
|
||||
if (!matchId) return;
|
||||
|
||||
const key = `${matchId}:${socketId}`;
|
||||
if (this.disconnectTimeouts.has(key)) return;
|
||||
|
||||
// Only arm the AFK timer for a player still in the race.
|
||||
const room0 = this.rooms.get(matchId);
|
||||
const st0 = room0?.playerStates.get(socketId);
|
||||
if (!st0 || !st0.alive || st0.finished) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.disconnectTimeouts.delete(key);
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
if (!room || room.status !== 'active') return;
|
||||
const state = room.playerStates.get(socketId);
|
||||
if (state && state.alive && !state.finished) {
|
||||
room._killPlayer(socketId, 'afk');
|
||||
this._broadcast(matchId, 'player_death', { id: socketId, killedBy: 'afk' });
|
||||
if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') {
|
||||
room.expire('all_dead');
|
||||
const engine = this.engines.get(matchId);
|
||||
if (engine) engine.stop();
|
||||
room.finalize();
|
||||
this._finalize(room).catch(err => this._log('[MatchManager] afk finalize error', err.message));
|
||||
if (room.status === 'finished') {
|
||||
this._endNow(room, room.endReason || 'all_dead');
|
||||
}
|
||||
}
|
||||
}, RECONNECT_GRACE_MS);
|
||||
@@ -349,53 +444,76 @@ class MatchManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reconnect: reattach socket to active match.
|
||||
* Handle reconnect: re-map a player from their OLD socket id to the new one across ALL room
|
||||
* state (occupants, playerStates, moveQueue, treasure carrier, winner) and manager maps,
|
||||
* clear the AFK timeout keyed by the OLD id, and never AFK-kill a reconnected player.
|
||||
* Ownership is verified: the reconnecting session must own the racer it attaches to.
|
||||
* @param {object} socket live socket (socket.id is the NEW id)
|
||||
* @param {{userId:number, sessionToken?:string, socketId?:string}} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
handleReconnect(socket, userId, sessionToken) {
|
||||
let matchId = this.socketToMatch.get(socket.id);
|
||||
if (!matchId) {
|
||||
// Old socket gone; try user-to-match map.
|
||||
matchId = this.userToMatch.get(userId);
|
||||
}
|
||||
handleReconnect(socket, session) {
|
||||
if (!this.enabled || !socket || !session) return false;
|
||||
const userId = session.userId;
|
||||
if (userId == null) return false;
|
||||
const newId = socket.id;
|
||||
|
||||
// Find the match this user belongs to (user map first — socket id has changed).
|
||||
let matchId = this.userToMatch.get(userId);
|
||||
if (!matchId) matchId = this.socketToMatch.get(newId) || null;
|
||||
if (!matchId) return false;
|
||||
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return false;
|
||||
|
||||
// Cancel disconnect timeout.
|
||||
const oldKey = `${matchId}:${socket.id}`;
|
||||
const timeout = this.disconnectTimeouts.get(oldKey);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.disconnectTimeouts.delete(oldKey);
|
||||
// Locate the OLD state key for this user and verify ownership. Matching by userId (the
|
||||
// stable DB id resolved from the connection) means a client can NEVER attach to another
|
||||
// player's racer.
|
||||
let oldId = null;
|
||||
for (const [id, state] of room.playerStates.entries()) {
|
||||
if (state.userId === userId) { oldId = id; break; }
|
||||
}
|
||||
if (oldId == null) return false;
|
||||
const ownerState = room.playerStates.get(oldId);
|
||||
if (!ownerState || ownerState.userId !== userId) return false;
|
||||
|
||||
// Clear the AFK disconnect timeout keyed by the OLD id (and defensively the new id).
|
||||
for (const k of [`${matchId}:${oldId}`, `${matchId}:${newId}`]) {
|
||||
const t = this.disconnectTimeouts.get(k);
|
||||
if (t) { clearTimeout(t); this.disconnectTimeouts.delete(k); }
|
||||
}
|
||||
|
||||
// Update mappings if the socket id changed.
|
||||
this.socketToMatch.set(socket.id, matchId);
|
||||
// Re-key ALL room + manager state from old -> new when the socket id changed.
|
||||
if (oldId !== newId) {
|
||||
const occ = room.occupants.get(oldId);
|
||||
if (occ) {
|
||||
room.occupants.delete(oldId);
|
||||
occ.id = newId;
|
||||
room.occupants.set(newId, occ);
|
||||
}
|
||||
const st = room.playerStates.get(oldId);
|
||||
if (st) {
|
||||
room.playerStates.delete(oldId);
|
||||
room.playerStates.set(newId, st);
|
||||
}
|
||||
if (room.moveQueue && room.moveQueue.has(oldId)) {
|
||||
const mv = room.moveQueue.get(oldId);
|
||||
room.moveQueue.delete(oldId);
|
||||
room.moveQueue.set(newId, mv);
|
||||
}
|
||||
if (room.treasure && room.treasure.carrierId === oldId) room.treasure.carrierId = newId;
|
||||
if (room.winnerId === oldId) room.winnerId = newId;
|
||||
this.socketToMatch.delete(oldId);
|
||||
this._lastMoveAt.delete(oldId);
|
||||
}
|
||||
|
||||
this.socketToMatch.set(newId, matchId);
|
||||
this.userToMatch.set(userId, matchId);
|
||||
socket.join(this._channel(matchId));
|
||||
|
||||
// Update occupant id if it differs (e.g. socket reconnect).
|
||||
const occ = room.occupants.get(socket.id);
|
||||
if (!occ) {
|
||||
// The old socket id may be the key in the room. Try to migrate by userId.
|
||||
for (const [oldId, state] of room.playerStates.entries()) {
|
||||
if (state.userId === userId) {
|
||||
const oldOcc = room.occupants.get(oldId);
|
||||
if (oldOcc) {
|
||||
room.occupants.delete(oldId);
|
||||
room.occupants.set(socket.id, oldOcc);
|
||||
this.socketToMatch.delete(oldId);
|
||||
this.socketToMatch.set(socket.id, matchId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send full state.
|
||||
socket.emit('match_rejoined', {
|
||||
matchId,
|
||||
state: room.toGameState(socket.id)
|
||||
state: room.toGameState(newId)
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -421,17 +539,20 @@ class MatchManager {
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [key, timeout] of this.disconnectTimeouts.entries()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
for (const [, timeout] of this.disconnectTimeouts.entries()) clearTimeout(timeout);
|
||||
this.disconnectTimeouts.clear();
|
||||
for (const t of this._pendingCleanups) clearTimeout(t);
|
||||
this._pendingCleanups.clear();
|
||||
for (const [id, room] of this.rooms.entries()) {
|
||||
if (room._watchdogs) room._watchdogs.forEach(t => clearTimeout(t));
|
||||
if (room._watchdogs) { room._watchdogs.forEach(t => clearTimeout(t)); room._watchdogs = null; }
|
||||
const engine = this.engines.get(id);
|
||||
if (engine) engine.stop();
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
}
|
||||
this.rooms.clear();
|
||||
this.engines.clear();
|
||||
this.socketToMatch.clear();
|
||||
this.userToMatch.clear();
|
||||
this._lastMoveAt.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* MatchPayoutService — crypto-race pot accounting and winner payout.
|
||||
*
|
||||
* On match start: convert held tickets to consumed entries and lock the pot.
|
||||
* On match end: calculate house fee, create one pending payout for the winner,
|
||||
* then schedule batch processing via the existing GameModeManager path.
|
||||
* On match start: commit each entrant's held ticket to the match and lock the pot (the ACTUAL
|
||||
* atomic amount collected), computing the house fee with exact integer math.
|
||||
* On match end: create ONE pending payout for the winner, capped at the true collected pot
|
||||
* minus the house fee, then hand it to the existing GameModeManager batch/retry machinery.
|
||||
*
|
||||
* This service does not send on-chain transactions itself; it reuses the existing
|
||||
* payout retry / batching machinery in GameModeManager.
|
||||
* This service does not send on-chain transactions itself; it reuses the existing payout retry
|
||||
* / batching path in GameModeManager. All pot/fee arithmetic is BigInt — no float precision
|
||||
* loss on large atomic amounts.
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
// Sentinel address for a winner who has not set a payout address yet. The payout row is stored
|
||||
// as 'needs_review' so the pot is a durable, queryable liability the winner can claim once they
|
||||
// provide an address — the house never silently keeps it, and the batcher never sends to this.
|
||||
const NO_ADDRESS_SENTINEL = 'PENDING_NO_ADDRESS';
|
||||
const DEFAULT_ENTRY_FEE_ATOMIC = 5000000000n; // 0.05 WOW at 11 decimals; only a last-resort default
|
||||
|
||||
class MatchPayoutService {
|
||||
constructor({ db, walletService, gameModeManager, debugManager } = {}) {
|
||||
this.db = db;
|
||||
@@ -24,21 +32,25 @@ class MatchPayoutService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by MatchScheduler when a crypto_race match is created.
|
||||
* Atomically converts each entrant's queue_join ticket into a consumed match ticket,
|
||||
* computes pot/house fee, and stores it on the match row.
|
||||
* Called by MatchScheduler BEFORE a crypto_race match is announced/started. Commits each
|
||||
* entrant's held queue-join ticket to this match, records the ACTUAL collected pot, and
|
||||
* computes the house fee via exact integer (BigInt) math. Throws on DB failure so the
|
||||
* caller can abort + refund (the transaction rolls back — no tickets are consumed).
|
||||
*/
|
||||
async collectEntryTickets(room, entrants) {
|
||||
if (!this.db || room.economy !== 'crypto_race') return;
|
||||
if (!this.db || !room || room.economy !== 'crypto_race') return;
|
||||
|
||||
const entryFee = this._entryFee();
|
||||
const houseFeePercent = this._houseFeePercent();
|
||||
const pot = entryFee * entrants.length;
|
||||
const houseFee = Math.floor((pot * houseFeePercent) / 100);
|
||||
const entryFee = this._entryFeeAtomic(); // BigInt atomic units per entry
|
||||
const houseFeeBp = this._houseFeeBasisPoints(); // integer basis points (0..10000)
|
||||
const count = BigInt((entrants && entrants.length) || 0);
|
||||
const pot = entryFee * count; // ACTUAL collected pot (BigInt)
|
||||
const houseFee = (pot * BigInt(houseFeeBp)) / 10000n; // floor via integer math — no float
|
||||
const housePercent = houseFeeBp / 100; // for the DECIMAL(5,2) audit column
|
||||
|
||||
await this.db.withTransaction(async (client) => {
|
||||
for (const entrant of entrants) {
|
||||
// Convert the held ticket to consumed.
|
||||
for (const entrant of (entrants || [])) {
|
||||
// Commit the held ticket to this match (delta 0 — it was already deducted at
|
||||
// queue-join; this row records the commitment for the audit trail).
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, match_id)
|
||||
VALUES ($1, 0, (
|
||||
@@ -46,7 +58,6 @@ class MatchPayoutService {
|
||||
), 'match_start', $2)
|
||||
`, [entrant.userId, room.id]);
|
||||
|
||||
// Update match_entrant to consumed.
|
||||
await client.query(`
|
||||
UPDATE match_entrants
|
||||
SET entry_consumed = TRUE
|
||||
@@ -54,7 +65,8 @@ class MatchPayoutService {
|
||||
`, [room.id, entrant.userId]);
|
||||
}
|
||||
|
||||
// Lock pot/house fee on the match row.
|
||||
// Lock the true pot / house fee on the match row (BigInt passed as strings so the
|
||||
// BIGINT columns are exact).
|
||||
await client.query(`
|
||||
UPDATE matches
|
||||
SET entry_fee_atomic = $1,
|
||||
@@ -62,44 +74,55 @@ class MatchPayoutService {
|
||||
house_fee_atomic = $3,
|
||||
house_fee_percent = $4
|
||||
WHERE id = $5
|
||||
`, [entryFee, pot, houseFee, houseFeePercent, room.id]);
|
||||
`, [entryFee.toString(), pot.toString(), houseFee.toString(), housePercent, room.id]);
|
||||
});
|
||||
|
||||
room.entryFeeAtomic = entryFee;
|
||||
room.potAtomic = pot;
|
||||
room.houseFeeAtomic = houseFee;
|
||||
room.houseFeePercent = houseFeePercent;
|
||||
// In-memory bookkeeping (informational). The authoritative amounts live on the match
|
||||
// row and are re-read (as BigInt) at payout time.
|
||||
room.entryFeeAtomic = Number(entryFee);
|
||||
room.potAtomic = Number(pot);
|
||||
room.houseFeeAtomic = Number(houseFee);
|
||||
room.houseFeePercent = housePercent;
|
||||
|
||||
this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}) for match ${room.id}`);
|
||||
this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}, ${housePercent}%) for match ${room.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by MatchManager when a crypto_race match finishes.
|
||||
* Creates one pending payout for the winner and hands it to the batch processor.
|
||||
* Called by MatchManager when a crypto_race match finishes. Creates ONE pending payout for
|
||||
* the winner capped at (pot − house fee). If the winner has no payout address the payout is
|
||||
* still recorded (status 'needs_review', a claimable liability) so the house never keeps it.
|
||||
*/
|
||||
async payoutWinner(room) {
|
||||
if (!this.db || room.economy !== 'crypto_race' || !room.winnerId) return;
|
||||
if (!this.db || !room || room.economy !== 'crypto_race' || !room.winnerId) return;
|
||||
|
||||
const winnerState = room.playerStates.get(room.winnerId);
|
||||
if (!winnerState || !winnerState.userId) return;
|
||||
if (!winnerState || winnerState.userId == null) return;
|
||||
|
||||
// Authoritative pot / fee from the match row (locked at collectEntryTickets).
|
||||
const matchRow = await this.db.query(`
|
||||
SELECT pot_atomic, house_fee_atomic
|
||||
FROM matches
|
||||
WHERE id = $1
|
||||
`, [room.id]);
|
||||
if (matchRow.rows.length === 0) return;
|
||||
if (!matchRow.rows || matchRow.rows.length === 0) return;
|
||||
|
||||
const pot = BigInt(matchRow.rows[0].pot_atomic || 0);
|
||||
const houseFee = BigInt(matchRow.rows[0].house_fee_atomic || 0);
|
||||
const winnerAmount = pot - houseFee;
|
||||
let pot, houseFee;
|
||||
try {
|
||||
pot = BigInt(matchRow.rows[0].pot_atomic ?? 0);
|
||||
houseFee = BigInt(matchRow.rows[0].house_fee_atomic ?? 0);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (houseFee < 0n) houseFee = 0n;
|
||||
if (houseFee > pot) houseFee = pot; // never pay a negative amount
|
||||
const winnerAmount = pot - houseFee; // capped at true collected pot − fee
|
||||
|
||||
if (winnerAmount <= 0) {
|
||||
if (winnerAmount <= 0n) {
|
||||
this._log(`[MatchPayoutService] no winner payout for match ${room.id} (pot=${pot} fee=${houseFee})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve payout address from user record.
|
||||
// Resolve payout address from the user record (may be absent).
|
||||
const userResult = await this.db.query(`
|
||||
SELECT id, payout_address
|
||||
FROM users
|
||||
@@ -107,17 +130,20 @@ class MatchPayoutService {
|
||||
LIMIT 1
|
||||
`, [winnerState.userId]);
|
||||
const user = userResult.rows[0];
|
||||
if (!user || !user.payout_address) {
|
||||
this._log(`[MatchPayoutService] winner has no payout address; match ${room.id} payout deferred`);
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
const hasAddress = typeof user.payout_address === 'string' && user.payout_address.trim().length > 0;
|
||||
const address = hasAddress ? user.payout_address.trim() : NO_ADDRESS_SENTINEL;
|
||||
const status = hasAddress ? 'pending' : 'needs_review';
|
||||
const reason = hasAddress ? 'match_winner' : 'match_winner_no_address';
|
||||
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
// Guard against double payout.
|
||||
// Guard against a double payout for this match (includes the deferred
|
||||
// no-address row so we never insert two).
|
||||
const existing = await client.query(`
|
||||
SELECT id FROM payouts
|
||||
WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed')
|
||||
WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed', 'needs_review')
|
||||
FOR UPDATE
|
||||
`, [room.id]);
|
||||
if (existing.rows.length > 0) {
|
||||
@@ -127,30 +153,54 @@ class MatchPayoutService {
|
||||
await client.query(`
|
||||
INSERT INTO payouts (user_id, match_id, payout_address, amount, multiplier, reason, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`, [user.id, room.id, user.payout_address, winnerAmount.toString(), 0, 'match_winner', 'pending']);
|
||||
`, [user.id, room.id, address, winnerAmount.toString(), 0, reason, status]);
|
||||
});
|
||||
|
||||
// Trigger the existing batch payout processor.
|
||||
if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
||||
this.gameModeManager._scheduleBatchPayout();
|
||||
if (hasAddress) {
|
||||
// Trigger the existing batch payout processor (5s debounce).
|
||||
if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
||||
this.gameModeManager._scheduleBatchPayout();
|
||||
}
|
||||
this._log(`[MatchPayoutService] queued winner payout ${winnerAmount} for match ${room.id}`);
|
||||
} else {
|
||||
this._log(`[MatchPayoutService] winner ${user.id} has no payout address; recorded ${winnerAmount} as a claimable 'needs_review' payout for match ${room.id}`);
|
||||
}
|
||||
|
||||
this._log(`[MatchPayoutService] queued winner payout ${winnerAmount} for match ${room.id}`);
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to queue match payout');
|
||||
this._log('[MatchPayoutService] payout error:', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
_entryFee() {
|
||||
const fee = this.gameModeManager?.singleGamePrice || process.env.MATCH_ENTRY_FEE_ATOMIC || 5000000000;
|
||||
const parsed = Number(fee);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
/**
|
||||
* Entry fee (atomic units, BigInt). Does NOT assume entryFee == singleGamePrice: an explicit
|
||||
* MATCH_ENTRY_FEE_ATOMIC (or a gameModeManager.matchEntryFeeAtomic) wins; singleGamePrice is
|
||||
* only a fallback, then a hard default.
|
||||
*/
|
||||
_entryFeeAtomic() {
|
||||
const raw = (process.env.MATCH_ENTRY_FEE_ATOMIC != null && process.env.MATCH_ENTRY_FEE_ATOMIC !== '')
|
||||
? process.env.MATCH_ENTRY_FEE_ATOMIC
|
||||
: (this.gameModeManager?.matchEntryFeeAtomic
|
||||
?? this.gameModeManager?.singleGamePrice
|
||||
?? null);
|
||||
if (raw == null) return DEFAULT_ENTRY_FEE_ATOMIC;
|
||||
try {
|
||||
// Atomic units are integers; strip any fractional part defensively before BigInt.
|
||||
const s = String(raw).trim().split('.')[0];
|
||||
const b = BigInt(s);
|
||||
return b > 0n ? b : DEFAULT_ENTRY_FEE_ATOMIC;
|
||||
} catch (_) {
|
||||
return DEFAULT_ENTRY_FEE_ATOMIC;
|
||||
}
|
||||
}
|
||||
|
||||
_houseFeePercent() {
|
||||
const v = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT);
|
||||
return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 5;
|
||||
/**
|
||||
* House fee as integer basis points (percent × 100), so a fractional percent like 5.5%
|
||||
* survives with no float precision loss in the pot math.
|
||||
*/
|
||||
_houseFeeBasisPoints() {
|
||||
const pct = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT);
|
||||
const p = Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : 5;
|
||||
return Math.round(p * 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+309
-104
@@ -2,21 +2,25 @@
|
||||
* MatchQueue — persisted, per-economy queue for match mode.
|
||||
*
|
||||
* Responsibilities:
|
||||
* • Join/leave a queue for each economy (free, credits_prestige, crypto_race).
|
||||
* • Atomically deduct credits or tickets into escrow on join; refund on leave.
|
||||
* • List queued players for a given economy.
|
||||
* • Drain a full economy bucket into a match at block time.
|
||||
* • Enqueue/leave a queue for each economy (free, credits_prestige, crypto_race).
|
||||
* • Atomically deduct credits or tickets into escrow on join; REFUND on EVERY cancellation
|
||||
* path (leave, stale cleanup, drain abort, shutdown, boot recovery), always writing a
|
||||
* ledger row — never a status flip alone.
|
||||
* • Drain a full economy bucket into a match at block time (memory only mutated after the DB
|
||||
* commit succeeds, so a failed drain leaves the queue intact).
|
||||
*
|
||||
* The queue is backed by `match_queue_entries` so state survives server restarts and
|
||||
* disconnects. In-memory maps are rebuilt on initialization.
|
||||
* Backed by `match_queue_entries` so state survives restarts. In-memory maps are rebuilt on
|
||||
* initialize(), which also refunds abandoned queue rows and recovers in-flight `matches`.
|
||||
*
|
||||
* This module has no Socket.IO coupling; MatchManager owns transport.
|
||||
* No Socket.IO coupling; MatchManager owns transport.
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
const ECONOMIES = Object.freeze(['free', 'credits_prestige', 'crypto_race']);
|
||||
const MATCH_ECONOMY_SET = new Set(ECONOMIES);
|
||||
// race_entry_transactions.reason is CHECK-constrained; map any cancellation reason into it.
|
||||
const RACE_LEDGER_REASONS = new Set(['queue_leave', 'match_cancel', 'refund']);
|
||||
|
||||
class MatchQueue {
|
||||
constructor({ db, gameModeManager = null, debugManager = null } = {}) {
|
||||
@@ -37,23 +41,12 @@ class MatchQueue {
|
||||
async initialize() {
|
||||
if (!this.enabled || !this.db) return;
|
||||
|
||||
// Cancel any stale matched-but-not-started entries (server crashed during drain).
|
||||
await this.db.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'cancelled', match_id = NULL
|
||||
WHERE status = 'matched'
|
||||
AND created_at < NOW() - INTERVAL '5 minutes'
|
||||
`);
|
||||
// MP-H3: refund + cancel abandoned entries and recover in-flight matches BEFORE
|
||||
// rebuilding memory, so escrow is never silently lost across a restart/crash.
|
||||
await this._recoverStaleQueueEntries();
|
||||
await this._recoverAbandonedMatches();
|
||||
|
||||
// Refund queued-but-stale entries older than 24 hours.
|
||||
await this.db.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'cancelled'
|
||||
WHERE status = 'queued'
|
||||
AND created_at < NOW() - INTERVAL '24 hours'
|
||||
`);
|
||||
|
||||
// Rebuild in-memory state.
|
||||
// Rebuild in-memory state from the surviving 'queued' rows.
|
||||
const result = await this.db.query(`
|
||||
SELECT id, user_id, economy, socket_id, session_token, created_at
|
||||
FROM match_queue_entries
|
||||
@@ -75,6 +68,98 @@ class MatchQueue {
|
||||
this._log(`[MatchQueue] Initialized: ${this.length('free')} free, ${this.length('credits_prestige')} prestige, ${this.length('crypto_race')} crypto`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund + cancel queue rows abandoned by a crash: 'matched' rows that never became a
|
||||
* running match (older than 5 min), and 'queued' rows older than 24h. Each refund is
|
||||
* written to the ledger; the row is only cancelled in the same transaction as its refund.
|
||||
*/
|
||||
async _recoverStaleQueueEntries() {
|
||||
let stale = [];
|
||||
try {
|
||||
const res = await this.db.query(`
|
||||
SELECT user_id, economy FROM match_queue_entries
|
||||
WHERE (status = 'matched' AND created_at < NOW() - INTERVAL '5 minutes')
|
||||
OR (status = 'queued' AND created_at < NOW() - INTERVAL '24 hours')
|
||||
`);
|
||||
stale = res.rows || [];
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] stale scan error', err.message);
|
||||
return;
|
||||
}
|
||||
for (const row of stale) {
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const upd = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'cancelled', match_id = NULL
|
||||
WHERE user_id = $1 AND economy = $2 AND status IN ('queued', 'matched')
|
||||
RETURNING id
|
||||
`, [row.user_id, row.economy]);
|
||||
if (upd.rowCount > 0) {
|
||||
await this._applyRefund(client, row.user_id, row.economy, 'match_cancel');
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] stale recovery error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot recovery for in-flight `matches` rows left 'starting'/'active' by a crash. If a
|
||||
* payout was already recorded for the match, let it stand (finalize the status); otherwise
|
||||
* cancel the match and refund every entrant so escrow is never stranded.
|
||||
*/
|
||||
async _recoverAbandonedMatches() {
|
||||
let rows = [];
|
||||
try {
|
||||
const res = await this.db.query(`SELECT id, economy FROM matches WHERE status IN ('starting', 'active')`);
|
||||
rows = res.rows || [];
|
||||
} catch (err) {
|
||||
// matches table may not exist in a minimal test DB — non-fatal.
|
||||
this._log('[MatchQueue] abandoned match scan error', err.message);
|
||||
return;
|
||||
}
|
||||
for (const m of rows) {
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
// If a payout already exists, the race effectively completed — don't refund
|
||||
// (that would double-pay the winner); just finalize the match status so the
|
||||
// batcher/retry can settle the payout.
|
||||
const pay = await client.query(`SELECT id FROM payouts WHERE match_id = $1 LIMIT 1`, [m.id]);
|
||||
if (pay.rows.length > 0) {
|
||||
await client.query(`
|
||||
UPDATE matches SET status = 'finished', ended_at = COALESCE(ended_at, NOW())
|
||||
WHERE id = $1 AND status IN ('starting', 'active')
|
||||
`, [m.id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cancel = await client.query(`
|
||||
UPDATE matches SET status = 'cancelled', ended_at = NOW()
|
||||
WHERE id = $1 AND status IN ('starting', 'active')
|
||||
RETURNING id
|
||||
`, [m.id]);
|
||||
if (cancel.rowCount === 0) return; // already handled by another process
|
||||
|
||||
const ent = await client.query(`SELECT user_id FROM match_entrants WHERE match_id = $1`, [m.id]);
|
||||
for (const e of ent.rows || []) {
|
||||
if (m.economy === 'crypto_race' || m.economy === 'credits_prestige') {
|
||||
await this._applyRefund(client, e.user_id, m.economy, 'match_cancel');
|
||||
}
|
||||
}
|
||||
await client.query(`
|
||||
UPDATE match_queue_entries SET status = 'cancelled'
|
||||
WHERE match_id = $1 AND status IN ('queued', 'matched')
|
||||
`, [m.id]);
|
||||
});
|
||||
this._log(`[MatchQueue] recovered abandoned match ${m.id} (${m.economy})`);
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] abandoned match recovery error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_validateEconomy(economy) {
|
||||
return MATCH_ECONOMY_SET.has(economy) ? economy : null;
|
||||
}
|
||||
@@ -92,33 +177,53 @@ class MatchQueue {
|
||||
return this._queues[economy].slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* socketHandlers-facing API: enqueue a resolved session into a match queue. Identity comes
|
||||
* from the (server-resolved) session; the economy is explicit.
|
||||
* @param {{userId:number, socketId:string, sessionToken?:string, economy?:string}} session
|
||||
* @param {{economy?:string}} [opts]
|
||||
*/
|
||||
async enqueue(session, opts = {}) {
|
||||
const economy = (opts && opts.economy) || session?.economy;
|
||||
return this.join({
|
||||
userId: session?.userId,
|
||||
socketId: session?.socketId,
|
||||
sessionToken: session?.sessionToken,
|
||||
economy
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a queue. For paid economies the cost is held in escrow.
|
||||
* @param {object} entry
|
||||
* @param {number} entry.userId
|
||||
* @param {string} entry.socketId
|
||||
* @param {string} entry.sessionToken
|
||||
* @param {string} entry.economy
|
||||
* @param {object} [entry.appearance]
|
||||
* @returns {Promise<{success:boolean, reason?:string, position?:number}>}
|
||||
*/
|
||||
async join(entry) {
|
||||
if (!this.enabled) return { success: false, reason: 'match_disabled' };
|
||||
if (!entry || entry.userId == null) return { success: false, reason: 'invalid_user' };
|
||||
const economy = this._validateEconomy(entry.economy);
|
||||
if (!economy) return { success: false, reason: 'invalid_economy' };
|
||||
|
||||
// session_token is NOT NULL in the schema; coerce a missing token to a stable-ish
|
||||
// string (reconnect matches on userId anyway).
|
||||
const normalized = {
|
||||
...entry,
|
||||
economy,
|
||||
sessionToken: entry.sessionToken != null ? String(entry.sessionToken) : String(entry.userId)
|
||||
};
|
||||
|
||||
try {
|
||||
if (economy === 'free') {
|
||||
return await this._joinFree(entry);
|
||||
return await this._joinFree(normalized);
|
||||
}
|
||||
if (economy === 'credits_prestige') {
|
||||
return await this._joinCreditsPrestige(entry);
|
||||
return await this._joinCreditsPrestige(normalized);
|
||||
}
|
||||
return await this._joinCryptoRace(entry);
|
||||
return await this._joinCryptoRace(normalized);
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to join match queue');
|
||||
this._log('[MatchQueue] join error:', normalized.message);
|
||||
return { success: false, reason: normalized.safeMessage || 'join_failed' };
|
||||
const normErr = normalizeError(err, 'Failed to join match queue');
|
||||
this._log('[MatchQueue] join error:', normErr.message);
|
||||
return { success: false, reason: normErr.safeMessage || 'join_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +270,7 @@ class MatchQueue {
|
||||
|
||||
const balanceAfter = creditResult.rows[0].credits;
|
||||
|
||||
// 2. Log credit transaction.
|
||||
// 2. Log credit transaction (escrow).
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata)
|
||||
VALUES ($1, $2, 'match_queue_join', $3, 'match', $4)
|
||||
@@ -186,7 +291,7 @@ class MatchQueue {
|
||||
|
||||
async _joinCryptoRace(entry) {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
// 1. Consume one race entry ticket atomically.
|
||||
// 1. Consume one race entry ticket atomically (escrow).
|
||||
const ticketResult = await client.query(`
|
||||
UPDATE users
|
||||
SET race_entries = race_entries - 1
|
||||
@@ -200,7 +305,7 @@ class MatchQueue {
|
||||
|
||||
const balanceAfter = ticketResult.rows[0].race_entries;
|
||||
|
||||
// 2. Log ticket transaction (escrow).
|
||||
// 2. Log ticket transaction.
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
|
||||
VALUES ($1, -1, $2, 'queue_join', $3)
|
||||
@@ -232,20 +337,95 @@ class MatchQueue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a queue before a match starts. Refund credits/tickets.
|
||||
* @param {number} userId
|
||||
* @param {string} economy
|
||||
* @returns {Promise<{success:boolean, reason?:string}>}
|
||||
* Apply a refund for one entrant within an open transaction. Amounts are DB-derived
|
||||
* (MP-H4): the credit refund reads the exact deducted amount from the ledger, so it is
|
||||
* correct even after a restart cleared the in-memory entry. Always writes a ledger row.
|
||||
*/
|
||||
async leave(userId, economy) {
|
||||
async _applyRefund(client, userId, economy, ledgerReason = 'queue_leave') {
|
||||
if (economy === 'credits_prestige') {
|
||||
const amount = await this._creditsJoinAmount(client, userId);
|
||||
if (amount <= 0) return 0;
|
||||
const r = await client.query(`
|
||||
UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits
|
||||
`, [amount, userId]);
|
||||
const bal = r.rows[0]?.credits ?? 0;
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata)
|
||||
VALUES ($1, $2, 'match_queue_refund', $3, 'match', $4)
|
||||
`, [userId, amount, bal, JSON.stringify({ economy, refunded: true, reason: ledgerReason })]);
|
||||
return amount;
|
||||
}
|
||||
if (economy === 'crypto_race') {
|
||||
const reason = RACE_LEDGER_REASONS.has(ledgerReason) ? ledgerReason : 'queue_leave';
|
||||
const r = await client.query(`
|
||||
UPDATE users SET race_entries = race_entries + 1 WHERE id = $1 RETURNING race_entries
|
||||
`, [userId]);
|
||||
const bal = r.rows[0]?.race_entries ?? 0;
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
|
||||
VALUES ($1, 1, $2, $3, $4)
|
||||
`, [userId, bal, reason, JSON.stringify({ economy, refunded: true, source: ledgerReason })]);
|
||||
return 1;
|
||||
}
|
||||
return 0; // free: nothing to refund
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact credit amount deducted at join, read from the ledger so refunds survive
|
||||
* restarts (MP-H4). Falls back to the configured cost if no ledger row is found.
|
||||
*/
|
||||
async _creditsJoinAmount(client, userId) {
|
||||
try {
|
||||
const r = await client.query(`
|
||||
SELECT amount FROM credit_transactions
|
||||
WHERE user_id = $1 AND reason = 'match_queue_join'
|
||||
AND (metadata->>'economy') = 'credits_prestige'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`, [userId]);
|
||||
if (r.rows.length > 0) {
|
||||
const amt = Math.abs(parseInt(r.rows[0].amount, 10));
|
||||
if (Number.isFinite(amt) && amt > 0) return amt;
|
||||
}
|
||||
} catch (_) { /* fall through to configured cost */ }
|
||||
return this._creditsCost();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a queue before a match starts, refunding credits/tickets. Accepts either a resolved
|
||||
* session object ({ userId, economy }) — the socketHandlers form — or the legacy
|
||||
* (userId, economy) pair. When no economy is supplied, leaves every economy the user is in.
|
||||
*/
|
||||
async leave(sessionOrUserId, maybeEconomy) {
|
||||
if (!this.enabled) return { success: false, reason: 'match_disabled' };
|
||||
|
||||
let userId, economy;
|
||||
if (sessionOrUserId && typeof sessionOrUserId === 'object') {
|
||||
userId = sessionOrUserId.userId;
|
||||
economy = sessionOrUserId.economy || maybeEconomy;
|
||||
} else {
|
||||
userId = sessionOrUserId;
|
||||
economy = maybeEconomy;
|
||||
}
|
||||
if (userId == null) return { success: false, reason: 'invalid_user' };
|
||||
|
||||
// No economy given → leave every economy the user is queued in.
|
||||
if (!economy) {
|
||||
const results = [];
|
||||
for (const eco of ECONOMIES) {
|
||||
results.push(await this._leaveOne(userId, eco));
|
||||
}
|
||||
const ok = results.some(r => r && r.success);
|
||||
return { success: ok, reason: ok ? undefined : 'not_queued' };
|
||||
}
|
||||
|
||||
economy = this._validateEconomy(economy);
|
||||
if (!economy) return { success: false, reason: 'invalid_economy' };
|
||||
return this._leaveOne(userId, economy);
|
||||
}
|
||||
|
||||
async _leaveOne(userId, economy) {
|
||||
try {
|
||||
const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
|
||||
const entry = memIdx >= 0 ? this._queues[economy][memIdx] : null;
|
||||
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const result = await client.query(`
|
||||
DELETE FROM match_queue_entries
|
||||
@@ -257,43 +437,64 @@ class MatchQueue {
|
||||
throw Object.assign(new Error('Not in queue'), { code: 'NOT_QUEUED' });
|
||||
}
|
||||
|
||||
if (economy === 'credits_prestige' && entry?.cost) {
|
||||
const cost = entry.cost;
|
||||
const creditResult = await client.query(`
|
||||
UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits
|
||||
`, [cost, userId]);
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata)
|
||||
VALUES ($1, $2, 'match_queue_leave', $3, 'match', $4)
|
||||
`, [userId, cost, creditResult.rows[0].credits, JSON.stringify({ economy: 'credits_prestige', refunded: true })]);
|
||||
}
|
||||
|
||||
if (economy === 'crypto_race') {
|
||||
const ticketResult = await client.query(`
|
||||
UPDATE users SET race_entries = race_entries + 1 WHERE id = $1 RETURNING race_entries
|
||||
`, [userId]);
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
|
||||
VALUES ($1, 1, $2, 'queue_leave', $3)
|
||||
`, [userId, ticketResult.rows[0].race_entries, JSON.stringify({ economy: 'crypto_race', refunded: true })]);
|
||||
}
|
||||
// MP-H4: refund is DB-derived inside _applyRefund, not read from volatile memory.
|
||||
await this._applyRefund(client, userId, economy, 'queue_leave');
|
||||
});
|
||||
|
||||
const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
|
||||
if (memIdx >= 0) this._queues[economy].splice(memIdx, 1);
|
||||
this._log(`[MatchQueue] leave user=${userId} economy=${economy}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to leave match queue');
|
||||
if (normalized.code === 'NOT_QUEUED') return { success: false, reason: 'not_queued' };
|
||||
return { success: false, reason: normalized.safeMessage || 'leave_failed' };
|
||||
const norm = normalizeError(err, 'Failed to leave match queue');
|
||||
if (norm.code === 'NOT_QUEUED') {
|
||||
// Keep memory consistent even if the DB row was already gone.
|
||||
const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
|
||||
if (memIdx >= 0) this._queues[economy].splice(memIdx, 1);
|
||||
return { success: false, reason: 'not_queued' };
|
||||
}
|
||||
return { success: false, reason: norm.safeMessage || 'leave_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain queued players for an economy into a match. Marks queue entries 'matched' and
|
||||
* returns the set of entrants. The caller creates the MatchRoom and starts it.
|
||||
* @param {string} economy
|
||||
* @param {number} maxPlayers
|
||||
* Refund a set of drained entrants (used when a race is aborted after drain marked them
|
||||
* 'matched', e.g. pot-collection failure or a <2-player defensive drain). Each refund is
|
||||
* written to the ledger and the queue row is cancelled in the same transaction, only when
|
||||
* the row was actually still queued/matched (so it can never double-refund).
|
||||
*/
|
||||
async refundEntries(entries, economy, reason = 'match_cancel') {
|
||||
if (!this.db || !Array.isArray(entries) || entries.length === 0) return;
|
||||
economy = this._validateEconomy(economy);
|
||||
if (!economy) return;
|
||||
|
||||
for (const e of entries) {
|
||||
const userId = e?.userId;
|
||||
if (userId == null) continue;
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const upd = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'cancelled', match_id = NULL
|
||||
WHERE user_id = $1 AND economy = $2 AND status IN ('queued', 'matched')
|
||||
RETURNING id
|
||||
`, [userId, economy]);
|
||||
if (upd.rowCount > 0) {
|
||||
await this._applyRefund(client, userId, economy, reason);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] refundEntries error', err.message);
|
||||
}
|
||||
const idx = this._queues[economy].findIndex(q => q.userId === userId);
|
||||
if (idx >= 0) this._queues[economy].splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain queued players for an economy into a match. Marks the DB rows 'matched' and returns
|
||||
* the entrants. The in-memory queue is only mutated AFTER the DB commit succeeds, so a
|
||||
* failed drain leaves the queue intact for the next block.
|
||||
* @returns {Promise<{entries:Array}|null>}
|
||||
*/
|
||||
async drain(economy, maxPlayers) {
|
||||
@@ -301,80 +502,84 @@ class MatchQueue {
|
||||
if (!economy || this._queues[economy].length < 2) return null;
|
||||
|
||||
const count = Math.min(maxPlayers, this._queues[economy].length);
|
||||
const drained = this._queues[economy].splice(0, count);
|
||||
const userIds = drained.map(e => e.userId);
|
||||
const candidates = this._queues[economy].slice(0, count);
|
||||
const userIds = candidates.map(e => e.userId);
|
||||
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const result = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'matched'
|
||||
WHERE user_id = ANY($1::int[]) AND economy = $2 AND status = 'queued'
|
||||
RETURNING id, user_id
|
||||
`, [userIds, economy]);
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const result = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'matched', matched_at = NOW()
|
||||
WHERE user_id = ANY($1::int[]) AND economy = $2 AND status = 'queued'
|
||||
RETURNING id, user_id
|
||||
`, [userIds, economy]);
|
||||
|
||||
if (result.rowCount !== userIds.length) {
|
||||
// Should never happen if memory state is in sync; abort.
|
||||
throw Object.assign(
|
||||
new Error(`Queue drain mismatch: expected ${userIds.length}, got ${result.rowCount}`),
|
||||
{ code: 'DRAIN_MISMATCH' }
|
||||
);
|
||||
}
|
||||
});
|
||||
if (result.rowCount !== userIds.length) {
|
||||
// Memory/DB out of sync — abort without mutating memory; retry next block.
|
||||
throw Object.assign(
|
||||
new Error(`Queue drain mismatch: expected ${userIds.length}, got ${result.rowCount}`),
|
||||
{ code: 'DRAIN_MISMATCH' }
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] drain error', err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
this._log(`[MatchQueue] drained ${drained.length} players for ${economy}`);
|
||||
return { entries: drained };
|
||||
// DB commit succeeded — now remove them from the in-memory queue.
|
||||
const idSet = new Set(userIds);
|
||||
this._queues[economy] = this._queues[economy].filter(e => !idSet.has(e.userId));
|
||||
this._log(`[MatchQueue] drained ${candidates.length} players for ${economy}`);
|
||||
return { entries: candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattach an in-memory entry after a socket reconnect. The DB row is keyed on user_id +
|
||||
* economy, so we just update the socket_id in memory and DB.
|
||||
* @param {number} userId
|
||||
* @param {string} economy
|
||||
* @param {string} newSocketId
|
||||
* @param {string} sessionToken
|
||||
* @returns {Promise<{inQueue:boolean, position?:number}>}
|
||||
* Reattach an in-memory entry after a socket reconnect while still IN THE QUEUE (not in a
|
||||
* running match). The DB row is keyed on user_id + economy.
|
||||
*/
|
||||
async reattach(userId, economy, newSocketId, sessionToken) {
|
||||
economy = this._validateEconomy(economy);
|
||||
if (!economy) return { inQueue: false };
|
||||
|
||||
const token = sessionToken != null ? String(sessionToken) : String(userId);
|
||||
const result = await this.db.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET socket_id = $1, session_token = $2
|
||||
WHERE user_id = $3 AND economy = $4 AND status = 'queued'
|
||||
RETURNING id, created_at
|
||||
`, [newSocketId, sessionToken, userId, economy]);
|
||||
`, [newSocketId, token, userId, economy]);
|
||||
|
||||
if (result.rowCount === 0) return { inQueue: false };
|
||||
|
||||
const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
|
||||
if (memIdx >= 0) {
|
||||
this._queues[economy][memIdx].socketId = newSocketId;
|
||||
this._queues[economy][memIdx].sessionToken = sessionToken;
|
||||
this._queues[economy][memIdx].sessionToken = token;
|
||||
return { inQueue: true, position: memIdx + 1 };
|
||||
}
|
||||
|
||||
// Memory was stale; rebuild this one from DB row.
|
||||
const row = result.rows[0];
|
||||
this._addToMemory(economy, {
|
||||
queueEntryId: row.id,
|
||||
userId,
|
||||
socketId: newSocketId,
|
||||
sessionToken,
|
||||
sessionToken: token,
|
||||
createdAt: new Date(row.created_at).getTime()
|
||||
});
|
||||
return { inQueue: true, position: this._queues[economy].findIndex(e => e.userId === userId) + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all queues (e.g. server shutdown). Refunds paid entries.
|
||||
* Cancel all queues (e.g. server shutdown). Refunds paid entries via _leaveOne (ledger row
|
||||
* written for each).
|
||||
*/
|
||||
async shutdown() {
|
||||
if (!this.enabled || !this.db) return;
|
||||
for (const economy of ECONOMIES) {
|
||||
const entries = this._queues[economy].slice();
|
||||
for (const e of entries) {
|
||||
await this.leave(e.userId, economy).catch(() => {});
|
||||
await this._leaveOne(e.userId, economy).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,21 @@
|
||||
* each enabled economy's queue into a MatchRoom (if ≥2 players are queued). Single players
|
||||
* carry over to the next block and can leave at any time.
|
||||
*
|
||||
* MatchScheduler creates MatchRooms but does not own Socket.IO broadcasting; it delegates
|
||||
* the started room to MatchManager for transport, persistence, and spectator wiring.
|
||||
* MatchScheduler creates MatchRooms but does not own Socket.IO broadcasting; it delegates the
|
||||
* started room to MatchManager for transport, the real countdown, persistence, and finish
|
||||
* handling. It does NOT start the engine directly — the manager's countdown does — so honest
|
||||
* and modified clients always start together.
|
||||
*/
|
||||
|
||||
const MatchRoom = require('../multiplayer/MatchRoom');
|
||||
const MatchEngine = require('../multiplayer/MatchEngine');
|
||||
const MatchState = require('../multiplayer/MatchState');
|
||||
const MatchPayoutService = require('./matchPayoutService');
|
||||
|
||||
const DEFAULT_MAX_PLAYERS = 4;
|
||||
const DEFAULT_TICK_MS = 250;
|
||||
const DEFAULT_MIN_DURATION_MS = 20000;
|
||||
const DEFAULT_HARD_CEILING_MS = 240000;
|
||||
const DEFAULT_COUNTDOWN_MS = 3000;
|
||||
|
||||
class MatchScheduler {
|
||||
constructor({
|
||||
@@ -27,7 +29,8 @@ class MatchScheduler {
|
||||
maxPlayers = null,
|
||||
tickMs = null,
|
||||
minDurationMs = null,
|
||||
hardCeilingMs = null
|
||||
hardCeilingMs = null,
|
||||
countdownMs = null
|
||||
} = {}) {
|
||||
this.matchQueue = matchQueue;
|
||||
this.matchManager = matchManager;
|
||||
@@ -38,7 +41,10 @@ class MatchScheduler {
|
||||
this.tickMs = tickMs || parseInt(process.env.MATCH_TICK_MS, 10) || DEFAULT_TICK_MS;
|
||||
this.minDurationMs = minDurationMs || parseInt(process.env.MATCH_MIN_DURATION_MS, 10) || DEFAULT_MIN_DURATION_MS;
|
||||
this.hardCeilingMs = hardCeilingMs || parseInt(process.env.MATCH_HARD_CEILING_MS, 10) || DEFAULT_HARD_CEILING_MS;
|
||||
this.countdownMs = countdownMs || parseInt(process.env.MATCH_COUNTDOWN_MS, 10) || DEFAULT_COUNTDOWN_MS;
|
||||
|
||||
// Retained for interface stability; the scheduler no longer owns any long-lived timers
|
||||
// (the hard-ceiling watchdog is owned solely by MatchManager, cleared on finalize).
|
||||
this._timeoutIds = [];
|
||||
}
|
||||
|
||||
@@ -54,7 +60,12 @@ class MatchScheduler {
|
||||
if (!this.enabled || !this.matchQueue || !this.matchManager) return;
|
||||
|
||||
for (const economy of ['free', 'credits_prestige', 'crypto_race']) {
|
||||
await this._drainEconomy(economy, blockHeight);
|
||||
try {
|
||||
await this._drainEconomy(economy, blockHeight);
|
||||
} catch (err) {
|
||||
// One economy failing must never block the others (or single-player blocks).
|
||||
this._log(`[MatchScheduler] drain ${economy} error`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +74,12 @@ class MatchScheduler {
|
||||
if (!drain) return;
|
||||
|
||||
const { entries } = drain;
|
||||
if (entries.length < 2) {
|
||||
// Defensive: should not happen because drain() requires ≥2, but refund just in case.
|
||||
for (const e of entries) await this.matchQueue.leave(e.userId, economy).catch(() => {});
|
||||
if (!entries || entries.length < 2) {
|
||||
// Defensive: drain() already marked these 'matched'; refund them so a race that did
|
||||
// not start never consumes credits/tickets.
|
||||
if (entries && entries.length) {
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +90,7 @@ class MatchScheduler {
|
||||
for (const e of entries) {
|
||||
entrants[e.socketId] = {
|
||||
userId: e.userId,
|
||||
name: null, // name is resolved later by MatchManager
|
||||
name: null, // resolved later by MatchManager
|
||||
socketId: e.socketId,
|
||||
sessionToken: e.sessionToken
|
||||
};
|
||||
@@ -91,29 +105,38 @@ class MatchScheduler {
|
||||
startBlockHeight: blockHeight,
|
||||
cryptoType: process.env.CRYPTO_TYPE || 'WOW'
|
||||
});
|
||||
|
||||
// Configure race-specific durations.
|
||||
room.minDurationMs = this.minDurationMs;
|
||||
room.hardCeilingMs = this.hardCeilingMs;
|
||||
|
||||
// Persist match and entrants synchronously before starting, so a crash mid-race can
|
||||
// be recovered from the DB on next boot.
|
||||
await this._persistMatch(room, entries, economy);
|
||||
|
||||
// Hand off to MatchManager for transport, reconnect mapping, and finish handling.
|
||||
this.matchManager.attach(room, entries, {
|
||||
db: this.matchManager.db,
|
||||
gameModeManager: this.matchManager.gameModeManager,
|
||||
tickMs: this.tickMs,
|
||||
minDurationMs: this.minDurationMs,
|
||||
hardCeilingMs: this.hardCeilingMs
|
||||
});
|
||||
|
||||
if (room.economy === 'crypto_race') {
|
||||
await this.matchPayoutService.collectEntryTickets(room, entries);
|
||||
// Persist match + entrants BEFORE any money movement or notification so a crash mid-race
|
||||
// is recoverable from the DB on next boot.
|
||||
try {
|
||||
await this._persistMatch(room, entries, economy);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] persist failed; refunding entrants', err.message);
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the engine.
|
||||
// MP-C4: collect the crypto pot / commit entry tickets BEFORE notifying players or
|
||||
// starting the engine. If collection fails, ABORT and REFUND every entrant — never
|
||||
// consume tickets for a race that did not start.
|
||||
if (economy === 'crypto_race') {
|
||||
const payoutService = this.matchManager?.matchPayoutService;
|
||||
try {
|
||||
if (!payoutService || typeof payoutService.collectEntryTickets !== 'function') {
|
||||
throw new Error('matchPayoutService unavailable');
|
||||
}
|
||||
await payoutService.collectEntryTickets(room, entries);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] pot collection failed; aborting + refunding', err.message);
|
||||
await this._abortMatch(room, entries, economy);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the engine and hand it to the manager, but DO NOT start it here — the manager's
|
||||
// countdown starts it once the pre-race timer elapses (server-authoritative start).
|
||||
const engine = new MatchEngine({
|
||||
room,
|
||||
tickMs: this.tickMs,
|
||||
@@ -121,11 +144,54 @@ class MatchScheduler {
|
||||
onFinish: (finishedRoom) => this.matchManager.onFinish(finishedRoom)
|
||||
});
|
||||
this.matchManager.setEngine(room.id, engine);
|
||||
engine.start();
|
||||
|
||||
// Hard-ceiling watchdog.
|
||||
const ceilingId = setTimeout(() => this._expireMatch(room.id, 'hard_ceiling'), this.hardCeilingMs);
|
||||
this._timeoutIds.push(ceilingId);
|
||||
// Hand off to MatchManager for transport, the real countdown, reconnect mapping, all
|
||||
// watchdog timers, and finish handling.
|
||||
this.matchManager.attach(room, entries, {
|
||||
db: this.matchManager.db,
|
||||
gameModeManager: this.matchManager.gameModeManager,
|
||||
tickMs: this.tickMs,
|
||||
minDurationMs: this.minDurationMs,
|
||||
hardCeilingMs: this.hardCeilingMs,
|
||||
countdownMs: this.countdownMs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a set of drained entrants (credits/tickets) and cancel their queue rows. Delegates
|
||||
* to MatchQueue which owns the money ledger; falls back to per-user leave if the batch
|
||||
* method is unavailable (older queue implementations).
|
||||
*/
|
||||
async _refund(entries, economy, reason) {
|
||||
if (!this.matchQueue) return;
|
||||
try {
|
||||
if (typeof this.matchQueue.refundEntries === 'function') {
|
||||
await this.matchQueue.refundEntries(entries, economy, reason);
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
await this.matchQueue.leave(e.userId, economy).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] refund error', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a match whose pot could not be collected: mark it cancelled and refund everyone.
|
||||
*/
|
||||
async _abortMatch(room, entries, economy) {
|
||||
if (this.matchManager?.db) {
|
||||
try {
|
||||
await this.matchManager.db.query(
|
||||
`UPDATE matches SET status = 'cancelled', ended_at = NOW() WHERE id = $1`,
|
||||
[room.id]
|
||||
);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] abort mark-cancelled error', err.message);
|
||||
}
|
||||
}
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
}
|
||||
|
||||
async _persistMatch(room, entries, economy) {
|
||||
@@ -172,10 +238,6 @@ class MatchScheduler {
|
||||
});
|
||||
}
|
||||
|
||||
_expireMatch(roomId, reason) {
|
||||
this.matchManager?.expire(roomId, reason);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const id of this._timeoutIds) clearTimeout(id);
|
||||
this._timeoutIds = [];
|
||||
|
||||
@@ -511,7 +511,9 @@ class PaymentHandlers {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
// Store & unref so tests / process can exit
|
||||
// Store & unref so tests / process can exit (the 30-min timer must never keep the
|
||||
// event loop alive on its own — in production the server keeps the process running).
|
||||
if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref();
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,23 @@
|
||||
* - stableId: prefers the session's stable user id (anon_token -> users.id); only when no
|
||||
* session is resolvable does it fall back to the ephemeral socket id. A client that
|
||||
* drops its token to mint a new identity is still caught by the IP limit.
|
||||
* - clientIp: the socket handshake address, or the first X-Forwarded-For hop when the
|
||||
* - clientIp: the socket handshake address, or the RIGHTMOST X-Forwarded-For hop when the
|
||||
* deployment is behind a trusted reverse proxy (TRUST_PROXY=true). XFF is ONLY trusted
|
||||
* when TRUST_PROXY is set, since otherwise a client could spoof it.
|
||||
*/
|
||||
|
||||
// S5: with a single trusted nginx proxy in front, nginx APPENDS the real client IP as the
|
||||
// last hop of X-Forwarded-For. The leftmost entries are attacker-controlled (a client can
|
||||
// pre-populate the header), so we must read the RIGHTMOST hop — the one the proxy added —
|
||||
// not the leftmost. Still gated on TRUST_PROXY so a direct-exposure deploy can't be spoofed.
|
||||
function clientIp(socket) {
|
||||
try {
|
||||
if (process.env.TRUST_PROXY === 'true') {
|
||||
const xff = socket.handshake?.headers?.['x-forwarded-for'];
|
||||
if (xff) return String(xff).split(',')[0].trim();
|
||||
if (xff) {
|
||||
const hops = String(xff).split(',').map(h => h.trim()).filter(Boolean);
|
||||
if (hops.length) return hops[hops.length - 1];
|
||||
}
|
||||
}
|
||||
return socket.handshake?.address || null;
|
||||
} catch (_) {
|
||||
|
||||
@@ -379,12 +379,18 @@ class SessionManager {
|
||||
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
// SECURE: Parameterized query for cleanup
|
||||
// Only delete truly disposable anonymous users: long-idle, zero credits, no payout
|
||||
// address, and — critically — NO history in any table that references users via a
|
||||
// RESTRICT/NO ACTION foreign key. Guarding with NOT EXISTS means the DELETE can never
|
||||
// abort on an FK violation, and we never remove a user who has payments, payouts, or
|
||||
// games on record (chat_messages / credit_transactions are optional extra guards).
|
||||
const result = await this.db.query(
|
||||
`DELETE FROM users
|
||||
WHERE last_seen < NOW() - INTERVAL '90 days'
|
||||
AND credits = 0
|
||||
RETURNING id`,
|
||||
`DELETE FROM users u
|
||||
WHERE u.last_seen < NOW() - INTERVAL '90 days' AND u.credits = 0 AND u.payout_address IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM payments p WHERE p.user_id = u.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM payouts po WHERE po.user_id = u.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM games g WHERE g.user_id = u.id)
|
||||
RETURNING u.id`,
|
||||
[] // No parameters needed
|
||||
);
|
||||
|
||||
@@ -397,6 +403,111 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C6/G6: Recover games left stuck at status='active' when the server was killed
|
||||
* mid-run. On a fresh boot no game can legitimately still be running, so every such row
|
||||
* is an orphan: idempotently refund the player what they paid (the linked confirmed
|
||||
* single_game payment, or the credits spent for a PAID_CREDITS game) and then finalize
|
||||
* the game's status. Safe to run repeatedly — each game is locked FOR UPDATE and skipped
|
||||
* unless still 'active', and each refund is deduped by a per-game credit_transactions
|
||||
* reason key, so re-runs neither double-refund nor double-finalize. index.js calls this
|
||||
* at startup right after recoverPendingPayments.
|
||||
*/
|
||||
async recoverOrphanedGames() {
|
||||
const summary = { finalized: 0, refunded: 0, creditsRefunded: 0 };
|
||||
try {
|
||||
const creditsPerGame = (this.gameModeManager && this.gameModeManager.creditsPerGameCost) || 1;
|
||||
|
||||
const orphans = await this.db.query(
|
||||
`SELECT id FROM games WHERE status = 'active' ORDER BY id ASC`,
|
||||
[]
|
||||
);
|
||||
|
||||
for (const orphan of orphans.rows) {
|
||||
try {
|
||||
const outcome = await this.db.withTransaction(async (client) => {
|
||||
// Lock the game row and re-check under the lock: another instance (or a prior
|
||||
// run) may already have finalized it.
|
||||
const lock = await client.query(
|
||||
`SELECT id, user_id, game_mode, payment_id, status
|
||||
FROM games WHERE id = $1 FOR UPDATE`,
|
||||
[orphan.id]
|
||||
);
|
||||
const game = lock.rows[0];
|
||||
if (!game || game.status !== 'active') return null; // already handled
|
||||
|
||||
const reason = `orphan_game_refunded:${game.id}`;
|
||||
let refunded = false;
|
||||
|
||||
if (game.user_id) {
|
||||
// Dedup: never refund the same orphaned game twice.
|
||||
const dup = await client.query(
|
||||
`SELECT 1 FROM credit_transactions WHERE user_id = $1 AND reason = $2 LIMIT 1`,
|
||||
[game.user_id, reason]
|
||||
);
|
||||
|
||||
if (dup.rows.length === 0) {
|
||||
// A refund is warranted when the player actually paid: a PAID_CREDITS game
|
||||
// consumed credits, or a PAID_SINGLE game had a confirmed payment.
|
||||
let doRefund = false;
|
||||
if (game.game_mode === 'PAID_CREDITS') {
|
||||
doRefund = true;
|
||||
} else if (game.payment_id != null) {
|
||||
const pay = await client.query(
|
||||
`SELECT status FROM payments WHERE id = $1`,
|
||||
[game.payment_id]
|
||||
);
|
||||
doRefund = !!pay.rows[0] && pay.rows[0].status === 'confirmed';
|
||||
}
|
||||
|
||||
if (doRefund) {
|
||||
const upd = await client.query(
|
||||
`UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`,
|
||||
[creditsPerGame, game.user_id]
|
||||
);
|
||||
const newBalance = upd.rows[0]?.credits ?? 0;
|
||||
await client.query(
|
||||
`INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, $3, $4, 'refund')`,
|
||||
[game.user_id, creditsPerGame, reason, newBalance]
|
||||
);
|
||||
refunded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the game: refunded games are marked 'refunded', the rest 'expired'.
|
||||
const newStatus = refunded ? 'refunded' : 'expired';
|
||||
await client.query(
|
||||
`UPDATE games SET status = $1, completed_at = COALESCE(completed_at, NOW()) WHERE id = $2`,
|
||||
[newStatus, game.id]
|
||||
);
|
||||
|
||||
return { refunded };
|
||||
});
|
||||
|
||||
if (outcome) {
|
||||
summary.finalized++;
|
||||
if (outcome.refunded) {
|
||||
summary.refunded++;
|
||||
summary.creditsRefunded += creditsPerGame;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[OrphanRecovery] Failed to recover game ${orphan.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.finalized > 0) {
|
||||
console.log(`[OrphanRecovery] Finalized ${summary.finalized} orphaned game(s); refunded ${summary.refunded} (${summary.creditsRefunded} credits).`);
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to recover orphaned games');
|
||||
console.error('[OrphanRecovery] Error recovering orphaned games:', normalized.message);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
generateSecureToken() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
+158
-12
@@ -344,7 +344,18 @@ class SocketHandlers {
|
||||
if (restored) {
|
||||
restoredGame = restored.game;
|
||||
const suspendedState = restored.suspendedState;
|
||||
|
||||
|
||||
// G1/C3: resume the block-timeout clock from the ORIGINAL entry block,
|
||||
// not the reconnect height. suspendedGameManager persists the entry block
|
||||
// and hands it back here; restore it onto the in-memory user so
|
||||
// checkGamesTimeout continues counting from where it left off. If it's
|
||||
// missing, leave blockRec unset — checkGamesTimeout records the first
|
||||
// observed height rather than treating the game as instant-death.
|
||||
const entryBlock = (restored.blockRec ?? suspendedState?.blockRec ?? null);
|
||||
if (memUser && entryBlock != null) {
|
||||
memUser.blockRec = entryBlock;
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🔄 [handleConnection] Restored game ${restoredGame.id} for user ${dbUserId}`);
|
||||
}
|
||||
@@ -450,8 +461,8 @@ class SocketHandlers {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message);
|
||||
});
|
||||
});
|
||||
socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button
|
||||
socket.on('play_free', () => this.handleAutoStart(socket, { free: true })); // Explicit free-play choice
|
||||
socket.on('auto_start', (data) => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {})); // New handler for start button
|
||||
socket.on('play_free', (data) => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true })); // Explicit free-play choice
|
||||
socket.on('join_queue', () => this.handleJoinQueue(socket)); // Queue instead of auto-start
|
||||
socket.on('early_entry', () => this.handleEarlyEntry(socket)); // Early entry without waiting for block
|
||||
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
|
||||
@@ -479,10 +490,28 @@ class SocketHandlers {
|
||||
socket.on('tavern_move', (data) => this.tavernManager.move(socket, data));
|
||||
socket.on('tavern_chat', (data) => this.tavernManager.chat(socket, data));
|
||||
socket.on('tavern_leave', () => this.tavernManager.leave(socket));
|
||||
// Match mode handlers
|
||||
// Match mode handlers. Every match listener is wrapped so a match-layer fault can
|
||||
// never crash the connection or leak a stack to the client — it logs and emits a
|
||||
// benign 'match_error' instead (C5). Identity is always resolved from the CONNECTION,
|
||||
// never from the client payload.
|
||||
socket.on('match_queue', (data) => this._handleMatchQueue(socket, data));
|
||||
socket.on('match_move', (data) => this.matchManager.move(socket, data));
|
||||
socket.on('match_leave', () => this.matchManager.leave(socket));
|
||||
socket.on('match_move', (data) => {
|
||||
try {
|
||||
this.matchManager.move(socket, data);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_move error:', err.message);
|
||||
socket.emit('match_error', { message: 'Move could not be processed.' });
|
||||
}
|
||||
});
|
||||
socket.on('match_leave', () => {
|
||||
try {
|
||||
this.matchManager.leave(socket);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_leave error:', err.message);
|
||||
socket.emit('match_error', { message: 'Could not leave the match.' });
|
||||
}
|
||||
});
|
||||
socket.on('match_reconnect', (data) => this._handleMatchReconnect(socket, data));
|
||||
// Tavern match spectator bridge
|
||||
socket.on('tavern_match_list', () => {
|
||||
const list = this.tavernMatchBridge ? this.tavernMatchBridge.getActiveMatches() : [];
|
||||
@@ -503,6 +532,13 @@ class SocketHandlers {
|
||||
// honoured when the instance allows free play; otherwise fall through to paid.
|
||||
const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled;
|
||||
|
||||
// C7: optional client-supplied entropy for provably-fair seed derivation. Must be
|
||||
// hex (≤64 chars); anything malformed is silently ignored (falls back to ''). This
|
||||
// is purely additive — existing clients that send no seed are unaffected.
|
||||
const clientSeed = (typeof opts.clientSeed === 'string' && /^[a-fA-F0-9]{0,64}$/.test(opts.clientSeed))
|
||||
? opts.clientSeed
|
||||
: undefined;
|
||||
|
||||
// Rate limiting for game starts — keyed on stable identity + IP so reconnecting
|
||||
// (new socket.id) can't reset the limit.
|
||||
const rlId = stableId(socket, this.sessionManager);
|
||||
@@ -582,7 +618,7 @@ class SocketHandlers {
|
||||
|
||||
// Process start (free / credits deduction / payment link)
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree });
|
||||
const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree, clientSeed });
|
||||
if (!startRes.success) {
|
||||
// Abort game
|
||||
this.activeGames.delete(socket.id);
|
||||
@@ -712,6 +748,89 @@ class SocketHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable match session/identity for a connection. Identity is derived ONLY
|
||||
* from the CONNECTION (the session cache, then a DB lookup by socket) — never from any
|
||||
* client-supplied payload — so a client can't act on another user's behalf (C5). Mirrors
|
||||
* how other handlers resolve identity via sessionManager.
|
||||
* @returns {Promise<{userId:number, socketId:string, sessionToken:(string|null), user:Object}|null>}
|
||||
*/
|
||||
async _resolveMatchSession(socket) {
|
||||
if (!this.sessionManager || !socket) return null;
|
||||
let sessionUser = null;
|
||||
try {
|
||||
sessionUser = this.sessionManager.sessions?.get(socket.id) || null;
|
||||
} catch (_) { sessionUser = null; }
|
||||
if (!sessionUser) {
|
||||
try { sessionUser = await this.sessionManager.getBySocket(socket.id); } catch (_) { sessionUser = null; }
|
||||
}
|
||||
if (!sessionUser || sessionUser.id == null) return null;
|
||||
return {
|
||||
userId: sessionUser.id,
|
||||
socketId: socket.id,
|
||||
sessionToken: sessionUser.anon_token || null,
|
||||
user: sessionUser
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a match-mode queue join/leave (C5/S4). The economy comes from the payload but
|
||||
* the IDENTITY is resolved from the connection, never from data. Never throws: any fault
|
||||
* is logged and reported to the client via 'match_error'. If match mode is disabled /
|
||||
* the queue is absent, respond benignly and return.
|
||||
* @param {Object} socket
|
||||
* @param {{action?:('join'|'leave'), economy?:string}} data
|
||||
*/
|
||||
async _handleMatchQueue(socket, data = {}) {
|
||||
try {
|
||||
// Match disabled or queue not wired → benign no-op.
|
||||
if (!this.matchQueue || (typeof this.matchQueue.isEnabled === 'function' && !this.matchQueue.isEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await this._resolveMatchSession(socket);
|
||||
if (!session) {
|
||||
socket.emit('match_error', { message: 'Please reconnect before joining a match.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const economy = (data && typeof data.economy === 'string') ? data.economy : undefined;
|
||||
const action = (data && data.action === 'leave') ? 'leave' : 'join';
|
||||
|
||||
// Carry economy on the session too, so a leave(session) that keys on economy and
|
||||
// an enqueue(session,{economy}) that keys on the explicit arg both stay correct.
|
||||
const matchSession = { ...session, economy };
|
||||
|
||||
if (action === 'leave') {
|
||||
await this.matchQueue.leave(matchSession);
|
||||
} else {
|
||||
await this.matchQueue.enqueue(matchSession, { economy });
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] _handleMatchQueue error:', err.message);
|
||||
socket.emit('match_error', { message: 'Could not update the match queue. Please try again.' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a match-mode reconnect (C5). Resolves identity from the connection and hands the
|
||||
* live socket + session to the match manager. Same never-throw discipline as the other
|
||||
* match listeners; benign no-op when match mode is disabled.
|
||||
*/
|
||||
async _handleMatchReconnect(socket, data = {}) {
|
||||
try {
|
||||
if (!this.matchManager || this.matchManager.enabled === false) {
|
||||
return;
|
||||
}
|
||||
const session = await this._resolveMatchSession(socket);
|
||||
if (!session) return;
|
||||
await this.matchManager.handleReconnect(socket, session);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_reconnect error:', err.message);
|
||||
socket.emit('match_error', { message: 'Could not rejoin the match.' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the active socket instance by id (io.sockets.sockets Map) */
|
||||
_getLiveSocket(socketId) {
|
||||
try {
|
||||
@@ -855,7 +974,16 @@ class SocketHandlers {
|
||||
// Clean up tavern presence
|
||||
if (this.tavernManager) {
|
||||
this.tavernManager.handleDisconnect(socketId);
|
||||
this.matchManager.handleDisconnect(socketId, null);
|
||||
}
|
||||
|
||||
// Clean up match presence (starts the AFK grace timer). New signature takes the
|
||||
// live socket (C5). Guarded so a match-layer fault can't abort disconnect cleanup.
|
||||
if (this.matchManager) {
|
||||
try {
|
||||
this.matchManager.handleDisconnect(socket);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match handleDisconnect error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Evict the cached session row LAST (after the suspend logic above read it),
|
||||
@@ -1084,14 +1212,16 @@ class SocketHandlers {
|
||||
* Start games for waiting players when a new block is found
|
||||
*/
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
return await this.queueHandler.startGamesForWaiting(blockHeight);
|
||||
// Also drain match-mode queues on the new block.
|
||||
// Start queued single-player games first and capture the result to return.
|
||||
const result = await this.queueHandler.startGamesForWaiting(blockHeight);
|
||||
// Then drain match-mode queues on the same block, fire-and-forget so a match-layer
|
||||
// error can never block or fail single-player block processing (MP-C3).
|
||||
if (this.matchScheduler) {
|
||||
this.matchScheduler.onBlock(blockHeight).catch(err => {
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.error('[SocketHandlers] Match scheduler block error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1110,9 +1240,25 @@ class SocketHandlers {
|
||||
const now = Date.now();
|
||||
const snapshot = Array.from(this.activeGames.entries());
|
||||
for (const [socketId, game] of snapshot) {
|
||||
// G3: the snapshot can go stale while we await a previous game-over. If this game
|
||||
// was concurrently won or otherwise ended (removed from activeGames on another
|
||||
// async path), never re-end it here as a loss/timeout.
|
||||
if (!this.activeGames.has(socketId)) continue;
|
||||
|
||||
const user = this.connectionHandler.getUserBySocket(socketId);
|
||||
if (!user) continue;
|
||||
|
||||
// G1/C3: only end when the entry block is DEFINED and a later block has arrived.
|
||||
// If we've never recorded an entry block for this player, record the current
|
||||
// height as the first observation instead of treating a missing value as either
|
||||
// immortal (never times out) or instant-death (times out on the first tick).
|
||||
if (user.blockRec == null) {
|
||||
user.blockRec = currentHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
const elapsedMs = now - (game.startedAt || now);
|
||||
if (user && user.blockRec && currentHeight > user.blockRec && elapsedMs >= graceMs) {
|
||||
if (currentHeight > user.blockRec && elapsedMs >= graceMs) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`💀 GAME TIMEOUT for player ${socketId}: entered on block ${user.blockRec}, died on block ${currentHeight}`);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ class SuspendedGameManager {
|
||||
hasTreasure: game.player?.hasTreasure || false,
|
||||
moveCount: game.moveCount || 0,
|
||||
blockHeight: game.blockHeight || null,
|
||||
// Entry block height (blockRec) for reconnect timeout continuity (C3).
|
||||
// Prefer an explicit value from the caller, then the value captured on the
|
||||
// game object at creation. Never invent one — a missing value stays null so
|
||||
// the timeout logic treats it as "not yet observed" rather than instant-death.
|
||||
blockRec: (additionalState.blockRec !== undefined && additionalState.blockRec !== null)
|
||||
? additionalState.blockRec
|
||||
: (game.blockRec ?? game.blockHeight ?? null),
|
||||
...additionalState
|
||||
};
|
||||
|
||||
@@ -114,7 +121,33 @@ class SuspendedGameManager {
|
||||
|
||||
// Update game with new socket ID
|
||||
game.socketId = newSocketId;
|
||||
|
||||
|
||||
// Keep a stable DB user id on the game object so completeGame can key on it
|
||||
// without any socket_id lookup. It is normally stamped at creation; backfill
|
||||
// from the alias just in case an older game object only carries one of them.
|
||||
if (game.userId == null && game.dbUserId != null) game.userId = game.dbUserId;
|
||||
if (game.dbUserId == null && game.userId != null) game.dbUserId = game.userId;
|
||||
|
||||
// Align the persisted game row's socket_id to the reconnecting socket so
|
||||
// downstream row-matching (game-over UPDATE, completion) stays consistent.
|
||||
// Fire-and-forget so restoreGame remains synchronous for its callers.
|
||||
try {
|
||||
const db = game.db || suspended.db || null;
|
||||
if (db && game.id) {
|
||||
Promise.resolve(
|
||||
db.query('UPDATE games SET socket_id = $1 WHERE dungeon_seed = $2', [newSocketId, game.id])
|
||||
).catch((err) => {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn(`[SuspendedGameManager] socket_id realign failed for game ${game.id}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn(`[SuspendedGameManager] socket_id realign threw for game ${game.id}: ${normalizeError(err).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the user reference if provided
|
||||
if (newUser) {
|
||||
game.user = newUser;
|
||||
@@ -136,7 +169,10 @@ class SuspendedGameManager {
|
||||
|
||||
return {
|
||||
game,
|
||||
suspendedState: suspended
|
||||
suspendedState: suspended,
|
||||
// Expose the entry block height so the reconnect path can restore
|
||||
// memUser.blockRec and keep the block-timeout logic continuous (C3).
|
||||
blockRec: suspended.blockRec ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+94
-1
@@ -799,9 +799,12 @@ router.get('/api/admin/users', adminAuth, asyncHandler(async (req, res) => {
|
||||
LIMIT $1 OFFSET $2
|
||||
`, params);
|
||||
|
||||
// Build the count query from the SAME params list as the main query so the
|
||||
// shared ${whereClause} (which references $3 when searching) always has its
|
||||
// parameter supplied. Passing a one-element array here threw for any search term.
|
||||
const countResult = await db.query(`
|
||||
SELECT COUNT(*) as total FROM users ${whereClause}
|
||||
`, search && search.length >= 3 ? [`%${search}%`] : []);
|
||||
`, search && search.length >= 3 ? params : []);
|
||||
|
||||
res.json({
|
||||
users: result.rows.map(u => ({
|
||||
@@ -882,9 +885,51 @@ router.get('/api/admin/users/:id', adminAuth, asyncHandler(async (req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Scan the wallet for a recent OUTGOING transfer to this payout's address (and,
|
||||
* when known, matching amount). Used before force-retrying a needs_review payout
|
||||
* that has no recorded tx_hash — if the funds already left the wallet we must NOT
|
||||
* re-send. Conservative: any RPC failure is treated as "not clean" so we never
|
||||
* risk a silent double-pay.
|
||||
* Returns { clean: boolean, txid?: string }.
|
||||
*/
|
||||
async function isRecentTransferClean(payout) {
|
||||
try {
|
||||
const resp = await walletRPCService.rpcCall('get_transfers', {
|
||||
out: true,
|
||||
pending: true,
|
||||
pool: true,
|
||||
account_index: walletRPCService.accountIndex || 0
|
||||
});
|
||||
const target = String(payout.payout_address || '');
|
||||
const amt = Number(payout.amount);
|
||||
for (const bucket of ['out', 'pending', 'pool']) {
|
||||
const list = resp.result?.[bucket];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const tx of list) {
|
||||
const dests = Array.isArray(tx.destinations) ? tx.destinations : [];
|
||||
for (const d of dests) {
|
||||
if (d.address === target && (!Number.isFinite(amt) || amt <= 0 || Number(d.amount) === amt)) {
|
||||
return { clean: false, txid: tx.txid };
|
||||
}
|
||||
}
|
||||
// Some transfers record the recipient at the top level without destinations.
|
||||
if (target && tx.address === target) {
|
||||
return { clean: false, txid: tx.txid };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { clean: true };
|
||||
} catch (err) {
|
||||
// Can't verify -> assume NOT clean to avoid a double-pay.
|
||||
return { clean: false, txid: null, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/payouts/:id/retry - Manually retry a failed payout
|
||||
router.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const confirm = req.query.confirm === 'true' || !!(req.body && req.body.confirm === true);
|
||||
|
||||
const payoutResult = await db.query(`
|
||||
SELECT * FROM payouts WHERE id = $1
|
||||
@@ -900,6 +945,54 @@ router.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req,
|
||||
throw new ValidationError('Payout already completed');
|
||||
}
|
||||
|
||||
// M2: never allow a silent double-pay. Before resetting a payout to 'pending'
|
||||
// (which requeues it for a fresh transfer) prove the funds have NOT already left
|
||||
// the wallet.
|
||||
if (payout.tx_hash) {
|
||||
// A tx hash is recorded — verify it is NOT on-chain before retrying.
|
||||
let txStatus;
|
||||
try {
|
||||
txStatus = await walletRPCService.checkTransactionStatus(payout.tx_hash);
|
||||
} catch (err) {
|
||||
// RPC uncertain — refuse rather than risk re-sending.
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `Could not verify on-chain status of tx ${payout.tx_hash}: ${err.message}. Refusing to retry.`
|
||||
});
|
||||
}
|
||||
if (txStatus && txStatus.exists) {
|
||||
// Tx is on-chain (confirmed or in mempool): the payout already went out.
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `Payout tx ${payout.tx_hash} is already on-chain (confirmed=${txStatus.confirmed}, in_mempool=${txStatus.in_mempool}). Refusing to retry to avoid a double-pay.`,
|
||||
onChain: true,
|
||||
txStatus
|
||||
});
|
||||
}
|
||||
// Not found on-chain -> safe to reset for retry.
|
||||
} else if (payout.status === 'needs_review') {
|
||||
// No tx_hash but flagged for manual review: require an explicit confirm flag AND
|
||||
// a clean recent-transfer check before allowing a resend.
|
||||
if (!confirm) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Payout is in needs_review with no tx_hash. Pass ?confirm=true (or {confirm:true}) to force a retry after manual review.',
|
||||
requiresConfirm: true
|
||||
});
|
||||
}
|
||||
const clean = await isRecentTransferClean(payout);
|
||||
if (!clean.clean) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: clean.error
|
||||
? `Refusing to retry: could not verify recent transfers (${clean.error}).`
|
||||
: `Refusing to retry: found a recent outgoing transfer to ${payout.payout_address} (txid ${clean.txid}) that may already fulfill this payout.`,
|
||||
onChain: !clean.error,
|
||||
txid: clean.txid || undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset status to pending for retry
|
||||
await db.query(`
|
||||
UPDATE payouts SET status = 'pending', last_error = 'Manual retry requested' WHERE id = $1
|
||||
|
||||
@@ -18,7 +18,8 @@ describe('SocketChatProvider', () => {
|
||||
const broadcast = [];
|
||||
const history = { saveMessage: (m) => { saved.push(m); return Promise.resolve(); } };
|
||||
const broadcastManager = {
|
||||
broadcastChatMessage: (username, text, ts, socketId) => broadcast.push({ username, text, ts, socketId })
|
||||
// The 4th arg is the sanitized public id (never the raw socket.id — S1 defense in depth).
|
||||
broadcastChatMessage: (username, text, ts, publicId) => broadcast.push({ username, text, ts, publicId })
|
||||
};
|
||||
const provider = new SocketChatProvider({ io, broadcastManager, historyManager: history });
|
||||
|
||||
@@ -27,7 +28,8 @@ describe('SocketChatProvider', () => {
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]).toMatchObject({ socketId: 'sock', username: 'abc123', message: 'hi', userId: 7 });
|
||||
expect(broadcast).toHaveLength(1);
|
||||
expect(broadcast[0]).toEqual({ username: 'abc123', text: 'hi', ts: 42, socketId: 'sock' });
|
||||
// userId is present, so the broadcast is attributed by the public user id ('7'), not the socket id.
|
||||
expect(broadcast[0]).toEqual({ username: 'abc123', text: 'hi', ts: 42, publicId: '7' });
|
||||
});
|
||||
|
||||
test('global publish falls back to io.emit when no broadcastManager is given', async () => {
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* matchRework_ultra — regression coverage for the match-mode production hardening pass.
|
||||
*
|
||||
* Exercises the confirmed defects the rework fixes: the move() crash (MP-C2), the real
|
||||
* countdown (MP-H6), reconnect re-keying + ownership (MP-H1/H2), winner consistency across
|
||||
* end paths, BigInt/capped/no-address payouts (MP-C4/H5), refund-on-every-cancellation
|
||||
* (MP-H3/H4), and payout-first isolated finalize (MP-C5). All timers created here are cleared
|
||||
* in afterEach so the suite leaves no open handles.
|
||||
*/
|
||||
|
||||
const MatchManager = require('../src/network/matchManager');
|
||||
const MatchPayoutService = require('../src/network/matchPayoutService');
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
const MatchEngine = require('../src/multiplayer/MatchEngine');
|
||||
|
||||
const SEED = '0'.repeat(64);
|
||||
const NEVER = 10 ** 9; // effectively-never timer duration for countdown/floor/ceiling
|
||||
|
||||
function makeIo() {
|
||||
const broadcasts = [];
|
||||
const sockets = new Map();
|
||||
return {
|
||||
broadcasts,
|
||||
sockets: { sockets },
|
||||
to(channel) {
|
||||
return { emit: (event, payload) => broadcasts.push({ channel, event, payload }) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeSocket(id) {
|
||||
return { id, joined: [], emitted: [], join(ch) { this.joined.push(ch); }, emit(ev, p) { this.emitted.push({ ev, p }); } };
|
||||
}
|
||||
|
||||
function makeCryptoFinishedRoom({ winner = 'a' } = {}) {
|
||||
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
|
||||
room.start();
|
||||
room.playerStates.get(winner).escaped = true;
|
||||
room.winnerId = winner;
|
||||
room.status = 'finished';
|
||||
room.finalize();
|
||||
return room;
|
||||
}
|
||||
|
||||
describe('match rework — crash + countdown (MatchManager)', () => {
|
||||
const managers = [];
|
||||
function mgr(opts = {}) {
|
||||
const m = new MatchManager({ io: makeIo(), db: null, debugManager: null, ...opts });
|
||||
m.enabled = true;
|
||||
managers.push(m);
|
||||
return m;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (managers.length) managers.pop().shutdown();
|
||||
jest.useRealTimers();
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('MP-C2: move() no longer references undefined db/gameModeManager and never throws', () => {
|
||||
const m = mgr();
|
||||
// No match for this socket -> benign no-op (previously threw ReferenceError: db).
|
||||
expect(() => m.move({ id: 'ghost' }, { dx: 1, dy: 0 })).not.toThrow();
|
||||
// Garbage / missing payload is null-safe.
|
||||
expect(() => m.move({ id: 'ghost' }, null)).not.toThrow();
|
||||
expect(() => m.move({ id: 'ghost' }, { dx: 'x' })).not.toThrow();
|
||||
// Disabled manager returns benignly.
|
||||
m.enabled = false;
|
||||
expect(() => m.move({ id: 'ghost' }, { dx: 1, dy: 0 })).not.toThrow();
|
||||
});
|
||||
|
||||
test('MP-H6: moves are rejected until the countdown elapses, then accepted', () => {
|
||||
const m = mgr();
|
||||
m.moveCooldownMs = 0; // isolate the status gate from flood control
|
||||
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
|
||||
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
|
||||
{ countdownMs: NEVER, minDurationMs: NEVER, hardCeilingMs: NEVER });
|
||||
|
||||
expect(room.status).toBe('starting');
|
||||
m.move({ id: 's1' }, { dx: 1, dy: 0 });
|
||||
expect(room.moveQueue.size).toBe(0); // rejected during 'starting'
|
||||
|
||||
room.start(); // simulate the countdown elapsing
|
||||
m.move({ id: 's1' }, { dx: 1, dy: 0 });
|
||||
expect(room.moveQueue.size).toBe(1); // accepted once 'active'
|
||||
});
|
||||
|
||||
test('MP-H6: the countdown (not the scheduler) starts the engine + activates the room', () => {
|
||||
jest.useFakeTimers();
|
||||
const m = mgr();
|
||||
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
|
||||
const engine = new MatchEngine({ room, tickMs: NEVER, onFinish: (r) => m.onFinish(r) });
|
||||
m.setEngine(room.id, engine);
|
||||
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
|
||||
{ countdownMs: 3000, minDurationMs: NEVER, hardCeilingMs: NEVER, tickMs: NEVER });
|
||||
|
||||
expect(room.status).toBe('starting');
|
||||
jest.advanceTimersByTime(3000);
|
||||
expect(room.status).toBe('active');
|
||||
expect(engine.running).toBe(true);
|
||||
});
|
||||
|
||||
test('leaked-timer safety: expire() is idempotent and clears the hard-ceiling watchdog', () => {
|
||||
const m = mgr();
|
||||
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
|
||||
const engine = new MatchEngine({ room, tickMs: NEVER, onFinish: (r) => m.onFinish(r) });
|
||||
m.setEngine(room.id, engine);
|
||||
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
|
||||
{ countdownMs: NEVER, minDurationMs: NEVER, hardCeilingMs: NEVER });
|
||||
room.start();
|
||||
|
||||
m.expire(room.id, 'admin');
|
||||
expect(room._finalized).toBe(true);
|
||||
expect(room._watchdogs).toBeNull(); // ceiling/floor/countdown timers cleared
|
||||
expect(engine.running).toBe(false); // engine stopped
|
||||
// Second call must be a benign no-op (no double finalize, no throw).
|
||||
expect(() => m.expire(room.id, 'admin')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => {
|
||||
const managers = [];
|
||||
afterEach(() => { while (managers.length) managers.pop().shutdown(); delete process.env.MATCH_ENABLED; });
|
||||
|
||||
function activeMatch() {
|
||||
const m = new MatchManager({ io: makeIo(), db: null, debugManager: null });
|
||||
m.enabled = true;
|
||||
managers.push(m);
|
||||
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
|
||||
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
|
||||
{ countdownMs: NEVER, minDurationMs: NEVER, hardCeilingMs: NEVER });
|
||||
room.start();
|
||||
return { m, room };
|
||||
}
|
||||
|
||||
test('reconnect re-maps old socket id to new across occupants, playerStates + manager maps', () => {
|
||||
const { m, room } = activeMatch();
|
||||
// Disconnect s1 -> arms the AFK grace timer keyed by the OLD id.
|
||||
m.handleDisconnect({ id: 's1' });
|
||||
expect(m.disconnectTimeouts.has(`${room.id}:s1`)).toBe(true);
|
||||
|
||||
const sock = makeSocket('s1new');
|
||||
const ok = m.handleReconnect(sock, { userId: 1 });
|
||||
expect(ok).toBe(true);
|
||||
|
||||
// Occupant + state re-keyed to the new socket id, old key gone.
|
||||
expect(room.occupants.has('s1new')).toBe(true);
|
||||
expect(room.occupants.has('s1')).toBe(false);
|
||||
expect(room.occupants.get('s1new').id).toBe('s1new'); // occupant.id updated too
|
||||
expect(room.playerStates.has('s1new')).toBe(true);
|
||||
expect(room.playerStates.get('s1new').userId).toBe(1);
|
||||
expect(room.playerStates.has('s1')).toBe(false);
|
||||
|
||||
// Manager maps re-keyed; AFK timer for the OLD id cleared (never AFK-kill a reconnect).
|
||||
expect(m.socketToMatch.get('s1new')).toBe(room.id);
|
||||
expect(m.socketToMatch.has('s1')).toBe(false);
|
||||
expect(m.disconnectTimeouts.has(`${room.id}:s1`)).toBe(false);
|
||||
expect(sock.emitted.some(e => e.ev === 'match_rejoined')).toBe(true);
|
||||
});
|
||||
|
||||
test('a session that does not own a racer cannot attach to the match', () => {
|
||||
const { m, room } = activeMatch();
|
||||
const evil = makeSocket('evil');
|
||||
const ok = m.handleReconnect(evil, { userId: 999 }); // no racer with userId 999
|
||||
expect(ok).toBe(false);
|
||||
expect(room.occupants.has('evil')).toBe(false);
|
||||
expect(m.socketToMatch.has('evil')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — winner consistency across end paths', () => {
|
||||
test('all-dead: winnerId equals the placement #1 player (never a stranded null)', () => {
|
||||
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
|
||||
room.start();
|
||||
room._killPlayer('a', 'monster');
|
||||
room._killPlayer('b', 'monster');
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('all_dead');
|
||||
room.finalize();
|
||||
|
||||
const placeOne = [...room.playerStates.entries()].find(([, s]) => s.placement === 1);
|
||||
expect(placeOne).toBeTruthy();
|
||||
expect(room.winnerId).toBe(placeOne[0]); // payout target == leaderboard #1
|
||||
expect(room.winnerId).not.toBeNull();
|
||||
});
|
||||
|
||||
test('escape: the first player out stays the winner and is placement #1', () => {
|
||||
const room = makeCryptoFinishedRoom({ winner: 'a' });
|
||||
expect(room.winnerId).toBe('a');
|
||||
expect(room.playerStates.get('a').placement).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — payout economics (MP-C4/H5)', () => {
|
||||
function capturingDb({ address = 'WALLET', pot = '20000', fee = '1000' } = {}) {
|
||||
const captured = [];
|
||||
return {
|
||||
captured,
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: pot, house_fee_atomic: fee }] };
|
||||
if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: address }] };
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
const client = {
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('SELECT id FROM payouts')) return { rows: [] };
|
||||
return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
return fn(client);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('winner with an address gets a pending payout capped at pot minus house fee', async () => {
|
||||
const db = capturingDb({ address: 'WALLET', pot: '20000', fee: '1000' });
|
||||
const svc = new MatchPayoutService({ db });
|
||||
await svc.payoutWinner(makeCryptoFinishedRoom());
|
||||
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
||||
expect(ins.params[3]).toBe('19000'); // amount = 20000 - 1000
|
||||
expect(ins.params[5]).toBe('match_winner');
|
||||
expect(ins.params[6]).toBe('pending');
|
||||
});
|
||||
|
||||
test('MP-H5: a winner with NO address still records a claimable payout (never forfeited)', async () => {
|
||||
const db = capturingDb({ address: null, pot: '20000', fee: '1000' });
|
||||
const svc = new MatchPayoutService({ db });
|
||||
await svc.payoutWinner(makeCryptoFinishedRoom());
|
||||
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
||||
expect(ins).toBeTruthy();
|
||||
expect(ins.params[3]).toBe('19000'); // full owed amount recorded
|
||||
expect(ins.params[6]).toBe('needs_review'); // deferred, batcher won't send it
|
||||
expect(ins.params[5]).toBe('match_winner_no_address');
|
||||
expect(ins.params[2]).toBe('PENDING_NO_ADDRESS'); // sentinel, non-null, claimable
|
||||
});
|
||||
|
||||
test('BigInt exactness: a pot beyond 2^53 loses no precision', async () => {
|
||||
const db = capturingDb({ address: 'WALLET', pot: '90071992547409920001', fee: '1' });
|
||||
const svc = new MatchPayoutService({ db });
|
||||
await svc.payoutWinner(makeCryptoFinishedRoom());
|
||||
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
||||
expect(ins.params[3]).toBe('90071992547409920000'); // exact; a float would round this
|
||||
});
|
||||
|
||||
test('collectEntryTickets uses integer basis-point math for a fractional house fee', async () => {
|
||||
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000000000';
|
||||
process.env.MATCH_HOUSE_FEE_PERCENT = '5.5';
|
||||
const captured = [];
|
||||
const db = { async withTransaction(fn) { return fn({ async query(sql, params) { captured.push({ sql, params }); return { rows: [{ race_entries: 1 }], rowCount: 1 }; } }); } };
|
||||
const svc = new MatchPayoutService({ db });
|
||||
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
|
||||
await svc.collectEntryTickets(room, [{ userId: 1 }, { userId: 2 }]);
|
||||
const upd = captured.find(q => q.sql.includes('UPDATE matches'));
|
||||
expect(upd.params[0]).toBe('10000000000'); // entry fee
|
||||
expect(upd.params[1]).toBe('20000000000'); // pot = fee * 2
|
||||
expect(upd.params[2]).toBe('1100000000'); // house fee = pot * 550bp / 10000 (floor)
|
||||
expect(room.potAtomic).toBeGreaterThan(0);
|
||||
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
|
||||
delete process.env.MATCH_HOUSE_FEE_PERCENT;
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — refunds on every cancellation path (MP-H3/H4)', () => {
|
||||
afterEach(() => { delete process.env.MATCH_ENABLED; delete process.env.MATCH_CREDITS_COST; });
|
||||
|
||||
test('refundEntries cancels the queue row AND writes a ticket refund ledger row', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const captured = [];
|
||||
const db = {
|
||||
async query() { return { rows: [], rowCount: 0 }; },
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('UPDATE match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
return { rows: [{ race_entries: 5 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const q = new MatchQueue({ db });
|
||||
await q.refundEntries([{ userId: 1 }], 'crypto_race', 'match_cancel');
|
||||
expect(captured.some(c => c.sql.includes("status = 'cancelled'"))).toBe(true);
|
||||
expect(captured.some(c => c.sql.includes('race_entries = race_entries + 1'))).toBe(true);
|
||||
expect(captured.some(c => c.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
|
||||
});
|
||||
|
||||
test('MP-H4: a credits leave after restart refunds the DB-derived amount, not memory', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
// No MATCH_CREDITS_COST set -> configured default is 1; the ledger says 7 was deducted.
|
||||
const captured = [];
|
||||
const db = {
|
||||
async query() { return { rows: [], rowCount: 0 }; },
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('DELETE FROM match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
if (sql.includes("reason = 'match_queue_join'")) return { rows: [{ amount: '-7' }], rowCount: 1 };
|
||||
if (sql.includes('UPDATE users SET credits')) return { rows: [{ credits: 107 }], rowCount: 1 };
|
||||
return { rows: [{}], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const q = new MatchQueue({ db }); // in-memory queue is EMPTY (simulates a restart)
|
||||
const res = await q.leave(1, 'credits_prestige');
|
||||
expect(res.success).toBe(true);
|
||||
const upd = captured.find(c => c.sql.includes('UPDATE users SET credits'));
|
||||
expect(upd.params[0]).toBe(7); // exact deducted amount from the ledger, not config (1)
|
||||
});
|
||||
|
||||
test('leave accepts a resolved session object (socketHandlers form)', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = {
|
||||
async query() { return { rows: [], rowCount: 0 }; },
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql) {
|
||||
if (sql.includes('DELETE FROM match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
return { rows: [{ race_entries: 3 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const q = new MatchQueue({ db });
|
||||
const res = await q.leave({ userId: 1, economy: 'crypto_race' });
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — payout-first isolated finalize (MP-C5)', () => {
|
||||
afterEach(() => { delete process.env.MATCH_ENABLED; });
|
||||
|
||||
test('the winner payout runs FIRST and the match_end broadcast still fires when later steps throw', async () => {
|
||||
const io = makeIo();
|
||||
const throwingDb = { async withTransaction() { throw new Error('persist boom'); } };
|
||||
const m = new MatchManager({ io, db: throwingDb, debugManager: null });
|
||||
m.enabled = true;
|
||||
|
||||
const order = [];
|
||||
m.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
|
||||
m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); throw new Error('lb boom'); };
|
||||
|
||||
const room = makeCryptoFinishedRoom();
|
||||
m.rooms.set(room.id, room);
|
||||
await m._finalize(room);
|
||||
|
||||
expect(order[0]).toBe('payout'); // payout is attempted before persist/leaderboard
|
||||
// Persist threw and leaderboard threw, yet the result was still broadcast to clients.
|
||||
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(true);
|
||||
m.shutdown();
|
||||
});
|
||||
});
|
||||
+34
-17
@@ -2,6 +2,21 @@ const MatchScheduler = require('../src/network/matchScheduler');
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
|
||||
describe('MatchScheduler', () => {
|
||||
let scheduler = null;
|
||||
let manager = null;
|
||||
|
||||
afterEach(() => {
|
||||
// The scheduler starts long-lived timers when it drains a queue: a hard-ceiling
|
||||
// setTimeout (owned by the scheduler) and, per match, an engine tick interval (owned
|
||||
// by the manager via setEngine). Stop both so Jest can exit instead of leaking handles
|
||||
// (an unstopped ceiling timer later fires manager.expire() and crashes the process).
|
||||
if (scheduler) scheduler.shutdown();
|
||||
if (manager) for (const engine of manager.engines.values()) engine.stop();
|
||||
scheduler = null;
|
||||
manager = null;
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
function makeQueue() {
|
||||
const entries = [];
|
||||
let nextId = 1;
|
||||
@@ -26,45 +41,46 @@ describe('MatchScheduler', () => {
|
||||
|
||||
function makeManager() {
|
||||
const attached = [];
|
||||
const engines = new Map();
|
||||
return {
|
||||
attached,
|
||||
engines,
|
||||
db: null,
|
||||
attach(room, entrants, opts) {
|
||||
this.attached.push({ room, entrants, opts });
|
||||
},
|
||||
setEngine(id, engine) {},
|
||||
setEngine(id, engine) { engines.set(id, engine); },
|
||||
onTick() {},
|
||||
onFinish() {}
|
||||
onFinish() {},
|
||||
expire() {}
|
||||
};
|
||||
}
|
||||
|
||||
test('does nothing when disabled', async () => {
|
||||
process.env.MATCH_ENABLED = 'false';
|
||||
const queue = makeQueue();
|
||||
const manager = makeManager();
|
||||
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
await scheduler.onBlock(100);
|
||||
expect(manager.attached.length).toBe(0);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('does nothing when fewer than 2 players are queued', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const queue = makeQueue();
|
||||
const manager = makeManager();
|
||||
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
||||
await scheduler.onBlock(100);
|
||||
expect(manager.attached.length).toBe(0);
|
||||
expect(queue.length('free')).toBe(1);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('drains free queue into a match when 2+ players queued', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const queue = makeQueue();
|
||||
const manager = makeManager();
|
||||
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
||||
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
|
||||
await scheduler.onBlock(100);
|
||||
@@ -73,20 +89,21 @@ describe('MatchScheduler', () => {
|
||||
expect(room.economy).toBe('free');
|
||||
expect(entrants.length).toBe(2);
|
||||
expect(queue.length('free')).toBe(0);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('starts the match engine after attach', async () => {
|
||||
test('hands off the engine without starting it (real countdown, no head-start)', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const queue = makeQueue();
|
||||
const manager = makeManager();
|
||||
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, tickMs: 100 });
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, tickMs: 100 });
|
||||
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
|
||||
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
|
||||
await scheduler.onBlock(100);
|
||||
const { room } = manager.attached[0];
|
||||
// Engine exists and was started via attach (the actual engine lives in scheduler internals).
|
||||
expect(room.status).toBe('active');
|
||||
delete process.env.MATCH_ENABLED;
|
||||
// MP-H6: the scheduler creates the engine and hands it to the manager (setEngine) but
|
||||
// does NOT start it. The room stays in 'starting' — moves are rejected — until the
|
||||
// manager's countdown elapses, so honest and modified clients always start together.
|
||||
expect(manager.engines.size).toBe(1);
|
||||
expect(room.status).toBe('starting');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Payment-safety fixes (M1, M3, M4, M5, C1).
|
||||
*
|
||||
* Covers the gameModeManager hardening:
|
||||
* - M1: an ambiguous SINGLE payout failure is marked 'needs_review' (not 'failed') + alerts.
|
||||
* - M3: a concurrent single_game payment claim aborts cleanly (no duplicate game / throw).
|
||||
* - M4: completeGame refuses to insert a payout it can't attach to a resolved game + user.
|
||||
* - M5: the payout amount is clamped to the configured max (cap+alert) / skipped below min.
|
||||
* - C1: getOrCreateUser(socketId, { create: false }) returns null without inserting a user.
|
||||
*/
|
||||
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
|
||||
const createMockDb = () => {
|
||||
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
|
||||
return {
|
||||
query: jest.fn().mockResolvedValue({ rows: [] }),
|
||||
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
|
||||
_mockClient: mockClient
|
||||
};
|
||||
};
|
||||
|
||||
const createMockPaymentConfig = () => ({
|
||||
getConfig: () => ({
|
||||
paymentsEnabled: true,
|
||||
currency: { symbol: 'WOW', decimals: 11 },
|
||||
modes: {
|
||||
direct: { enabled: true, price: 100000000000n },
|
||||
credits: { enabled: false, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
|
||||
},
|
||||
payouts: {
|
||||
rules: {
|
||||
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1000000000n, maxPayout: 10000000000000n },
|
||||
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n, minPayout: 1000000000n, maxPayout: 5000000000000n }
|
||||
}
|
||||
},
|
||||
preferences: { preferCreditsFirst: true },
|
||||
earlyEntry: { enabled: false }
|
||||
}),
|
||||
getLegacyGameMode: () => 'PAID_SINGLE',
|
||||
eventBus: { on: () => {} }
|
||||
});
|
||||
|
||||
function buildGmm(walletService = { processPayout: jest.fn(), processBatchPayout: jest.fn() }) {
|
||||
const db = createMockDb();
|
||||
const gmm = new GameModeManager(db, walletService, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig());
|
||||
gmm._scheduleBatchPayout = jest.fn(); // never actually schedule
|
||||
gmm.alertService = { sendAlert: jest.fn().mockResolvedValue() };
|
||||
return { gmm, db };
|
||||
}
|
||||
|
||||
const ONE_PENDING = [
|
||||
{ id: 42, user_id: 1, game_id: 7, payout_address: 'addrA', amount: '100000000000', multiplier: 2, reason: 'escape' }
|
||||
];
|
||||
|
||||
describe('M1 — single payout ambiguous failure', () => {
|
||||
test('is marked needs_review (never failed) and alerts', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn().mockRejectedValue(new Error('RPC timeout after broadcast?')),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: ONE_PENDING }) // SELECT ... FOR UPDATE SKIP LOCKED
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
|
||||
expect(reviewCall).toBeDefined();
|
||||
expect(reviewCall[1][2]).toBe(42); // payout id
|
||||
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
|
||||
expect(failedCall).toBeUndefined();
|
||||
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('single_payout_failed', expect.any(Object));
|
||||
});
|
||||
|
||||
test('a pre-broadcast insufficient-funds error stays pending (safe retry), no alert', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn().mockRejectedValue(new Error('not enough unlocked money to transfer')),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: ONE_PENDING })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$3/i.test(c[0]));
|
||||
expect(pendingCall).toBeDefined();
|
||||
expect(gmm.alertService.sendAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('M3 — atomic single_game payment claim', () => {
|
||||
test('aborts cleanly when the payment was already consumed (claim returns no row)', async () => {
|
||||
const { gmm } = buildGmm();
|
||||
// withTransaction runs cb with a client whose claim SELECT returns no rows.
|
||||
gmm.db.withTransaction = jest.fn(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }));
|
||||
|
||||
const res = await gmm._processGameStartWithPayment({ id: 1, payout_address: 'addr' }, { id: 55 }, 'seed-1');
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.alreadyConsumed).toBe(true);
|
||||
});
|
||||
|
||||
test('treats a games.payment_id unique violation (23505) as already consumed, not a throw', async () => {
|
||||
const { gmm } = buildGmm();
|
||||
gmm.db.withTransaction = jest.fn(async (cb) => {
|
||||
const client = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ id: 55 }] }) // claim SELECT ... FOR UPDATE finds the payment
|
||||
.mockImplementationOnce(() => { const e = new Error('duplicate key'); e.code = '23505'; throw e; })
|
||||
};
|
||||
return cb(client); // the throw propagates like the real withTransaction (rollback + rethrow)
|
||||
});
|
||||
|
||||
const res = await gmm._processGameStartWithPayment({ id: 1, payout_address: 'addr' }, { id: 55 }, 'seed-1');
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.alreadyConsumed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('M4 — completeGame never inserts an unattached payout', () => {
|
||||
test('refuses payout when the paying user cannot be resolved from the game row', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, /* no user_id */ game_mode: 'PAID_SINGLE', payout_address: 'addr' }] }) // game record
|
||||
.mockResolvedValue({ rows: [] }); // completion UPDATE + existing-payout check
|
||||
|
||||
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
||||
|
||||
expect(result.reason).toBe('Paying user not resolved');
|
||||
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
||||
expect(inserted).toBeUndefined();
|
||||
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_unresolved_user', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('M5 — payout amount is clamped to configured bounds', () => {
|
||||
test('caps an over-max payout and alerts', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
|
||||
payout_escape_amount: '99999999999999', payout_treasure_amount: '99999999999999',
|
||||
payout_escape_mult: '2', payout_treasure_mult: '3'
|
||||
}] }) // game record
|
||||
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
|
||||
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'addr' }] }) // user lookup by id
|
||||
.mockResolvedValueOnce({ rows: [{ id: 999 }] }); // INSERT payout
|
||||
|
||||
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
||||
|
||||
expect(result.payout.amount).toBe('10000000000000'); // capped to maxPayout
|
||||
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_over_max', expect.any(Object));
|
||||
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
||||
expect(inserted[1][3]).toBe('10000000000000'); // amount param is the capped value
|
||||
});
|
||||
|
||||
test('skips a below-min payout entirely (no insert)', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
|
||||
payout_escape_amount: '1', payout_treasure_amount: '1',
|
||||
payout_escape_mult: '2', payout_treasure_mult: '3'
|
||||
}] })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
||||
|
||||
expect(result.reason).toBe('Below minimum payout');
|
||||
expect(result.payout).toBeNull();
|
||||
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
||||
expect(inserted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('C1 — getOrCreateUser create:false', () => {
|
||||
test('returns null and never inserts when no user row exists', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.query.mockResolvedValue({ rows: [] }); // socket lookup finds nothing
|
||||
|
||||
const user = await gmm.getOrCreateUser('ghost-socket', { create: false });
|
||||
|
||||
expect(user).toBeNull();
|
||||
const insert = db.query.mock.calls.find(c => /INSERT INTO users/i.test(c[0]));
|
||||
expect(insert).toBeUndefined();
|
||||
});
|
||||
|
||||
test('still creates by default for existing callers', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [] }) // socket lookup: none
|
||||
.mockResolvedValueOnce({ rows: [{ id: 7, socket_id: 's' }] }); // INSERT ... RETURNING *
|
||||
|
||||
const user = await gmm.getOrCreateUser('s');
|
||||
|
||||
expect(user).toEqual({ id: 7, socket_id: 's' });
|
||||
const insert = db.query.mock.calls.find(c => /INSERT INTO users/i.test(c[0]));
|
||||
expect(insert).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -300,7 +300,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
test('processes winning game with payout correctly', async () => {
|
||||
// Game record lookup
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, payment_mode: 'direct' }]
|
||||
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
||||
});
|
||||
// Game update
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] });
|
||||
@@ -329,7 +329,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
test('treasure bonus applied correctly to payout', async () => {
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, payment_mode: 'direct' }]
|
||||
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
||||
});
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] });
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
||||
@@ -351,7 +351,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
test('no payout for losing game', async () => {
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, payment_mode: 'direct' }]
|
||||
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
||||
});
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
||||
|
||||
@@ -364,7 +364,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
test('no payout when user has no payout address', async () => {
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, payment_mode: 'direct' }]
|
||||
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
||||
});
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
||||
@@ -382,7 +382,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
test('prevents duplicate payout for same game', async () => {
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, payment_mode: 'direct' }]
|
||||
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
|
||||
});
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
||||
// Existing payout found
|
||||
@@ -407,7 +407,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 };
|
||||
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, game_mode: 'PAID_CREDITS' }] // Game was started with credits
|
||||
rows: [{ id: 1, user_id: 1, game_mode: 'PAID_CREDITS' }] // Game was started with credits
|
||||
});
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
||||
@@ -657,7 +657,7 @@ describe('Edge Cases and Error Handling', () => {
|
||||
test('handles payout insert failure gracefully', async () => {
|
||||
gmm.gameMode = 'PAID_SINGLE';
|
||||
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, payment_mode: 'direct' }] });
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }] });
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('Payout uses the snapshot recorded at game start', () => {
|
||||
// live 2x = 200000000000), treasure pays 999999.
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr',
|
||||
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr',
|
||||
payout_escape_amount: '123456', payout_treasure_amount: '999999',
|
||||
payout_escape_mult: '2.000', payout_treasure_mult: '3.000'
|
||||
}] }) // game record lookup
|
||||
@@ -79,7 +79,7 @@ describe('Payout uses the snapshot recorded at game start', () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // no snapshot cols
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // no snapshot cols
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] })
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('product grants', () => {
|
||||
|
||||
expect(summary).toEqual({
|
||||
credits: 0,
|
||||
raceEntries: 0,
|
||||
packs: ['iso-dungeon'],
|
||||
premiumLevel: null
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const DungeonGenerator = require('../src/game/dungeon');
|
||||
const Monster = require('../src/game/monster');
|
||||
const { createSeededRNG, hashSeed, verifyGame } = require('../src/game/provablyFair');
|
||||
const { createSeededRNG, hashSeed, verifyGame, deriveSeed } = require('../src/game/provablyFair');
|
||||
|
||||
const SEED_A = 'a'.repeat(64);
|
||||
const SEED_B = 'b'.repeat(64);
|
||||
@@ -48,6 +48,57 @@ describe('Provably fair: deterministic dungeon generation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provably fair: seed derivation (server + client seed)', () => {
|
||||
test('deriveSeed is HMAC-SHA256(key=serverSeed, msg=clientSeed) hex', () => {
|
||||
const crypto = require('crypto');
|
||||
const expected = crypto.createHmac('sha256', SEED_A).update('my-client-seed').digest('hex');
|
||||
expect(deriveSeed(SEED_A, 'my-client-seed')).toBe(expected);
|
||||
// 64-char hex output
|
||||
expect(deriveSeed(SEED_A, 'my-client-seed')).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('clientSeed defaults to empty string (non-breaking) and is deterministic', () => {
|
||||
const crypto = require('crypto');
|
||||
const expected = crypto.createHmac('sha256', SEED_A).update('').digest('hex');
|
||||
expect(deriveSeed(SEED_A)).toBe(expected);
|
||||
expect(deriveSeed(SEED_A)).toBe(deriveSeed(SEED_A, ''));
|
||||
});
|
||||
|
||||
test('different client seeds yield different derived seeds (client perturbs outcome)', () => {
|
||||
expect(deriveSeed(SEED_A, 'x')).not.toBe(deriveSeed(SEED_A, 'y'));
|
||||
// and a different server seed changes the result too
|
||||
expect(deriveSeed(SEED_A, 'x')).not.toBe(deriveSeed(SEED_B, 'x'));
|
||||
});
|
||||
|
||||
test('a verifier can reproduce the dungeon from serverSeed + clientSeed', () => {
|
||||
const serverSeed = SEED_A;
|
||||
const clientSeed = 'player-chosen';
|
||||
// Commitment is over the serverSeed only (locked before clientSeed is known).
|
||||
const commitment = hashSeed(serverSeed);
|
||||
expect(verifyGame(serverSeed, commitment).valid).toBe(true);
|
||||
// The effective seed drives generation; the verifier re-derives it and regenerates.
|
||||
const effective = deriveSeed(serverSeed, clientSeed);
|
||||
const fp1 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(effective, 'WOW'));
|
||||
const fp2 = DungeonGenerator.layoutFingerprint(
|
||||
DungeonGenerator.regenerateFromSeed(deriveSeed(serverSeed, clientSeed), 'WOW')
|
||||
);
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provably fair: seeded RNG is bounded to [0, 1)', () => {
|
||||
test('every value stays within [0, 1) across many draws and seeds', () => {
|
||||
for (let s = 0; s < 8; s++) {
|
||||
const rng = createSeededRNG((s.toString(16)).repeat(64).slice(0, 64));
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const v = rng();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dungeon reachability (Phase 3.2)', () => {
|
||||
test('generated dungeons always have a reachable exit (many seeds)', () => {
|
||||
const cfg = { primaryFloor: "'1", secondaryFloor: "'2" };
|
||||
|
||||
@@ -23,9 +23,11 @@ describe('rateLimitContext.clientIp', () => {
|
||||
test('uses handshake address by default (ignores XFF when proxy not trusted)', () => {
|
||||
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9' }, address: '10.0.0.1' } })).toBe('10.0.0.1');
|
||||
});
|
||||
test('uses first X-Forwarded-For hop when TRUST_PROXY=true', () => {
|
||||
test('uses the rightmost (proxy-appended) X-Forwarded-For hop when TRUST_PROXY=true', () => {
|
||||
process.env.TRUST_PROXY = 'true';
|
||||
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9, 10.0.0.1' }, address: '10.0.0.1' } })).toBe('9.9.9.9');
|
||||
// A single trusted nginx APPENDS the real client IP as the last hop; the leftmost entries
|
||||
// are client-spoofable, so the rightmost is the only trustworthy one for rate limiting.
|
||||
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9, 10.0.0.1' }, address: '7.7.7.7' } })).toBe('10.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+18
-8
@@ -134,12 +134,20 @@ describe('SQL Injection Prevention', () => {
|
||||
let dbAvailable = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Try to connect to real DB for DatabaseManager tests
|
||||
realDb = new DatabaseManager();
|
||||
await realDb.initialize();
|
||||
// Check if connection was successful (initialize doesn't throw, it returns silently)
|
||||
dbAvailable = realDb.connected === true;
|
||||
|
||||
// Only attempt a real DB connection when a usable password is configured. Without one,
|
||||
// node-postgres throws inside the SASL handshake *after* opening the TCP socket and
|
||||
// orphans that socket (it survives pool.end()), which would keep Jest from exiting. A
|
||||
// missing password simply means "DB not available" here — and these tests already fall
|
||||
// back to mocks in that case.
|
||||
const hasDbPassword = typeof process.env.DB_PASSWORD === 'string' && process.env.DB_PASSWORD.length > 0;
|
||||
if (hasDbPassword) {
|
||||
// Try to connect to real DB for DatabaseManager tests
|
||||
realDb = new DatabaseManager();
|
||||
await realDb.initialize();
|
||||
// Check if connection was successful (initialize doesn't throw, it returns silently)
|
||||
dbAvailable = realDb.connected === true;
|
||||
}
|
||||
|
||||
if (!dbAvailable) {
|
||||
console.log('⚠️ Database not available, DatabaseManager tests will use mocks');
|
||||
}
|
||||
@@ -164,8 +172,10 @@ describe('SQL Injection Prevention', () => {
|
||||
if (sessionManager) {
|
||||
sessionManager.dispose();
|
||||
}
|
||||
if (realDb && dbAvailable) {
|
||||
await realDb.close();
|
||||
// Always close the pool: initialize() creates it even when the connection fails, so
|
||||
// gating on dbAvailable would leak the pg pool handle and keep Jest from exiting.
|
||||
if (realDb) {
|
||||
try { await realDb.close(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user