Wownerogue

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

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
const AddressManager = require('../src/network/addressManager');
const { AppError } = require('../src/utils/errors');
describe('AddressManager', () => {
const sampleAddress = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn';
function createManager({ existingAddress = null, cryptoType = null, network = null, walletValidation = null } = {}) {
const emitMock = jest.fn();
const toMock = jest.fn().mockReturnValue({ emit: emitMock });
const broadcastMock = { sendStatusUpdate: jest.fn() };
const gameModeManager = {
cryptoType,
network,
setUserPayoutAddress: jest.fn().mockResolvedValue(true),
getOrCreateUser: jest.fn().mockResolvedValue({ payout_address: existingAddress })
};
if (walletValidation) {
gameModeManager.validatePayoutAddress = jest.fn().mockResolvedValue(walletValidation);
}
const manager = new AddressManager({
gameModeManager,
broadcastManager: broadcastMock,
io: { to: toMock },
debugManager: { CONSOLE_LOGGING: false },
onConfirmed: jest.fn()
});
return { manager, emitMock, toMock, broadcastMock, gameModeManager };
}
test('saveAddress persists and emits confirmation', async () => {
const { manager, emitMock, gameModeManager } = createManager();
await manager.saveAddress('socket-1', sampleAddress);
expect(gameModeManager.setUserPayoutAddress).toHaveBeenCalledWith('socket-1', sampleAddress);
expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ address: sampleAddress }));
});
test('saveAddress rejects invalid address input', async () => {
const { manager } = createManager();
await expect(manager.saveAddress('socket-2', 'not-an-address')).rejects.toBeInstanceOf(AppError);
});
test('handleDetection skips confirmation when address already set', async () => {
const { manager, emitMock, gameModeManager } = createManager({ existingAddress: sampleAddress });
await manager.handleDetection('socket-3', sampleAddress);
expect(gameModeManager.getOrCreateUser).toHaveBeenCalled();
expect(manager.pending.has('socket-3')).toBe(false);
expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ message: expect.stringContaining('already set') }));
});
test('accepts stagenet XMR prefixes and rejects mainnet prefixes on a stagenet server', async () => {
const { manager, gameModeManager } = createManager({
cryptoType: 'XMR',
network: 'stagenet',
walletValidation: { valid: true, nettype: 'stagenet' }
});
const stagenetAddress = `5${'1'.repeat(94)}`;
const mainnetAddress = `4${'1'.repeat(94)}`;
expect(manager.isValidAddress(stagenetAddress)).toBe(true);
expect(manager.isValidAddress(mainnetAddress)).toBe(false);
expect(await manager.saveAddress('socket-stage', stagenetAddress)).toBe(stagenetAddress);
expect(gameModeManager.setUserPayoutAddress).toHaveBeenCalledWith('socket-stage', stagenetAddress);
});
test('wallet-RPC validation fails closed before an address is persisted', async () => {
const { manager, gameModeManager } = createManager({
cryptoType: 'XMR',
network: 'stagenet',
walletValidation: { valid: false, nettype: 'mainnet' }
});
const stagenetShapedAddress = `7${'1'.repeat(94)}`;
expect(await manager.saveAddress('socket-bad', stagenetShapedAddress)).toBe(false);
expect(gameModeManager.setUserPayoutAddress).not.toHaveBeenCalled();
});
});
+225
View File
@@ -0,0 +1,225 @@
const fs = require('fs');
const path = require('path');
const createAdminRoutes = require('../src/routes/admin');
const AlertService = require('../src/services/alertService');
function makeRouter(db) {
return createAdminRoutes({
db,
gameModeManager: {},
walletRPCService: {},
socketHandlers: {},
io: {},
alertService: null
});
}
function handlerFor(router, method, routePath) {
const layer = router.stack.find(item => item.route?.path === routePath
&& item.route.methods[method]);
if (!layer) throw new Error(`Missing ${method.toUpperCase()} ${routePath}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function invoke(handler, req) {
return new Promise((resolve, reject) => {
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
resolve({ status: this.statusCode, payload });
return this;
}
};
handler(req, res, reject);
});
}
describe('admin financial operations visibility', () => {
test.each(['processing', 'needs_review'])(
'payout list accepts the %s filter and reports every operational total',
async status => {
const db = {
query: jest.fn()
.mockResolvedValueOnce({ rows: [{
id: 7,
game_id: 8,
user_id: 9,
amount: '1200',
payout_address: 'address',
status,
tx_hash: null,
fee: null,
retry_count: 1,
last_error: 'ambiguous result',
created_at: new Date('2026-07-21T00:00:00Z'),
processed_at: null
}] })
.mockResolvedValueOnce({ rows: [{
pending_count: '1',
processing_count: '2',
failed_count: '3',
review_count: '4',
completed_count: '5',
permanently_failed_count: '6',
batched_count: '7',
total_volume: '8000'
}] })
};
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/payouts');
const response = await invoke(handler, { query: { status } });
expect(response.status).toBe(200);
expect(db.query.mock.calls[0][1]).toEqual([50, 0, status]);
expect(response.payload.payouts[0]).toMatchObject({ status, lastError: 'ambiguous result' });
expect(response.payload.totals).toMatchObject({
pending: 1,
processing: 2,
failed: 3,
needsReview: 4,
completed: 5,
permanentlyFailed: 6,
batched: 7
});
}
);
test('refund attention filter lists requested, processing, and needs_review rows', async () => {
const db = {
query: jest.fn()
.mockResolvedValueOnce({ rows: [{
id: 11,
payment_id: 12,
user_id: 13,
payment_status: 'refunded',
status: 'needs_review',
amount: '900',
payout_address: 'refund-address',
credits_deducted: '2',
purchase_progress_deducted: '2',
race_entries_deducted: 0,
tx_hash: null,
error_message: 'wallet outcome unknown',
reason: 'operator request',
created_at: new Date('2026-07-21T00:00:00Z'),
updated_at: new Date('2026-07-21T00:01:00Z')
}] })
.mockResolvedValueOnce({ rows: [{
recorded_count: '1',
requested_count: '2',
processing_count: '3',
completed_count: '4',
review_count: '5',
completed_volume: '600'
}] })
};
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/refunds');
const response = await invoke(handler, { query: { status: 'attention' } });
expect(db.query.mock.calls[0][1]).toEqual([
50,
0,
['requested', 'processing', 'needs_review']
]);
expect(response.payload.refunds[0]).toMatchObject({
id: 11,
status: 'needs_review',
error: 'wallet outcome unknown'
});
expect(response.payload.totals).toMatchObject({
recorded: 1,
requested: 2,
processing: 3,
completed: 4,
needsReview: 5
});
});
test('dashboard sends the retry endpoint its required explicit confirmation', () => {
const html = fs.readFileSync(path.join(__dirname, '../html/admin.html'), 'utf8');
const retryFunction = html.match(/async function retryPayout[\s\S]*?async function loadRefunds/)[0];
expect(retryFunction).toContain("'Content-Type': 'application/json'");
expect(retryFunction).toContain('body: JSON.stringify({ confirm: true })');
expect(html).toContain('<option value="processing">Processing</option>');
expect(html).toContain('<option value="needs_review">Needs Review</option>');
expect(html).toContain('id="refundStatusFilter"');
});
});
describe('financial review alerts', () => {
let logSpy;
let errorSpy;
beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
});
test('alerts for manual-review and stale nonterminal rows without mutating them', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [{
payout_review_count: '2',
refund_review_count: '1',
stale_payout_processing_count: '1',
stale_refund_nonterminal_count: '2'
}] }) };
const service = new AlertService({ db, walletService: null, debugManager: {} });
service.sendAlert = jest.fn().mockResolvedValue({ sent: true });
service.resolveAlert = jest.fn().mockResolvedValue({ sent: false });
await service.checkFinancialReviewRows();
expect(db.query).toHaveBeenCalledTimes(1);
expect(db.query.mock.calls[0][1]).toEqual([service.financialReviewStaleMs]);
expect(service.sendAlert).toHaveBeenCalledWith(
'financial_needs_review',
expect.objectContaining({ level: 'crit' })
);
expect(service.sendAlert).toHaveBeenCalledWith(
'financial_nonterminal_stale',
expect.objectContaining({ level: 'crit' })
);
expect(service.resolveAlert).not.toHaveBeenCalled();
expect(db.query.mock.calls[0][0]).not.toMatch(/\bUPDATE\b/i);
});
test('resolves both aggregate alerts after all review queues clear', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [{
payout_review_count: '0',
refund_review_count: '0',
stale_payout_processing_count: '0',
stale_refund_nonterminal_count: '0'
}] }) };
const service = new AlertService({ db, walletService: null, debugManager: {} });
service.sendAlert = jest.fn();
service.resolveAlert = jest.fn().mockResolvedValue({ sent: true });
await service.checkFinancialReviewRows();
expect(service.sendAlert).not.toHaveBeenCalled();
expect(service.resolveAlert).toHaveBeenCalledWith('financial_needs_review');
expect(service.resolveAlert).toHaveBeenCalledWith('financial_nonterminal_stale');
});
test('periodic checks include the financial review query', async () => {
const service = new AlertService({ db: {}, walletService: null, debugManager: {} });
service.checkWalletBalance = jest.fn().mockResolvedValue();
service.checkWalletConnection = jest.fn().mockResolvedValue();
service.checkPendingPayouts = jest.fn().mockResolvedValue();
service.checkFinancialReviewRows = jest.fn().mockResolvedValue();
await service.runChecks();
expect(service.checkFinancialReviewRows).toHaveBeenCalledTimes(1);
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Admin routes extraction smoke test (Phase 4.1).
*
* Verifies the extracted admin route factory loads and registers all expected routes
* with a stub ctx: catches load-time/reference errors from the index.js -> routes/admin.js
* split without needing a live server.
*/
const createAdminRoutes = require('../src/routes/admin');
const createAuthRoutes = require('../src/routes/auth');
function stubCtx() {
return {
db: { query: jest.fn(), getClient: jest.fn() },
gameModeManager: {},
walletRPCService: {},
socketHandlers: {},
io: {},
alertService: null
};
}
describe('admin routes factory', () => {
test('builds a router and registers the expected admin endpoints', () => {
const router = createAdminRoutes(stubCtx());
expect(typeof router).toBe('function'); // express router is a middleware function
const paths = router.stack.filter(l => l.route).map(l => l.route.path);
const expected = [
'/api/admin/refund/payment',
'/api/admin/credits/adjust',
'/api/admin/alerts/test-email',
'/api/admin/queue',
'/api/admin/queue/remove',
'/api/admin/chat',
'/api/admin/chat/:id',
'/api/admin/users/:id/chat-ban',
'/api/admin/users/search',
'/api/admin/stats/overview',
'/api/admin/stats/payouts',
'/api/admin/stats/refunds',
'/api/admin/stats/games',
'/api/admin/users',
'/api/admin/users/:id',
'/api/admin/payouts/:id/retry'
];
for (const p of expected) {
expect(paths).toContain(p);
}
expect(paths.length).toBe(expected.length);
});
});
describe('auth (smirk) routes factory', () => {
test('builds a router and registers the smirk endpoints', () => {
const query = jest.fn();
const router = createAuthRoutes({
db: { query, withTransaction: jest.fn() },
sessionManager: {
generateSecureToken: jest.fn(),
disconnectUserSessions: jest.fn()
}
});
const paths = router.stack.filter(l => l.route).map(l => l.route.path);
expect(paths).toEqual(expect.arrayContaining([
'/api/auth/smirk/challenge',
'/api/auth/smirk/verify',
'/api/auth/smirk/status'
]));
expect(paths.length).toBe(3);
});
});
+57
View File
@@ -0,0 +1,57 @@
const AlertService = require('../src/services/alertService');
describe('AlertService exact reserve gate', () => {
const keys = ['BALANCE_CRITICAL', 'BALANCE_WARN', 'LOW_BALANCE_THRESHOLD'];
let previous;
let logSpy;
let errorSpy;
beforeEach(() => {
previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
process.env.BALANCE_CRITICAL = '10000000000000001';
process.env.BALANCE_WARN = '10000000000000002';
delete process.env.LOW_BALANCE_THRESHOLD;
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
});
test('compares balances above Number.MAX_SAFE_INTEGER without rounding', async () => {
const service = new AlertService({
walletService: {
isHealthy: true,
getBalance: jest.fn(async () => ({ unlocked_balance: '10000000000000000' }))
},
db: null,
debugManager: { CONSOLE_LOGGING: false }
});
expect(service.balanceCriticalThreshold).toBe(10000000000000001n);
await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({ halted: true }));
});
test('wallet balance read errors fail closed for new paid games', async () => {
const service = new AlertService({
walletService: {
isHealthy: true,
getBalance: jest.fn(async () => { throw new Error('rpc unavailable'); })
},
db: null,
debugManager: { CONSOLE_LOGGING: false }
});
await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({
halted: true,
reason: expect.stringContaining('could not be verified')
}));
expect(service.isBalanceCritical).toBe(true);
});
});
+49
View File
@@ -0,0 +1,49 @@
const Appearance = require('../src/multiplayer/appearance');
describe('appearance catalog normalization', () => {
test('normalizes character colors and equipment slots', () => {
expect(Appearance.normalizeAppearance({
avatar: 'char-ranger',
tint: 'moss',
equipment: { body: 'mail', head: 'helm', shield: 'bad', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'bad', shield: 'rose', weapon: 'ash' }
})).toEqual({
avatar: 'char-ranger',
tint: 'teal',
equipment: { body: 'mail', head: 'helm', shield: 'none', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'none', shield: 'rose', weapon: 'ash' }
});
});
test('uses legacy tint as the default character color ramp', () => {
expect(Appearance.normalizeAppearance({
avatar: 'char-ranger',
tint: 'moss'
})).toEqual({
avatar: 'char-ranger',
tint: 'moss',
equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' },
colors: { base: 'moss', skin: 'natural', hair: 'copper', body: 'moss', head: 'moss', shield: 'moss', weapon: 'moss' }
});
});
test('strips character-only fields from color and premium skin avatars', () => {
expect(Appearance.normalizeAppearance({
avatar: 'monero-knight',
tint: 'teal',
equipment: { body: 'mail' }
})).toEqual({
avatar: 'monero-knight',
tint: 'none',
equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' }
});
});
test('falls back unknown avatars to default', () => {
expect(Appearance.normalizeAppearance('rainbow')).toEqual({
avatar: 'default',
tint: 'none',
equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' }
});
});
});
+19
View File
@@ -0,0 +1,19 @@
const Appearance = require('../src/multiplayer/appearance');
describe('appearance normalization for 3D avatars', () => {
test('preserves base colors for model avatars', () => {
const ap = Appearance.normalizeAppearance({
avatar: 'kenney-survivor-male',
tint: 'gold',
colors: { base: 'teal', skin: 'umber', hair: 'silver' },
equipment: { body: 'mail', head: 'helm' }
});
expect(ap).toMatchObject({
avatar: 'kenney-survivor-male',
tint: 'teal',
equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' },
colors: expect.objectContaining({ base: 'teal', skin: 'umber', hair: 'silver' })
});
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* The auth limiter's bounded key map must EVICT, never refuse.
*
* `createIpRateLimiter` caps its key map to bound memory. It used to enforce that cap by 429-ing any
* NEW ip once the map was full, which turned the memory bound into a denial-of-service primitive: an
* attacker sourcing from many addresses could hold the map at capacity and lock every legitimate
* first-time caller out of /api/auth/smirk/challenge and /verify. Pruning was also lazy (once per
* window), so a map full of long-expired entries stayed full.
*/
const express = require('express');
const http = require('http');
const createAuthRoutes = require('../src/routes/auth');
// The limiter is a private closure, so exercise it through a real router with a stub db/session.
function startServer() {
const app = express();
app.use(express.json());
app.set('trust proxy', true);
app.use(createAuthRoutes({
db: { query: async () => ({ rows: [] }), withTransaction: async (fn) => fn({ query: async () => ({ rows: [] }) }) },
sessionManager: { generateSecureToken: () => 't', disconnectUserSessions: () => {} }
}));
app.use((err, req, res, _next) => res.status(err.statusCode || 500).json({ error: err.message }));
return new Promise((resolve) => {
const server = http.createServer(app);
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
async function challengeFrom(server, ip) {
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/api/auth/smirk/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': ip },
body: JSON.stringify({ socketId: 'sock-1' })
});
return res.status;
}
describe('auth IP rate limiter', () => {
let server;
beforeAll(async () => { server = await startServer(); });
afterAll(() => server && server.close());
test('a fresh IP is never 429-ed just because many other IPs have been seen', async () => {
// Saturate well past the point where the old implementation started refusing new keys.
// 10k requests would be slow; 1.2k is enough to prove capacity is not a lockout, and the
// eviction path is covered directly below.
for (let i = 0; i < 1200; i++) {
await challengeFrom(server, `10.0.${Math.floor(i / 256)}.${i % 256}`);
}
const status = await challengeFrom(server, '203.0.113.77');
expect(status).not.toBe(429);
}, 60000);
test('an individual IP is still limited within its window', async () => {
const ip = '198.51.100.5';
const seen = [];
for (let i = 0; i < 14; i++) seen.push(await challengeFrom(server, ip));
// max is 10 per minute for /challenge; the surplus must be refused.
expect(seen.filter((s) => s === 429).length).toBeGreaterThan(0);
}, 30000);
});
describe('eviction policy', () => {
// Exercise the bound directly rather than issuing 10k HTTP requests.
const AUTH_WINDOW_MS = 60 * 1000;
function limiterHarness(maxKeys) {
const hits = new Map();
let lastSweep = 0;
const sweepExpired = (now) => {
for (const [key, value] of hits) {
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
}
lastSweep = now;
};
const evictOldest = (count) => {
const oldest = Array.from(hits.entries())
.sort((l, r) => l[1].windowStart - r[1].windowStart)
.slice(0, count);
for (const [key] of oldest) hits.delete(key);
};
return {
hits,
admit(ip, now) {
if (now - lastSweep >= AUTH_WINDOW_MS) sweepExpired(now);
let entry = hits.get(ip);
if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) {
if (!entry && hits.size >= maxKeys) {
sweepExpired(now);
if (hits.size >= maxKeys) evictOldest(Math.max(1, Math.ceil(maxKeys * 0.1)));
}
entry = { count: 0, windowStart: now };
hits.set(ip, entry);
}
entry.count += 1;
return true;
}
};
}
test('expired entries are reclaimed before any live window is discarded', () => {
const h = limiterHarness(10);
const t0 = 1_000_000;
for (let i = 0; i < 10; i++) h.admit(`old-${i}`, t0);
expect(h.hits.size).toBe(10);
// A full window later every entry is stale; the newcomer should reclaim, not evict-and-refuse.
h.admit('fresh', t0 + AUTH_WINDOW_MS + 1);
expect(h.hits.has('fresh')).toBe(true);
expect(h.hits.size).toBe(1);
});
test('when every window is live, the OLDEST are evicted and the newcomer is admitted', () => {
const h = limiterHarness(10);
const t0 = 1_000_000;
for (let i = 0; i < 10; i++) h.admit(`ip-${i}`, t0 + i); // all within one window
h.admit('newcomer', t0 + 11);
expect(h.hits.has('newcomer')).toBe(true);
expect(h.hits.has('ip-0')).toBe(false); // oldest discarded
expect(h.hits.has('ip-9')).toBe(true); // newest retained
expect(h.hits.size).toBeLessThanOrEqual(10); // memory bound still honoured
});
});
+347
View File
@@ -0,0 +1,347 @@
/**
* Batch payout dispatch tests (Phase 0.3).
*
* transfer_split sends ONE on-chain tx to many destinations, so every payout row in a
* batch shares that tx_hash. These tests guard:
* - the whole batch is marked completed with the shared tx_hash in one transaction
* (no row stranded mid-batch by a unique-index collision), and
* - an ambiguous batch failure is marked 'needs_review' (NOT 'failed'), so the retry
* service won't re-send and double-pay.
*/
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: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: {
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm(walletService) {
const db = createMockDb();
const debugManager = { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 };
const gmm = new GameModeManager(db, walletService, debugManager, createMockPaymentConfig());
return { gmm, db };
}
const TWO_PENDING = [
{ id: 10, user_id: 1, game_id: 'g1', payout_address: 'addrA', amount: '200000000000', multiplier: 2, reason: 'escape' },
{ id: 11, user_id: 2, game_id: 'g2', payout_address: 'addrB', amount: '300000000000', multiplier: 3, reason: 'escape' }
];
const ONE_PENDING = [TWO_PENDING[0]];
const SHARED_TX_HASH = 'e'.repeat(64);
describe('Batch payout dispatch', () => {
test('shared runtime dispatch predicate stops internal batch paths before DB or wallet work', async () => {
const walletService = {
getBalance: jest.fn(),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
gmm.payoutDispatchAllowed = jest.fn(() => false);
gmm._scheduleBatchPayout();
await gmm._processPendingPayouts();
expect(gmm.payoutDispatchAllowed).toHaveBeenCalled();
expect(gmm._batchPayoutTimer).toBeNull();
expect(db.withTransaction).not.toHaveBeenCalled();
expect(db.query).not.toHaveBeenCalled();
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
});
test('marks the whole batch completed with the shared tx_hash in one transaction', async () => {
const walletService = {
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({ success: true, tx_hash_list: [SHARED_TX_HASH], totalFee: 200 }),
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' })
};
const { gmm, db } = buildGmm(walletService);
// First withTransaction = gather: SELECT returns 2 pending rows, then UPDATE ... processing.
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING }) // SELECT ... FOR UPDATE SKIP LOCKED
.mockResolvedValue({ rows: [] }); // everything else
await gmm._processPendingPayouts();
// The batch RPC was called once with both destinations.
expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1);
expect(walletService.processBatchPayout.mock.calls[0][0]).toHaveLength(2);
// The completion UPDATE set the shared tx_hash on the whole batch via id = ANY(...).
const calls = db._mockClient.query.mock.calls.map(c => c[0]);
const batchUpdate = calls.find(sql => /UPDATE payouts SET tx_hash/i.test(sql) && /id = ANY/i.test(sql));
expect(batchUpdate).toBeDefined();
expect(batchUpdate).toMatch(/status = 'completed'/);
const batchUpdateCall = db._mockClient.query.mock.calls.find(c => /UPDATE payouts SET tx_hash/i.test(c[0]) && /id = ANY/i.test(c[0]));
expect(batchUpdateCall[1][0]).toBe(SHARED_TX_HASH); // tx_hash param
expect(batchUpdateCall[1][3]).toEqual([10, 11]); // ids array
// Per-user stats incremented for each winner inside the same transaction (BIGINT, no float).
const statUpdates = calls.filter(sql => /UPDATE users SET total_amount_won/i.test(sql));
expect(statUpdates.length).toBe(2);
});
test('an ambiguous batch failure is marked needs_review, not failed', async () => {
const walletService = {
// Enough unlocked balance so the pre-flight check passes and we reach the transfer.
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockRejectedValue(new Error('RPC timeout'))
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
// Failure handling uses db.query (outside a transaction) to mark the batch needs_review.
// Status is passed as a bound param ($1), so check the params array, not the SQL text.
const failCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
expect(failCall).toBeDefined();
expect(failCall[0]).toMatch(/id = ANY/i);
// Must NOT mark them 'failed' (which the retry service would auto-resend).
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
expect(failedCall).toBeUndefined();
});
test('single payout retains a valid wallet hash when the completion DB write fails', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: SHARED_TX_HASH, fee: 20 }),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: ONE_PENDING })
.mockResolvedValueOnce({ rows: [] })
.mockRejectedValueOnce(new Error('database write failed after broadcast'));
await gmm._processPendingPayouts();
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(review[0]).toMatch(/tx_hash = COALESCE\(\$3, tx_hash\)/i);
expect(review[1][2]).toBe(SHARED_TX_HASH);
expect(review[1][3]).toBe(10);
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false);
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
});
test('batch retains its one valid wallet hash when the completion DB write fails', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({
success: true,
tx_hash_list: [SHARED_TX_HASH],
totalFee: 200
})
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValueOnce({ rows: [] })
.mockRejectedValueOnce(new Error('database write failed after batch broadcast'));
await gmm._processPendingPayouts();
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(review[0]).toMatch(/tx_hash = COALESCE\(\$4, tx_hash\)/i);
expect(review[1][2]).toEqual([10, 11]);
expect(review[1][3]).toBe(SHARED_TX_HASH);
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false);
expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1);
});
test('multiple valid wallet hashes are not guessed onto every liability', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockResolvedValue({
success: true,
tx_hash_list: ['a'.repeat(64), 'b'.repeat(64)],
totalFee: 200
})
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(db._mockClient.query.mock.calls.some(call => /status = 'completed'/i.test(call[0]))).toBe(false);
// ...but the EVIDENCE that the coins were broadcast must survive. A split is exactly what
// transfer_split is for; quarantining the rows with tx_hash NULL stranded already-sent funds
// with nothing for an operator (or the retry service's chain guard) to reconcile against.
expect(review[1][3]).toBe('a'.repeat(64)); // COALESCE($4, tx_hash)
expect(review[1][1]).toContain('a'.repeat(64)); // last_error carries the full list
expect(review[1][1]).toContain('b'.repeat(64));
});
test('a PRE-BROADCAST failure returns the batch to pending, not needs_review', async () => {
// Address validation and the transfer gate run strictly before transfer_split can send
// anything, so those rows are provably unsent and safe to retry. Quarantining them in
// needs_review parked them permanently: the retry service deliberately skips that status.
const preBroadcast = Object.assign(new Error('One or more payout addresses failed validation.'),
{ preBroadcast: true });
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockRejectedValue(preBroadcast)
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'needs_review')).toBe(false);
const deferred = db.query.mock.calls.find(call => call[1]?.[0] === 'pending');
expect(deferred).toBeDefined();
});
test('an ambiguous POST-broadcast failure still quarantines the batch', async () => {
const ambiguous = new Error('socket hang up'); // no preBroadcast tag: may have sent
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockRejectedValue(ambiguous)
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'needs_review')).toBe(true);
});
test('MONERO LOCKING: defers the batch to pending when unlocked balance is insufficient (no transfer attempted)', async () => {
// TWO_PENDING needs 200000000000 + 300000000000 = 500000000000 atomic.
// Unlocked balance is far below that (outputs locked ~10 blocks) -> defer, do not send.
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '500000000000', unlocked_balance: '1000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
// No on-chain transfer was attempted.
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
expect(walletService.processPayout).not.toHaveBeenCalled();
// The defer UPDATE sets status = 'pending' literally in SQL and passes ids as $1.
const deferSql = db.query.mock.calls.find(c => /SET status = 'pending'/i.test(c[0]) && /id = ANY/i.test(c[0]));
expect(deferSql).toBeDefined();
expect(deferSql[1][0]).toEqual([10, 11]); // ids array param
});
test('defers without transfer when unlocked balance cannot be verified', async () => {
const walletService = {
getBalance: jest.fn().mockRejectedValue(new Error('wallet unavailable')),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
const deferred = db.query.mock.calls.find(call => /wallet unlocked balance could not be verified/.test(call[1]?.[1] || ''));
expect(deferred).toBeDefined();
expect(deferred[1][0]).toEqual([10, 11]);
});
test('rechecks a gate that closes after claim and wallet probe before transfer', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({
balance: '10000000000000',
unlocked_balance: '10000000000000'
}),
processPayout: jest.fn(),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
let checks = 0;
gmm.payoutDispatchAllowed = jest.fn(() => ++checks === 1);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(walletService.getBalance).toHaveBeenCalledTimes(1);
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
expect(db.query.mock.calls.some(call => call[1]?.[1] === 'Payout dispatch paused before wallet transfer')).toBe(true);
});
test('a pre-broadcast insufficient-funds batch error is retried (pending), not needs_review', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockRejectedValue(new Error('not enough unlocked money to transfer'))
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
// A funds error means nothing broadcast -> safe to retry: status='pending', NOT needs_review.
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = ANY/i.test(c[0]));
expect(pendingCall).toBeDefined();
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
expect(reviewCall).toBeUndefined();
});
});
+164
View File
@@ -0,0 +1,164 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
CANARY_HANDSHAKE_CONFIRM,
CANARY_HANDSHAKE_PROTOCOL,
computeProof,
createCanaryDatabaseIdentityHandler,
equalProof,
readDatabaseIdentity
} = require('../src/services/canaryDatabaseIdentity');
function protectedNonceFile(nonce = 'ab'.repeat(32)) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-canary-nonce-'));
const fileName = path.join(directory, 'nonce');
fs.writeFileSync(fileName, `${nonce}\n`, { mode: 0o600 });
return { directory, fileName, nonce };
}
function enabledEnv(fileName, overrides = {}) {
return {
NODE_ENV: 'production',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
PORT: '3102',
DB_NAME: 'monerogue_canary_direct_e2e',
CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e',
CANARY_DATABASE_HANDSHAKE: CANARY_HANDSHAKE_CONFIRM,
CANARY_DATABASE_NONCE_FILE: fileName,
...overrides
};
}
function responseRecorder() {
return {
statusCode: 200,
body: null,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; }
};
}
describe('isolated canary database identity endpoint', () => {
test('is absent by default and fails closed on partial or non-stagenet configuration', () => {
expect(createCanaryDatabaseIdentityHandler({ db: {}, env: {} })).toBeNull();
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: { CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e' }
})).toThrow(/CANARY_DATABASE_HANDSHAKE/);
const fixture = protectedNonceFile();
try {
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName, { MONERO_NETWORK: 'mainnet' })
})).toThrow(/XMR stagenet/);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName, { PORT: '3000' })
})).toThrow(/3102/);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName, { DB_NAME: 'monerogue' })
})).toThrow(/DB_NAME/);
fs.chmodSync(fixture.fileName, 0o644);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(fixture.fileName)
})).toThrow(/group\/world/);
fs.chmodSync(fixture.fileName, 0o600);
const link = path.join(fixture.directory, 'nonce-link');
fs.symlinkSync(fixture.fileName, link);
expect(() => createCanaryDatabaseIdentityHandler({
db: {}, env: enabledEnv(link)
})).toThrow();
} finally {
fs.rmSync(fixture.directory, { recursive: true, force: true });
}
});
test('signs the application pool exact cluster, OID, database, and caller challenge', async () => {
const fixture = protectedNonceFile();
const identity = {
cluster_id: '7612345678901234567',
database_oid: '24591',
database_name: 'monerogue_canary_direct_e2e'
};
const db = { query: jest.fn().mockResolvedValue({ rows: [identity] }) };
try {
const handler = createCanaryDatabaseIdentityHandler({
db, env: enabledEnv(fixture.fileName)
});
const challenge = '12'.repeat(32);
const response = responseRecorder();
await handler({
get: () => challenge,
socket: { remoteAddress: '::ffff:127.0.0.1' }
}, response);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(expect.objectContaining({
protocol: CANARY_HANDSHAKE_PROTOCOL,
challenge,
database: {
clusterId: identity.cluster_id,
databaseOid: identity.database_oid,
databaseName: identity.database_name
}
}));
const expected = computeProof(fixture.nonce, response.body.database, challenge);
expect(equalProof(response.body.proof, expected)).toBe(true);
expect(JSON.stringify(response.body)).not.toContain(fixture.nonce);
expect(db.query.mock.calls[0][0]).toContain('pg_control_system()');
} finally {
fs.rmSync(fixture.directory, { recursive: true, force: true });
}
});
test('rejects malformed challenges and refuses an application pool on another database', async () => {
const fixture = protectedNonceFile();
try {
const wrongDb = { query: jest.fn().mockResolvedValue({ rows: [{
cluster_id: '7612345678901234567',
database_oid: '24592',
database_name: 'monerogue_canary_credits_e2e'
}] }) };
const handler = createCanaryDatabaseIdentityHandler({
db: wrongDb, env: enabledEnv(fixture.fileName)
});
const malformed = responseRecorder();
const remote = responseRecorder();
await handler({
get: () => '34'.repeat(32), socket: { remoteAddress: '192.0.2.10' }
}, remote);
expect(remote.statusCode).toBe(404);
expect(wrongDb.query).not.toHaveBeenCalled();
await handler({
get: () => 'not-a-challenge', socket: { remoteAddress: '127.0.0.1' }
}, malformed);
expect(malformed.statusCode).toBe(400);
expect(wrongDb.query).not.toHaveBeenCalled();
const mismatch = responseRecorder();
await handler({
get: () => '34'.repeat(32), socket: { remoteAddress: '::1' }
}, mismatch);
expect(mismatch.statusCode).toBe(503);
expect(mismatch.body).toEqual({ error: 'Canary database identity mismatch.' });
} finally {
fs.rmSync(fixture.directory, { recursive: true, force: true });
}
});
test('requires one unambiguous row from PostgreSQL identity functions', async () => {
await expect(readDatabaseIdentity({
query: jest.fn().mockResolvedValue({ rows: [] })
})).rejects.toThrow(/ambiguous/);
await expect(readDatabaseIdentity({
query: jest.fn().mockResolvedValue({ rows: [{
cluster_id: '', database_oid: '1', database_name: 'db'
}] })
})).rejects.toThrow(/cluster identity/);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Operator-owned catalog + three-way unlock model (migration 024).
*
* The old snapshotForUser unlocked EVERY premium pack the moment total_credits_purchased > 0
* (any purchase → level 'credits' → hasPremiumLevel → all packs). The new rule unlocks a pack via
* exactly one of: it's free · an explicit grant · lifetime spend >= its threshold · tier >= its tier.
*/
const Entitlements = require('../src/multiplayer/entitlements');
const CatalogService = require('../src/services/catalogService');
const { snapshotForUser, DEFAULT_CATALOG } = Entitlements;
describe('three-way unlock: default (seed) catalog', () => {
test('free pack is always unlocked; premium packs are locked with no spend', () => {
const s = snapshotForUser({ credits: 0, total_credits_purchased: 0 });
expect(s.packs['original']).toBe(true); // free baseline
expect(s.packs['roguelike-interior']).toBe(false); // now 1-credit premium
expect(s.packs['generated-skins']).toBe(false); // threshold 5, no spend
expect(s.packs['iso-dungeon']).toBe(false);
expect(s.packs['kenney-3d-characters']).toBe(false);
expect(s.premium).toBe(false);
expect(s.level).toBe('free');
expect(s.tier).toBe(0);
});
test('graduated ladder: 1 credit unlocks the first tilepack only, not skins/iso/3D', () => {
const s = snapshotForUser({ credits: 5, total_credits_purchased: 1 });
expect(s.packs['roguelike-interior']).toBe(true); // threshold 1
expect(s.packs['generated-skins']).toBe(false); // threshold 5
expect(s.packs['iso-dungeon']).toBe(false); // threshold 10
expect(s.packs['kenney-3d-characters']).toBe(false); // threshold 50
expect(s.premium).toBe(true); // a premium pack IS unlocked
});
test('graduated ladder: 50 credits unlocks the whole ladder', () => {
const s = snapshotForUser({ credits: 0, total_credits_purchased: 50 });
expect(s.packs['roguelike-interior']).toBe(true);
expect(s.packs['generated-skins']).toBe(true);
expect(s.packs['iso-dungeon']).toBe(true);
expect(s.packs['roguelike-dungeon']).toBe(true);
expect(s.packs['iso-medieval']).toBe(true);
expect(s.packs['kenney-3d-characters']).toBe(true);
});
test('a credit buyer is NOT auto-promoted to a premium tier', () => {
const s = snapshotForUser({ total_credits_purchased: 100 });
expect(s.level).toBe('free'); // spend gives unlocks, not a tier
expect(s.tier).toBe(0);
});
test('explicit grant unlocks a pack with zero spend', () => {
const s = snapshotForUser({ total_credits_purchased: 0 }, [{ pack_id: 'iso-dungeon' }]);
expect(s.packs['iso-dungeon']).toBe(true);
expect(s.packs['generated-skins']).toBe(false); // not granted, not bought
});
});
describe('THE bug fix: differentiated thresholds', () => {
const catalog = {
'skin-a': { id: 'skin-a', label: 'A', tier: 0, unlockMinCredits: 1, grantOnly: false },
'pack-b': { id: 'pack-b', label: 'B', tier: 0, unlockMinCredits: 10, grantOnly: false }
};
test('buying below the higher threshold unlocks only the cheaper pack', () => {
const s = snapshotForUser({ total_credits_purchased: 5 }, [], catalog);
expect(s.packs['skin-a']).toBe(true); // spent 5 >= 1
expect(s.packs['pack-b']).toBe(false); // spent 5 < 10 (old code wrongly unlocked this)
});
test('crossing the higher threshold unlocks the bigger pack', () => {
const s = snapshotForUser({ total_credits_purchased: 10 }, [], catalog);
expect(s.packs['skin-a']).toBe(true);
expect(s.packs['pack-b']).toBe(true);
});
});
describe('tier ladder', () => {
const catalog = {
't2-pack': { id: 't2-pack', label: 'T2', tier: 2, unlockMinCredits: null, grantOnly: false }
};
test('supporter (tier 1) cannot use a tier-2 pack; premium (tier 2) can', () => {
expect(snapshotForUser({ premium_level: 'supporter' }, [], catalog).packs['t2-pack']).toBe(false);
expect(snapshotForUser({ premium_level: 'premium' }, [], catalog).packs['t2-pack']).toBe(true);
expect(snapshotForUser({ premium_level: 'operator' }, [], catalog).packs['t2-pack']).toBe(true);
});
test('spend never crosses a tier-only pack', () => {
expect(snapshotForUser({ total_credits_purchased: 9999 }, [], catalog).packs['t2-pack']).toBe(false);
});
});
describe('grant-only packs', () => {
const catalog = {
'exclusive': { id: 'exclusive', label: 'X', tier: 0, unlockMinCredits: null, grantOnly: true }
};
test('grant-only is not unlocked by spend or tier: only an explicit grant', () => {
expect(snapshotForUser({ total_credits_purchased: 9999, premium_level: 'operator' }, [], catalog).packs['exclusive']).toBe(false);
expect(snapshotForUser({}, [{ pack_id: 'exclusive' }], catalog).packs['exclusive']).toBe(true);
});
});
describe('canUsePack reads the snapshot', () => {
test('gates on the computed packs map', () => {
const locked = snapshotForUser({ total_credits_purchased: 0 });
expect(Entitlements.canUsePack(locked, 'original')).toBe(true); // free
expect(Entitlements.canUsePack(locked, 'iso-dungeon')).toBe(false); // locked
const unlocked = snapshotForUser({ total_credits_purchased: 10 }); // iso threshold is 10
expect(Entitlements.canUsePack(unlocked, 'iso-dungeon')).toBe(true);
});
});
describe('CatalogService', () => {
test('falls back to the built-in default when the table is missing', async () => {
const db = { query: async () => { const e = new Error('relation "cosmetic_catalog" does not exist'); e.code = '42P01'; throw e; } };
const svc = new CatalogService({ db });
expect(await svc.getCatalog()).toBe(DEFAULT_CATALOG);
});
test('maps DB rows into the catalog shape', async () => {
const db = { query: async () => ({ rows: [
{ pack_id: 'skin-a', label: 'A', kind: 'skin', projection: 'topdown', tier: 0, unlock_min_credits: '3', grant_only: false, active: true, metadata: {} },
{ pack_id: 'free-x', label: 'Free', kind: 'render-pack', projection: null, tier: 0, unlock_min_credits: null, grant_only: false, active: true, metadata: {} }
] }) };
const cat = await new CatalogService({ db }).getCatalog();
expect(cat['skin-a'].unlockMinCredits).toBe(3);
expect(cat['skin-a'].premium).toBe(true);
expect(cat['free-x'].premium).toBe(false);
});
test('caches within the TTL', async () => {
let calls = 0;
const db = { query: async () => { calls++; return { rows: [] }; } };
const svc = new CatalogService({ db, ttlMs: 10000 });
await svc.getCatalog();
await svc.getCatalog();
expect(calls).toBe(1);
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* ChainProfile registry (Pillar 3 foundation): one source for chain params across Smirk's five
* PoW chains, and the fix for decimals silently defaulting to 12 for anything but WOW/XMR.
*/
const ChainProfile = require('../src/chain/chainProfile');
const { inferCurrencyDecimals } = require('../src/game/helpers/gameModeUtils');
describe('ChainProfile', () => {
test('decimals are correct for every Smirk chain', () => {
expect(ChainProfile.decimalsFor('WOW')).toBe(11);
expect(ChainProfile.decimalsFor('XMR')).toBe(12);
expect(ChainProfile.decimalsFor('BTC')).toBe(8);
expect(ChainProfile.decimalsFor('LTC')).toBe(8);
expect(ChainProfile.decimalsFor('GRIN')).toBe(9);
});
test('mean block time + adapter family per chain', () => {
expect(ChainProfile.meanBlockTimeMsFor('GRIN')).toBe(60000); // ~1 min, fast floor
expect(ChainProfile.meanBlockTimeMsFor('BTC')).toBe(600000); // ~10 min, slow end
expect(ChainProfile.familyFor('WOW')).toBe('monero');
expect(ChainProfile.familyFor('BTC')).toBe('utxo');
expect(ChainProfile.familyFor('LTC')).toBe('utxo');
expect(ChainProfile.familyFor('GRIN')).toBe('mimblewimble');
});
test('atomic divisor is a BigInt of the right scale', () => {
expect(ChainProfile.atomicDivisor('WOW')).toBe(100000000000n); // 1e11
expect(ChainProfile.atomicDivisor('BTC')).toBe(100000000n); // 1e8
expect(ChainProfile.atomicDivisor('GRIN')).toBe(1000000000n); // 1e9
});
test('case-insensitive; unknown falls back safely', () => {
expect(ChainProfile.getProfile('grin').decimals).toBe(9);
expect(ChainProfile.isSupported('DOGE')).toBe(false);
const fb = ChainProfile.getProfile('DOGE');
expect(fb.decimals).toBe(12);
expect(fb.family).toBe('monero');
});
});
describe('inferCurrencyDecimals now delegates to ChainProfile', () => {
test('WOW/XMR unchanged; BTC/GRIN fixed (were silently 12)', () => {
expect(inferCurrencyDecimals('WOW')).toBe(11);
expect(inferCurrencyDecimals('XMR')).toBe(12);
expect(inferCurrencyDecimals('BTC')).toBe(8);
expect(inferCurrencyDecimals('GRIN')).toBe(9);
expect(inferCurrencyDecimals('')).toBe(12);
});
});
+35
View File
@@ -0,0 +1,35 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadCharSprites() {
const context = {
console,
window: { console }
};
context.window.window = context.window;
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/charSprites.js'), 'utf8'),
context
);
return context.window.RK;
}
describe('roguelike character sprites', () => {
test('equipment stays fixed color unless an asset explicitly opts into tinting', () => {
const RK = loadCharSprites();
const parts = RK.charOverlayParts({
avatar: 'char-wizard',
tint: 'gold',
colors: { base: 'gold', body: 'moss', head: 'teal' },
equipment: { body: 'mail', head: 'helm', shield: 'round', weapon: 'sword' }
});
expect(parts).toEqual(expect.arrayContaining([
expect.objectContaining({ slot: 'body', id: 'mail', tint: 'none', colorable: false }),
expect.objectContaining({ slot: 'head', id: 'helm', tint: 'none', colorable: false }),
expect.objectContaining({ slot: 'shield', id: 'round', tint: 'none', colorable: false }),
expect.objectContaining({ slot: 'weapon', id: 'sword', tint: 'none', colorable: false })
]));
});
});
+69
View File
@@ -0,0 +1,69 @@
const SocketChatProvider = require('../src/network/chat/SocketChatProvider');
function makeFakeIo() {
const io = {
emitted: [], // { channel|null, event, payload }
to(channel) {
return { emit: (event, payload) => io.emitted.push({ channel, event, payload }) };
},
emit(event, payload) { io.emitted.push({ channel: null, event, payload }); }
};
return io;
}
describe('SocketChatProvider', () => {
test('global publish persists via history and broadcasts via broadcastManager', async () => {
const io = makeFakeIo();
const saved = [];
const broadcast = [];
const history = { saveMessage: (m) => { saved.push(m); return Promise.resolve(); } };
const broadcastManager = {
// 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 });
await provider.publish({ scope: 'global', username: 'abc123', text: 'hi', ts: 42, socketId: 'sock', userId: 7 });
expect(saved).toHaveLength(1);
expect(saved[0]).toMatchObject({ socketId: 'sock', username: 'abc123', message: 'hi', userId: 7 });
expect(broadcast).toHaveLength(1);
// 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 () => {
const io = makeFakeIo();
const provider = new SocketChatProvider({ io });
await provider.publish({ scope: 'global', username: 'x', text: 'yo', ts: 1 });
const ev = io.emitted.find(e => e.event === 'chat_broadcast' && e.channel === null);
expect(ev).toBeTruthy();
expect(ev.payload).toMatchObject({ username: 'x', message: 'yo', timestamp: 1 });
});
test('room-scoped publish delivers only to that room and tags the scope', async () => {
const io = makeFakeIo();
const provider = new SocketChatProvider({ io });
await provider.publish({ scope: 'tavern:main', username: 'bob', text: 'hey', ts: 5 });
const ev = io.emitted.find(e => e.event === 'chat_broadcast');
expect(ev.channel).toBe('tavern:main');
expect(ev.payload).toMatchObject({ username: 'bob', message: 'hey', timestamp: 5, scope: 'tavern:main' });
});
test('getHistory: global reads the history manager; room scopes are ephemeral', async () => {
const io = makeFakeIo();
const history = { getRecentMessages: (n) => Promise.resolve([{ message: 'old', n }]) };
const provider = new SocketChatProvider({ io, historyManager: history });
await expect(provider.getHistory({ scope: 'global', limit: 10 })).resolves.toEqual([{ message: 'old', n: 10 }]);
await expect(provider.getHistory({ scope: 'tavern:main' })).resolves.toEqual([]);
});
test('an injected history manager is not shut down by the provider (caller owns it)', async () => {
const io = makeFakeIo();
let shut = false;
const history = { shutdown: () => { shut = true; } };
const provider = new SocketChatProvider({ io, historyManager: history });
await provider.shutdown();
expect(shut).toBe(false);
});
});
+234
View File
@@ -0,0 +1,234 @@
'use strict';
const {
CloneMigrationVerificationError,
MINIMUM_RELEASE_MIGRATION,
cloneMigrationConfirmation,
discoverMigrationManifest,
isDisposableCloneDatabaseName,
runCloneMigrations,
validateCloneMigrationEnvironment,
verifyExactMigrationLedger
} = require('../src/scripts/migrate-disposable-clone');
function safeEnvironment(overrides = {}) {
const database = overrides.DB_NAME || 'monerogue_restore_release_20260721';
return {
DB_HOST: '127.0.0.1',
DB_PORT: '5432',
DB_NAME: database,
DB_USER: 'monerogue_migrator',
CLONE_MIGRATION_EXPECT_DATABASE: database,
CLONE_MIGRATION_CONFIRM: cloneMigrationConfirmation(database),
...overrides
};
}
class FakePool {
constructor(config, { actualDatabase = config.database, hideLatest = false } = {}) {
this.config = config;
this.actualDatabase = actualDatabase;
this.hideLatest = hideLatest;
this.ledger = new Set();
this.transactions = [];
this.ended = false;
}
async connect() {
const pool = this;
let pending = null;
return {
async query(sql, params = []) {
if (/current_database\(\)/.test(sql)) {
return { rows: [{ database_name: pool.actualDatabase }], rowCount: 1 };
}
if (sql === 'BEGIN') {
pending = null;
return { rows: [], rowCount: null };
}
if (/INSERT INTO schema_migrations/.test(sql)) {
pending = params[0];
return { rows: [], rowCount: 1 };
}
if (sql === 'COMMIT') {
if (pending) pool.ledger.add(pending);
pool.transactions.push(pending);
pending = null;
return { rows: [], rowCount: null };
}
if (sql === 'ROLLBACK') {
pending = null;
return { rows: [], rowCount: null };
}
return { rows: [], rowCount: null };
},
release() {}
};
}
async query(sql, params = []) {
if (/CREATE TABLE IF NOT EXISTS schema_migrations/.test(sql)) {
return { rows: [], rowCount: null };
}
if (/SELECT 1 FROM schema_migrations/.test(sql)) {
return { rows: [], rowCount: this.ledger.has(params[0]) ? 1 : 0 };
}
if (/SELECT filename FROM schema_migrations/.test(sql)) {
let filenames = [...this.ledger].sort();
if (this.hideLatest) filenames = filenames.slice(0, -1);
return { rows: filenames.map(filename => ({ filename })), rowCount: filenames.length };
}
throw new Error(`unexpected fake pool query: ${sql}`);
}
async end() {
this.ended = true;
}
}
describe('disposable clone migration target guard', () => {
test.each([
'monerogue_clone_release_a1',
'wownerogue_restore_20260721',
'wowngeon_canary_direct_e2e'
])('accepts an explicitly disposable application database: %s', database => {
expect(isDisposableCloneDatabaseName(database)).toBe(true);
expect(validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), []))
.toEqual(expect.objectContaining({ database, expectedDatabase: database }));
});
test.each([
'monerogue',
'wownerogue',
'wowngeon',
'monerogue_prod_clone',
'monerogue_clone_prd',
'monerogue_clone_production2026',
'monerogue_master_restore',
'wownerogue_clone_live',
'wownerogue_clone_live1',
'monerogue_mainnet_restore',
'postgres_restore_20260721',
'monerogue_staging_20260721',
'monerogue-clone-release'
])('refuses a live-looking, unscoped, or malformed name: %s', database => {
expect(isDisposableCloneDatabaseName(database)).toBe(false);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), []))
.toThrow(/clone, restore, or canary/);
});
test('requires an exact expected database, database-bound confirmation, and local host', () => {
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
CLONE_MIGRATION_EXPECT_DATABASE: 'monerogue_restore_other'
}), [])).toThrow(/exactly equal/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
CLONE_MIGRATION_CONFIRM: 'yes'
}), [])).toThrow(/exactly bind/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
DB_HOST: 'database.internal'
}), [])).toThrow(/loopback/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
DB_HOST: '/tmp/untrusted-postgresql-socket'
}), [])).toThrow(/loopback/);
expect(validateCloneMigrationEnvironment(safeEnvironment({
DB_HOST: '/var/run/postgresql'
}), [])).toEqual(expect.objectContaining({ host: '/var/run/postgresql' }));
});
test('forbids argv and credential-bearing DATABASE_URL before constructing a pool', async () => {
expect(() => validateCloneMigrationEnvironment(safeEnvironment(), ['--database', 'x']))
.toThrow(/command-line/);
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
DATABASE_URL: 'postgres://user:secret@127.0.0.1/db'
}), [])).toThrow(/DATABASE_URL/);
const poolFactory = jest.fn();
await expect(runCloneMigrations({
env: safeEnvironment(), argv: ['--password', 'secret'], poolFactory
})).rejects.toThrow(/command-line/);
expect(poolFactory).not.toHaveBeenCalled();
});
});
describe('disposable clone migration execution', () => {
let logSpy;
beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
});
test('discovers the contiguous repository manifest through migration 43 dynamically', () => {
const manifest = discoverMigrationManifest();
expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION);
expect(manifest.latestFilename).toBe('043_durable_solo_restart_snapshots.sql');
expect(manifest.files).toHaveLength(43);
});
test('runs the normal ordered migration implementation and proves the exact ledger', async () => {
const secret = 'do-not-print-this-database-password';
const env = safeEnvironment({ DB_PASSWORD: secret });
let pool;
const output = [];
const result = await runCloneMigrations({
env,
argv: [],
poolFactory: config => {
pool = new FakePool(config);
return pool;
},
logger: { info: line => output.push(line) }
});
expect(pool.transactions).toEqual(result.files);
expect(result.latestFilename).toBe('043_durable_solo_restart_snapshots.sql');
expect(output).toContain('clone_migration_status=ok');
expect(output).toContain('latest_migration=043_durable_solo_restart_snapshots.sql');
expect(output.join('\n')).not.toContain(secret);
expect(pool.config).toEqual(expect.objectContaining({
database: env.DB_NAME,
host: '127.0.0.1',
max: 1,
application_name: 'wowngeon-disposable-clone-migrations'
}));
expect(pool.ended).toBe(true);
});
test('checks server database identity before applying a migration', async () => {
let pool;
await expect(runCloneMigrations({
env: safeEnvironment(),
argv: [],
poolFactory: config => {
pool = new FakePool(config, { actualDatabase: 'monerogue_restore_wrong' });
return pool;
},
logger: { info: jest.fn() }
})).rejects.toThrow(/exact expected clone name/);
expect(pool.transactions).toEqual([]);
expect(pool.ended).toBe(true);
});
test('fails closed if the post-run ledger is not exactly the repository manifest', async () => {
await expect(runCloneMigrations({
env: safeEnvironment(),
argv: [],
poolFactory: config => new FakePool(config, { hideLatest: true }),
logger: { info: jest.fn() }
})).rejects.toBeInstanceOf(CloneMigrationVerificationError);
});
test('rejects missing, extra, or reordered ledger entries', () => {
const manifest = discoverMigrationManifest();
const rows = manifest.files.map(filename => ({ filename }));
expect(verifyExactMigrationLedger(rows, manifest)).toBe(true);
expect(() => verifyExactMigrationLedger(rows.slice(1), manifest)).toThrow(/count/);
expect(() => verifyExactMigrationLedger([...rows].reverse(), manifest)).toThrow(/differs/);
expect(() => verifyExactMigrationLedger([...rows, { filename: '999_unknown.sql' }], manifest))
.toThrow(/count/);
});
});
+100
View File
@@ -0,0 +1,100 @@
const STORAGE_KEY = 'wowngeon_paid_ack';
function policy(version, isTestNetwork = false) {
return {
policyVersion: version,
paidAcknowledgementRequired: true,
minimumAge: 18,
service: {
isTestNetwork,
currencyLabel: isTestNetwork ? 'sXMR' : 'WOW',
cryptoType: isTestNetwork ? 'XMR' : 'WOW'
},
links: { terms: '/terms', privacy: '/privacy', responsiblePlay: '/responsible-play' }
};
}
function response(value) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(value) });
}
describe('browser commerce acknowledgement cache', () => {
let values;
beforeEach(() => {
jest.resetModules();
values = new Map();
global.sessionStorage = {
getItem: jest.fn(key => values.has(key) ? values.get(key) : null),
setItem: jest.fn((key, value) => values.set(key, value)),
removeItem: jest.fn(key => values.delete(key))
};
global.fetch = jest.fn();
delete global.CommerceConsent;
});
afterEach(() => {
delete global.fetch;
delete global.sessionStorage;
delete global.CommerceConsent;
});
test('accepts only the five-field canonical cache and clears extra data', async () => {
global.fetch.mockReturnValueOnce(response(policy('browser-v1')));
const consent = require('../html/js/legal/commerceConsent');
await consent.loadPolicy();
values.set(STORAGE_KEY, JSON.stringify({
policyVersion: 'browser-v1',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false,
acknowledgedAt: 'must-not-be-stored'
}));
expect(consent.acknowledgement()).toBeNull();
expect(values.has(STORAGE_KEY)).toBe(false);
});
test('server version rejection clears acceptance and refetches current policy', async () => {
global.fetch
.mockReturnValueOnce(response(policy('browser-v1')))
.mockReturnValueOnce(response(policy('browser-v2')));
const consent = require('../html/js/legal/commerceConsent');
await consent.loadPolicy();
values.set(STORAGE_KEY, JSON.stringify({
policyVersion: 'browser-v1',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false
}));
expect(consent.acknowledgement()).toEqual(expect.objectContaining({ policyVersion: 'browser-v1' }));
const refreshed = await consent.reject({ code: 'PAID_ACK_VERSION', policyVersion: 'browser-v2' });
expect(refreshed.policyVersion).toBe('browser-v2');
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(consent.acknowledgement()).toBeNull();
expect(values.has(STORAGE_KEY)).toBe(false);
});
test('disconnect-facing clear removes both memory and session copies', async () => {
global.fetch.mockReturnValueOnce(response(policy('browser-v1')));
const consent = require('../html/js/legal/commerceConsent');
await consent.loadPolicy();
values.set(STORAGE_KEY, JSON.stringify({
policyVersion: 'browser-v1',
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false
}));
expect(consent.acknowledgement()).not.toBeNull();
consent.clear();
expect(consent.acknowledgement()).toBeNull();
expect(values.has(STORAGE_KEY)).toBe(false);
});
});
+280
View File
@@ -0,0 +1,280 @@
const {
buildCommerceDisclosure,
safeContactUrl,
validatePaidAcknowledgement
} = require('../src/config/commerceDisclosurePolicy');
const PaymentHandlers = require('../src/network/paymentHandlers');
function manager(overrides = {}) {
return {
cryptoType: 'XMR',
currencyLabel: 'sXMR',
network: 'stagenet',
isTestNetwork: true,
gameName: 'Monerogue',
paymentsEnabled: true,
freePlayEnabled: true,
directModeEnabled: true,
creditsModeEnabled: true,
payoutsEnabled: true,
isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE',
...overrides
};
}
function env(overrides = {}) {
return {
NODE_ENV: 'production',
LEGAL_POLICY_VERSION: '2026-07-21-v1',
TERMS_EFFECTIVE_DATE: '2026-07-21',
MINIMUM_AGE: '21',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
OPERATOR_NAME: 'Test Operator',
OPERATOR_CONTACT_URL: 'mailto:support@example.invalid',
MATCH_ENABLED: 'false',
...overrides
};
}
describe('commerce disclosure and paid-action policy', () => {
test('describes active test-network payout mode without conflating it with mainnet', () => {
const disclosure = buildCommerceDisclosure(manager(), env());
expect(disclosure).toEqual(expect.objectContaining({
policyVersion: '2026-07-21-v1',
minimumAge: 21,
paidAcknowledgementRequired: true
}));
expect(disclosure.service).toEqual(expect.objectContaining({
network: 'stagenet',
isTestNetwork: true,
paidPrestigeOnly: false,
soloPayoutsEnabled: true
}));
});
test('publishes the immutable play.wowne.ro scope and Such Software identity', () => {
const disclosure = buildCommerceDisclosure(manager({
cryptoType: 'WOW',
currencyLabel: 'WOW',
network: 'mainnet',
isTestNetwork: false,
directModeEnabled: false,
creditsModeEnabled: true,
payoutsEnabled: false,
isPayoutEnabledForMode: () => false
}), env({
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
OPERATOR_NAME: 'untrusted override',
OPERATOR_CONTACT_URL: 'mailto:untrusted@example.invalid'
}));
expect(disclosure.operator).toEqual({
name: 'Such Software',
contactUrl: 'mailto:apps@such.software',
contactLabel: 'apps@such.software'
});
expect(disclosure.operatedProduct).toEqual(expect.objectContaining({
id: 'such-play-wow-prestige',
hostname: 'play.wowne.ro',
noRealValueNotice: null
}));
expect(disclosure.operatedProduct.scopeNotice).toContain('pay-for-credits leaderboard/prestige');
expect(disclosure.operatedProduct.scopeNotice).toContain('offers no prizes, winnings, cash-out, or payouts');
expect(disclosure.operatedProduct.scopeNotice).toContain('not offered or marketed as gambling');
expect(disclosure.operatedProduct.scopeNotice).toContain('legal classification depends on applicable law');
});
test('publishes conspicuous no-real-value monerogue.app scope and MIT operator boundary', () => {
const disclosure = buildCommerceDisclosure(manager({
creditsModeEnabled: false
}), env({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
}));
expect(disclosure.operatedProduct.scopeNotice).toContain('single-player 2×/3× outcomes are test gambling mechanics');
expect(disclosure.operatedProduct.noRealValueNotice).toContain('NO REAL VALUE');
expect(disclosure.operatedProduct.commerceSummary).toMatch(/never send mainnet XMR/i);
expect(disclosure.operatedProduct.commerceSummary).toContain('Purchased-credit entry');
expect(disclosure.service.paidCreditsEnabled).toBe(false);
expect(disclosure.service.cryptoMatchPayoutsEnabled).toBe(false);
expect(disclosure.software).toEqual(expect.objectContaining({
license: 'MIT',
publisherName: 'Such Software'
}));
expect(disclosure.software.rightsNotice).toContain('copyright and permission notice');
expect(disclosure.software.warrantyNotice).toContain('AS IS');
expect(disclosure.software.legalAdviceNotice).toContain('not legal advice');
expect(disclosure.software.thirdPartyNotice).toContain('solely responsible');
expect(disclosure.software.thirdPartyNotice).toContain('does not make that deployment a Such Software service');
expect(disclosure.software.thirdPartyNotice).toContain('does not operate, supervise, endorse, or accept responsibility');
});
test('an unprofiled deployment remains generic and is not represented as Such-operated', () => {
const disclosure = buildCommerceDisclosure(manager(), env());
expect(disclosure.operatedProduct).toBeNull();
expect(disclosure.operator.name).toBe('Test Operator');
expect(disclosure.software.operatedBoundaryNotice).toContain('operates only play.wowne.ro and monerogue.app');
});
test('describes paid prestige separately when every payout path is off', () => {
const disclosure = buildCommerceDisclosure(manager({
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
payoutsEnabled: false, isPayoutEnabledForMode: () => false
}), env());
expect(disclosure.service.paidPrestigeOnly).toBe(true);
expect(disclosure.service.anyPayoutsEnabled).toBe(false);
});
test('does not advertise a stale solo payout flag when that paid mode is disabled', () => {
const disclosure = buildCommerceDisclosure(manager({
directModeEnabled: false,
creditsModeEnabled: true,
isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE'
}), env());
expect(disclosure.service.directPaidEntryEnabled).toBe(false);
expect(disclosure.service.paidCreditsEnabled).toBe(true);
expect(disclosure.service.soloPayoutsEnabled).toBe(false);
const activeCreditsPayout = buildCommerceDisclosure(manager({
directModeEnabled: false,
creditsModeEnabled: true,
isPayoutEnabledForMode: mode => mode === 'PAID_CREDITS'
}), env());
expect(activeCreditsPayout.service.soloPayoutsEnabled).toBe(true);
const noPaidModes = buildCommerceDisclosure(manager({
directModeEnabled: false,
creditsModeEnabled: false,
isPayoutEnabledForMode: () => true
}), env());
expect(noPaidModes.service.soloPayoutsEnabled).toBe(false);
expect(noPaidModes.service.anyPayoutsEnabled).toBe(false);
});
test('fails closed on missing, stale, incomplete, or testnet-unaware acknowledgement', () => {
const disclosure = buildCommerceDisclosure(manager(), env());
const good = {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: true
};
expect(validatePaidAcknowledgement(null, disclosure).ok).toBe(false);
expect(validatePaidAcknowledgement({ ...good, policyVersion: 'old' }, disclosure).code).toBe('PAID_ACK_VERSION');
expect(validatePaidAcknowledgement({ ...good, riskAccepted: false }, disclosure).code).toBe('PAID_ACK_INCOMPLETE');
expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: false }, disclosure).code).toBe('PAID_ACK_TESTNET');
expect(validatePaidAcknowledgement({ ...good, extra: true }, disclosure).code).toBe('PAID_ACK_MALFORMED');
expect(validatePaidAcknowledgement({
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true
}, disclosure).code).toBe('PAID_ACK_MALFORMED');
expect(validatePaidAcknowledgement(good, disclosure)).toEqual({
ok: true,
code: 'ACKNOWLEDGED',
acknowledgement: Object.freeze(good)
});
});
test('mainnet acknowledgement is canonical and carries an explicit false testnet flag', () => {
const disclosure = buildCommerceDisclosure(manager({
cryptoType: 'WOW',
currencyLabel: 'WOW',
network: 'mainnet',
isTestNetwork: false
}), env());
const good = {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false
};
expect(validatePaidAcknowledgement(good, disclosure)).toEqual({
ok: true,
code: 'ACKNOWLEDGED',
acknowledgement: Object.freeze(good)
});
expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: true }, disclosure).code)
.toBe('PAID_ACK_MALFORMED');
});
test('keeps acknowledgement UI enabled for paid match entitlements while invoice intake is off', () => {
const disclosure = buildCommerceDisclosure(manager({
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: true,
_getMatchEconomies: () => ({ free: true, credits_prestige: true })
}), env({ PAID_ACKNOWLEDGEMENT_REQUIRED: 'false', MATCH_ENABLED: 'true' }));
expect(disclosure.service.paymentsEnabled).toBe(false);
expect(disclosure.paidAcknowledgementRequired).toBe(true);
});
test('allows no acknowledgement only where the policy is actually disabled', () => {
const disclosure = buildCommerceDisclosure(manager({
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: false
}), {
NODE_ENV: 'test', PAID_ACKNOWLEDGEMENT_REQUIRED: 'false'
});
expect(validatePaidAcknowledgement(null, disclosure)).toEqual({
ok: true, code: 'NOT_REQUIRED', acknowledgement: null
});
});
test('contact URLs reject executable and unrelated schemes', () => {
expect(safeContactUrl('javascript:alert(1)')).toBeNull();
expect(safeContactUrl('data:text/html,boom')).toBeNull();
expect(safeContactUrl('mailto:support@example.invalid')).toBe('mailto:support@example.invalid');
expect(safeContactUrl('https://example.invalid/support')).toBe('https://example.invalid/support');
});
test('payment handler refuses an invoice before wallet or database work when acknowledgement is absent', async () => {
const previousNodeEnv = process.env.NODE_ENV;
const previousVersion = process.env.LEGAL_POLICY_VERSION;
process.env.NODE_ENV = 'production';
process.env.LEGAL_POLICY_VERSION = 'server-enforcement-v1';
const emit = jest.fn();
const createPaymentRequest = jest.fn();
const handler = new PaymentHandlers({
io: { to: () => ({ emit }) },
gameModeManager: {
...manager({ isTestNetwork: false, network: 'mainnet' }),
createPaymentRequest,
isPayoutEnabledForMode: () => false
},
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: { sendStatusUpdate: jest.fn() },
sessionManager: null
});
try {
await handler.handlePaymentRequest({ id: 'socket-1' }, { type: 'single_game' });
expect(createPaymentRequest).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
code: 'PAID_ACK_REQUIRED', policyVersion: 'server-enforcement-v1'
}));
expect(emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({
code: 'PAID_ACK_REQUIRED'
}));
} finally {
handler.dispose();
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = previousNodeEnv;
if (previousVersion === undefined) delete process.env.LEGAL_POLICY_VERSION;
else process.env.LEGAL_POLICY_VERSION = previousVersion;
}
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Per-IP concurrent-socket cap (anti-abuse / resource hoarding).
*
* The rate limiter caps NEW connections and game starts per minute, but nothing capped how
* many sockets a single IP could hold OPEN at once. ConnectionHandler now tracks live sockets
* per IP and refuses new ones past the cap; these tests exercise that bookkeeping directly.
*/
const ConnectionHandler = require('../src/network/connectionHandler');
function makeHandler(maxPerIp) {
if (maxPerIp != null) process.env.MAX_SOCKETS_PER_IP = String(maxPerIp);
else delete process.env.MAX_SOCKETS_PER_IP;
return new ConnectionHandler({
io: { sockets: { sockets: new Map() } },
broadcastManager: { broadcastUserCount() {}, sendStatusUpdate() {} },
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 },
sessionManager: null,
rateLimiter: { getStats: () => ({}) }
});
}
describe('per-IP concurrent socket cap', () => {
let h;
afterEach(() => { if (h) h.shutdown(); h = null; delete process.env.MAX_SOCKETS_PER_IP; });
test('allows up to the cap, then rejects further sockets from the same IP', () => {
h = makeHandler(3);
expect(h._trackIpSocket('1.2.3.4', 's1')).toBe(true);
expect(h._trackIpSocket('1.2.3.4', 's2')).toBe(true);
expect(h._trackIpSocket('1.2.3.4', 's3')).toBe(true);
expect(h._trackIpSocket('1.2.3.4', 's4')).toBe(false); // over cap
});
test('a freed slot lets a new socket in', () => {
h = makeHandler(2);
expect(h._trackIpSocket('5.5.5.5', 'a')).toBe(true);
expect(h._trackIpSocket('5.5.5.5', 'b')).toBe(true);
expect(h._trackIpSocket('5.5.5.5', 'c')).toBe(false);
h._untrackIpSocket('a');
expect(h._trackIpSocket('5.5.5.5', 'c')).toBe(true);
});
test('different IPs have independent budgets', () => {
h = makeHandler(1);
expect(h._trackIpSocket('1.1.1.1', 'x')).toBe(true);
expect(h._trackIpSocket('2.2.2.2', 'y')).toBe(true); // different IP, fine
expect(h._trackIpSocket('1.1.1.1', 'z')).toBe(false);
});
test('re-tracking the same socket id is idempotent (reconnect within same id)', () => {
h = makeHandler(1);
expect(h._trackIpSocket('9.9.9.9', 'same')).toBe(true);
expect(h._trackIpSocket('9.9.9.9', 'same')).toBe(true); // not double-counted
});
test('missing IP is never blocked (cannot attribute)', () => {
h = makeHandler(1);
expect(h._trackIpSocket(null, 's1')).toBe(true);
expect(h._trackIpSocket(undefined, 's2')).toBe(true);
});
test('defaults to 10 when MAX_SOCKETS_PER_IP unset', () => {
h = makeHandler(null);
expect(h.maxSocketsPerIp).toBe(10);
});
});
+15
View File
@@ -0,0 +1,15 @@
const DatabaseManager = require('../src/db/databaseManager');
describe('DatabaseManager readiness freshness', () => {
test('requires both a connection and a recent successful query', () => {
const db = new DatabaseManager();
expect(db.isHealthy()).toBe(false);
db.connected = true;
db.lastSuccessfulQueryAt = Date.now();
expect(db.isHealthy(1000)).toBe(true);
db.lastSuccessfulQueryAt = Date.now() - 2000;
expect(db.isHealthy(1000)).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
const DebugManager = require('../src/debug/debugManager');
describe('DebugManager daemon freshness', () => {
const originalEnv = process.env.NODE_ENV;
const originalSource = process.env.BLOCK_SOURCE;
beforeEach(() => {
process.env.NODE_ENV = 'production';
process.env.BLOCK_SOURCE = 'daemon';
});
afterAll(() => {
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
if (originalSource === undefined) delete process.env.BLOCK_SOURCE;
else process.env.BLOCK_SOURCE = originalSource;
});
test('rejects a stale cached height until a recent RPC poll succeeds', () => {
const manager = new DebugManager({ broadcastBlockHeight: jest.fn() });
manager.lastProductionBlockHeight = 123;
manager.lastSuccessfulRpcAt = 0;
expect(manager.isChainHealthy()).toBe(false);
manager.lastSuccessfulRpcAt = Date.now();
expect(manager.isChainHealthy(1000)).toBe(true);
manager.lastSuccessfulRpcAt = Date.now() - 2000;
expect(manager.isChainHealthy(1000)).toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const fs = require('fs');
const path = require('path');
const deployFile = name => fs.readFileSync(
path.join(__dirname, '..', 'scripts', 'deploy', name),
'utf8'
);
const directiveValues = (unit, directive) => unit
.split(/\r?\n/)
.filter(line => line.startsWith(`${directive}=`))
.flatMap(line => line.slice(directive.length + 1).trim().split(/\s+/));
describe('deployment unit wallet boundaries', () => {
test.each([
['monerogue', 'monerogue-wallet.service', 'monero-wallet-rpc.service'],
['wownerogue', 'wownerogue-wallet.service', 'wownero-wallet-rpc.service']
])('%s application starts only with its dedicated wallet unit', (instance, dedicated, legacy) => {
const unit = deployFile(`${instance}.service`);
expect(directiveValues(unit, 'After')).toContain(dedicated);
expect(directiveValues(unit, 'Requires')).toContain(dedicated);
expect(directiveValues(unit, 'Wants')).not.toContain(legacy);
expect(directiveValues(unit, 'Requires')).not.toContain(legacy);
expect(unit).toContain(`EnvironmentFile=/etc/${instance}/app.env`);
expect(unit).toContain(`EnvironmentFile=/etc/${instance}/wallet-rpc.env`);
for (const unsafeLoaderVariable of [
'BASH_ENV',
'PATH',
'LD_PRELOAD',
'LD_AUDIT',
'NODE_OPTIONS',
'OPENSSL_CONF',
'DATABASE_URL',
'PGPASSWORD',
'NPM_CONFIG_NODE_OPTIONS',
'npm_config_node_options'
]) {
expect(directiveValues(unit, 'UnsetEnvironment')).toContain(unsafeLoaderVariable);
}
});
test('firewall ordering covers dedicated wallets and retained rollback units', () => {
const unit = deployFile('wowngeon-firewall.service');
for (const service of [
'monerogue-wallet.service',
'wownerogue-wallet.service',
'monero-wallet-rpc.service',
'wownero-wallet-rpc.service'
]) {
expect(unit).toContain(service);
}
});
});
+31
View File
@@ -0,0 +1,31 @@
const { DigestAuthClient, parseDigestChallenge } = require('../src/payments/digestAuth');
describe('wallet RPC Digest authentication', () => {
test('parses quoted qop lists and required fields', () => {
expect(parseDigestChallenge('Digest realm="wallet", nonce="n1", qop="auth,auth-int", algorithm=MD5'))
.toMatchObject({ realm: 'wallet', nonce: 'n1', qop: 'auth,auth-int', algorithm: 'MD5' });
});
test('caches the challenge and increments nonce-count without another unauthenticated probe', async () => {
const unauthorized = new Error('unauthorized');
unauthorized.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="wallet", nonce="nonce", qop="auth"' }
};
const http = { post: jest.fn()
.mockRejectedValueOnce(unauthorized)
.mockResolvedValue({ data: { result: {} } }) };
const client = new DigestAuthClient(http, {
username: 'user',
password: 'pass',
randomBytes: () => Buffer.alloc(16, 1)
});
await client.post('http://127.0.0.1:38083/json_rpc', { id: '1' });
await client.post('http://127.0.0.1:38083/json_rpc', { id: '2' });
expect(http.post).toHaveBeenCalledTimes(3);
expect(http.post.mock.calls[1][2].headers.Authorization).toContain('nc=00000001');
expect(http.post.mock.calls[2][2].headers.Authorization).toContain('nc=00000002');
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Credits unification, step ②: a direct/single_game entry is recorded as "buy 1 credit and
* spend it": the balance nets to zero but total_credits_purchased advances, so direct play
* unlocks the same tier/threshold cosmetics as buying credits.
*/
const GameModeManager = require('../src/game/gameModeManager');
const mockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: false, creditsPerGame: 1, packages: [] }
},
payouts: { rules: { direct: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 0n, maxPayout: 0n } } },
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function makeGmm(userRow) {
const client = { query: jest.fn().mockResolvedValue({ rows: [userRow] }) };
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }), // user_pack_entitlements lookup
withTransaction: jest.fn().mockImplementation(async (cb) => cb(client)),
_client: client
};
const gmm = new GameModeManager(db, { processPayout: jest.fn() }, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, mockPaymentConfig());
gmm.getOrCreateUser = jest.fn().mockResolvedValue({ id: 7 });
return { gmm, db, client };
}
describe('recordDirectEntryPurchase', () => {
test('advances total_credits_purchased and unlocks threshold-1 packs', async () => {
const { gmm, client } = makeGmm({ total_credits_purchased: 1, credits: 0, premium_level: null });
const rec = await gmm.recordDirectEntryPurchase('sock');
expect(rec).toBeTruthy();
expect(rec.totalCreditsPurchased).toBe(1);
expect(rec.balance).toBe(0); // net zero: bought and spent
const insert = client.query.mock.calls.find(c => /INSERT INTO credit_transactions/i.test(c[0]));
expect(insert).toBeTruthy();
expect(insert[0]).toMatch(/'direct_entry'/); // audits the purchase leg
expect(insert[0]).toMatch(/'game_entry'/); // audits the spend leg
// A direct entry = 1 credit, which unlocks the first tilepack (roguelike-interior, threshold
// 1) but not skins (5) / Iso (10) / 3D (50).
expect(rec.entitlements.packs['roguelike-interior']).toBe(true);
expect(rec.entitlements.packs['generated-skins']).toBe(false);
expect(rec.entitlements.packs['iso-dungeon']).toBe(false);
expect(rec.entitlements.premium).toBe(true);
// ...but a direct buyer is still NOT a premium tier.
expect(rec.entitlements.level).toBe('free');
});
test('returns null when the session has no user', async () => {
const { gmm } = makeGmm({});
gmm.getOrCreateUser = jest.fn().mockResolvedValue(null);
expect(await gmm.recordDirectEntryPurchase('sock')).toBe(null);
});
});
+115
View File
@@ -0,0 +1,115 @@
const crypto = require('crypto');
const DungeonGenerator = require('../src/game/dungeon');
const Game = require('../src/game/game');
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
const LEGACY_OVERLAP_SEED = '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9';
const LEGACY_OVERLAP_FINGERPRINT = '9fd69a09c5f7a233b03538142994dd932afb4f2d6cfa19baab7041d5303edb50';
function objectiveKeys(dungeon) {
return [dungeon.entrance, dungeon.exit, dungeon.treasure].map(point => point.join(','));
}
describe('versioned playable dungeon objectives', () => {
const saved = {};
beforeAll(() => {
for (const key of ['DIFFICULTY_PRESET', 'DUNGEON_LEVELS', 'GAME_MODE', 'PAYMENTS_ENABLED']) {
saved[key] = process.env[key];
}
process.env.DIFFICULTY_PRESET = 'easy';
process.env.DUNGEON_LEVELS = '1';
process.env.GAME_MODE = 'FREE';
process.env.PAYMENTS_ENABLED = 'false';
});
afterAll(() => {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test('v2 deterministically retries a known v1 entrance/exit overlap', () => {
const v1 = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
const v2 = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v2'
});
expect(v1.entrance).toEqual(v1.exit);
expect([v1.entrance, v1.exit, v1.treasure]).toEqual([[17, 8], [17, 8], [16, 8]]);
expect(DungeonGenerator.layoutFingerprint(v1)).toBe(LEGACY_OVERLAP_FINGERPRINT);
expect(new Set(objectiveKeys(v1)).size).toBeLessThan(3);
expect(new Set(objectiveKeys(v2)).size).toBe(3);
expect(DungeonGenerator.hasDistinctReachableObjectives(v2, {
primaryFloor: "'1",
secondaryFloor: "'2"
})).toBe(true);
const repeated = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v2'
});
expect(DungeonGenerator.layoutFingerprint(repeated)).toBe(DungeonGenerator.layoutFingerprint(v2));
});
test('easy-profile v2 samples always have distinct reachable objectives', () => {
for (let i = 0; i < 100; i += 1) {
const seed = crypto.createHash('sha256').update(`objective-sample-${i}`).digest('hex');
const dungeon = DungeonGenerator.regenerateFromSeed(seed, 'XMR', {
width: 30,
height: 15,
generatorVersion: DungeonGenerator.GENERATOR_VERSION
});
expect(new Set(objectiveKeys(dungeon)).size).toBe(3);
expect(DungeonGenerator.hasDistinctReachableObjectives(dungeon, {
primaryFloor: "'1",
secondaryFloor: "'2"
})).toBe(true);
}
});
test('historical v1 fingerprints remain verifiable after v2 becomes current', () => {
const legacy = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
expect(DungeonGenerator.layoutFingerprint(legacy)).toBe(LEGACY_OVERLAP_FINGERPRINT);
const result = attachDungeonVerification({ valid: true }, {
effectiveSeed: LEGACY_OVERLAP_SEED,
proofContext: {
cryptoType: 'XMR',
maxDepth: 1,
generatorVersion: 'dungeon-generator-v1',
generationOptions: { width: 30, height: 15 }
},
expectedFingerprint: LEGACY_OVERLAP_FINGERPRINT,
generatorVersion: 'dungeon-generator-v1'
});
expect(result.valid).toBe(true);
expect(result.generatorVersion).toBe('dungeon-generator-v1');
expect(result.layoutMatches).toBe(true);
});
test('public game options cannot select a historical generator', () => {
const game = Game.createStandardGame('public-socket', { id: 'public-socket' }, {
cryptoType: 'XMR',
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
expect(game.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameConfig).not.toHaveProperty('generatorVersion');
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameProof.context.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Tests for Early Entry feature
* Early Entry allows players in free/credits mode to enter the dungeon immediately
* without waiting for the next block, with the risk of dying if a block is found.
*/
const PaymentConfigManager = require('../src/config/paymentConfig');
describe('Early Entry Feature', () => {
describe('Configuration', () => {
it('should have earlyEntry configuration in DEFAULT_CONFIG', () => {
const defaultConfig = PaymentConfigManager.DEFAULT_CONFIG;
expect(defaultConfig.earlyEntry).toBeDefined();
expect(typeof defaultConfig.earlyEntry.enabled).toBe('boolean');
expect(typeof defaultConfig.earlyEntry.allowInFreeMode).toBe('boolean');
expect(typeof defaultConfig.earlyEntry.allowInCreditsMode).toBe('boolean');
});
it('should have sensible defaults', () => {
const defaultConfig = PaymentConfigManager.DEFAULT_CONFIG;
// These should be enabled by default for better UX in free/credits modes
expect(defaultConfig.earlyEntry.enabled).toBe(true);
expect(defaultConfig.earlyEntry.allowInFreeMode).toBe(true);
expect(defaultConfig.earlyEntry.allowInCreditsMode).toBe(true);
});
it('should include earlyEntry in manager config', () => {
const manager = new PaymentConfigManager();
const config = manager.getConfig();
expect(config.earlyEntry).toBeDefined();
expect(config.earlyEntry.enabled).toBe(true);
});
});
describe('QueueHandler.isEarlyEntryAllowed', () => {
let mockQueueHandler;
const earlyEntryConfig = PaymentConfigManager.DEFAULT_CONFIG.earlyEntry;
beforeEach(() => {
// Create a mock queue handler with the method we want to test
mockQueueHandler = {
gameModeManager: {
gameMode: 'FREE'
},
isEarlyEntryAllowed: function() {
const config = earlyEntryConfig;
// Check if early entry is enabled globally
if (!config || !config.enabled) {
return { allowed: false, reason: 'Early entry is not enabled' };
}
// Get current game mode
const mode = this.gameModeManager?.gameMode;
// Check mode-specific permissions
if (mode === 'FREE') {
if (!config.allowInFreeMode) {
return { allowed: false, reason: 'Early entry not available in free mode' };
}
return { allowed: true };
}
if (mode === 'PAID_CREDITS') {
if (!config.allowInCreditsMode) {
return { allowed: false, reason: 'Early entry not available in credits mode' };
}
return { allowed: true };
}
// Direct payment mode - not allowed
if (mode === 'PAID_SINGLE') {
return { allowed: false, reason: 'Early entry not available in direct payment mode' };
}
return { allowed: false, reason: 'Unknown game mode' };
}
};
});
it('should allow early entry in FREE mode when configured', () => {
mockQueueHandler.gameModeManager.gameMode = 'FREE';
const result = mockQueueHandler.isEarlyEntryAllowed();
expect(result.allowed).toBe(true);
});
it('should allow early entry in PAID_CREDITS mode when configured', () => {
mockQueueHandler.gameModeManager.gameMode = 'PAID_CREDITS';
const result = mockQueueHandler.isEarlyEntryAllowed();
expect(result.allowed).toBe(true);
});
it('should NOT allow early entry in PAID_SINGLE mode', () => {
mockQueueHandler.gameModeManager.gameMode = 'PAID_SINGLE';
const result = mockQueueHandler.isEarlyEntryAllowed();
expect(result.allowed).toBe(false);
expect(result.reason).toContain('direct payment');
});
});
describe('QueueManager.startEarlyGame', () => {
it('should set isEarlyEntry flag on user', async () => {
// Test that when startEarlyGame is called, the user gets marked
const mockUser = { id: 'test-socket-id' };
const blockHeight = 12345;
// Simulate what startEarlyGame does
mockUser.blockRec = blockHeight;
mockUser.isEarlyEntry = true;
expect(mockUser.blockRec).toBe(12345);
expect(mockUser.isEarlyEntry).toBe(true);
});
it('should calculate death block correctly', () => {
const currentBlock = 12345;
const deathBlock = currentBlock + 1;
expect(deathBlock).toBe(12346);
});
});
describe('GameModeManager.getGameModeInfo', () => {
const GameModeManager = require('../src/game/gameModeManager');
it('should include earlyEntry config in getGameModeInfo', () => {
const manager = new GameModeManager({ cryptoType: 'WOW' });
const info = manager.getGameModeInfo();
expect(info.earlyEntry).toBeDefined();
expect(typeof info.earlyEntry.enabled).toBe('boolean');
expect(typeof info.earlyEntry.allowInFreeMode).toBe('boolean');
expect(typeof info.earlyEntry.allowInCreditsMode).toBe('boolean');
});
});
});
+96
View File
@@ -0,0 +1,96 @@
const fs = require('fs');
const path = require('path');
describe('database economic identity immutability', () => {
const readMigration = name => fs.readFileSync(
path.join(__dirname, `../src/migrations/${name}`), 'utf8'
);
test('committed solo owner is frozen on and after commitment', () => {
const initial = readMigration('032_solo_liability_invariants.sql');
const forward = readMigration('038_economic_identity_immutability.sql');
for (const sql of [initial, forward]) {
expect(sql).toMatch(/NEW\.user_id IS DISTINCT FROM OLD\.user_id/i);
expect(sql).toMatch(/OLD\.payout_committed_at IS NULL[\s\S]*NEW\.payout_committed_at IS NOT NULL[\s\S]*NEW\.user_id IS DISTINCT FROM OLD\.user_id/i);
}
});
test('receipt-backed or final invoice economics, destination, product, and provider are frozen', () => {
const forward = readMigration('038_economic_identity_immutability.sql');
for (const field of [
'user_id', 'payment_type', 'expected_amount', 'subaddress', 'address_index',
'provider_id', 'provider_invoice_id', 'payment_mode', 'credit_package_id',
'product_id', 'product_grants', 'description', 'expires_at'
]) {
expect(forward).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
}
expect(forward).toMatch(/OLD\.status IN \('confirmed', 'refunded', 'expired'\)/i);
expect(forward).toMatch(/EXISTS \(SELECT 1 FROM payment_receipts r WHERE r\.payment_id = OLD\.id\)/i);
expect(forward).toMatch(/OLD\.fairness_bound_at IS NULL AND NEW\.fairness_bound_at IS NOT NULL/i);
});
test('confirmed-payment refunds change operational status without rewriting immutable description', () => {
const refundService = fs.readFileSync(
path.join(__dirname, '../src/services/paymentRefundService.js'), 'utf8'
);
const update = refundService.match(/UPDATE payments[\s\S]*?WHERE id = \$1/gi) || [];
expect(update).toHaveLength(1);
expect(update[0]).toMatch(/SET status = 'refunded'/i);
expect(update[0]).not.toMatch(/description/i);
});
test('payment receipt authorization evidence is append-only', () => {
const forward = readMigration('038_economic_identity_immutability.sql');
expect(forward).toMatch(/CREATE TRIGGER trg_payment_receipts_append_only/i);
expect(forward).toMatch(/BEFORE UPDATE OR DELETE ON payment_receipts/i);
expect(forward).toMatch(/payment receipt evidence is append-only/i);
});
test('payout economics are immutable with only the exact saved-address claim exception', () => {
const sql = readMigration('038_economic_identity_immutability.sql');
for (const field of ['user_id', 'game_id', 'match_id', 'amount', 'multiplier', 'reason']) {
expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
}
expect(sql).toMatch(/OLD\.payout_address = 'PENDING_NO_ADDRESS'/i);
expect(sql).toMatch(/OLD\.status = 'needs_review'/i);
expect(sql).toMatch(/NEW\.status = 'pending'/i);
expect(sql).toMatch(/OLD\.tx_hash IS NULL[\s\S]*NEW\.tx_hash IS NULL/i);
expect(sql).toMatch(/u\.id = OLD\.user_id[\s\S]*u\.payout_address = NEW\.payout_address/i);
expect(sql).toMatch(/CREATE TRIGGER trg_payouts_immutable_obligation/i);
});
test('unresolved solo-win obligations are recordable but excluded from automatic address claim', () => {
const initial = readMigration('038_economic_identity_immutability.sql');
const forward = readMigration('039_unresolved_solo_payout_review.sql');
const runtime = fs.readFileSync(
path.join(__dirname, '../src/game/gameModeManager.js'), 'utf8'
);
expect(runtime).toContain("'solo_winner_identity_review'");
expect(forward).toContain("'solo_winner_identity_review'");
expect(forward).toMatch(/status = 'needs_review'[\s\S]*tx_hash IS NULL/i);
expect(initial).toMatch(
/OLD\.reason IN \('match_winner_no_address', 'solo_winner_no_address'\)/i
);
expect(initial).not.toMatch(
/OLD\.reason IN \([^)]*solo_winner_identity_review/i
);
});
test('paid entropy precommit witness is shaped, immutable, and locks active fairness identity', () => {
const sql = readMigration('040_paid_match_entropy_precommit.sql');
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_tip_height BIGINT/i);
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_verified_at TIMESTAMPTZ/i);
expect(sql).toMatch(/entropy_precommit_tip_height < start_block_height/i);
expect(sql).toMatch(/NEW\.entropy_precommit_tip_height IS DISTINCT FROM OLD\.entropy_precommit_tip_height/i);
expect(sql).toMatch(/NEW\.entropy_precommit_verified_at IS DISTINCT FROM OLD\.entropy_precommit_verified_at/i);
expect(sql).toMatch(/NOT \(OLD\.status = 'pending' AND NEW\.status = 'starting'\)/i);
for (const field of [
'economy', 'variant', 'ruleset_id', 'difficulty_preset', 'max_players',
'seed_hash', 'dungeon', 'start_block_height'
]) {
expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
}
expect(sql).toMatch(/CREATE TRIGGER trg_matches_immutable_entropy_precommit/i);
});
});
+48
View File
@@ -0,0 +1,48 @@
const Appearance = require('../src/multiplayer/appearance');
const Entitlements = require('../src/multiplayer/entitlements');
describe('cosmetic entitlements', () => {
test('free users cannot select premium generated skins or 3D avatars', () => {
const entitlements = Entitlements.snapshotForUser({ credits: 0, total_credits_purchased: 0 });
expect(Entitlements.canUsePack(entitlements, 'generated-skins')).toBe(false);
expect(Entitlements.canUsePack(entitlements, 'kenney-3d-characters')).toBe(false);
expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('default');
expect(Entitlements.normalizeAppearance({ avatar: 'kenney-survivor-male' }, entitlements).avatar).toBe('default');
});
test('graduated ladder: credit spend unlocks packs by threshold', () => {
const entitlements = Entitlements.snapshotForUser({ credits: 7, total_credits_purchased: 10 });
expect(entitlements.premium).toBe(true);
expect(entitlements.level).toBe('free'); // spend unlocks packs by threshold, but isn't a premium tier
expect(entitlements.packs['generated-skins']).toBe(true); // threshold 1
expect(entitlements.packs['iso-dungeon']).toBe(true); // threshold 10: just reached
expect(entitlements.packs['kenney-3d-characters']).toBe(false); // threshold 25: not yet
expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('monero-knight');
});
test('explicit pack grants can unlock one pack without unlocking all packs', () => {
const entitlements = Entitlements.snapshotForUser(
{ credits: 0, total_credits_purchased: 0 },
[{ pack_id: 'generated-skins' }]
);
expect(entitlements.premium).toBe(true);
expect(entitlements.packs['generated-skins']).toBe(true);
expect(entitlements.packs['kenney-3d-characters']).toBe(false);
expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('monero-knight');
expect(Entitlements.normalizeAppearance({ avatar: 'kenney-survivor-male' }, entitlements).avatar).toBe('default');
});
test('appearance catalog reports all premium avatar ids, including 3D models', () => {
expect(Appearance.premiumAvatarIds()).toEqual(expect.arrayContaining([
'monero-knight',
'wownero-rogue',
'cypher-operative',
'kenney-survivor-male',
'kenney-survivor-female'
]));
expect(Appearance.avatarPack('kenney-survivor-male')).toBe('kenney-3d-characters');
});
});
+874
View File
@@ -0,0 +1,874 @@
const EnvironmentValidator = require('../src/config/environmentValidator');
function config(overrides = {}) {
const base = {
paymentsEnabled: true,
currency: { symbol: 'XMR' },
modes: {
direct: { enabled: true, price: 10000n },
credits: {
enabled: true,
packages: [{ id: 'small', credits: 10, price: 90000000000n }]
}
},
payouts: {
enabled: false,
rules: {
direct: {
enabled: false,
multipliers: { escape: 2, escapeWithTreasure: 3 },
minPayout: 1n,
maxPayout: 50000n
},
credits: {
enabled: false,
multipliers: { escape: 2, escapeWithTreasure: 3 },
baseValue: 10000n,
minPayout: 1n,
maxPayout: 50000n
}
}
}
};
return {
...base,
...overrides,
modes: {
...base.modes,
...(overrides.modes || {})
},
payouts: {
...base.payouts,
...(overrides.payouts || {}),
rules: {
direct: {
...base.payouts.rules.direct,
...(overrides.payouts?.rules?.direct || {})
},
credits: {
...base.payouts.rules.credits,
...(overrides.payouts?.rules?.credits || {})
}
}
}
};
}
function productionEnv(overrides = {}) {
return {
NODE_ENV: 'production',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
PAYMENTS_ENABLED: 'true',
PAYOUTS_ENABLED: 'false',
DIRECT_PAYOUTS_ENABLED: 'false',
CREDITS_PAYOUTS_ENABLED: 'false',
PRIMARY_WALLET_ENDPOINT: 'http://127.0.0.1:38083',
WALLET_RPC_USER: 'monerogue',
WALLET_RPC_PASSWORD: 'wallet-rpc-correct-horse-2026',
PRIMARY_RPC_ENDPOINT: 'http://127.0.0.1:38081',
DB_PASSWORD: 'correct-horse-battery-staple-2026',
ADMIN_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
OPERATOR_NAME: 'Test Operator LLC',
OPERATOR_CONTACT_URL: 'mailto:support@operator.invalid',
LEGAL_POLICY_VERSION: '2026-07-21-v1',
TERMS_EFFECTIVE_DATE: '2026-07-21',
MINIMUM_AGE: '18',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
BLOCK_SOURCE: 'daemon',
TRUST_PROXY: 'true',
MATCH_PAID_ENTROPY_DELAY_BLOCKS: '2',
MATCH_PAID_ENTROPY_CONFIRMATIONS: '2',
PAYOUT_MAX_PER_GAME: '50000',
BALANCE_CRITICAL: '50000',
BALANCE_WARN: '100000',
CREDITS_PACKAGES: '[{"id":"small","credits":10,"price":"90000000000"}]',
...overrides
};
}
const silentLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
};
describe('EnvironmentValidator production launch gate', () => {
beforeEach(() => jest.clearAllMocks());
test('accepts explicit paid-credits plus free-play style config with payouts disabled', () => {
const validator = new EnvironmentValidator({ env: productionEnv(), logger: silentLogger });
const result = validator.assertValid(config());
expect(result.errors).toEqual([]);
expect(result.money).toEqual(expect.objectContaining({
paymentsEnabled: true,
payoutsEnabled: false
}));
});
test('accepts a complete HTTPS mainnet financial-event sink without exposing its token', () => {
const token = 'A1b2C3d4E5f6G7h8J9k0L1m2N3p4Q5r6';
const result = new EnvironmentValidator({
env: productionEnv({
MONERO_NETWORK: 'mainnet',
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/v1/events',
FINANCIAL_EVENT_SINK_TOKEN: token,
FINANCIAL_EVENT_ACCOUNT_REF: 'wowngeon:receipts'
}),
logger: silentLogger
}).validate(config());
expect(result.errors).toEqual([]);
expect(JSON.stringify(result)).not.toContain(token);
});
test('rejects partial, insecure, weak, credential-bearing, or testnet accounting export', () => {
const token = 'A1b2C3d4E5f6G7h8J9k0L1m2N3p4Q5r6';
const cases = [
[
{ FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events' },
'must be configured together'
],
[
{
FINANCIAL_EVENT_SINK_URL: 'http://ledger.operator.invalid/events',
FINANCIAL_EVENT_SINK_TOKEN: token,
MONERO_NETWORK: 'mainnet'
},
'must use https:// in production'
],
[
{
FINANCIAL_EVENT_SINK_URL: 'https://user:pass@ledger.operator.invalid/events',
FINANCIAL_EVENT_SINK_TOKEN: token,
MONERO_NETWORK: 'mainnet'
},
'must not embed credentials'
],
[
{
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events',
FINANCIAL_EVENT_SINK_TOKEN: 'change-me'
},
'strong non-placeholder secret'
],
[
{
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events',
FINANCIAL_EVENT_SINK_TOKEN: token
},
'must be unset on non-mainnet networks'
],
[
{
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events',
FINANCIAL_EVENT_SINK_TOKEN: token,
FINANCIAL_EVENT_ACCOUNT_REF: 'customer@example.invalid',
MONERO_NETWORK: 'mainnet'
},
'must be a non-PII identifier'
]
];
for (const [overrides, expected] of cases) {
const result = new EnvironmentValidator({
env: productionEnv(overrides), logger: silentLogger
}).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining(expected)]));
expect(JSON.stringify(result)).not.toContain(token);
}
});
test('accepts the exact Such Software play.wowne.ro operated-product contract', () => {
const env = productionEnv({
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
HOSTED_BY: 'https://play.wowne.ro',
OPERATOR_NAME: 'Such Software',
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
OPERATOR_CONTACT_LABEL: 'apps@such.software',
LEGAL_POLICY_VERSION: '2026-07-22-v1',
TERMS_EFFECTIVE_DATE: '2026-07-22',
CRYPTO_TYPE: 'WOW',
MONERO_NETWORK: 'mainnet',
FREE_PLAY_ENABLED: 'true',
GAME_MODE: 'PAID_CREDITS',
PAYMENT_MODES: 'credits',
DIRECT_PAYMENT_ENABLED: 'false',
CREDITS_ENABLED: 'true',
PAYOUTS_ENABLED: 'false',
DIRECT_PAYOUTS_ENABLED: 'false',
CREDITS_PAYOUTS_ENABLED: 'false',
ALLOW_MAINNET_PAYOUTS: 'false',
SOLO_ENABLED: 'true',
TAVERN_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'false',
MATCH_PAYOUTS_ENABLED: 'false'
});
const operatedConfig = config({
currency: { symbol: 'WOW' },
modes: {
direct: { enabled: false, price: 10000n },
credits: {
enabled: true,
packages: [{ id: 'small', credits: 10, price: 90000000000n }]
}
}
});
expect(new EnvironmentValidator({ env, logger: silentLogger })
.assertValid(operatedConfig).errors).toEqual([]);
for (const gameMode of [undefined, 'PAID_SINGLE']) {
const driftedEnv = { ...env };
if (gameMode === undefined) delete driftedEnv.GAME_MODE;
else driftedEnv.GAME_MODE = gameMode;
expect(new EnvironmentValidator({ env: driftedEnv, logger: silentLogger })
.validate(operatedConfig).errors).toContain(
'OPERATED_PRODUCT_PROFILE=such-play-wow-prestige requires GAME_MODE=PAID_CREDITS.'
);
}
expect(new EnvironmentValidator({ env, logger: silentLogger }).validate({
...operatedConfig,
products: { cosmetic: [{ id: 'unrelated-pack', price: 1n }] }
}).errors).toContain(
'OPERATED_PRODUCT_PROFILE=such-play-wow-prestige requires no standalone products or bundled entitlement grants; credit packages may grant only their top-level credits and bonus.'
);
});
test('accepts the exact direct-only Such Software monerogue.app stagenet 2x/3x contract', () => {
const env = productionEnv({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet',
HOSTED_BY: 'https://monerogue.app',
OPERATOR_NAME: 'Such Software',
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
OPERATOR_CONTACT_LABEL: 'apps@such.software',
LEGAL_POLICY_VERSION: '2026-07-23-v2',
TERMS_EFFECTIVE_DATE: '2026-07-23',
FREE_PLAY_ENABLED: 'true',
GAME_MODE: 'PAID_SINGLE',
PAYMENT_MODES: 'direct',
DIRECT_PAYMENT_ENABLED: 'true',
CREDITS_ENABLED: 'false',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'false',
DIRECT_PAYOUT_ESCAPE: '2.0',
DIRECT_PAYOUT_TREASURE: '3.0',
ALLOW_MAINNET_PAYOUTS: 'false',
SOLO_ENABLED: 'true',
TAVERN_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'false',
MATCH_PAYOUTS_ENABLED: 'false'
});
const operatedConfig = config({
modes: {
direct: { enabled: true, price: 10000n },
credits: {
enabled: false,
packages: [{ id: 'small', credits: 10, price: 90000000000n }]
}
},
payouts: {
enabled: true,
rules: {
direct: { enabled: true },
credits: { enabled: false }
}
}
});
expect(new EnvironmentValidator({ env, logger: silentLogger })
.assertValid(operatedConfig).errors).toEqual([]);
const outOfScopeCatalogs = [
{
...operatedConfig,
products: { cosmetic: [{ id: 'pack', price: 20000n }] }
},
...[
{ packs: ['kenney-3d-characters'] },
{ premiumLevel: 'supporter' },
{ race_entries: 2, race_entry_value_atomic: '10000' },
{ future_entitlement: true },
{ credits: 10 }
].map(grants => ({
...operatedConfig,
modes: {
...operatedConfig.modes,
credits: {
...operatedConfig.modes.credits,
packages: [{ id: 'small', credits: 10, price: 90000000000n, grants }]
}
}
}))
];
for (const widenedCatalog of outOfScopeCatalogs) {
expect(new EnvironmentValidator({ env, logger: silentLogger })
.validate(widenedCatalog).errors).toContain(
'OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet requires no standalone products or bundled entitlement grants; credit packages may grant only their top-level credits and bonus.'
);
}
});
test('play.wowne.ro profile rejects direct entry or any payout activation', () => {
const env = productionEnv({
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
HOSTED_BY: 'https://play.wowne.ro',
OPERATOR_NAME: 'Such Software',
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
OPERATOR_CONTACT_LABEL: 'apps@such.software',
CRYPTO_TYPE: 'WOW',
MONERO_NETWORK: 'mainnet',
FREE_PLAY_ENABLED: 'true',
GAME_MODE: 'PAID_CREDITS',
PAYMENT_MODES: 'direct,credits',
DIRECT_PAYMENT_ENABLED: 'true',
CREDITS_ENABLED: 'true',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'false',
ALLOW_MAINNET_PAYOUTS: 'false',
MATCH_CRYPTO_RACE_ENABLED: 'false',
MATCH_PAYOUTS_ENABLED: 'false'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
currency: { symbol: 'WOW' },
payouts: { enabled: true, rules: { direct: { enabled: true } } }
}));
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('requires PAYMENT_MODES=credits'),
expect.stringContaining('requires DIRECT_PAYMENT_ENABLED=false'),
expect.stringContaining('requires PAYOUTS_ENABLED=false'),
expect.stringContaining('requires DIRECT_PAYOUTS_ENABLED=false')
]));
});
test('operated profiles fail closed on identity, network, mode, multiplier, or payout drift', () => {
const env = productionEnv({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet',
HOSTED_BY: 'https://play.wowne.ro',
OPERATOR_NAME: 'Someone Else',
OPERATOR_CONTACT_URL: 'mailto:other@example.invalid',
OPERATOR_CONTACT_LABEL: 'Other',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'mainnet',
FREE_PLAY_ENABLED: 'true',
GAME_MODE: 'PAID_CREDITS',
PAYMENT_MODES: 'direct,credits',
DIRECT_PAYMENT_ENABLED: 'true',
CREDITS_ENABLED: 'true',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUT_ESCAPE: '4',
DIRECT_PAYOUT_TREASURE: '3',
CREDITS_PAYOUT_ESCAPE: '2',
CREDITS_PAYOUT_TREASURE: '3',
ALLOW_MAINNET_PAYOUTS: 'true',
SOLO_ENABLED: 'false',
TAVERN_ENABLED: 'false',
MATCH_ENABLED: 'false',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: true } }
}
}));
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('requires HOSTED_BY=https://monerogue.app'),
expect.stringContaining('requires OPERATOR_NAME=Such Software'),
expect.stringContaining('requires OPERATOR_CONTACT_URL=mailto:apps@such.software'),
expect.stringContaining('requires LEGAL_POLICY_VERSION=2026-07-23-v2'),
expect.stringContaining('requires TERMS_EFFECTIVE_DATE=2026-07-23'),
expect.stringContaining('requires MONERO_NETWORK=stagenet'),
expect.stringContaining('requires GAME_MODE=PAID_SINGLE'),
expect.stringContaining('requires PAYMENT_MODES=direct'),
expect.stringContaining('requires CREDITS_ENABLED=false'),
expect.stringContaining('requires CREDITS_PAYOUTS_ENABLED=false'),
expect.stringContaining('requires DIRECT_PAYOUT_ESCAPE=2'),
expect.stringContaining('requires SOLO_ENABLED=true'),
expect.stringContaining('requires TAVERN_ENABLED=true'),
expect.stringContaining('requires MATCH_ENABLED=true'),
expect.stringContaining('requires MATCH_CRYPTO_RACE_ENABLED=false'),
expect.stringContaining('requires MATCH_PAYOUTS_ENABLED=false'),
expect.stringContaining('requires ALLOW_MAINNET_PAYOUTS=false')
]));
});
test('unknown operated profile is rejected while an omitted profile preserves generic MIT hosting', () => {
expect(new EnvironmentValidator({ env: productionEnv(), logger: silentLogger })
.validate(config()).errors).toEqual([]);
const result = new EnvironmentValidator({
env: productionEnv({ OPERATED_PRODUCT_PROFILE: 'third-party-claim' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain(
'OPERATED_PRODUCT_PROFILE=third-party-claim is unknown. Remove it for an independent MIT deployment or use a reviewed Such Software profile.'
);
});
test('operated identity cannot bypass its exact contract outside production', () => {
const env = productionEnv({
NODE_ENV: 'development',
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
HOSTED_BY: 'http://localhost:3000',
OPERATOR_NAME: 'Such Software'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('requires HOSTED_BY=https://play.wowne.ro'),
expect.stringContaining('requires CRYPTO_TYPE=WOW'),
expect.stringContaining('requires PAYMENT_MODES=credits')
]));
});
test('accepts explicitly capped stagenet crypto match payouts', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '50000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4'
});
const result = new EnvironmentValidator({ env, logger: silentLogger })
.assertValid(config({
payouts: { enabled: true },
products: {
cosmetic: [{
id: 'race_2',
price: 20000n,
grants: { race_entries: 2, race_entry_value_atomic: '10000' }
}]
}
}));
expect(result.money.matchPayoutsEnabled).toBe(true);
});
test.each([
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '1'],
['MATCH_PAID_ENTROPY_CONFIRMATIONS', '1'],
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '2.5'],
['MATCH_PAID_ENTROPY_CONFIRMATIONS', '9007199254740992'],
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '101']
])('rejects unsafe production paid entropy setting %s=%s', (key, value) => {
const result = new EnvironmentValidator({
env: productionEnv({ MATCH_ENABLED: 'true', [key]: value }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain(
`${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.`
);
});
test('rejects crypto co-op and a cap below the configured maximum pot liability', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '100',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'coop-escape',
MATCH_MAX_PLAYERS: '4'
});
const result = new EnvironmentValidator({ env, logger: silentLogger })
.validate(config({ payouts: { enabled: true } }));
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('single-winner crypto payout semantics'),
expect.stringContaining('below the configured 4-player winner liability')
]));
});
test('rejects mismatched or underfunded race-ticket products', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '50000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
payouts: { enabled: true },
products: {
cosmetic: [
{
id: 'wrong-value',
price: 20000n,
grants: { race_entries: 2, race_entry_value_atomic: '9000' }
},
{
id: 'underfunded',
price: 19999n,
grants: { race_entries: 2, race_entry_value_atomic: '10000' }
}
]
}
}));
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('wrong-value backing must equal MATCH_ENTRY_FEE_ATOMIC'),
expect.stringContaining('underfunded price must fund at least 20000')
]));
});
test('rejects a 100% or over-precision match house fee', () => {
for (const value of ['100', '5.555']) {
const result = new EnvironmentValidator({
env: productionEnv({ MATCH_HOUSE_FEE_PERCENT: value }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain(
'MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.'
);
}
});
test('missing production secrets and RPC endpoints are fatal', () => {
const env = productionEnv({
ADMIN_API_KEY: 'change-me',
DB_PASSWORD: '',
PRIMARY_WALLET_ENDPOINT: '',
PRIMARY_RPC_ENDPOINT: ''
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('PRIMARY_WALLET_ENDPOINT'),
expect.stringContaining('PRIMARY_RPC_ENDPOINT'),
expect.stringContaining('ADMIN_API_KEY'),
expect.stringContaining('DB_PASSWORD')
]));
});
test('wallet RPC authentication requires a complete credential pair', () => {
const result = new EnvironmentValidator({
env: productionEnv({ WALLET_RPC_USER: 'rpc-user', WALLET_RPC_PASSWORD: '' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.');
});
test('production payments reject unauthenticated or remotely exposed wallet RPC', () => {
const missing = productionEnv({ WALLET_RPC_USER: '', WALLET_RPC_PASSWORD: '' });
const missingResult = new EnvironmentValidator({ env: missing, logger: silentLogger }).validate(config());
expect(missingResult.errors).toContain(
'Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.'
);
const remote = productionEnv({ PRIMARY_WALLET_ENDPOINT: 'http://192.0.2.20:38083' });
const remoteResult = new EnvironmentValidator({ env: remote, logger: silentLogger }).validate(config());
expect(remoteResult.errors).toContain(
'Production wallet-rpc over plaintext HTTP must bind to a loopback address.'
);
});
test('payout-only recovery still requires an authenticated loopback wallet', () => {
const payoutOnlyConfig = config({
paymentsEnabled: false,
modes: {
direct: { enabled: false },
credits: { enabled: false, packages: [] }
},
payouts: { enabled: true }
});
const env = productionEnv({
PAYMENTS_ENABLED: 'false',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYMENT_ENABLED: 'false',
CREDITS_ENABLED: 'false',
MONERO_NETWORK: 'mainnet',
WALLET_RPC_USER: '',
WALLET_RPC_PASSWORD: '',
PRIMARY_WALLET_ENDPOINT: 'http://192.0.2.20:38083'
});
const errors = new EnvironmentValidator({ env, logger: silentLogger })
.validate(payoutOnlyConfig).errors;
expect(errors).toEqual(expect.arrayContaining([
expect.stringContaining('Production financial workers require WALLET_RPC_USER'),
expect.stringContaining('plaintext HTTP must bind to a loopback'),
expect.stringContaining('ALLOW_MAINNET_PAYOUTS=true')
]));
env.PRIMARY_WALLET_ENDPOINT = '';
expect(new EnvironmentValidator({ env, logger: silentLogger })
.validate(payoutOnlyConfig).errors)
.toEqual(expect.arrayContaining([
expect.stringContaining('Financial workers require a wallet'),
expect.stringContaining('PRIMARY_WALLET_ENDPOINT')
]));
});
test('payout-only recovery cannot bypass the mainnet acknowledgement', () => {
const payoutOnlyConfig = config({
paymentsEnabled: false,
modes: {
direct: { enabled: false },
credits: { enabled: false, packages: [] }
},
payouts: { enabled: true }
});
const env = productionEnv({
PAYMENTS_ENABLED: 'false',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYMENT_ENABLED: 'false',
CREDITS_ENABLED: 'false',
MONERO_NETWORK: 'mainnet'
});
const validator = new EnvironmentValidator({ env, logger: silentLogger });
expect(validator.validate(payoutOnlyConfig).errors).toContain(
'Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.'
);
env.ALLOW_MAINNET_PAYOUTS = 'true';
expect(validator.validate(payoutOnlyConfig).errors).toEqual([]);
});
test('production requires explicit payout intent instead of dangerous defaults', () => {
const env = productionEnv();
delete env.PAYOUTS_ENABLED;
delete env.DIRECT_PAYOUTS_ENABLED;
delete env.CREDITS_PAYOUTS_ENABLED;
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('explicit PAYOUTS_ENABLED'),
expect.stringContaining('explicit DIRECT_PAYOUTS_ENABLED'),
expect.stringContaining('explicit CREDITS_PAYOUTS_ENABLED')
]));
});
test('production paid play requires truthful operator metadata and acknowledgement policy', () => {
const result = new EnvironmentValidator({
env: productionEnv({
OPERATOR_NAME: 'CHANGE_ME',
OPERATOR_CONTACT_URL: 'javascript:alert(1)',
LEGAL_POLICY_VERSION: '',
TERMS_EFFECTIVE_DATE: '2026-02-30',
MINIMUM_AGE: '0',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'false'
}),
logger: silentLogger
}).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('PAID_ACKNOWLEDGEMENT_REQUIRED=true'),
expect.stringContaining('LEGAL_POLICY_VERSION'),
expect.stringContaining('TERMS_EFFECTIVE_DATE'),
expect.stringContaining('MINIMUM_AGE'),
expect.stringContaining('OPERATOR_NAME'),
expect.stringContaining('OPERATOR_CONTACT_URL')
]));
});
test('rejects misspelled booleans and legacy modes instead of using defaults', () => {
const env = productionEnv({
GAME_MODE: 'FRE',
PAYOUTS_ENABLED: 'flase',
DIRECT_PAYOUTS_ENABLED: 'treu'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('GAME_MODE must be'),
expect.stringContaining('PAYOUTS_ENABLED must be an explicit boolean'),
expect.stringContaining('DIRECT_PAYOUTS_ENABLED must be an explicit boolean')
]));
});
test('rejects malformed atomic amounts and payout multipliers', () => {
const env = productionEnv({
DIRECT_GAME_PRICE: '0.01 XMR',
PAYOUT_MAX_PER_GAME: '-1',
DIRECT_PAYOUT_ESCAPE: 'double'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining('DIRECT_GAME_PRICE must be a non-negative integer'),
expect.stringContaining('PAYOUT_MAX_PER_GAME must be a non-negative integer'),
expect.stringContaining('DIRECT_PAYOUT_ESCAPE must be a finite number')
]));
});
test('rejects unknown payment modes instead of silently disabling them', () => {
const result = new EnvironmentValidator({
env: productionEnv({ PAYMENT_MODES: 'direct,magic' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('PAYMENT_MODES must contain only direct and/or credits.');
});
test('rejects an invalid trusted-proxy hop count', () => {
const result = new EnvironmentValidator({
env: productionEnv({ TRUST_PROXY_HOPS: 'all' }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('TRUST_PROXY_HOPS must be an integer from 1 through 8.');
});
test('mainnet payout dispatch needs a deliberate acknowledgement', () => {
const payoutConfig = config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: true } }
}
});
const env = productionEnv({
MONERO_NETWORK: 'mainnet',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true'
});
const validator = new EnvironmentValidator({ env, logger: silentLogger });
expect(validator.validate(payoutConfig).errors)
.toContain('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.');
env.ALLOW_MAINNET_PAYOUTS = 'true';
expect(validator.validate(payoutConfig).errors).toEqual([]);
});
test('stagenet 2x/3x payouts do not require the mainnet acknowledgement', () => {
const payoutConfig = config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: true } }
}
});
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true'
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig);
expect(result.errors).toEqual([]);
expect(result.money.payoutsEnabled).toBe(true);
});
test('rejects direct 3x above cap and credits 2x below minimum', () => {
const env = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'true'
});
const payoutConfig = config({
modes: { direct: { enabled: true, price: 20000n } },
payouts: {
enabled: true,
rules: {
direct: { enabled: true, maxPayout: 50000n },
credits: { enabled: true, baseValue: 1000n, minPayout: 3000n }
}
}
});
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig);
expect(result.errors).toEqual(expect.arrayContaining([
'Direct escapeWithTreasure payout (60000 atomic units) exceeds its configured maximum (50000).',
'Credits escape payout (2000 atomic units) is below its configured minimum (3000).'
]));
});
test('payout launch gate requires a reserve that covers the largest accepted liability', () => {
const payoutConfig = config({
payouts: {
enabled: true,
rules: { direct: { enabled: true }, credits: { enabled: false } }
}
});
const missing = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
PAYOUT_MAX_PER_GAME: '60000',
BALANCE_CRITICAL: '',
BALANCE_WARN: ''
});
expect(new EnvironmentValidator({ env: missing, logger: silentLogger })
.validate(payoutConfig).errors).toEqual(expect.arrayContaining([
expect.stringContaining('BALANCE_CRITICAL must be an explicit positive'),
expect.stringContaining('BALANCE_WARN must be an explicit positive')
]));
const underfunded = productionEnv({
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
PAYOUT_MAX_PER_GAME: '60000',
BALANCE_CRITICAL: '59999',
BALANCE_WARN: '59998'
});
expect(new EnvironmentValidator({ env: underfunded, logger: silentLogger })
.validate(payoutConfig).errors).toEqual(expect.arrayContaining([
expect.stringContaining('BALANCE_CRITICAL must be at least PAYOUT_MAX_PER_GAME (60000'),
expect.stringContaining('BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL')
]));
});
test.each([
['BLOCK_SOURCE', 'simulated'],
['SIMULATED_BLOCKS', 'true'],
['FORCE_SIMULATED_BLOCKS', 'true']
])('rejects production simulation via %s', (key, value) => {
const result = new EnvironmentValidator({
env: productionEnv({ [key]: value }),
logger: silentLogger
}).validate(config());
expect(result.errors).toContain('Simulated blocks are forbidden when NODE_ENV=production.');
});
test('assertValid throws a typed error for startup to fail closed', () => {
const validator = new EnvironmentValidator({
env: productionEnv({ ADMIN_API_KEY: 'weak' }),
logger: silentLogger
});
expect(() => validator.assertValid(config())).toThrow(expect.objectContaining({
name: 'EnvironmentValidationError',
code: 'INVALID_ENVIRONMENT'
}));
});
});
describe('EnvironmentValidator development ergonomics', () => {
test('missing wallet/daemon endpoints warn without hard-stopping development', () => {
const env = { NODE_ENV: 'development', CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'stagenet' };
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
expect(result.errors).toEqual([]);
expect(result.warnings.some(message => message.includes('PRIMARY_WALLET_ENDPOINT'))).toBe(true);
expect(result.warnings.some(message => message.includes('PRIMARY_RPC_ENDPOINT'))).toBe(true);
});
});
+251
View File
@@ -0,0 +1,251 @@
const FairnessOfferManager = require('../src/game/fairnessOfferManager');
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
const {
hashSeed,
deriveSeed,
createGameProof,
getPreGameCommitment,
getPostGameReveal,
verifyGameProof
} = require('../src/game/provablyFair');
const SERVER_SEED = 'a'.repeat(64);
const CLIENT_SEED = '12'.repeat(32);
describe('two-phase solo fairness offers', () => {
test('publishes a commitment before client seed and consumes it exactly once', () => {
let now = 1000;
const offers = new FairnessOfferManager({
now: () => now,
seedFactory: () => SERVER_SEED,
idFactory: () => 'offer-1',
ttlMs: 60000
});
const published = offers.ensureOffer('socket-a');
expect(published).toEqual(expect.objectContaining({
offerId: 'offer-1',
commitment: hashSeed(SERVER_SEED),
issuedAt: 1000,
proofVersion: 2
}));
now = 2000; // client contribution arrives strictly after publication
const consumed = offers.consume('socket-a', {
offerId: published.offerId,
clientSeed: CLIENT_SEED.toUpperCase()
});
expect(consumed.success).toBe(true);
expect(consumed.proofInput.offerIssuedAt).toBeLessThan(now);
expect(consumed.proofInput.clientSeed).toBe(CLIENT_SEED);
expect(consumed.proofInput.commitment).toBe(published.commitment);
expect(offers.consume('socket-a', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
})).toEqual(expect.objectContaining({ success: false, code: 'OFFER_REPLAYED' }));
});
test('cannot steal, swap, or attach a client seed without echoing the published offer', () => {
const offers = new FairnessOfferManager({
seedFactory: () => SERVER_SEED,
idFactory: () => 'offer-owner'
});
const published = offers.ensureOffer('owner');
expect(offers.consume('attacker', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
}).code).toBe('OFFER_OWNER_MISMATCH');
expect(offers.consume('owner', { clientSeed: CLIENT_SEED }).code).toBe('OFFER_REQUIRED');
const consumed = offers.consume('owner', {
offerId: published.offerId,
clientSeed: CLIENT_SEED
});
expect(consumed.success).toBe(true);
expect(() => createGameProof('game', CLIENT_SEED, {
...consumed.proofInput,
commitment: 'b'.repeat(64)
})).toThrow(/commitment does not match/i);
});
test('legacy clients may consume the already-published current offer with an empty seed', () => {
const offers = new FairnessOfferManager({
seedFactory: () => SERVER_SEED,
idFactory: () => 'legacy-offer'
});
offers.ensureOffer('legacy');
const consumed = offers.consume('legacy');
expect(consumed.success).toBe(true);
expect(consumed.proofInput.clientSeed).toBe('');
});
});
describe('v2 proof binds the accepted client seed to the played dungeon', () => {
function precommit(clientSeed, offerId) {
return {
proofVersion: 2,
offerId,
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed
};
}
test('Game derives its RNG before layout generation and persists reproducible context', () => {
const game = new Game('socket', { id: 1 }, { fairnessProof: precommit(CLIENT_SEED, 'offer-a') });
expect(game.gameProof.seed).toBe(deriveSeed(SERVER_SEED, CLIENT_SEED));
expect(game.gameProof.clientSeed).toBe(CLIENT_SEED);
expect(game.gameProof.offerId).toBe('offer-a');
const context = game.gameProof.context;
const regenerated = DungeonGenerator.regenerateFromSeed(
game.gameProof.seed,
context.cryptoType,
context.generationOptions
);
if (context.maxDepth > 1) regenerated.treasure = null;
expect(DungeonGenerator.layoutFingerprint(regenerated)).toBe(game.gameProof.layoutFingerprint);
});
test('changing the client seed changes both effective seed and dungeon', () => {
const one = new Game('one', { id: 1 }, { fairnessProof: precommit('01', 'offer-1') });
const two = new Game('two', { id: 2 }, { fairnessProof: precommit('02', 'offer-2') });
expect(one.gameProof.seed).not.toBe(two.gameProof.seed);
expect(one.gameProof.layoutFingerprint).not.toBe(two.gameProof.layoutFingerprint);
});
test('pre-game data hides server seed, reveal links by game id, and tampering fails', () => {
const proof = createGameProof('game-uuid', CLIENT_SEED, precommit(CLIENT_SEED, 'offer-z'));
proof.layoutFingerprint = 'f'.repeat(64);
const before = getPreGameCommitment(proof);
expect(before.clientSeed).toBe(CLIENT_SEED);
expect(before.offerId).toBe('offer-z');
expect(before.serverSeed).toBeUndefined();
expect(before.seed).toBeUndefined();
const reveal = getPostGameReveal(proof);
expect(reveal.verificationUrl).toBe('/verify/game-uuid');
expect(verifyGameProof(reveal).valid).toBe(true);
expect(verifyGameProof({ ...reveal, clientSeed: '03' }).valid).toBe(false);
expect(verifyGameProof({ ...reveal, effectiveSeed: 'b'.repeat(64), seed: 'b'.repeat(64) }).valid).toBe(false);
expect(verifyGameProof({ ...reveal, serverSeed: 'b'.repeat(64) }).valid).toBe(false);
});
test('post-game reveal preserves the terminal result after endGame changes runtime state', () => {
const game = new Game('result-socket', { id: 3, endGame() {} }, {
fairnessProof: precommit(CLIENT_SEED, 'result-offer')
});
game.endGame('won', { score: 10 });
expect(game.gameState).toBe('ended');
expect(game.getProofReveal().gameResult.won).toBe(true);
});
});
describe('all-depth versioned verification', () => {
let savedLevels;
let savedCrypto;
beforeEach(() => {
savedLevels = process.env.DUNGEON_LEVELS;
savedCrypto = process.env.CRYPTO_TYPE;
process.env.DUNGEON_LEVELS = '3';
process.env.CRYPTO_TYPE = 'WOW';
});
afterEach(() => {
if (savedLevels === undefined) delete process.env.DUNGEON_LEVELS;
else process.env.DUNGEON_LEVELS = savedLevels;
if (savedCrypto === undefined) delete process.env.CRYPTO_TYPE;
else process.env.CRYPTO_TYPE = savedCrypto;
});
test('persists and deterministically verifies every advertised depth', () => {
const fairnessProof = {
proofVersion: 2,
offerId: 'all-depth-offer',
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed: CLIENT_SEED
};
const game = new Game('depth-socket', { id: 7 }, { fairnessProof });
const reveal = game.getProofReveal();
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameProof.layoutFingerprints).toHaveLength(3);
expect(game.gameProof.layoutFingerprints.map(item => item.depth)).toEqual([1, 2, 3]);
const result = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprint: reveal.layoutFingerprint,
expectedFingerprints: reveal.layoutFingerprints,
generatorVersion: reveal.generatorVersion
});
expect(result.valid).toBe(true);
expect(result.verificationScope).toBe('all_depths');
expect(result.verifiedDepths).toEqual([1, 2, 3]);
expect(result.levels.every(level => level.matches)).toBe(true);
});
test('fails if any depth is tampered with or omitted', () => {
const game = new Game('tamper-socket', { id: 8 }, {
fairnessProof: {
proofVersion: 2,
offerId: 'tamper-offer',
offerIssuedAt: 123,
serverSeed: SERVER_SEED,
commitment: hashSeed(SERVER_SEED),
clientSeed: CLIENT_SEED
}
});
const reveal = game.getProofReveal();
const tampered = reveal.layoutFingerprints.map(item => ({ ...item }));
tampered[1].fingerprint = '0'.repeat(64);
const mismatch = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprints: tampered,
generatorVersion: reveal.generatorVersion
});
expect(mismatch.valid).toBe(false);
expect(mismatch.levels[1].matches).toBe(false);
const incomplete = attachDungeonVerification(verifyGameProof(reveal), {
effectiveSeed: reveal.effectiveSeed,
proofContext: reveal.context,
expectedFingerprints: reveal.layoutFingerprints.slice(0, 2),
generatorVersion: reveal.generatorVersion
});
expect(incomplete.valid).toBe(false);
expect(incomplete.regenerationError).toBe('incomplete_layout_manifest');
});
});
describe('browser contribution helper', () => {
test('generates a fresh 256-bit client seed after selecting the offer', () => {
const oldWindow = global.window;
const oldSocket = global.socket;
global.window = {
crypto: { getRandomValues: (bytes) => { for (let i = 0; i < bytes.length; i++) bytes[i] = i; return bytes; } }
};
global.socket = { emit: jest.fn() };
jest.resetModules();
const BrowserHandlers = require('../html/js/network/socketHandlers.js');
BrowserHandlers._fairnessOffer = { offerId: 'browser-offer', commitment: hashSeed(SERVER_SEED) };
const payload = BrowserHandlers.fairnessAttempt({ free: true });
expect(payload.free).toBe(true);
expect(payload.fairnessOfferId).toBe('browser-offer');
expect(payload.clientSeed).toMatch(/^[0-9a-f]{64}$/);
expect(BrowserHandlers._pendingFairnessAttempt.commitment).toBe(hashSeed(SERVER_SEED));
global.window = oldWindow;
global.socket = oldSocket;
});
});
+28
View File
@@ -0,0 +1,28 @@
'use strict';
const fs = require('fs');
const path = require('path');
const deploy = name => fs.readFileSync(path.join(__dirname, '..', 'scripts', 'deploy', name), 'utf8');
describe('deployment financial-audit contract', () => {
const audit = deploy('financial-audit.sql');
test('retained consumed match anchors are not treated as live escrow', () => {
expect(audit).toMatch(/match_queue_entries\s+WHERE status IS NULL OR status NOT IN \('consumed', 'cancelled'\)/);
expect(audit).not.toMatch(/status NOT IN \('cancelled'\)/);
});
test('nullable or unknown money/game states and a stale schema ledger fail closed', () => {
expect(audit).toContain("payouts WHERE status IS DISTINCT FROM 'completed'");
expect(audit).toMatch(/FROM payouts\s+WHERE status IS DISTINCT FROM 'completed'/);
expect(audit).not.toMatch(
/FROM payouts\s+WHERE status IS NULL OR status NOT IN \('recorded', 'completed'\)/
);
expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')");
expect(audit).toContain("MAX(filename) = '043_durable_solo_restart_snapshots.sql'");
expect(audit.match(/status IS NULL OR status NOT IN \('recorded', 'completed'\)/g))
.toHaveLength(2);
expect(audit).toContain('COUNT(*) = 43');
});
});
@@ -0,0 +1,80 @@
'use strict';
const fs = require('fs');
const path = require('path');
const MIGRATIONS = path.join(__dirname, '../src/migrations');
const AUDIT = path.join(__dirname, '../scripts/deploy/financial-constraint-audit.sql');
const VALIDATE = path.join(__dirname, '../scripts/deploy/financial-constraint-validate-restore.sql');
const CONSTRAINTS = Object.freeze([
['payments', 'payments_status_check'],
['payments', 'payments_payment_type_check'],
['payouts', 'payouts_status_check'],
['games', 'games_payout_commitment_complete'],
['games', 'games_layout_fingerprints_array_check'],
['race_entry_lots', 'race_entry_lots_remaining_within_original'],
['match_queue_entries', 'match_queue_entries_status_check'],
['match_queue_entries', 'match_queue_entries_escrow_nonnegative'],
['match_queue_entries', 'match_queue_committed_escrow_complete'],
['matches', 'matches_payout_liability_amount_nonnegative'],
['matches', 'matches_payout_liability_cap_positive'],
['matches', 'matches_payout_liability_complete'],
['matches', 'matches_payout_liability_economics_consistent'],
['race_entry_lots', 'race_entry_lots_refund_shape'],
['payment_refunds', 'payment_refunds_progress_nonnegative'],
['games', 'games_entry_consumption_shape'],
['payments', 'payments_fairness_binding_complete'],
['payouts', 'payouts_no_address_review_shape']
]);
function migrationNotValidConstraintNames() {
const names = new Set();
for (const fileName of fs.readdirSync(MIGRATIONS).filter(name => name.endsWith('.sql'))) {
const sql = fs.readFileSync(path.join(MIGRATIONS, fileName), 'utf8');
for (const marker of sql.matchAll(/NOT\s+VALID\s*;/gi)) {
const prefix = sql.slice(0, marker.index);
const declarations = [...prefix.matchAll(/ADD\s+CONSTRAINT\s+([a-z0-9_]+)/gi)];
if (declarations.length) names.add(declarations.at(-1)[1].toLowerCase());
}
}
return [...names].sort();
}
describe('historical financial constraint release gates', () => {
const expectedNames = CONSTRAINTS.map(([, name]) => name).sort();
test('inventory stays synchronized with every migration NOT VALID constraint', () => {
expect(migrationNotValidConstraintNames()).toEqual(expectedNames);
expect(CONSTRAINTS).toHaveLength(18);
});
test('read-only audit names every constraint and checks historical predicates', () => {
const sql = fs.readFileSync(AUDIT, 'utf8');
expect(sql).toMatch(/BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;/i);
expect(sql).toContain("current_database() = :'expected_database'");
expect(sql).toContain('historical_constraints_clean');
expect(sql).toContain('IS FALSE');
expect(sql).not.toMatch(/\bALTER\s+TABLE\b/i);
expect(sql).not.toMatch(/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE)\b/i);
for (const [, name] of CONSTRAINTS) {
expect(sql).toContain(`'${name}'`);
}
});
test('disposable-restore gate explicitly validates every constraint then rolls back', () => {
const sql = fs.readFileSync(VALIDATE, 'utf8');
expect(sql).toContain("current_database() ~* '(restore|scratch)'");
expect(sql).toContain('I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE');
expect(sql).toContain('\\ir financial-constraint-audit.sql');
expect(sql).toContain('SET LOCAL lock_timeout');
expect(sql).toMatch(/ROLLBACK;[\s\S]*PASS: PostgreSQL validated all 18 constraints/i);
for (const [table, name] of CONSTRAINTS) {
expect(sql).toContain(
`ALTER TABLE public.${table} VALIDATE CONSTRAINT ${name};`
);
}
expect((sql.match(/\bVALIDATE CONSTRAINT\s+[a-z0-9_]+;/gi) || []))
.toHaveLength(CONSTRAINTS.length);
});
});
+388
View File
@@ -0,0 +1,388 @@
'use strict';
const fs = require('fs');
const path = require('path');
const FinancialEventExporter = require('../src/services/financialEventExporter');
const {
buildFinancialEvent,
sourceSnapshotFromPayment,
DELIVERY_CONTRACT
} = FinancialEventExporter;
const ENV = {
NODE_ENV: 'production',
CRYPTO_TYPE: 'WOW',
MONERO_NETWORK: 'mainnet',
FINANCIAL_EVENT_SINK_URL: 'https://accounting.example.test/api/events/wowngeon',
FINANCIAL_EVENT_SINK_TOKEN: 'a'.repeat(64),
FINANCIAL_EVENT_ACCOUNT_REF: 'wowngeon:receipts',
FINANCIAL_EVENT_BATCH_SIZE: '2'
};
const PAYMENT = {
id: 22,
payment_type: 'credits_package',
expected_amount: '900000000000',
received_amount: '900000000000',
confirmed_at: '2026-07-22T14:30:00Z',
provider_id: 'native-monero',
product_id: 'small',
receipts: [{
evidence_type: 'chain_output',
evidence_id: `${'a'.repeat(64)}:${'b'.repeat(64)}`,
payout_address: 'wallet-address-must-not-escape'
}]
};
function paymentClaim(overrides = {}) {
return {
id: 5,
event_id: 'payment-confirmed-22',
event_kind: 'payment.confirmed',
source_type: 'payment',
source_id: 22,
aggregate_id: 22,
payload_snapshot: sourceSnapshotFromPayment(PAYMENT),
delivery_payload: null,
attempts: 1,
...overrides
};
}
function healthRows(overrides = {}) {
return { rows: [{ backlog: '0', dead_letters: '0', ignored_rows: '0', ...overrides }] };
}
function standardDb({ claims = [paymentClaim()], onQuery } = {}) {
const queue = [...claims];
const db = {
query: jest.fn(async (sql, params = []) => {
if (onQuery) {
const custom = await onQuery(sql, params);
if (custom) return custom;
}
if (/attempts >= \$1/i.test(sql)) return { rows: [], rowCount: 0 };
if (/RETURNING id, event_id, event_kind/i.test(sql)) {
const next = queue.shift();
return { rows: next ? [next] : [], rowCount: next ? 1 : 0 };
}
if (/SET delivery_payload = \$2::jsonb/i.test(sql)) {
return { rows: [{ delivery_payload: JSON.parse(params[1]) }], rowCount: 1 };
}
if (/SET status = 'delivered'/i.test(sql)) return { rows: [{ id: params[0] }], rowCount: 1 };
if (/COUNT\(\*\) FILTER/i.test(sql)) return healthRows();
return { rows: [], rowCount: 0 };
})
};
return db;
}
describe('financial event payload snapshots', () => {
test('builds a backend-neutral, unpriced WOW receipt without customer or wallet data', () => {
const source = sourceSnapshotFromPayment(PAYMENT);
const event = buildFinancialEvent(source, ENV);
expect(event).toEqual(expect.objectContaining({
schema: 'financial-event/v1',
id: 'payment-confirmed-22',
producer: 'wowngeon',
product: 'wowngeon',
activity: 'customer-payment',
account_refs: ['wowngeon:receipts'],
amounts: {},
attributes: expect.objectContaining({
valuation_required: true,
atomic_amount: '900000000000',
atomic_decimals: 11,
receipt_count: 1
})
}));
expect(event.legs[0]).toEqual(expect.objectContaining({
asset: 'WOW', quantity: '9', direction: 'in', usd_value: '0'
}));
expect(JSON.stringify(source)).not.toContain(PAYMENT.receipts[0].evidence_id);
expect(JSON.stringify({ source, event })).not.toMatch(
/user_id|socket_id|ip_address|subaddress|payout_address|provider_invoice_id|wallet-address-must-not-escape/i
);
});
test('exports only the immutable snapshot after the payment becomes mutable/refunded', async () => {
const snapshot = sourceSnapshotFromPayment(PAYMENT);
const mutatedPayment = { ...PAYMENT, status: 'refunded', received_amount: '1' };
const db = standardDb({ claims: [paymentClaim({ payload_snapshot: snapshot })] });
const fetchFn = jest.fn(async () => ({ ok: true, status: 201 }));
const exporter = new FinancialEventExporter({ db, env: ENV, fetchFn, logger: { warn: jest.fn() } });
await expect(exporter.runOnce()).resolves.toEqual({
skipped: false, delivered: 1, deferred: 0, deadLettered: 0, ignored: 0
});
const delivered = JSON.parse(fetchFn.mock.calls[0][1].body);
expect(delivered.attributes.atomic_amount).toBe('900000000000');
expect(mutatedPayment.received_amount).toBe('1');
expect(db.query.mock.calls.some(([sql]) => /FROM payments\b/i.test(sql))).toBe(false);
});
test.each([
['recorded', 'customer-payment-reversal'],
['completed', 'customer-refund']
])('exports final %s refund as an outgoing leg', (refundStatus, activity) => {
const event = buildFinancialEvent({
schema: 'financial-event-source/v1',
event_id: 'payment-refund-91',
event_kind: 'payment.refund',
source_type: 'payment_refund',
source_id: 91,
aggregate_id: 22,
occurred_at: '2026-07-22T15:00:00Z',
atomic_amount: '450000000000',
refund_status: refundStatus
}, ENV);
expect(event).toEqual(expect.objectContaining({
id: 'payment-refund-91',
activity,
attributes: expect.objectContaining({ refund_status: refundStatus })
}));
expect(event.legs).toEqual([expect.objectContaining({
quantity: '4.5', direction: 'out', asset: 'WOW'
})]);
});
test.each(['requested', 'processing', 'needs_review'])('refuses non-final %s refund snapshots', status => {
expect(() => buildFinancialEvent({
schema: 'financial-event-source/v1',
event_id: 'payment-refund-91',
event_kind: 'payment.refund',
source_type: 'payment_refund',
source_id: 91,
aggregate_id: 22,
occurred_at: '2026-07-22T15:00:00Z',
atomic_amount: '1',
refund_status: status
}, ENV)).toThrow(/final exportable state/);
});
test('rejects PII-shaped account references and credential-bearing sink URLs', () => {
expect(() => buildFinancialEvent(sourceSnapshotFromPayment(PAYMENT), {
...ENV,
FINANCIAL_EVENT_ACCOUNT_REF: 'customer@example.invalid'
})).toThrow(/non-PII/);
expect(() => new FinancialEventExporter({
db: { query: jest.fn() },
env: { ...ENV, FINANCIAL_EVENT_SINK_URL: 'https://user:pass@accounting.example.test/events' },
fetchFn: jest.fn()
})).toThrow(/must not embed credentials/);
});
});
describe('at-least-once financial event delivery', () => {
test('uses one stable idempotency key and stored body when the durable acknowledgement is retried', async () => {
const claim1 = paymentClaim({ attempts: 1 });
let storedPayload = null;
let claimNumber = 0;
let acknowledgementNumber = 0;
const db = standardDb({
claims: [],
onQuery: async (sql, params) => {
if (/RETURNING id, event_id, event_kind/i.test(sql)) {
claimNumber += 1;
if (claimNumber === 1) return { rows: [claim1], rowCount: 1 };
if (claimNumber === 2) return { rows: [], rowCount: 0 };
if (claimNumber === 3) {
return { rows: [{ ...claim1, attempts: 2, delivery_payload: storedPayload }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
if (/SET delivery_payload = \$2::jsonb/i.test(sql)) {
storedPayload = JSON.parse(params[1]);
return { rows: [{ delivery_payload: storedPayload }], rowCount: 1 };
}
if (/SET status = 'delivered'/i.test(sql)) {
acknowledgementNumber += 1;
if (acknowledgementNumber === 1) throw new Error('database acknowledgement unavailable');
return { rows: [{ id: params[0] }], rowCount: 1 };
}
return null;
}
});
const fetchFn = jest.fn(async () => ({ ok: true, status: 202 }));
const exporter = new FinancialEventExporter({ db, env: ENV, fetchFn, logger: { warn: jest.fn() } });
await expect(exporter.runOnce()).resolves.toEqual(expect.objectContaining({ deferred: 1 }));
await expect(exporter.runOnce()).resolves.toEqual(expect.objectContaining({ delivered: 1 }));
expect(fetchFn).toHaveBeenCalledTimes(2);
const first = fetchFn.mock.calls[0][1];
const second = fetchFn.mock.calls[1][1];
expect(first.headers['idempotency-key']).toBe('payment-confirmed-22');
expect(second.headers['idempotency-key']).toBe(first.headers['idempotency-key']);
expect(second.body).toBe(first.body);
expect(DELIVERY_CONTRACT).toEqual(expect.objectContaining({ guarantee: 'at-least-once' }));
expect(exporter.getDeliveryContract().deduplication).toMatch(/Idempotency-Key/);
});
test('bounds repeated failures and moves the last attempt to dead letter', async () => {
const db = standardDb({
claims: [paymentClaim({ attempts: 2 })],
onQuery: async (sql, params) => {
if (/SET status = 'dead_letter'/i.test(sql) && /WHERE id = \$1/i.test(sql)) {
return { rows: [{ id: params[0] }], rowCount: 1 };
}
if (/COUNT\(\*\) FILTER/i.test(sql)) return healthRows({ backlog: '0', dead_letters: '1' });
return null;
}
});
const fetchFn = jest.fn(async () => ({ ok: false, status: 503 }));
const exporter = new FinancialEventExporter({
db,
env: { ...ENV, FINANCIAL_EVENT_MAX_ATTEMPTS: '2' },
fetchFn,
logger: { warn: jest.fn() }
});
await expect(exporter.runOnce()).resolves.toEqual({
skipped: false, delivered: 0, deferred: 0, deadLettered: 1, ignored: 0
});
expect(exporter.getHealth()).toEqual(expect.objectContaining({
attempted: 1, deadLettered: 1, deadLetters: 1, backlog: 0
}));
expect(exporter.getPublicHealth()).toEqual(expect.objectContaining({
contract: 'at-least-once', deadLettered: 1, deadLetters: 1, backlog: 0
}));
expect(JSON.stringify(exporter.getPublicHealth())).not.toMatch(
/accounting\.example|authorization|token|lastError|event_id|payment-confirmed/i
);
});
test('malformed stored snapshots fail closed and dead-letter without an HTTP attempt', async () => {
const malformed = paymentClaim({
attempts: 1,
payload_snapshot: { ...paymentClaim().payload_snapshot, occurred_at: null }
});
const db = standardDb({
claims: [malformed],
onQuery: async (sql, params) => {
if (/SET status = 'dead_letter'/i.test(sql) && /WHERE id = \$1/i.test(sql)) {
return { rows: [{ id: params[0] }], rowCount: 1 };
}
return null;
}
});
const fetchFn = jest.fn();
const exporter = new FinancialEventExporter({
db,
env: { ...ENV, FINANCIAL_EVENT_MAX_ATTEMPTS: '1' },
fetchFn,
logger: { warn: jest.fn() }
});
await expect(exporter.runOnce()).resolves.toEqual(expect.objectContaining({ deadLettered: 1 }));
expect(fetchFn).not.toHaveBeenCalled();
});
});
describe('non-mainnet suppression and migration safety', () => {
test('keeps backlog counters current when the optional mainnet sink is disabled', async () => {
const db = {
query: jest.fn(async sql => {
if (/COUNT\(\*\) FILTER/i.test(sql)) {
return healthRows({ backlog: '7', dead_letters: '2', ignored_rows: '1' });
}
throw new Error(`unexpected SQL: ${sql}`);
})
};
const exporter = new FinancialEventExporter({
db,
env: { NODE_ENV: 'production', CRYPTO_TYPE: 'WOW', MONERO_NETWORK: 'mainnet' },
fetchFn: jest.fn(),
logger: { warn: jest.fn() }
});
await expect(exporter.runOnce()).resolves.toEqual({
skipped: true, delivered: 0, deferred: 0, deadLettered: 0, ignored: 0
});
expect(exporter.getPublicHealth()).toEqual(expect.objectContaining({
enabled: false, backlog: 7, deadLetters: 2, ignoredRows: 1
}));
expect(exporter.start()).toBe(true);
exporter.stop();
});
test.each(['stagenet', 'testnet'])('continuously suppresses XMR %s rows without a configured sink', async network => {
const db = {
query: jest.fn(async sql => {
if (/ignored_reason = 'non_mainnet_network'/i.test(sql)) {
return { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 };
}
if (/COUNT\(\*\) FILTER/i.test(sql)) return healthRows({ ignored_rows: '2' });
throw new Error(`unexpected SQL: ${sql}`);
})
};
const fetchFn = jest.fn();
const exporter = new FinancialEventExporter({
db,
env: { NODE_ENV: 'production', CRYPTO_TYPE: 'XMR', MONERO_NETWORK: network },
fetchFn,
logger: { warn: jest.fn() }
});
await expect(exporter.runOnce()).resolves.toEqual({
skipped: false, delivered: 0, deferred: 0, deadLettered: 0, ignored: 2
});
expect(fetchFn).not.toHaveBeenCalled();
expect(exporter.getHealth()).toEqual(expect.objectContaining({
exportAllowed: false, ignored: 2, ignoredRows: 2
}));
const suppressionSql = db.query.mock.calls.find(([sql]) =>
/ignored_reason = 'non_mainnet_network'/i.test(sql)
)[0];
expect(suppressionSql).toMatch(/lease_until = NULL/i);
expect(suppressionSql).toMatch(/dead_lettered_at = NULL/i);
expect(suppressionSql).toMatch(/status IN \('pending', 'in_flight', 'dead_letter'\)/i);
});
test('does not dead-letter an actively leased attempt from another worker', () => {
const source = fs.readFileSync(path.join(
__dirname, '../src/services/financialEventExporter.js'
), 'utf8');
expect(source).toMatch(
/status = 'in_flight' AND lease_until < NOW\(\)[\s\S]*RETURNING id/
);
});
test('wires sanitized exporter counters into public health without a readiness dependency', () => {
const indexSource = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
expect(indexSource).toMatch(
/health\.financialEvents = financialEventExporter\.getPublicHealth\(\)/
);
expect(indexSource).not.toMatch(
/ready\s*=\s*[^;\n]*financialEventExporter|getPublicHealth\(\)[^;\n]*ready/i
);
});
test('042 corrects 041 without rereading at delivery or exporting unsettled refunds', () => {
const migration = fs.readFileSync(path.join(
__dirname, '../src/migrations/042_immutable_financial_event_snapshots.sql'
), 'utf8');
expect(migration).toMatch(/payload_snapshot JSONB/i);
expect(migration).toMatch(/delivery_payload JSONB/i);
expect(migration).toMatch(/COALESCE\(p\.confirmed_at, p\.created_at, o\.created_at, NOW\(\)\)/i);
expect(migration).toMatch(/malformed_legacy_snapshot/i);
expect(migration).toMatch(/NEW\.status IN \('recorded', 'completed'\)/i);
expect(migration).toMatch(/WHERE r\.status IN \('recorded', 'completed'\)/i);
expect(migration).not.toMatch(/WHERE r\.status IN \([^)]*needs_review/i);
expect(migration).toMatch(/reject_financial_event_identity_mutation/i);
expect(migration).toMatch(
/payload_snapshot->>'event_id'\) IS NOT DISTINCT FROM event_id/i
);
expect(migration).toMatch(
/delivery_payload->>'id'\) IS NOT DISTINCT FROM event_id/i
);
expect(migration).toMatch(/OLD\.delivery_payload IS NOT NULL[\s\S]*NEW\.delivery_payload IS DISTINCT/i);
expect(migration).toMatch(
/OLD\.delivery_payload IS NULL AND NEW\.delivery_payload IS NOT NULL[\s\S]*OLD\.status IS DISTINCT FROM 'in_flight'/i
);
expect(migration).not.toMatch(/payout_address|subaddress|provider_invoice_id|evidence_id|tx_hash/i);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Post-review follow-ups.
*
* 1. No-address match winner reconciliation: setUserPayoutAddress converts a claimable
* 'needs_review' match payout (reason 'match_winner_no_address') into a sendable 'pending'
* payout once a real address is set, and NEVER touches other 'needs_review' rows (e.g. the
* ambiguous single-payout rows, which would double-pay if reset).
* 2. Match boot-recovery age guard: _recoverAbandonedMatches reclaims all in-flight matches
* immediately in single-instance mode, but only aged-out ones when MATCH_SINGLE_INSTANCE=false.
*/
const GameModeManager = require('../src/game/gameModeManager');
const MatchQueue = require('../src/network/matchQueue');
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: { direct: { enabled: true, price: 100000000000n }, credits: { enabled: false, creditsPerGame: 1, packages: [] } },
payouts: { rules: { direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1000000000n, maxPayout: 10000000000000n } } },
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm() {
const calls = [];
const db = {
query: jest.fn().mockImplementation(async (sql) => {
calls.push(sql);
if (/UPDATE\s+payouts/i.test(sql)) return { rowCount: 1, rows: [{ id: 99 }] };
return { rowCount: 0, rows: [] };
}),
withTransaction: jest.fn().mockImplementation(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }))
};
const gmm = new GameModeManager(db, { processPayout: jest.fn(), processBatchPayout: jest.fn() },
{ CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig());
gmm._scheduleBatchPayout = jest.fn();
gmm.alertService = { sendAlert: jest.fn().mockResolvedValue() };
gmm.getOrCreateUser = jest.fn().mockResolvedValue({ id: 7 });
return { gmm, db, calls };
}
describe('Follow-up 1: no-address match winner reconciliation', () => {
test('converts claimable rows and kicks the batcher when an address is set', async () => {
const { gmm, db } = buildGmm();
const ok = await gmm.setUserPayoutAddress('sock', 'Wo3pRealAddress');
expect(ok).toBe(true);
const payoutUpdate = db.query.mock.calls.find(c => /UPDATE\s+payouts/i.test(c[0]));
expect(payoutUpdate).toBeTruthy();
// Narrow, safe filter: only explicit no-address liabilities, never generic needs_review.
expect(payoutUpdate[0]).toMatch(/reason\s+IN\s*\(\s*'match_winner_no_address'\s*,\s*'solo_winner_no_address'\s*\)/);
expect(payoutUpdate[0]).toMatch(/status\s*=\s*'needs_review'/);
expect(payoutUpdate[0]).toMatch(/PENDING_NO_ADDRESS/);
expect(payoutUpdate[0]).toMatch(/SET payout_address = \$1, status = 'pending'/);
expect(payoutUpdate[1]).toEqual(['Wo3pRealAddress', 7]);
expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1); // rowCount > 0
});
test('does not run reconciliation for an empty/cleared address', async () => {
const { gmm, db } = buildGmm();
await gmm.setUserPayoutAddress('sock', ' ');
expect(db.query.mock.calls.some(c => /UPDATE\s+payouts/i.test(c[0]))).toBe(false);
expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled();
});
test('does not schedule a batch when there is nothing to reconcile', async () => {
const { gmm, db } = buildGmm();
db.query.mockImplementation(async () => ({ rowCount: 0, rows: [] })); // no claimable rows
await gmm.setUserPayoutAddress('sock', 'Wo3pRealAddress');
expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled();
});
});
describe('Follow-up 2: match boot-recovery age guard', () => {
function queueWithCapturingDb() {
const params = [];
const db = {
query: jest.fn().mockImplementation(async (sql, p) => { params.push({ sql, p }); return { rows: [] }; }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }))
};
return { q: new MatchQueue({ db }), db, params };
}
const ENV = process.env.MATCH_SINGLE_INSTANCE;
const CEIL = process.env.MATCH_HARD_CEILING_MS;
afterEach(() => {
if (ENV === undefined) delete process.env.MATCH_SINGLE_INSTANCE; else process.env.MATCH_SINGLE_INSTANCE = ENV;
if (CEIL === undefined) delete process.env.MATCH_HARD_CEILING_MS; else process.env.MATCH_HARD_CEILING_MS = CEIL;
});
test('single-instance (default): reclaims all in-flight matches (age threshold 0)', async () => {
delete process.env.MATCH_SINGLE_INSTANCE;
const { q, params } = queueWithCapturingDb();
await q._recoverAbandonedMatches();
const scan = params.find(x => /FROM matches/i.test(x.sql));
expect(scan).toBeTruthy();
expect(scan.sql).toMatch(/COALESCE\(started_at, created_at\)/);
expect(scan.p).toEqual([0]);
});
test('multi-instance: only reclaims matches older than hard ceiling + buffer', async () => {
process.env.MATCH_SINGLE_INSTANCE = 'false';
process.env.MATCH_HARD_CEILING_MS = '240000';
const { q, params } = queueWithCapturingDb();
await q._recoverAbandonedMatches();
const scan = params.find(x => /FROM matches/i.test(x.sql));
expect(scan.p).toEqual([300]); // (240000 + 60000) / 1000
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Free-play entry tests (Feature 1).
*
* When a player explicitly chooses free play (even on an instance that also sells
* credits/entry), the game must be recorded as game_mode='FREE' (so it lands on the Pleb
* leaderboard, not the Hall of Champions) with no payment and no payout.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockDb = () => {
const client = { query: jest.fn().mockResolvedValue({ rows: [] }) };
return {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(client)),
_client: client
};
};
const cfg = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: { rules: { direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } }, credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n } } },
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm() {
const db = createMockDb();
const gmm = new GameModeManager(db, { processPayout: jest.fn() }, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, cfg());
// Resolve identity straight to a stable user row.
gmm.sessionManager = { getBySocket: jest.fn().mockResolvedValue({ id: 1 }) };
db.query.mockImplementation(async (text) => {
if (/SELECT \* FROM users WHERE id = \$1/i.test(text)) return { rows: [{ id: 1, credits: 0 }] };
return { rows: [] };
});
return { gmm, db };
}
describe('free-play game start (forceFree)', () => {
test('records game_mode=FREE and counts the game, no payment', async () => {
const { gmm, db } = buildGmm();
const res = await gmm.processGameStart('sock1', 'seed-123', { forceFree: true });
expect(res.success).toBe(true);
expect(res.effectiveMode).toBe('FREE');
const calls = db._client.query.mock.calls.map(c => c[0]);
expect(calls.find(s => /UPDATE games SET game_mode = 'FREE'/i.test(s))).toBeDefined();
expect(calls.find(s => /UPDATE users SET total_games_played/i.test(s))).toBeDefined();
});
test('offers a play_free option when free play is enabled alongside payments', async () => {
const { gmm } = buildGmm();
gmm.freePlayEnabled = true;
const opts = await gmm.getPaymentOptionsForUser('sock1');
expect(opts.options.some(o => o.type === 'play_free' && o.mode === 'FREE')).toBe(true);
});
test('does NOT offer free play when disabled', async () => {
const { gmm } = buildGmm();
gmm.freePlayEnabled = false;
const opts = await gmm.getPaymentOptionsForUser('sock1');
expect(opts.options.some(o => o.type === 'play_free')).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
const GameManager = require('../src/game/gameManager');
function makeHarness(completion) {
const targetedEmit = jest.fn();
const io = {
to: jest.fn(() => ({ emit: targetedEmit })),
emit: jest.fn()
};
const game = {
id: 'game-seed',
player: { hasTreasure: false },
moveCount: 12,
startedAt: Date.now() - 1000,
endGame: jest.fn(),
getProofReveal: jest.fn(() => ({ commitment: 'proof' }))
};
const gameModeManager = {
completeGame: jest.fn().mockResolvedValue(completion),
db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) }
};
const manager = new GameManager({
activeGames: new Map([['socket-1', game]]),
io,
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false },
gameModeManager
});
return { manager, game, io };
}
async function finishWin(completion) {
const harness = makeHarness(completion);
await harness.manager.handleGameOver(
{ id: 'socket-1' },
harness.game,
'won',
'escaped',
'Escaped',
100
);
return harness.io.emit.mock.calls.find(([event]) => event === 'win_feed')?.[1];
}
describe('solo win feed payout status', () => {
test('marks a win paid when completion contains a nested payout amount', async () => {
const feed = await finishWin({
success: true,
payout: { status: 'queued', amount: '200000000000', multiplier: 2 }
});
expect(feed).toEqual(expect.objectContaining({ name: 'Alice', score: 100, paid: true }));
});
test('does not trust an obsolete top-level amount when no payout was created', async () => {
const feed = await finishWin({ success: true, amount: '200000000000', payout: null });
expect(feed).toEqual(expect.objectContaining({ paid: false }));
});
});
+684
View File
@@ -0,0 +1,684 @@
/**
* GameModeManager Tests
* Tests for mixed mode, credits, and payment logic
*/
// Mock the database
const mockClient = {
query: jest.fn().mockResolvedValue({ rows: [] })
};
const mockDb = {
query: jest.fn(),
withTransaction: jest.fn().mockImplementation(async (callback) => {
return callback(mockClient);
})
};
// Mock wallet service
const mockWalletService = {
createPaymentRequest: jest.fn(),
processPayout: jest.fn()
};
// Mock debug manager
const mockDebugManager = {
CONSOLE_LOGGING: false
};
// Mock payment config manager
const mockPaymentConfigManager = {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: {
rules: {
direct: { multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 } }
}
},
preferences: { preferCreditsFirst: true }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
};
const GameModeManager = require('../src/game/gameModeManager');
describe('GameModeManager', () => {
let gmm;
beforeEach(() => {
jest.clearAllMocks();
mockDb.query.mockReset();
mockClient.query.mockReset().mockResolvedValue({ rows: [] });
gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager);
});
describe('getEffectiveModeForUser', () => {
test('returns FREE mode when payments disabled', () => {
gmm.paymentsEnabled = false;
const result = gmm.getEffectiveModeForUser({ credits: 5 });
expect(result.mode).toBe('FREE');
});
test('returns PAID_CREDITS when user has credits and preferCreditsFirst', () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.preferCreditsFirst = true;
gmm.creditsPerGameCost = 1;
const result = gmm.getEffectiveModeForUser({ credits: 5 });
expect(result.mode).toBe('PAID_CREDITS');
expect(result.hasCredits).toBe(true);
expect(result.bothModesEnabled).toBe(true);
});
test('returns PAID_SINGLE when user has no credits in mixed mode', () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.preferCreditsFirst = true;
gmm.creditsPerGameCost = 1;
const result = gmm.getEffectiveModeForUser({ credits: 0 });
expect(result.mode).toBe('PAID_SINGLE');
expect(result.hasCredits).toBe(false);
});
test('returns correct mode when only direct is enabled', () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = false;
const result = gmm.getEffectiveModeForUser({ credits: 10 });
expect(result.mode).toBe('PAID_SINGLE');
expect(result.bothModesEnabled).toBe(false);
});
test('returns correct mode when only credits is enabled', () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = false;
gmm.creditsModeEnabled = true;
const result = gmm.getEffectiveModeForUser({ credits: 5 });
expect(result.mode).toBe('PAID_CREDITS');
});
});
describe('production payout and free-play policy', () => {
test('financial recovery gate blocks invoice intake and every paid start but preserves explicit free play', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.financialAdmissionAllowed = () => false;
expect(gmm.isPaymentIntakeEnabled('single_game')).toBe(false);
expect(gmm.isPaymentIntakeEnabled('credits_package')).toBe(false);
expect(gmm.isPaymentIntakeEnabled('cosmetic_pack')).toBe(false);
const user = { id: 7, credits: 5, payout_address: 'wallet' };
jest.spyOn(gmm, 'getOrCreateUser').mockResolvedValue(user);
const paid = await gmm.processGameStart('socket-paid', 'game-paid');
expect(paid).toMatchObject({
success: false,
code: 'FINANCIAL_RECOVERY_PENDING'
});
expect(mockDb.query).not.toHaveBeenCalled();
expect(mockDb.withTransaction).not.toHaveBeenCalled();
expect(await gmm._processGameStartWithCredits(user, 'socket-paid', 'game-credits'))
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
expect(await gmm._processGameStartWithPayment(user, { id: 9 }, 'game-direct'))
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
expect(mockDb.withTransaction).not.toHaveBeenCalled();
jest.spyOn(gmm, '_processGameStartFree').mockResolvedValue({
success: true,
effectiveMode: 'FREE'
});
await expect(gmm.processGameStart('socket-free', 'game-free', { forceFree: true }))
.resolves.toEqual({ success: true, effectiveMode: 'FREE' });
});
test('master payout switch overrides enabled per-mode rules and stops dispatch', async () => {
gmm.payoutsEnabled = false;
gmm.directPayoutEnabled = true;
gmm.creditsPayoutEnabled = true;
gmm.directRequiresAddress = true;
expect(gmm.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
expect(gmm.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
expect(gmm.requiresPayoutAddressForMode('PAID_SINGLE')).toBe(false);
await gmm._processPendingPayouts();
expect(mockDb.withTransaction).not.toHaveBeenCalled();
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('shutdown cancels a queued payout debounce and rejects later scheduling', async () => {
jest.useFakeTimers();
try {
const processPending = jest.spyOn(gmm, '_processPendingPayouts').mockResolvedValue();
gmm._scheduleBatchPayout();
expect(jest.getTimerCount()).toBe(1);
gmm.shutdown();
expect(gmm._batchPayoutTimer).toBeNull();
expect(jest.getTimerCount()).toBe(0);
gmm._scheduleBatchPayout();
jest.advanceTimersByTime(5000);
await Promise.resolve();
expect(processPending).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
});
test('unified paid config does not accidentally inherit free mode when FREE_PLAY_ENABLED is false', () => {
const oldGameMode = process.env.GAME_MODE;
const oldFree = process.env.FREE_PLAY_ENABLED;
try {
delete process.env.GAME_MODE;
process.env.FREE_PLAY_ENABLED = 'false';
const manager = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager);
expect(manager.paymentsEnabled).toBe(true);
expect(manager.freePlayEnabled).toBe(false);
} finally {
if (oldGameMode === undefined) delete process.env.GAME_MODE; else process.env.GAME_MODE = oldGameMode;
if (oldFree === undefined) delete process.env.FREE_PLAY_ENABLED; else process.env.FREE_PLAY_ENABLED = oldFree;
}
});
test('game mode info advertises the selected safe ruleset catalog', () => {
const old = process.env.MATCH_RULESET_ID;
try {
process.env.MATCH_RULESET_ID = 'last-alive';
const match = gmm.getGameModeInfo().modes.match;
expect(match.activeRuleset).toEqual(expect.objectContaining({
id: 'last-alive',
label: 'Last Alive',
winCondition: 'last-alive'
}));
expect(match.rulesets.map(r => r.id)).toEqual(expect.arrayContaining(['race', 'last-alive', 'score-attack', 'coop-escape']));
expect(match.rulesets.map(r => r.id)).not.toContain('solo-classic');
} finally {
if (old === undefined) delete process.env.MATCH_RULESET_ID; else process.env.MATCH_RULESET_ID = old;
}
});
test('game mode info publishes only a recognized operated product profile id', () => {
const old = process.env.OPERATED_PRODUCT_PROFILE;
try {
process.env.OPERATED_PRODUCT_PROFILE = 'such-play-wow-prestige';
expect(gmm.getGameModeInfo().operatedProductProfileId)
.toBe('such-play-wow-prestige');
process.env.OPERATED_PRODUCT_PROFILE = 'unreviewed-profile';
expect(gmm.getGameModeInfo().operatedProductProfileId).toBeNull();
} finally {
if (old === undefined) delete process.env.OPERATED_PRODUCT_PROFILE;
else process.env.OPERATED_PRODUCT_PROFILE = old;
}
});
test('game mode info binds Socket.IO clients to the immutable release', () => {
const release = {
verified: true,
id: `git-${'a'.repeat(12)}`,
commit: 'a'.repeat(40)
};
gmm.releaseIdentity = release;
expect(gmm.getGameModeInfo().release).toEqual(release);
});
test('crypto race economy is hidden when the payout master switch is off', () => {
const keys = [
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
'MATCH_MAX_PLAYERS'
];
const old = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_ENABLED = 'true';
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_PAYOUT_MAX = '1000000000000';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
process.env.MATCH_MAX_PLAYERS = '4';
gmm.payoutsEnabled = false;
expect(gmm._getMatchEconomies().crypto_race).toBeUndefined();
gmm.payoutsEnabled = true;
expect(gmm._getMatchEconomies().crypto_race).toBe(true);
} finally {
for (const key of keys) {
if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key];
}
}
});
});
describe('calculatePayout', () => {
test('publishes only the double/triple outcomes implemented by payout calculation', () => {
gmm.directPayoutMultipliers = {
escape: 2,
escapeWithTreasure: 3,
perfectRun: 5,
experimentalJackpot: 20
};
gmm.creditPayoutMultipliers = {
escape: 2,
escapeWithTreasure: 3,
perfectRun: 3
};
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_SINGLE')).toStrictEqual({
escape: 2,
escapeWithTreasure: 3
});
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_CREDITS')).toStrictEqual({
escape: 2,
escapeWithTreasure: 3
});
const publicInfo = gmm.getGameModeInfo();
expect(publicInfo.payoutMultipliers).toStrictEqual({
direct: { escape: 2, escapeWithTreasure: 3 },
credits: { escape: 2, escapeWithTreasure: 3 }
});
});
test('publishes exact effective outcomes after cap/min policy', () => {
gmm.payoutsEnabled = true;
gmm.directPayoutEnabled = true;
gmm.creditsPayoutEnabled = true;
gmm.singleGamePrice = 20000;
gmm.creditsPayoutBaseValue = 1000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
gmm.configSnapshot.payouts.rules.direct.minPayout = 1n;
gmm.configSnapshot.payouts.rules.direct.maxPayout = 50000n;
gmm.configSnapshot.payouts.rules.credits.minPayout = 3000n;
gmm.configSnapshot.payouts.rules.credits.maxPayout = 50000n;
const outcomes = gmm.getGameModeInfo().payoutOutcomes;
expect(outcomes.direct.escape).toEqual(expect.objectContaining({
payable: true,
amountAtomic: '40000',
rawAmountAtomic: '40000',
capApplied: false
}));
expect(outcomes.direct.escapeWithTreasure).toEqual(expect.objectContaining({
payable: true,
amountAtomic: '50000',
rawAmountAtomic: '60000',
capApplied: true
}));
expect(outcomes.credits.escape).toEqual(expect.objectContaining({
payable: false,
amountAtomic: '0',
rawAmountAtomic: '2000',
suppressedReason: 'below_minimum'
}));
const snapshot = gmm._computePayoutSnapshot('PAID_SINGLE');
expect(snapshot.treasureAmount).toBe('50000');
expect(snapshot.terms.version).toBe(2);
});
test('calculates direct mode payout correctly', () => {
gmm.singleGamePrice = 100000000000; // 1 WOW
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false });
expect(result.amount).toBe(200000000000); // 2x
expect(result.multiplier).toBe(2);
});
test('calculates treasure bonus correctly', () => {
gmm.singleGamePrice = 100000000000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: true });
expect(result.amount).toBe(300000000000); // 3x
expect(result.multiplier).toBe(3);
});
test('returns zero payout for credits mode when disabled', () => {
gmm.creditsPayoutBaseValue = 100000000000;
gmm.creditPayoutMultipliers = { escape: 0, escapeWithTreasure: 0 };
const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false });
expect(result.amount).toBe(0);
});
test('calculates credits mode payout when enabled', () => {
gmm.creditsPayoutBaseValue = 50000000000; // 0.5 WOW base
gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 };
const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false });
expect(result.amount).toBe(75000000000); // 0.5 * 1.5
});
});
describe('getPaymentOptionsForUser', () => {
beforeEach(() => {
mockDb.query.mockResolvedValue({ rows: [{ id: 1, credits: 5, payout_address: 'wow1addr' }] });
});
test('returns use_credit option when user has credits', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.creditsPerGameCost = 1;
const result = await gmm.getPaymentOptionsForUser('socket1');
const useCreditOption = result.options.find(o => o.type === 'use_credit');
expect(useCreditOption).toBeDefined();
expect(useCreditOption.mode).toBe('PAID_CREDITS');
});
test('returns pay_direct option', async () => {
gmm.directModeEnabled = true;
const result = await gmm.getPaymentOptionsForUser('socket1');
const directOption = result.options.find(o => o.type === 'pay_direct');
expect(directOption).toBeDefined();
expect(directOption.mode).toBe('PAID_SINGLE');
});
test('returns buy_credits option when credits mode enabled', async () => {
gmm.creditsModeEnabled = true;
const result = await gmm.getPaymentOptionsForUser('socket1');
const buyOption = result.options.find(o => o.type === 'buy_credits');
expect(buyOption).toBeDefined();
});
});
describe('processCreditsPackageConfirmation', () => {
test('adds correct credits from package info', async () => {
// Step 1: db.query, the SELECT payment lookup
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: '', status: 'pending' }]
});
// Step 2: withTransaction, then client.query calls
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'free' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(15); // 10 + 5 bonus
});
test('applies bundled pack grants from package info', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: '', status: 'pending', payment_type: 'credits_package' }]
});
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }], rowCount: 1 };
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, {
id: 'bundle',
credits: 10,
grants: {
credits: 15,
packs: ['kenney-3d-characters'],
premiumLevel: 'supporter'
}
});
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(15);
expect(result.grantsApplied.packs.map(p => p.id)).toEqual(['kenney-3d-characters']);
expect(result.entitlements.packs['kenney-3d-characters']).toBe(true);
const entitlementInsert = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO user_pack_entitlements/i.test(sql));
expect(entitlementInsert).toBeDefined();
expect(entitlementInsert[1][1]).toBe('kenney-3d-characters');
});
test('applies standalone cosmetic pack product without granting credits', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{
user_id: 1,
description: '3D pack',
status: 'pending',
payment_type: 'cosmetic_pack',
product_id: 'pack_3d',
product_grants: { credits: 0, packs: [{ id: 'kenney-3d-characters' }], premiumLevel: null }
}]
});
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 456 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }], rowCount: 1 };
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 456 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processProductPaymentConfirmation('socket1', 456, null, 1000);
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(0);
expect(result.entitlements.packs['kenney-3d-characters']).toBe(true);
expect(mockClient.query.mock.calls.some(([sql]) => /INSERT INTO credit_transactions/i.test(sql))).toBe(false);
});
test('creates an exact funded lot from the durable confirmed-payment grant snapshot', async () => {
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
mockDb.query.mockResolvedValueOnce({
rows: [{
user_id: 1,
description: 'Two race entries',
status: 'pending',
payment_type: 'cosmetic_pack',
product_id: 'race_2',
product_grants: {
credits: 0,
raceEntries: 2,
raceEntryValueAtomic: '10000',
packs: [],
premiumLevel: null
},
received_amount: '20000',
expected_amount: '20000'
}]
});
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 700 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) {
return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
}
if (/SET credits = credits/i.test(sql)) {
return {
rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }],
rowCount: 1
};
}
if (/SET race_entries = race_entries \+/i.test(sql)) {
return { rows: [{ race_entries: 2 }], rowCount: 1 };
}
if (/INSERT INTO race_entry_lots/i.test(sql)) {
return { rows: [{ id: 88 }], rowCount: 1 };
}
if (/SELECT pack_id/i.test(sql)) return { rows: [], rowCount: 0 };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processProductPaymentConfirmation('new-socket', 700, {
id: 'changed-live-catalog',
grants: { race_entries: 99, race_entry_value_atomic: '1' }
}, '20000');
expect(result.success).toBe(true);
const lot = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_lots/i.test(sql));
expect(lot[1]).toEqual([1, 700, '10000', 2, 'race_2']);
const ticketLedger = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_transactions/i.test(sql));
expect(ticketLedger[1][3]).toBe(700);
} finally {
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
});
test('does not confirm an underfunded race-ticket invoice', async () => {
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
mockDb.query.mockResolvedValueOnce({
rows: [{
user_id: 1,
status: 'pending',
payment_type: 'cosmetic_pack',
product_id: 'race_2',
product_grants: {
credits: 0,
raceEntries: 2,
raceEntryValueAtomic: '10000'
},
received_amount: '19999',
expected_amount: '20000'
}]
});
const result = await gmm.processProductPaymentConfirmation('new-socket', 701, null, '19999');
expect(result.success).toBe(false);
expect(mockDb.withTransaction).not.toHaveBeenCalled();
} finally {
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
});
test('falls back to default credits when package info missing and no description', async () => {
// Step 1: db.query, the SELECT payment lookup with no credits in description
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: 'Some payment', status: 'pending' }]
});
// Step 2: withTransaction, then client.query calls
mockClient.query.mockImplementation(async (sql) => {
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 10, total_credits_purchased: 10, premium_level: 'free' }], rowCount: 1 };
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
return { rows: [], rowCount: 1 };
});
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(10); // Default fallback
});
});
describe('canUserStartGame - mixed mode', () => {
test('allows game with credits in mixed mode', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.creditsPerGameCost = 1;
mockDb.query
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] }) // getOrCreateUser lookup
.mockResolvedValueOnce({ rows: [] }); // update last_active
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(true);
expect(result.useCredits).toBe(true);
expect(result.effectiveMode).toBe('PAID_CREDITS');
});
test('allows game with confirmed payment when user has no credits', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.creditsPerGameCost = 1;
mockDb.query
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser lookup
.mockResolvedValueOnce({ rows: [] }) // update last_active
.mockResolvedValueOnce({ rows: [{ id: 999 }] }); // confirmed payment query
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(true);
expect(result.paymentId).toBe(999);
expect(result.effectiveMode).toBe('PAID_SINGLE');
});
test('when direct is preferred, a confirmed direct entry is not replaced by an existing credit', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.preferCreditsFirst = false;
mockDb.query
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ id: 999 }] });
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(true);
expect(result.paymentId).toBe(999);
expect(result.effectiveMode).toBe('PAID_SINGLE');
});
test('denies game when no credits and no payment in mixed mode', async () => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.creditsPerGameCost = 1;
mockDb.query
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser lookup
.mockResolvedValueOnce({ rows: [] }) // update last_active
.mockResolvedValueOnce({ rows: [] }) // no confirmed payment
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser for getPaymentOptions
.mockResolvedValueOnce({ rows: [] }); // update last_active
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(false);
expect(result.action).toBe('choose_payment');
});
});
});
+51
View File
@@ -0,0 +1,51 @@
const Game = require('../src/game/game');
const GameManager = require('../src/game/gameManager');
describe('paid game persistence gate', () => {
afterEach(() => jest.restoreAllMocks());
test('a failed durable INSERT aborts before the game becomes active', async () => {
const game = { id: 'durable-seed', gameProof: {} };
jest.spyOn(Game, 'createStandardGame').mockReturnValue(game);
const user = { id: 'socket-1', joinGame: jest.fn() };
const activeGames = new Map();
const dbError = new Error('database unavailable');
const manager = new GameManager({
activeGames,
io: {},
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 },
gameModeManager: {
paymentsEnabled: true,
gameMode: 'PAID_SINGLE',
db: { query: jest.fn().mockRejectedValue(dbError) }
}
});
await expect(manager.createGameForUser(user)).rejects.toThrow('database unavailable');
expect(user.joinGame).not.toHaveBeenCalled();
expect(activeGames.has(user.id)).toBe(false);
expect(game.dbId).toBeNull();
});
test('a paid game also rejects an INSERT that returns no durable id', async () => {
const game = { id: 'missing-id', gameProof: {} };
jest.spyOn(Game, 'createStandardGame').mockReturnValue(game);
const user = { id: 'socket-2', joinGame: jest.fn() };
const manager = new GameManager({
activeGames: new Map(),
io: {},
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 },
gameModeManager: {
paymentsEnabled: true,
gameMode: 'PAID_SINGLE',
db: { query: jest.fn().mockResolvedValue({ rows: [] }) }
}
});
await expect(manager.createGameForUser(user)).rejects.toThrow('Durable game record is required');
expect(user.joinGame).not.toHaveBeenCalled();
});
});
+192
View File
@@ -0,0 +1,192 @@
const { buildPublicHealth } = require('../src/services/healthService');
describe('public health payload', () => {
test('is ready only when required production dependencies are up', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: true,
walletHealthy: true,
payoutsEnabled: false,
now: 1,
uptime: 10
});
expect(health).toMatchObject({
status: 'ok',
ready: true,
checks: { database: 'up', chain: 'up', wallet: 'up' },
money: { paymentsEnabled: true, payoutsEnabled: false },
release: { verified: false, id: null, commit: null }
});
});
test('publishes only a self-consistent immutable release identity', () => {
const commit = 'a'.repeat(40);
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
releaseIdentity: {
verified: true,
id: `git-${commit.slice(0, 12)}`,
commit
}
});
expect(health.release).toEqual({
verified: true,
id: `git-${commit.slice(0, 12)}`,
commit
});
const mismatched = buildPublicHealth({
releaseIdentity: {
verified: true,
id: `git-${'b'.repeat(12)}`,
commit
}
});
expect(mismatched.release).toEqual({
verified: false,
id: null,
commit: null
});
});
test('free-only instances do not require a wallet', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: false,
walletHealthy: false
});
expect(health.ready).toBe(true);
expect(health.checks.wallet).toBe('not_required');
});
test('payout-only settlement requires a wallet without claiming intake is enabled', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: false,
payoutsEnabled: true,
walletHealthy: false
});
expect(health.ready).toBe(false);
expect(health.checks.wallet).toBe('down');
expect(health.money).toEqual({ paymentsEnabled: false, payoutsEnabled: true });
expect(health.paymentsEnabled).toBe(false);
});
test('reports degraded readiness when the daemon or required wallet is down', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 0,
paymentsEnabled: true,
walletHealthy: false
});
expect(health.status).toBe('degraded');
expect(health.ready).toBe(false);
expect(health.checks).toMatchObject({ chain: 'down', wallet: 'down' });
});
test('keeps readiness closed while startup financial recovery is pending', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
paymentsEnabled: true,
walletHealthy: true,
financialRecoveryReady: false
});
expect(health.ready).toBe(false);
expect(health.checks.financialRecovery).toBe('pending');
});
test('does not treat a stale cached block height as a healthy daemon', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: false,
paymentsEnabled: false
});
expect(health.ready).toBe(false);
expect(health.checks.chain).toBe('down');
});
test('never includes sensitive operational detail', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 1,
paymentsEnabled: true,
walletHealthy: true
});
expect(JSON.stringify(health)).not.toMatch(/balance|endpoint|password|memory|rateLimiter/i);
});
test('requires verified daemon and wallet identities for production money mode', () => {
const identity = {
verified: true,
expected: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' },
actual: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' },
reason: 'internal-reason',
endpoint: 'http://user:password@wallet.test'
};
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: true,
paymentsEnabled: true,
walletHealthy: true,
identityRequired: true,
daemonIdentity: identity,
walletIdentity: identity
});
expect(health).toMatchObject({
ready: true,
checks: { daemonIdentity: 'verified', walletIdentity: 'verified' },
chain: { network: 'stagenet' },
identities: {
daemon: { required: true, verified: true },
wallet: { required: true, verified: true }
}
});
expect(JSON.stringify(health)).not.toMatch(/secret-internal|internal-reason|endpoint|password/);
});
test('fails readiness on a daemon identity mismatch', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: true,
paymentsEnabled: false,
identityRequired: true,
daemonIdentity: { verified: false }
});
expect(health.ready).toBe(false);
expect(health.checks).toMatchObject({ chain: 'down', daemonIdentity: 'unverified' });
});
test('fails payout-only readiness on a wallet identity mismatch', () => {
const health = buildPublicHealth({
databaseReady: true,
blockHeight: 123,
chainHealthy: true,
paymentsEnabled: false,
payoutsEnabled: true,
walletHealthy: true,
identityRequired: true,
daemonIdentity: { verified: true },
walletIdentity: { verified: false }
});
expect(health.ready).toBe(false);
expect(health.checks).toMatchObject({ wallet: 'down', walletIdentity: 'unverified' });
});
});
+8
View File
@@ -0,0 +1,8 @@
'use strict';
const path = require('path');
const { createRequire } = require('module');
// The application is the package boundary; top-level tests must resolve its dependencies from
// src/package.json instead of accidentally using an unrelated repository-root node_modules tree.
module.exports = createRequire(path.join(__dirname, '..', '..', 'src', 'package.json'));
+67
View File
@@ -0,0 +1,67 @@
/**
* Identity resolution tests (Phase 2.1).
*
* getOrCreateUser must resolve through the stable anon_token identity (via SessionManager)
* rather than the mutable, non-unique socket_id, and must NOT create a duplicate "orphan"
* user row when a session already exists.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: { rules: { direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } }, credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n } } },
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm() {
const calls = { byId: 0, bySocketSelect: 0, insert: 0 };
const db = {
query: jest.fn(async (text, params = []) => {
if (/SELECT \* FROM users WHERE id = \$1/i.test(text)) { calls.byId++; return { rows: [{ id: params[0], credits: 5, socket_id: 'sockNEW' }] }; }
if (/UPDATE users SET last_active/i.test(text)) return { rows: [] };
if (/SELECT \* FROM users WHERE socket_id = \$1/i.test(text)) { calls.bySocketSelect++; return { rows: [] }; }
if (/INSERT INTO users/i.test(text)) { calls.insert++; return { rows: [{ id: 99, socket_id: params[0] }] }; }
return { rows: [] };
})
};
const gmm = new GameModeManager(db, { processPayout: jest.fn() }, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig());
return { gmm, db, calls };
}
describe('getOrCreateUser identity resolution', () => {
test('resolves through the session stable id and does not create a duplicate', async () => {
const { gmm, calls } = buildGmm();
// Session resolves the socket to stable user id 1 (cache populated at connect).
gmm.sessionManager = { getBySocket: jest.fn().mockResolvedValue({ id: 1 }) };
const user = await gmm.getOrCreateUser('sockNEW');
expect(user.id).toBe(1);
expect(gmm.sessionManager.getBySocket).toHaveBeenCalledWith('sockNEW');
expect(calls.byId).toBe(1); // resolved by stable id
expect(calls.insert).toBe(0); // NO duplicate orphan row created
expect(calls.bySocketSelect).toBe(0); // did not fall back to socket_id lookup
});
test('falls back to socket_id lookup when no session manager is wired', async () => {
const { gmm, calls } = buildGmm();
// No gmm.sessionManager (legacy path).
const user = await gmm.getOrCreateUser('sockLEGACY');
expect(calls.bySocketSelect).toBe(1); // used socket_id lookup
// socket_id lookup returned no rows in this mock -> created as last resort
expect(calls.insert).toBe(1);
expect(user.id).toBe(99);
});
});
+102
View File
@@ -0,0 +1,102 @@
const IdentityService = require('../src/network/identityService');
function makeSocket(id = 'sockA') {
return { id, emit: jest.fn() };
}
function makeDb({ user, grants = [] } = {}) {
const state = {
user: {
id: 1,
socket_id: 'sockA',
credits: 0,
total_credits_purchased: 0,
premium_level: 'free',
appearance: {
avatar: 'char-ranger',
tint: 'teal',
equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' },
colors: { base: 'teal', skin: 'warm', hair: 'silver', body: 'gold', head: 'none', shield: 'rose', weapon: 'none' }
},
...user
},
grants
};
const db = {
state,
query: jest.fn(async (text, params = []) => {
if (/SELECT pack_id/i.test(text)) return { rows: state.grants };
if (/UPDATE users[\s\S]*SET appearance/i.test(text)) {
state.user = { ...state.user, appearance: JSON.parse(params[0]) };
return { rows: [state.user] };
}
return { rows: [] };
})
};
return db;
}
describe('IdentityService', () => {
test('loads stored appearance and entitlements for a socket user', async () => {
const db = makeDb({ user: { total_credits_purchased: 5, credits: 2 } });
const sessionManager = { sessions: new Map([['sockA', db.state.user]]) };
const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db };
const svc = new IdentityService({ db, gameModeManager, sessionManager });
const snapshot = await svc.identityForSocket(makeSocket());
expect(snapshot.appearance).toEqual(db.state.user.appearance);
expect(snapshot.entitlements.premium).toBe(true);
expect(snapshot.entitlements.packs['generated-skins']).toBe(true); // 5 credits >= threshold 1
expect(snapshot.entitlements.packs['iso-dungeon']).toBe(false); // < threshold 10
});
test('saves normalized appearance and refreshes the session cache', async () => {
const db = makeDb({ user: { total_credits_purchased: 0 } });
const sessionManager = { sessions: new Map([['sockA', db.state.user]]) };
const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db };
const svc = new IdentityService({ db, gameModeManager, sessionManager });
const snapshot = await svc.saveAppearanceForSocket(makeSocket(), {
avatar: 'char-ranger',
tint: 'bad',
equipment: { body: 'mail', head: 'bad', shield: 'round', weapon: 'laser' },
colors: { base: 'violet', skin: 'bad', hair: 'black', body: 'gold', head: 'bad', shield: 'rose', weapon: 'bad' }
});
expect(snapshot.appearance).toEqual({
avatar: 'char-ranger',
tint: 'violet',
equipment: { body: 'mail', head: 'none', shield: 'round', weapon: 'none' },
colors: { base: 'violet', skin: 'natural', hair: 'black', body: 'gold', head: 'none', shield: 'rose', weapon: 'none' }
});
expect(db.state.user.appearance).toEqual(snapshot.appearance);
expect(sessionManager.sessions.get('sockA').appearance).toEqual(snapshot.appearance);
});
test('falls back unauthorized premium appearance before saving', async () => {
const db = makeDb({ user: { total_credits_purchased: 0 } });
const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db };
const svc = new IdentityService({ db, gameModeManager });
const snapshot = await svc.saveAppearanceForSocket(makeSocket(), { avatar: 'monero-knight' });
expect(snapshot.appearance.avatar).toBe('default');
expect(db.state.user.appearance.avatar).toBe('default');
});
test('allows an explicitly granted pack appearance', async () => {
const db = makeDb({
user: { total_credits_purchased: 0 },
grants: [{ pack_id: 'generated-skins' }]
});
const gameModeManager = { getOrCreateUser: jest.fn(async () => db.state.user), db };
const svc = new IdentityService({ db, gameModeManager });
const snapshot = await svc.saveAppearanceForSocket(makeSocket(), { avatar: 'monero-knight' });
expect(snapshot.appearance.avatar).toBe('monero-knight');
expect(snapshot.entitlements.packs['generated-skins']).toBe(true);
expect(snapshot.entitlements.packs['kenney-3d-characters']).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Input validation / hardening tests (Phase 2.3).
*
* register_client must validate the client-supplied id before it goes into clientSocketMap
* (as both key and value), or a client could poison the map / impersonate mappings.
*/
const ConnectionHandler = require('../src/network/connectionHandler');
function buildHandler() {
const ch = new ConnectionHandler({
io: { sockets: { sockets: new Map() } },
broadcastManager: { broadcastUserCount: () => {} },
debugManager: { CONSOLE_LOGGING: false },
sessionManager: null,
rateLimiter: null
});
return ch;
}
function fakeSocket(id) {
const emitted = [];
return { id, emit: (event, data) => emitted.push({ event, data }), _emitted: emitted };
}
describe('handleRegisterClient validation', () => {
let ch;
afterEach(() => ch && ch.shutdown && ch.shutdown());
test('accepts a valid clientId and records the mapping', () => {
ch = buildHandler();
const sock = fakeSocket('sock1');
ch.handleRegisterClient(sock, { clientId: 'client-abc_123' });
expect(ch.clientSocketMap.get('client-abc_123')).toBe('sock1');
expect(ch.clientSocketMap.get('sock1')).toBe('client-abc_123');
expect(sock._emitted.find(e => e.event === 'socket_registered').data.success).toBe(true);
});
test.each([
['missing', undefined],
['empty', ''],
['too long', 'x'.repeat(65)],
['bad chars', 'evil<script>'],
['non-string', 12345]
])('rejects %s clientId and does not poison the map', (_label, badId) => {
ch = buildHandler();
const sock = fakeSocket('sock2');
ch.handleRegisterClient(sock, { clientId: badId });
expect(ch.clientSocketMap.size).toBe(0);
const reg = sock._emitted.find(e => e.event === 'socket_registered');
expect(reg.data.success).toBe(false);
});
});
describe('session resume credential transport', () => {
let ch;
afterEach(() => ch && ch.shutdown && ch.shutdown());
test('accepts auth payload token and ignores URL query token', () => {
ch = buildHandler();
expect(ch._getResumeToken({
handshake: { auth: { resumeToken: 'auth-secret' }, query: { resumeToken: 'url-secret' } }
})).toBe('auth-secret');
expect(ch._getResumeToken({
handshake: { auth: {}, query: { resumeToken: 'url-secret' } }
})).toBeNull();
});
});
+94
View File
@@ -0,0 +1,94 @@
const GameModeManager = require('../src/game/gameModeManager');
const LatePaymentReconciler = require('../src/services/latePaymentReconciler');
describe('late incoming payment reconciliation', () => {
test('bounded scanner sends an expired native receipt only to manual review', async () => {
const row = {
id: 7,
user_id: 3,
status: 'expired',
subaddress: 'late-address',
address_index: 11,
expected_amount: '100',
provider_id: 'native-monero',
provider_invoice_id: 'late-address'
};
const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) };
const receipt = {
evidenceType: 'chain_output', providerId: 'native-monero',
txHash: 'a'.repeat(64), outputId: 'b'.repeat(64),
evidenceId: `${'a'.repeat(64)}:${'b'.repeat(64)}`,
addressIndex: 11, amount: '100', confirmed: true
};
const walletService = {
accountIndex: 0,
addressToUser: new Map(),
addressToSocket: new Map(),
checkPaymentStatus: jest.fn(async address => {
expect(address).toBe('late-address');
expect(walletService.addressToUser.get(address)).toEqual(expect.objectContaining({
amount: 100,
addressIndex: 11
}));
return { confirmed: true, complete: true, amount: 100, receipts: [receipt] };
}),
stopPaymentMonitoring: jest.fn()
};
const gameModeManager = {
paymentProviders: { get: jest.fn(() => null) },
reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: true })
};
const service = new LatePaymentReconciler({
db, gameModeManager, walletService, lookbackHours: 24, batchSize: 20,
logger: { warn: jest.fn(), error: jest.fn() }
});
const result = await service.runOnce();
expect(result).toEqual({ skipped: false, checked: 1, reviews: 1, errors: 0 });
expect(db.query.mock.calls[0][1]).toEqual([24, 20]);
expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(7, [receipt]);
expect(walletService.addressToUser.has('late-address')).toBe(false);
expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address');
});
test('durable late receipt stays expired and creates review without any entitlement write', async () => {
const calls = [];
const client = {
query: jest.fn(async (sql, params = []) => {
calls.push({ sql, params });
if (/SELECT id, status, expected_amount/i.test(sql)) {
return { rows: [{
id: 8, status: 'expired', expected_amount: '100',
provider_id: 'native-monero', address_index: 12
}] };
}
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
const outputId = 'd'.repeat(64);
const receipt = {
evidenceType: 'chain_output', providerId: 'native-monero',
txHash: 'c'.repeat(64), outputId,
evidenceId: `${'c'.repeat(64)}:${outputId}`,
addressIndex: 12, amount: '100', confirmed: true
};
const result = await manager.reconcileLatePaymentForReview(8, [receipt]);
expect(result).toEqual(expect.objectContaining({
checked: true,
needsReview: true,
coverageComplete: true
}));
expect(calls.some(call => /INSERT INTO payment_receipts/i.test(call.sql))).toBe(true);
expect(calls.some(call => /INSERT INTO payment_late_reviews/i.test(call.sql))).toBe(true);
expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true);
expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false);
});
});
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const createErrorMiddleware = require('../src/middleware/errorHandler');
const { createLeaderboardHandler } = require('../src/routes/leaderboard');
const SOURCE_MATCH = '11111111-1111-4111-8111-111111111111';
const COMPLETED_AT = '2026-07-20T12:00:00.000Z';
function fixtureDatabase() {
const users = new Map([
[1, { id: 1, display_name: 'CreditSolo', payout_address: null }],
[2, { id: 2, display_name: 'LegacyDirect', payout_address: null }],
[3, { id: 3, display_name: 'MatchOutcome', payout_address: null }],
[4, { id: 4, display_name: 'MatchLink', payout_address: null }],
[5, { id: 5, display_name: 'FreeSolo', payout_address: null }],
[6, { id: 6, display_name: 'FreeMatch', payout_address: null }]
]);
const games = [
{
user_id: 1, game_mode: 'PAID_CREDITS', status: 'won', outcome: 'escaped',
score: 500, dungeon_seed: 'solo-credit', completed_at: COMPLETED_AT
},
{
user_id: 2, game_mode: 'PAID_SINGLE', status: 'lost', outcome: 'caught_by_monster',
score: 450, dungeon_seed: 'legacy-direct', completed_at: COMPLETED_AT
},
{
user_id: 3, game_mode: 'PAID_CREDITS', status: 'won', outcome: 'match_winner',
score: 990, dungeon_seed: 'legacy-match-without-link', completed_at: COMPLETED_AT
},
{
user_id: 4, game_mode: 'PAID_CREDITS', status: 'won', outcome: 'escaped',
score: 980, dungeon_seed: SOURCE_MATCH, completed_at: COMPLETED_AT
},
{
user_id: 5, game_mode: 'FREE', status: 'lost', outcome: 'caught_by_monster',
score: 300, dungeon_seed: 'solo-free', completed_at: COMPLETED_AT
},
{
user_id: 6, game_mode: 'FREE', status: 'won', outcome: 'match_winner',
score: 350, dungeon_seed: SOURCE_MATCH, completed_at: COMPLETED_AT
}
];
const calls = [];
return {
calls,
async query(sql, params) {
calls.push({ sql, params });
const [cutoff, gameModes, excludeMatchGenerated, limit] = params;
const rows = games.filter(game => {
if (!gameModes.includes(game.game_mode)) return false;
if (!['won', 'lost'].includes(game.status) || game.score <= 0) return false;
if (cutoff && new Date(game.completed_at) <= cutoff) return false;
if (excludeMatchGenerated
&& (String(game.outcome || '').startsWith('match_')
|| game.dungeon_seed === SOURCE_MATCH)) return false;
return true;
});
const aggregate = new Map();
for (const game of rows) {
const user = users.get(game.user_id);
const current = aggregate.get(user.id) || {
id: user.id,
name: user.display_name || `Anon#${user.id}`,
best_score: 0,
wins: 0,
games_played: 0
};
current.best_score = Math.max(current.best_score, game.score);
current.wins += game.status === 'won' ? 1 : 0;
current.games_played += 1;
aggregate.set(user.id, current);
}
return {
rows: Array.from(aggregate.values())
.sort((a, b) => b.best_score - a.best_score)
.slice(0, limit)
};
}
};
}
async function invokeLeaderboardRoute(profileId, query = {}) {
const db = fixtureDatabase();
const handler = createLeaderboardHandler({
db,
getOperatedProductProfileId: () => profileId,
now: () => new Date('2026-07-21T12:00:00.000Z')
});
const req = { query, path: '/api/leaderboard', method: 'GET' };
const response = { status: 200, body: null };
const res = {
status(status) {
response.status = status;
return this;
},
json(body) {
response.body = body;
return this;
}
};
try {
await handler(req, res);
} catch (error) {
createErrorMiddleware({ logger: { error: jest.fn() } })(error, req, res, () => {});
}
return { response, db };
}
describe('GET /api/leaderboard operated-product integrity', () => {
test('Wownero Champions includes credit-entry solo only', async () => {
const { response, db } = await invokeLeaderboardRoute('such-play-wow-prestige', {
board: 'champions',
period: 'all'
});
expect(response.status).toBe(200);
expect(response.body.board).toBe('champions');
expect(response.body.leaderboard.map(row => row.name)).toEqual(['CreditSolo']);
expect(db.calls[0].params.slice(1, 3)).toEqual([['PAID_CREDITS'], true]);
});
test('Monerogue Champions includes direct solo only, never historical credit or match rows', async () => {
const { response, db } = await invokeLeaderboardRoute('such-monerogue-stagenet', {
board: 'champions',
period: 'all'
});
expect(response.status).toBe(200);
expect(response.body.leaderboard.map(row => row.name))
.toEqual(['LegacyDirect']);
expect(db.calls[0].params.slice(1, 3))
.toEqual([['PAID_SINGLE'], true]);
});
test('independent deployments retain the generic opt-in crypto-match mapping', async () => {
const { response, db } = await invokeLeaderboardRoute(null, {
board: 'champions',
period: 'all'
});
expect(response.status).toBe(200);
expect(response.body.leaderboard.map(row => row.name)).toEqual([
'MatchOutcome',
'MatchLink',
'CreditSolo',
'LegacyDirect'
]);
expect(db.calls[0].params.slice(1, 3))
.toEqual([['PAID_SINGLE', 'PAID_CREDITS'], false]);
});
test('missing board defaults to Pleb and preserves free competitive rows', async () => {
const { response, db } = await invokeLeaderboardRoute('such-play-wow-prestige', {
period: 'all'
});
expect(response.status).toBe(200);
expect(response.body.board).toBe('pleb');
expect(response.body.leaderboard.map(row => row.name))
.toEqual(['FreeMatch', 'FreeSolo']);
expect(db.calls[0].params.slice(1, 3)).toEqual([['FREE'], false]);
});
test.each(['all', 'unknown', ''])(
'explicit deprecated or invalid board %p returns 400 without querying',
async board => {
const { response, db } = await invokeLeaderboardRoute(
'such-play-wow-prestige',
{ board }
);
expect(response.status).toBe(400);
expect(response.body).toEqual(expect.objectContaining({
error: 'INVALID_LEADERBOARD_BOARD',
details: expect.objectContaining({
allowedBoards: ['pleb', 'champions', 'prestige'],
missingBoardDefault: 'pleb'
})
}));
expect(db.calls).toHaveLength(0);
}
);
});
+111
View File
@@ -0,0 +1,111 @@
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('../src/views/legalPages');
function disclosure(overrides = {}, managerOverrides = {}) {
const manager = {
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
gameName: '<script>bad</script>', paymentsEnabled: true, freePlayEnabled: true,
directModeEnabled: true, creditsModeEnabled: true, payoutsEnabled: false,
isPayoutEnabledForMode: () => false,
...managerOverrides
};
return buildCommerceDisclosure(manager, {
NODE_ENV: 'production', LEGAL_POLICY_VERSION: 'v1', TERMS_EFFECTIVE_DATE: '2026-07-21',
MINIMUM_AGE: '18', PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
OPERATOR_NAME: '<img src=x onerror=alert(1)>',
OPERATOR_CONTACT_URL: 'javascript:alert(1)',
...overrides
});
}
describe('production disclosure pages', () => {
test.each([
['terms', renderTerms, 'Paid prestige mode'],
['privacy', renderPrivacy, 'random browser session token'],
['responsible play', renderResponsiblePlay, 'automated self-exclusion system']
])('%s page is mode-aware, complete, and HTML-escapes operator data', (_name, render, expected) => {
const html = render(disclosure());
expect(html).toContain(expected);
expect(html).toContain('/responsible-play');
expect(html).toContain('&lt;script&gt;bad&lt;/script&gt;');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).not.toContain('<script>bad</script>');
expect(html).not.toContain('href="javascript:');
});
test('terms state the exact solo and PvP leaderboard mappings', () => {
const html = renderTerms(disclosure());
expect(html).toContain('FREE runs use the Pleb board');
expect(html).toContain('PAID_SINGLE and PAID_CREDITS runs use the Hall of Champions');
expect(html).toContain('credits_prestige matches use the Prestige board');
expect(html).toContain('enabled crypto_race records Hall of Champions rows');
expect(html).toContain('remain historical Hall of Champions results');
});
test('generic self-hosted pages carry the MIT conditions and third-party operator disclaimer', () => {
const html = renderTerms(disclosure());
expect(html).toContain('Independent deployment');
expect(html).toContain('solely responsible for its deployment');
expect(html).toContain('does not make that deployment a Such Software service');
expect(html).toContain('does not operate, supervise, endorse, or accept responsibility');
expect(html).toContain('copyright and permission notice');
expect(html).toContain('AS IS');
expect(html).toContain('not legal advice');
expect(html).toContain('operates only play.wowne.ro and monerogue.app');
});
test('play.wowne.ro pages state credits-only prestige and the no-payout product positioning', () => {
const html = renderTerms(disclosure({
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige'
}, {
directModeEnabled: false,
creditsModeEnabled: true
}));
expect(html).toContain('Such Software');
expect(html).toContain('apps@such.software');
expect(html).toContain('pay-for-credits leaderboard/prestige play');
expect(html).toContain('offers no prizes, winnings, cash-out, or payouts');
expect(html).toContain('not offered or marketed as gambling');
expect(html).toContain('legal classification depends on applicable law');
expect(html).toContain('Only PAID_CREDITS solo runs use the Hall of Champions');
expect(html).toContain('historical PAID_SINGLE runs and match-generated game rows are excluded');
expect(html).toContain('does not offer crypto_race');
expect(html).not.toContain('NO REAL VALUE');
});
test('monerogue.app pages conspicuously state stagenet 2x/3x test gambling has no real value', () => {
const html = renderResponsiblePlay(disclosure({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
}, {
cryptoType: 'XMR',
currencyLabel: 'sXMR',
network: 'stagenet',
isTestNetwork: true,
creditsModeEnabled: false,
payoutsEnabled: true,
isPayoutEnabledForMode: () => true
}));
expect(html).toContain('single-player 2×/3× outcomes are test gambling mechanics');
expect(html).toContain('NO REAL VALUE');
expect(html).toContain('Never send mainnet XMR');
expect(html).toContain('no real-money or mainnet gambling is offered');
const terms = renderTerms(disclosure({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
}, {
cryptoType: 'XMR',
currencyLabel: 'sXMR',
network: 'stagenet',
isTestNetwork: true,
creditsModeEnabled: false,
payoutsEnabled: true,
isPayoutEnabledForMode: () => true
}));
expect(terms).toContain('Only PAID_SINGLE solo runs use the Hall of Champions');
expect(terms).toContain('historical PAID_CREDITS runs and match-generated game rows are excluded');
expect(terms).toContain('does not sell credits');
expect(terms).toContain('does not offer crypto_race');
});
});
+19
View File
@@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');
describe('match client durable finish and fairness messaging', () => {
const source = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8');
test('does not treat a settlement-pending event as a final result', () => {
const pending = source.match(/socket\.on\('match_settlement_pending',[\s\S]*?\n \}\);/);
expect(pending).toBeTruthy();
expect(pending[0]).toContain('not final yet');
expect(pending[0]).not.toContain('inRace = false');
});
test('checks the revealed seed against the published SHA-256 commitment', () => {
expect(source).toContain("subtle.digest('SHA-256'");
expect(source).toContain("actual === String(fairness.seedHash).toLowerCase()");
expect(source).toContain('seed proof ✓');
});
});
+497
View File
@@ -0,0 +1,497 @@
const MatchQueue = require('../src/network/matchQueue');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const MatchScheduler = require('../src/network/matchScheduler');
const MatchManager = require('../src/network/matchManager');
describe('match queue crash/restart invariants', () => {
const priorMatchEnabled = process.env.MATCH_ENABLED;
beforeEach(() => {
process.env.MATCH_ENABLED = 'true';
});
afterEach(() => {
if (priorMatchEnabled === undefined) delete process.env.MATCH_ENABLED;
else process.env.MATCH_ENABLED = priorMatchEnabled;
});
test('persisting a match links every exact queue row before inserting linked entrants', async () => {
const captured = [];
const entries = [
{ userId: 7, socketId: 's7', queueEntryId: 101 },
{ userId: 8, socketId: 's8', queueEntryId: 102 }
];
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('UPDATE match_queue_entries') && sql.includes('SET match_id')) {
return {
rows: entries.map(entry => ({
id: entry.queueEntryId,
user_id: entry.userId
})),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const room = new MatchRoom({
economy: 'crypto_race',
entrants: {
s7: { userId: 7, socketId: 's7' },
s8: { userId: 8, socketId: 's8' }
},
seed: 'a'.repeat(64)
});
const scheduler = new MatchScheduler({ matchQueue: {}, matchManager: { db } });
await scheduler._persistMatch(room, entries, 'crypto_race');
const link = captured.find(call => call.sql.includes('SET match_id'));
expect(link.params).toEqual([room.id, [101, 102], 'crypto_race']);
expect(link.sql).toContain("status = 'matched'");
expect(link.sql).toContain('match_id IS NULL');
const entrantInserts = captured.filter(call => call.sql.includes('INSERT INTO match_entrants'));
expect(entrantInserts.map(call => call.params)).toEqual([
[room.id, 7, 's7', 101],
[room.id, 8, 's8', 102]
]);
scheduler.shutdown();
});
test('a finished match with linked consumed tickets is never treated as stale escrow', async () => {
let staleSql = '';
const db = {
query: jest.fn(async (sql) => {
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM match_queue_entries') && sql.includes("status = 'matched'")) {
staleSql = sql;
return { rows: [], rowCount: 0 };
}
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn()
};
const queue = new MatchQueue({ db });
await queue.initialize();
await queue.shutdown();
expect(staleSql).toContain("status = 'matched' AND match_id IS NULL");
expect(staleSql).not.toContain("status = 'consumed'");
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('a crash after drain but before match persistence refunds the unlinked hold exactly once', async () => {
const state = {
queueStatus: 'matched',
matchId: null,
lotRemaining: 0,
userTickets: 0,
ledgerRows: 0
};
const db = {
async query(sql) {
if (sql.includes('FROM match_queue_entries')
&& state.queueStatus === 'matched'
&& state.matchId === null) {
return {
rows: [{
id: 41,
user_id: 9,
economy: 'crypto_race',
escrow_amount: '1',
race_entry_lot_id: 71
}],
rowCount: 1
};
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('UPDATE match_queue_entries')) {
expect(sql).toContain("status = 'matched' AND match_id IS NULL");
if (state.queueStatus !== 'matched' || state.matchId !== null) {
return { rows: [], rowCount: 0 };
}
state.queueStatus = 'cancelled';
return {
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
rowCount: 1
};
}
if (sql.includes('UPDATE race_entry_lots')) {
expect(sql).toContain('refunded_at IS NULL');
expect(sql).toContain('remaining_entries + $1 <= original_entries');
state.lotRemaining += 1;
return { rows: [{ id: 71 }], rowCount: 1 };
}
if (sql.includes('UPDATE users')) {
state.userTickets += 1;
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
state.ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue._recoverStaleQueueEntries();
await queue._recoverStaleQueueEntries();
expect(state).toEqual(expect.objectContaining({
queueStatus: 'cancelled',
lotRemaining: 1,
userTickets: 1,
ledgerRows: 1
}));
});
test('an abandoned starting match restores a consumed funded ticket only once', async () => {
const state = {
matchStatus: 'starting',
queueStatus: 'consumed',
lotRemaining: 0,
userTickets: 0,
entrantRefunded: false,
ledgerRows: 0
};
const db = {
async query(sql) {
if (sql.includes('FROM matches') && state.matchStatus === 'starting') {
return { rows: [{ id: 'match-9', economy: 'crypto_race' }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('SELECT id FROM payouts')) return { rows: [], rowCount: 0 };
if (sql.includes('UPDATE matches SET status')) {
if (state.matchStatus !== 'starting') return { rows: [], rowCount: 0 };
state.matchStatus = 'cancelled';
return { rows: [{ id: 'match-9' }], rowCount: 1 };
}
if (sql.includes('FROM match_entrants') && sql.includes('queue_entry_id')) {
return {
rows: [{ id: 91, user_id: 9, queue_entry_id: 41 }],
rowCount: 1
};
}
if (sql.includes('UPDATE match_queue_entries') && sql.includes('match_id = $2')) {
if (state.queueStatus !== 'consumed') return { rows: [], rowCount: 0 };
state.queueStatus = 'cancelled';
return {
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
rowCount: 1
};
}
if (sql.includes('UPDATE race_entry_lots')) {
state.lotRemaining += 1;
return { rows: [{ id: 71 }], rowCount: 1 };
}
if (sql.includes('UPDATE users')) {
state.userTickets += 1;
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
state.ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
if (sql.includes('UPDATE match_entrants')) {
state.entrantRefunded = true;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue._recoverAbandonedMatches();
await queue._recoverAbandonedMatches();
expect(state).toEqual({
matchStatus: 'cancelled',
queueStatus: 'cancelled',
lotRemaining: 1,
userTickets: 1,
entrantRefunded: true,
ledgerRows: 1
});
});
test('queue initialization stays closed when the abandoned-match scan fails', async () => {
const db = {
query: jest.fn(async sql => {
if (sql.includes('FROM matches')) throw new Error('scan unavailable');
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn()
};
const queue = new MatchQueue({ db });
await expect(queue.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'abandoned_matches',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(queue.initialized).toBe(false);
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('queue initialization rejects after processing peers when one match remains unresolved', async () => {
let transactionCount = 0;
const db = {
query: jest.fn(async sql => {
if (sql.includes('FROM matches')) {
return {
rows: [
{ id: 'unresolved-match', economy: 'crypto_race' },
{ id: 'resolved-match', economy: 'crypto_race' }
],
rowCount: 2
};
}
return { rows: [], rowCount: 0 };
}),
withTransaction: jest.fn(async fn => {
transactionCount += 1;
if (transactionCount === 1) throw new Error('refund transaction failed');
return fn({
query: jest.fn(async sql => {
if (sql.includes('SELECT id FROM payouts')) {
return { rows: [{ id: 91 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
});
})
};
const queue = new MatchQueue({ db });
await expect(queue.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'abandoned_matches',
scanFailed: false,
scanned: 2,
resolved: 1,
unresolved: [{ type: 'match', id: 'unresolved-match' }]
}
});
expect(queue.initialized).toBe(false);
expect(db.withTransaction).toHaveBeenCalledTimes(2);
});
test('paid queue admission is closed before durable recovery completes', async () => {
const queue = new MatchQueue({ db: {} });
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
success: false,
reason: 'financial_recovery_pending'
});
});
test('a later liability-recovery failure closes paid joins, drains, and freezes dynamically', async () => {
const queue = new MatchQueue({
db: {},
isFinancialRecoveryReady: () => false
});
queue.initialized = true;
queue._queues.credits_prestige.push(
{ queueEntryId: 1, userId: 1, socketId: 's1' },
{ queueEntryId: 2, userId: 2, socketId: 's2' }
);
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
success: false,
reason: 'financial_recovery_pending'
});
await expect(queue.drain('credits_prestige', 2, 2)).resolves.toBeNull();
await expect(queue.freezePaidMatch({
economy: 'credits_prestige',
maxPlayers: 2,
minPlayers: 2,
freezeBlockHeight: 10,
targetBlockHeight: 11,
rulesetId: 'race'
})).resolves.toBeNull();
});
test('MATCH_ENABLED=false still refunds durable queued escrow during boot', async () => {
process.env.MATCH_ENABLED = 'false';
let userTickets = 0;
let lotTickets = 0;
let ledgerRows = 0;
const db = {
async query(sql) {
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
if (sql.includes("status = 'matched'") && sql.includes('INTERVAL')) {
return { rows: [], rowCount: 0 };
}
if (sql.includes("WHERE status = 'queued'")) {
return {
rows: [{
id: 55,
user_id: 12,
economy: 'crypto_race',
socket_id: 'old-socket',
session_token: 'session',
created_at: new Date().toISOString()
}],
rowCount: 1
};
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('DELETE FROM match_queue_entries')) {
return {
rows: [{ id: 55, escrow_amount: '1', race_entry_lot_id: 77 }],
rowCount: 1
};
}
if (sql.includes('UPDATE users')) {
userTickets += 1;
return { rows: [{ race_entries: userTickets }], rowCount: 1 };
}
if (sql.includes('UPDATE race_entry_lots')) {
lotTickets += 1;
return { rows: [{ id: 77 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO race_entry_transactions')) {
ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const queue = new MatchQueue({ db });
await queue.initialize();
await queue.shutdown();
expect(queue.isEnabled()).toBe(false);
expect(queue.length('crypto_race')).toBe(0);
expect({ userTickets, lotTickets, ledgerRows }).toEqual({
userTickets: 1,
lotTickets: 1,
ledgerRows: 1
});
});
test('MATCH_ENABLED=false still reconciles an already-finished accepted liability', async () => {
process.env.MATCH_ENABLED = 'false';
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
scanned: 1,
created: 1,
failed: 0
});
await manager.initialize();
expect(manager.enabled).toBe(false);
expect(manager.matchPayoutService.reconcileFinishedLiabilities).toHaveBeenCalledTimes(1);
manager.shutdown();
});
test('finished accepted-liability failures keep match recovery and startup closed', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
});
await expect(manager.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'finished_match_liabilities',
scanFailed: false,
scanned: 1,
resolved: 0,
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
}
});
expect(manager.financialRecoveryReady).toBe(false);
expect(manager._liabilityReconcileTimer).toBeNull();
manager.shutdown();
});
test('finished-liability scan errors have explicit fail-closed startup semantics', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn()
.mockRejectedValue(new Error('liability scan unavailable'));
await expect(manager.initialize()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'finished_match_liabilities',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(manager.financialRecoveryReady).toBe(false);
manager.shutdown();
});
test('bounded reconciliation retries a later transient failure and reopens the dynamic gate', async () => {
const manager = new MatchManager({ db: {}, io: null });
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
ok: true, scanned: 0, created: 0, failed: 0, unresolved: []
});
await manager.initialize();
manager.matchPayoutService.reconcileFinishedLiabilities
.mockResolvedValueOnce({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-retry' }]
})
.mockResolvedValueOnce({
ok: true,
scanned: 1,
created: 1,
failed: 0,
unresolved: []
});
const failed = await manager._reconcileFinishedLiabilities({ failClosed: false });
expect(failed.ok).toBe(false);
expect(manager.financialRecoveryReady).toBe(false);
const retried = await manager._reconcileFinishedLiabilities({ failClosed: false });
expect(retried).toMatchObject({ ok: true, scanned: 1, created: 1, failed: 0 });
expect(manager.financialRecoveryReady).toBe(true);
manager.shutdown();
});
});
+151
View File
@@ -0,0 +1,151 @@
const MatchLeaderboard = require('../src/network/matchLeaderboard');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const { WIN } = require('../src/game/rulesets');
describe('MatchLeaderboard', () => {
function makeDb() {
const captured = [];
return {
captured,
async withTransaction(fn) {
const client = {
async query(sql, params) {
captured.push({ sql, params });
return { rowCount: 1, rows: [] };
}
};
return await fn(client);
}
};
}
function makeFinishedRoom(economy) {
const room = new MatchRoom({
entrants: {
a: { userId: 1, name: 'A' },
b: { userId: 2, name: 'B' }
},
economy,
seed: '0000000000000000000000000000000000000000000000000000000000000000'
});
room.start();
room.expire('hard_ceiling');
room.finalize();
return room;
}
test('free race inserts synthetic FREE records', async () => {
const db = makeDb();
const lb = new MatchLeaderboard({ db });
const room = makeFinishedRoom('free');
await lb.postMatch(room);
const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
expect(gamesInserts.length).toBe(2);
expect(gamesInserts[0].params[2]).toBe('FREE');
});
test('prestige race broadcasts without inserting games', async () => {
const emitted = [];
const io = { emit: (e, p) => emitted.push({ e, p }) };
const db = makeDb();
const lb = new MatchLeaderboard({ db, io });
const room = makeFinishedRoom('credits_prestige');
await lb.postMatch(room);
const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
expect(gamesInserts.length).toBe(0);
expect(emitted.some(e => e.e === 'leaderboard_update' && e.p.board === 'prestige')).toBe(true);
});
test('crypto race inserts PAID_CREDITS records', async () => {
const db = makeDb();
const lb = new MatchLeaderboard({ db });
const room = makeFinishedRoom('crypto_race');
await lb.postMatch(room);
const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
expect(gamesInserts.length).toBe(2);
expect(gamesInserts[0].params[2]).toBe('PAID_CREDITS');
});
test('last-alive win/loss follows winnerId + placement, never the escaped flag', async () => {
const db = makeDb();
const lb = new MatchLeaderboard({ db });
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
economy: 'free',
ruleset: {
id: 'test-last-alive', mode: 'pvp',
entities: { monster: false, pvpCombat: true },
players: { min: 2, max: 2 },
winCondition: { type: WIN.LAST_ALIVE }
},
seed: '0'.repeat(64)
});
room.start();
room._finishPlayer('a', true);
room.winCondition.onExit(room, 'a');
room.finalize();
expect(room.playerStates.get('a').escaped).toBe(true);
expect(room.winnerId).toBe('b');
expect(room.playerStates.get('b').placement).toBe(1);
await lb.postMatch(room);
const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games'));
const bySocket = new Map(inserts.map(q => [q.params[1], q.params]));
expect(bySocket.get('a').slice(3, 5)).toEqual(['lost', 'match_loss']);
expect(bySocket.get('b').slice(3, 5)).toEqual(['won', 'match_winner']);
});
test('score-attack awards the authoritative score winner even when another entrant escaped', async () => {
const db = makeDb();
const lb = new MatchLeaderboard({ db });
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
economy: 'free',
ruleset: {
id: 'test-score', mode: 'race', entities: { monster: false },
players: { min: 2, max: 2 }, winCondition: { type: WIN.HIGH_SCORE }
},
seed: '0'.repeat(64)
});
room.start();
room.playerStates.get('a').escaped = true;
room.playerStates.get('a').finished = true;
room.playerStates.get('a').moves = 200;
room.playerStates.get('b').hasTreasure = true;
room.playerStates.get('b').moves = 1;
room.expire('hard_ceiling');
room.finalize();
expect(room.winnerId).toBe('b');
await lb.postMatch(room);
const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games'));
const bySocket = new Map(inserts.map(q => [q.params[1], q.params]));
expect(bySocket.get('a')[3]).toBe('lost');
expect(bySocket.get('b')[3]).toBe('won');
});
test('co-op results stay in match history and never enter individual boards', async () => {
const emitted = [];
const db = makeDb();
const lb = new MatchLeaderboard({ db, io: { emit: (e, p) => emitted.push({ e, p }) } });
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
economy: 'free',
ruleset: {
id: 'coop-escape', mode: 'coop',
entities: { monster: false }, players: { min: 2, max: 2 },
winCondition: { type: WIN.ALL_ESCAPE }
},
seed: '0'.repeat(64)
});
room.start();
room.expire('hard_ceiling');
room.finalize();
expect(room.winnerId).toBeNull();
await lb.postMatch(room);
expect(db.captured).toHaveLength(0);
expect(emitted).toHaveLength(0);
});
});
+297
View File
@@ -0,0 +1,297 @@
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 MatchScheduler = require('../src/network/matchScheduler');
const {
buildPaidEntrantFreeze,
deriveFutureBlockMatchSeed
} = require('../src/network/matchFairness');
const MATCH_ENV_KEYS = [
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
'MATCH_RULESET_ID', 'MATCH_MAX_PLAYERS', 'PAYOUTS_ENABLED', 'PAYMENTS_ENABLED'
];
describe('match payout liability production invariants', () => {
let priorEnv;
beforeEach(() => {
priorEnv = Object.fromEntries(MATCH_ENV_KEYS.map(key => [key, process.env[key]]));
Object.assign(process.env, {
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '1000000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4',
PAYOUTS_ENABLED: 'true',
PAYMENTS_ENABLED: 'true'
});
});
afterEach(() => {
for (const [key, value] of Object.entries(priorEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
const gameModeManager = (overrides = {}) => ({
payoutsEnabled: true,
directModeEnabled: true,
creditsModeEnabled: false,
_scheduleBatchPayout: jest.fn(),
...overrides
});
function cryptoRoom(rulesetId = 'race') {
return new MatchRoom({
economy: 'crypto_race',
rulesetId,
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: '0'.repeat(64)
});
}
test('MATCH_PAYOUT_MAX is checked before any ticket commitment transaction', async () => {
process.env.MATCH_PAYOUT_MAX = '18000'; // two entries owe 19,000 after the 5% fee
const db = { withTransaction: jest.fn() };
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
await expect(service.collectEntryTickets(cryptoRoom(), [
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
]))
.rejects.toMatchObject({ code: 'PAYOUT_CAP_EXCEEDED' });
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('crypto co-op is rejected before commitment because split semantics do not exist', async () => {
process.env.MATCH_RULESET_ID = 'coop-escape';
const db = { withTransaction: jest.fn() };
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
await expect(service.collectEntryTickets(cryptoRoom('coop-escape'), [
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
]))
.rejects.toMatchObject({ code: 'UNSUPPORTED_CRYPTO_RULESET' });
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('scheduler clamps entrants to the selected ruleset player contract', () => {
const scheduler = new MatchScheduler({
matchQueue: {},
matchManager: {},
rulesetId: 'race',
maxPlayers: 32
});
expect(scheduler.minPlayers).toBe(2);
expect(scheduler.maxPlayers).toBe(8);
scheduler.shutdown();
});
test('hot-disable refunds queued crypto escrow and removes memory only after commit', async () => {
let available = true;
const captured = [];
const db = {
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 };
if (sql.includes('UPDATE users')) return { rows: [{ race_entries: 2 }], rowCount: 1 };
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const manager = gameModeManager({
_getMatchEconomies: () => available ? { free: true, crypto_race: true } : { free: true }
});
const queue = new MatchQueue({ db, gameModeManager: manager });
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
available = false;
await queue.refundUnavailableQueues();
expect(queue.length('crypto_race')).toBe(0);
expect(captured.some(q => q.sql.includes('race_entries = race_entries + $1'))).toBe(true);
expect(captured.some(q => q.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
});
test('failed hot-disable refund remains queued for the next reconciliation attempt', async () => {
const db = { async withTransaction() { throw new Error('db down'); } };
const manager = gameModeManager({ _getMatchEconomies: () => ({ free: true }) });
const queue = new MatchQueue({ db, gameModeManager: manager });
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
await queue.refundUnavailableQueues();
expect(queue.length('crypto_race')).toBe(1);
});
test('finished liability is reconciled after a transient payout insertion failure', async () => {
let insertFails = true;
let payoutInserted = false;
let successfulInserts = 0;
const captured = [];
const db = {
async query(sql) {
if (sql.includes('SELECT m.id')) {
return payoutInserted
? { rows: [], rowCount: 0 }
: { rows: [{ id: 'match-1' }], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
return { rows: [{
id: 'match-1', economy: 'crypto_race', status: 'finished', winner_user_id: 7,
payout_liability_amount_atomic: '19000',
payout_liability_cap_atomic: '1000000',
payout_liability_accepted_at: new Date().toISOString()
}], rowCount: 1 };
}
if (sql.includes('FROM payouts')) {
return payoutInserted
? { rows: [{ id: 88 }], rowCount: 1 }
: { rows: [], rowCount: 0 };
}
if (sql.includes('FROM users')) return { rows: [{ id: 7, payout_address: 'wallet' }], rowCount: 1 };
if (sql.includes('INSERT INTO payouts') && insertFails) throw new Error('insert unavailable');
if (sql.includes('INSERT INTO payouts')) {
payoutInserted = true;
successfulInserts += 1;
}
return { rows: [{ id: 88 }], rowCount: 1 };
}
});
}
};
const manager = gameModeManager();
const service = new MatchPayoutService({ db, gameModeManager: manager });
expect(await service.reconcileFinishedLiabilities()).toEqual({
ok: false,
scanned: 1,
created: 0,
failed: 1,
unresolved: [{ type: 'match_liability', id: 'match-1' }]
});
insertFails = false;
expect(await service.reconcileFinishedLiabilities()).toEqual({
ok: true,
scanned: 1,
created: 1,
failed: 0,
unresolved: []
});
expect(await service.reconcileFinishedLiabilities()).toEqual({
ok: true,
scanned: 0,
created: 0,
failed: 0,
unresolved: []
});
expect(successfulInserts).toBe(1);
expect(manager._scheduleBatchPayout).toHaveBeenCalledTimes(1);
expect(captured.filter(q => q.sql.includes('INSERT INTO payouts'))).toHaveLength(2);
});
test('manager durably persists completion before attempting liability insertion', async () => {
const manager = new MatchManager({ io: null, db: {}, debugManager: null });
manager.enabled = true;
const order = [];
manager._persistFinish = async () => { order.push('persist'); };
manager.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
manager.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); };
const room = cryptoRoom();
room.winnerId = 'a';
room.status = 'finished';
await manager._finalize(room);
expect(order.slice(0, 2)).toEqual(['persist', 'payout']);
manager.shutdown();
});
test('a future-block-frozen match with an ambiguous collect acknowledgement continues only when the accepted snapshot exists', async () => {
const entries = [
{ userId: 1, socketId: 'a', sessionToken: 'ta', queueEntryId: 11 },
{ userId: 2, socketId: 'b', sessionToken: 'tb', queueEntryId: 12 }
];
const matchQueue = {
refundEntries: jest.fn()
};
const payoutService = {
collectEntryTickets: jest.fn().mockRejectedValue(new Error('COMMIT acknowledgement lost')),
getAcceptedLiability: jest.fn().mockResolvedValue({
entry_fee_atomic: '10000',
pot_atomic: '20000',
house_fee_atomic: '1000',
house_fee_percent: '5',
payout_liability_amount_atomic: '19000',
payout_liability_cap_atomic: '1000000',
payout_liability_terms: { version: 2 }
})
};
const matchManager = {
db: null,
gameModeManager: gameModeManager(),
matchPayoutService: payoutService,
setEngine: jest.fn(),
attach: jest.fn(),
onTick: jest.fn(),
onFinish: jest.fn()
};
const scheduler = new MatchScheduler({
matchQueue,
matchManager,
blockCountProvider: async () => 902,
blockEntropyProvider: async height => ({
block_header: { height, hash: 'ab'.repeat(32) }
})
});
const freeze = buildPaidEntrantFreeze({
freezeBlockHeight: 899,
targetBlockHeight: 900,
economy: 'crypto_race',
rulesetId: 'race',
queueEntryIds: entries.map(entry => entry.queueEntryId)
});
const seedProof = deriveFutureBlockMatchSeed({
blockHash: 'ab'.repeat(32),
blockHeight: 900,
freeze
});
await scheduler._startEntries({
entries,
economy: 'crypto_race',
blockHeight: 900,
seedProof,
frozen: {
id: '11111111-1111-4111-8111-111111111111',
freeze,
freezeCommitment: freeze.freezeCommitment,
entries
}
});
expect(payoutService.collectEntryTickets).toHaveBeenCalledTimes(1);
expect(payoutService.getAcceptedLiability).toHaveBeenCalledTimes(1);
expect(matchQueue.refundEntries).not.toHaveBeenCalled();
expect(matchManager.attach).toHaveBeenCalledTimes(1);
const room = matchManager.attach.mock.calls[0][0];
expect(room.payoutLiabilityAmountAtomic).toBe('19000');
scheduler.shutdown();
});
});
+165
View File
@@ -0,0 +1,165 @@
const MatchPayoutService = require('../src/network/matchPayoutService');
const MatchRoom = require('../src/multiplayer/MatchRoom');
describe('MatchPayoutService', () => {
const enabledEnv = (overrides = {}) => ({
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'true',
MATCH_PAYOUTS_ENABLED: 'true',
MATCH_PAYOUT_MAX: '100000000000000000',
MATCH_ENTRY_FEE_ATOMIC: '10000',
MATCH_HOUSE_FEE_PERCENT: '5',
PAYOUTS_ENABLED: 'true',
PAYMENTS_ENABLED: 'true',
MATCH_RULESET_ID: 'race',
MATCH_MAX_PLAYERS: '4',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
...overrides
});
function makeDb({ address = 'WALLET_ADDRESS', accepted = true, amount = '19000', cap = '1000000', existing = null } = {}) {
const captured = [];
return {
captured,
async query(sql) {
if (sql.includes('SELECT m.id')) return { rows: [] };
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
const client = {
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
const ids = params[0];
return {
rows: ids.map((id, index) => ({
id,
user_id: index + 1,
race_entry_lot_id: 100 + index,
escrow_amount: '1',
escrow_value_atomic: '10000'
})),
rowCount: ids.length
};
}
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
return { rows: [{
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
payout_liability_amount_atomic: amount,
payout_liability_cap_atomic: cap,
payout_liability_accepted_at: accepted ? new Date().toISOString() : null
}], rowCount: 1 };
}
if (sql.includes('FROM payouts') && sql.includes('LIMIT 1')) {
return { rows: existing ? [existing] : [], rowCount: existing ? 1 : 0 };
}
if (sql.includes('FROM users') && sql.includes('payout_address')) {
return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
}
return { rows: [{ id: 99 }], rowCount: 1 };
}
};
return fn(client);
}
};
}
function manager(overrides = {}) {
return {
payoutsEnabled: true,
directModeEnabled: true,
creditsModeEnabled: false,
_scheduleBatchPayout: jest.fn(),
...overrides
};
}
function makeRoom() {
const room = new MatchRoom({
economy: 'crypto_race',
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: '0'.repeat(64)
});
room.start();
room.playerStates.get('a').escaped = true;
room.winnerId = 'a';
room.status = 'finished';
room.finalize();
return room;
}
test('collectEntryTickets atomically locks exact pot and accepted payout terms', async () => {
const db = makeDb();
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
const room = makeRoom();
await service.collectEntryTickets(room, [
{ userId: 1, socketId: 'a', queueEntryId: 11 },
{ userId: 2, socketId: 'b', queueEntryId: 12 }
]);
const updateMatch = db.captured.find(q => q.sql.includes('payout_liability_accepted_at = NOW()'));
expect(updateMatch).toBeTruthy();
expect(updateMatch.params.slice(0, 6)).toEqual([
'10000', '20000', '1000', 5, '19000', '100000000000000000'
]);
expect(JSON.parse(updateMatch.params[6])).toEqual(expect.objectContaining({
playerCount: 2,
payoutAmountAtomic: '19000',
rulesetId: 'race'
}));
expect(room.potAtomic).toBe('20000');
});
test('accepted ON -> OFF liability still creates a pending payout', async () => {
const db = makeDb();
const gmm = manager({ payoutsEnabled: false });
const service = new MatchPayoutService({
db,
env: enabledEnv({ MATCH_PAYOUTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false' }),
gameModeManager: gmm
});
const result = await service.payoutWinner(makeRoom());
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(result).toEqual(expect.objectContaining({ created: true, amount: '19000' }));
expect(insert.params[3]).toBe('19000');
expect(insert.params[5]).toBe('match_winner');
// Global off pauses dispatch, but does not suppress recording the obligation.
expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1);
});
test('OFF -> ON never creates a retroactive match liability', async () => {
const db = makeDb({ accepted: false });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
const result = await service.payoutWinner(makeRoom());
expect(result).toEqual({ created: false, reason: 'liability_not_accepted' });
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
});
test('needs_review payout identity blocks a replacement row', async () => {
const db = makeDb({ existing: { id: 77, status: 'needs_review' } });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
const result = await service.payoutWinner(makeRoom());
expect(result).toEqual({ created: false, reason: 'payout_exists', payoutId: 77 });
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
});
test('winner with no address records a claimable needs_review liability', async () => {
const db = makeDb({ address: null });
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
await service.payoutWinner(makeRoom());
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(insert.params[2]).toBe('PENDING_NO_ADDRESS');
expect(insert.params[5]).toBe('match_winner_no_address');
expect(insert.params[6]).toBe('needs_review');
});
});
+241
View File
@@ -0,0 +1,241 @@
const MatchQueue = require('../src/network/matchQueue');
const SocketHandlers = require('../src/network/socketHandlers');
describe('MatchQueue', () => {
function makeDb() {
let nextQueueId = 1;
const queueUsers = new Map();
return {
queries: [],
lastParams: null,
async query(sql, params) {
this.queries.push(sql);
this.lastParams = params || [];
// Default stub: empty rows.
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
const client = {
queries: [],
async query(sql, params) {
this.queries.push({ sql, params });
let rows = [{ id: 1, created_at: new Date().toISOString(), credits: 100, race_entries: 1 }];
let rowCount = 1;
if (sql.includes('INSERT INTO match_queue_entries')) {
const id = nextQueueId++;
queueUsers.set(String(id), params[0]);
rows = [{ id, created_at: new Date().toISOString() }];
} else if (sql.includes('UPDATE match_queue_entries') && sql.includes('id = ANY')) {
const ids = params[0] || [];
rows = ids.map(id => ({ id, user_id: queueUsers.get(String(id)) }));
rowCount = ids.length;
}
return { rows, rowCount };
}
};
return await fn(client);
}
};
}
test('disabled when MATCH_ENABLED is not true', () => {
const q = new MatchQueue({});
expect(q.isEnabled()).toBe(false);
});
test('validates economy', async () => {
process.env.MATCH_ENABLED = 'true';
const q = new MatchQueue({});
const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'bogus' });
expect(result.success).toBe(false);
expect(result.reason).toBe('invalid_economy');
delete process.env.MATCH_ENABLED;
});
test('rejects a syntactically valid economy that the server has disabled', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
const gameModeManager = { _getMatchEconomies: () => ({ free: true }) };
const q = new MatchQueue({ db, gameModeManager });
q.initialized = true;
const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'crypto_race' });
expect(result).toEqual({ success: false, reason: 'economy_disabled' });
expect(q.length('crypto_race')).toBe(0);
expect(db.queries).toHaveLength(0);
delete process.env.MATCH_ENABLED;
});
test('free join adds to memory queue', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
const q = new MatchQueue({ db });
const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
expect(result.success).toBe(true);
expect(q.length('free')).toBe(1);
delete process.env.MATCH_ENABLED;
});
test('credits join deducts credits atomically', async () => {
process.env.MATCH_ENABLED = 'true';
process.env.MATCH_CREDITS_COST = '5';
const db = makeDb();
const q = new MatchQueue({ db });
q.initialized = true;
const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
expect(result.success).toBe(true);
expect(q.length('credits_prestige')).toBe(1);
// Transaction captured in the stub client. We just verify join succeeded.
delete process.env.MATCH_ENABLED;
delete process.env.MATCH_CREDITS_COST;
});
test.each([
['credits_prestige', 'credits'],
['crypto_race', 'race_entries']
])('concurrent duplicate %s joins hold escrow exactly once', async (economy, debitColumn) => {
process.env.MATCH_ENABLED = 'true';
if (economy === 'crypto_race') {
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_PAYOUT_MAX = '1000000';
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
process.env.MATCH_MAX_PLAYERS = '4';
}
let queued = false;
let debits = 0;
let joinLedgerRows = 0;
const createdAt = new Date().toISOString();
const db = {
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('INSERT INTO match_queue_entries')) {
if (queued) return { rows: [], rowCount: 0 };
queued = true;
return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 };
}
if (sql.includes('UPDATE match_queue_entries') && sql.includes("status = 'queued'")) {
return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 };
}
if (sql.includes('UPDATE users') && sql.includes(`${debitColumn} = ${debitColumn} -`)) {
debits += 1;
return {
rows: [{ credits: 9, race_entries: 0 }],
rowCount: 1
};
}
if (sql.includes('SELECT id, unit_value_atomic') && sql.includes('FROM race_entry_lots')) {
return { rows: [{ id: 501, unit_value_atomic: '10000' }], rowCount: 1 };
}
if (sql.includes('UPDATE race_entry_lots')) {
return { rows: [{ id: 501 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO credit_transactions') || sql.includes('INSERT INTO race_entry_transactions')) {
joinLedgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 0 };
}
});
}
};
const gameModeManager = economy === 'crypto_race' ? {
payoutsEnabled: true,
directModeEnabled: true,
creditsModeEnabled: false,
_getMatchEconomies: () => ({ free: true, crypto_race: true })
} : null;
const q = new MatchQueue({ db, gameModeManager });
q.initialized = true;
const request = () => q.join({ userId: 9, socketId: 's9', sessionToken: 't9', economy });
const results = await Promise.all([request(), request()]);
expect(results.every(result => result.success)).toBe(true);
expect(results.filter(result => result.alreadyQueued).length).toBe(1);
expect(debits).toBe(1);
expect(joinLedgerRows).toBe(1);
expect(q.length(economy)).toBe(1);
delete process.env.MATCH_ENABLED;
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
delete process.env.MATCH_PAYOUT_MAX;
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
delete process.env.MATCH_HOUSE_FEE_PERCENT;
delete process.env.MATCH_MAX_PLAYERS;
});
test('drain requires at least two queued players', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
const q = new MatchQueue({ db });
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
const drain = await q.drain('free', 4);
expect(drain).toBeNull();
expect(q.length('free')).toBe(1);
delete process.env.MATCH_ENABLED;
});
test('drain returns two+ players and clears memory queue', async () => {
process.env.MATCH_ENABLED = 'true';
const db = makeDb();
const q = new MatchQueue({ db });
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
await q.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
const drain = await q.drain('free', 4);
expect(drain).toBeTruthy();
expect(drain.entries.length).toBe(2);
expect(q.length('free')).toBe(0);
delete process.env.MATCH_ENABLED;
});
test('leave refunds credits and removes from queue', async () => {
process.env.MATCH_ENABLED = 'true';
process.env.MATCH_CREDITS_COST = '3';
const db = makeDb();
const q = new MatchQueue({ db });
q.initialized = true;
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
const leave = await q.leave(1, 'credits_prestige');
expect(leave.success).toBe(true);
expect(q.length('credits_prestige')).toBe(0);
delete process.env.MATCH_ENABLED;
delete process.env.MATCH_CREDITS_COST;
});
});
describe('SocketHandlers match queue acknowledgements', () => {
test('emits the documented joined and left results to the client', async () => {
const emitted = [];
const socket = { id: 'sock', emit: (event, payload) => emitted.push({ event, payload }) };
const ctx = {
activeGames: new Map(),
matchQueue: {
isEnabled: () => true,
enqueue: jest.fn().mockResolvedValue({ success: true, position: 2 }),
leave: jest.fn().mockResolvedValue({ success: true })
},
rateLimiter: {
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
recordAttempt: jest.fn().mockResolvedValue(undefined)
},
_resolveMatchSession: jest.fn().mockResolvedValue({ userId: 1, socketId: 'sock', sessionToken: 't' }),
debugManager: { CONSOLE_LOGGING: false }
};
await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'join', economy: 'free' });
await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'leave', economy: 'free' });
expect(emitted).toContainEqual({
event: 'match_queue_joined',
payload: { success: true, position: 2, economy: 'free' }
});
expect(emitted).toContainEqual({
event: 'match_queue_left',
payload: { success: true, economy: 'free' }
});
});
});
+28
View File
@@ -0,0 +1,28 @@
const MatchRoom = require('../src/multiplayer/MatchRoom');
describe('MatchRoom reconnect safety', () => {
test('disconnect grace does not remove player state', () => {
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: '0000000000000000000000000000000000000000000000000000000000000000'
});
room.start();
expect(room.playerStates.get('a').alive).toBe(true);
// Simulate a forfeit / AFK kill.
room._killPlayer('a', 'afk');
expect(room.playerStates.get('a').alive).toBe(false);
expect(room.activePlayerCount).toBe(1);
});
test('all players dying ends match', () => {
const room = new MatchRoom({
entrants: { a: { userId: 1 }, b: { userId: 2 } },
seed: '0000000000000000000000000000000000000000000000000000000000000000'
});
room.start();
room._killPlayer('a', 'monster');
room._killPlayer('b', 'monster');
expect(room.status).toBe('finished');
expect(room.endReason).toBe('all_dead');
});
});
+501
View File
@@ -0,0 +1,501 @@
/**
* 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();
});
test('block deadline expires on the first later header after the active-play floor', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-21T12:00:00.000Z'));
const m = mgr();
const room = new MatchRoom({
economy: 'free', startBlockHeight: 100,
entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED
});
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: 20000, hardCeilingMs: NEVER });
room.start();
expect(m.expireBlockDeadlines(100)).toBe(0); // same header can never end its room
jest.advanceTimersByTime(19999);
expect(m.expireBlockDeadlines(101)).toBe(0); // later header, but floor not met
jest.advanceTimersByTime(1);
expect(m.expireBlockDeadlines(101)).toBe(1);
expect(room.status).toBe('finished');
expect(room.endReason).toBe('block_deadline');
expect(room.endBlockHeight).toBe(101);
expect(m.expireBlockDeadlines(102)).toBe(0); // terminal transition is idempotent
});
test('a ruleset can explicitly disable the block deadline', () => {
const m = mgr();
const room = new MatchRoom({
economy: 'free', startBlockHeight: 100,
entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED,
ruleset: {
id: 'no-block-clock', mode: 'race', players: { min: 2, max: 2 },
entities: { monster: false }, winCondition: { type: 'high-score' },
timing: { blockDeadline: false, minDurationMs: 0 }
}
});
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: 0, hardCeilingMs: NEVER });
room.start();
expect(m.expireBlockDeadlines(101)).toBe(0);
expect(room.status).toBe('active');
});
});
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);
});
test('a finished-room reconnect receives the terminal result immediately and once', () => {
const { m, room } = activeMatch();
room.expire('hard_ceiling');
room.finalize();
room._finalized = true; // durable finish retained during the normal cleanup grace
const sock = makeSocket('s1new');
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
const terminal = sock.emitted.filter(e => e.ev === 'match_end');
expect(terminal).toHaveLength(1);
expect(terminal[0].p).toEqual(expect.objectContaining({
matchId: room.id,
reason: 'hard_ceiling',
winnerId: room.winnerId
}));
expect(terminal[0].p.players.find(p => p.id === 's1new')).toEqual(expect.objectContaining({
placement: expect.any(Number),
score: expect.any(Number)
}));
expect(terminal[0].p.fairness.seed).toBe(SEED);
});
test('a reconnect during nondurable finish gets pending, never a premature winner', () => {
const { m, room } = activeMatch();
room.expire('hard_ceiling');
room.finalize();
room._finalized = false;
const sock = makeSocket('s1new');
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
expect(sock.emitted.some(e => e.ev === 'match_settlement_pending')).toBe(true);
expect(sock.emitted.some(e => e.ev === 'match_end')).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 = [];
const amount = (BigInt(pot) - BigInt(fee)).toString();
return {
captured,
async query() { return { rows: [], rowCount: 0 }; },
async withTransaction(fn) {
const client = {
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
return { rows: [{
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
payout_liability_amount_atomic: amount,
payout_liability_cap_atomic: amount,
payout_liability_accepted_at: new Date().toISOString()
}], rowCount: 1 };
}
if (sql.includes('FROM payouts')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM users')) return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
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: '900719925474099201', 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('900719925474099200'); // exact; a float would round this
});
test('collectEntryTickets uses integer basis-point math for a fractional house fee', async () => {
process.env.MATCH_ENABLED = 'true';
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
process.env.MATCH_PAYOUTS_ENABLED = 'true';
process.env.MATCH_PAYOUT_MAX = '1000000000000';
process.env.MATCH_MAX_PLAYERS = '4';
process.env.PAYOUTS_ENABLED = 'true';
process.env.PAYMENTS_ENABLED = 'true';
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 });
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
return {
rows: params[0].map((id, index) => ({
id,
user_id: index + 1,
race_entry_lot_id: 100 + index,
escrow_amount: '1',
escrow_value_atomic: '10000000000'
})),
rowCount: params[0].length
};
}
return { rows: [{ race_entries: 1, id: 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, queueEntryId: 11 },
{ userId: 2, queueEntryId: 12 }
]);
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).toBe('20000000000');
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
delete process.env.MATCH_HOUSE_FEE_PERCENT;
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
delete process.env.MATCH_PAYOUT_MAX;
delete process.env.MATCH_MAX_PLAYERS;
delete process.env.PAYOUTS_ENABLED;
delete process.env.PAYMENTS_ENABLED;
delete process.env.MATCH_ENABLED;
});
});
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: durable-finish isolated finalize (MP-C5)', () => {
afterEach(() => { delete process.env.MATCH_ENABLED; });
test('a nondurable winner creates no payout, leaderboard row, or final announcement', 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'); };
const room = makeCryptoFinishedRoom();
m.rooms.set(room.id, room);
await m._finalize(room);
expect(order).not.toContain('payout'); // no DB winner means no reconstructable payout target
expect(order).not.toContain('leaderboard'); // no synthetic result without a durable match
expect(room._finalized).not.toBe(true); // remains retryable in memory
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false);
expect(io.broadcasts.some(b => b.event === 'match_settlement_pending')).toBe(true);
m.shutdown();
});
test('a transient finish failure retries and announces only after durability succeeds', async () => {
jest.useFakeTimers();
const io = makeIo();
const m = new MatchManager({ io, db: {}, debugManager: null });
m.enabled = true;
m._persistFinish = jest.fn()
.mockRejectedValueOnce(new Error('temporary database outage'))
.mockResolvedValueOnce();
m.matchPayoutService.payoutWinner = jest.fn().mockResolvedValue();
m.matchLeaderboard.postMatch = jest.fn().mockResolvedValue();
const room = makeCryptoFinishedRoom();
m.rooms.set(room.id, room);
await m._finalize(room);
expect(room._finalized).not.toBe(true);
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false);
await jest.advanceTimersByTimeAsync(5000);
expect(room._finalized).toBe(true);
expect(m._persistFinish).toHaveBeenCalledTimes(2);
expect(m.matchPayoutService.payoutWinner).toHaveBeenCalledTimes(1);
expect(m.matchLeaderboard.postMatch).toHaveBeenCalledTimes(1);
expect(io.broadcasts.filter(b => b.event === 'match_end')).toHaveLength(1);
m.shutdown();
jest.useRealTimers();
});
});
+318
View File
@@ -0,0 +1,318 @@
const MatchRoom = require('../src/multiplayer/MatchRoom');
const MatchEngine = require('../src/multiplayer/MatchEngine');
const MatchState = require('../src/multiplayer/MatchState');
describe('MatchRoom', () => {
function makeRoom(opts = {}) {
return new MatchRoom({
entrants: {
a: { userId: 1, name: 'A' },
b: { userId: 2, name: 'B' }
},
difficultyPreset: 'race',
seed: '0000000000000000000000000000000000000000000000000000000000000000',
...opts
});
}
describe('construction', () => {
test('generates a provably-fair seed hash', () => {
const room = makeRoom();
expect(room.seed).toHaveLength(64);
expect(room.seedHash).toHaveLength(64);
expect(room.seedHash).not.toEqual(room.seed);
});
test('places all entrants at the entrance', () => {
const room = makeRoom();
expect(room.occupants.size).toBe(2);
const [e1, e2] = Array.from(room.occupants.values());
expect(e1.x).toBe(room.dungeon.entrance[0]);
expect(e1.y).toBe(room.dungeon.entrance[1]);
});
test('creates one shared monster', () => {
const room = makeRoom();
expect(room.monster).toBeTruthy();
expect(typeof room.monster.x).toBe('number');
});
test('starts in starting status', () => {
const room = makeRoom();
expect(room.status).toBe('starting');
});
});
describe('movement', () => {
test('single player moves one tile', () => {
const room = makeRoom();
room.start();
const occ = room.occupants.get('a');
const { x, y } = occ;
room.queueMove('a', 1, 0);
const result = room.resolveTick();
expect(result.events.some(e => e.type === 'move' && e.id === 'a')).toBe(true);
expect(occ.x).toBe(x + 1);
expect(occ.y).toBe(y);
});
test('wall blocks movement', () => {
const room = makeRoom();
room.start();
const occ = room.occupants.get('a');
// Find any wall tile and an adjacent walkable tile, then move toward the wall.
let wallX = -1, wallY = -1, fromX = -1, fromY = -1;
for (let y = 0; y < room.rows && wallX < 0; y++) {
for (let x = 0; x < room.cols && wallX < 0; x++) {
if (!room.isWalkable(x, y)) {
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
for (const [dx, dy] of neighbors) {
const nx = x + dx, ny = y + dy;
if (room.isWalkable(nx, ny)) {
wallX = x; wallY = y;
fromX = nx; fromY = ny;
break;
}
}
}
}
}
expect(wallX).toBeGreaterThanOrEqual(0);
occ.moveTo(fromX, fromY);
const dx = Math.sign(wallX - fromX);
const dy = Math.sign(wallY - fromY);
room.queueMove('a', dx, dy);
const result = room.resolveTick();
expect(result.events.some(e => e.type === 'move_failed' && e.id === 'a' && e.reason === 'blocked')).toBe(true);
expect(occ.x).toBe(fromX);
expect(occ.y).toBe(fromY);
});
test('players moving into the same tile bounce', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
const b = room.occupants.get('b');
// Force a simple setup: A at (2,2), B at (2,4), both walk to (2,3).
a.moveTo(2, 2);
b.moveTo(2, 4);
room.walkable[2][3] = true;
room.walkable[2][2] = true;
room.walkable[2][4] = true;
room.queueMove('a', 0, 1);
room.queueMove('b', 0, -1);
const result = room.resolveTick();
expect(result.events.filter(e => e.type === 'move_failed' && (e.id === 'a' || e.id === 'b')).length).toBeGreaterThanOrEqual(2);
expect(a.y).toBe(2);
expect(b.y).toBe(4);
});
test('players swapping tiles bounce (no ghosting through)', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
const b = room.occupants.get('b');
a.moveTo(2, 2);
b.moveTo(2, 3);
room.walkable[2][2] = true;
room.walkable[2][3] = true;
room.queueMove('a', 0, 1);
room.queueMove('b', 0, -1);
room.resolveTick();
expect(a.y).toBe(2);
expect(b.y).toBe(3);
});
});
describe('treasure and exits', () => {
test('player picking treasure marks them as carrier', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
room.treasure = { x: a.x + 1, y: a.y, carrierId: null };
room.walkable[a.y][a.x + 1] = true;
room.queueMove('a', 1, 0);
const result = room.resolveTick();
expect(result.events.some(e => e.type === 'treasure_pickup' && e.id === 'a')).toBe(true);
expect(room.playerStates.get('a').hasTreasure).toBe(true);
});
test('reaching exit finishes the match', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
const [ex, ey] = room.dungeon.exit;
a.moveTo(ex - 1, ey);
room.walkable[ey][ex] = true;
room.walkable[ey][ex - 1] = true;
room.queueMove('a', 1, 0);
const result = room.resolveTick();
expect(result.finished).toBe(true);
expect(room.status).toBe('finished');
expect(room.winnerId).toBe('a');
expect(result.events.some(e => e.type === 'player_exit' && e.id === 'a')).toBe(true);
});
test('treasure drops at corpse when carrier dies', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
room.treasure = { x: a.x + 1, y: a.y, carrierId: null };
room.walkable[a.y][a.x + 1] = true;
room.queueMove('a', 1, 0);
room.resolveTick();
expect(room.playerStates.get('a').hasTreasure).toBe(true);
// Kill the player by moving monster onto them.
room.monster.moveTo(a.x, a.y);
const result = room.resolveTick();
expect(room.playerStates.get('a').alive).toBe(false);
expect(room.treasure.carrierId).toBeNull();
expect(room.treasure.x).toBe(a.x);
expect(room.treasure.y).toBe(a.y);
});
});
describe('monster', () => {
test('monster kills player who moves onto it', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
// Put monster adjacent and player walks onto it.
const mx = a.x + 1;
const my = a.y;
room.monster.moveTo(mx, my);
room.walkable[my][mx] = true;
room.queueMove('a', 1, 0);
const result = room.resolveTick();
expect(room.playerStates.get('a').alive).toBe(false);
expect(result.events.some(e => e.type === 'player_death')).toBe(true);
});
test('fractional monster speed accumulates across ticks and zero stays stationary', () => {
const room = makeRoom();
room.start();
room.monster.hasLineOfSight = jest.fn(() => true);
room.monster.moveTowardPlayer = jest.fn(() => true);
room.difficultyConfig.monster.movesPerPlayerMove = 0.5;
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(0);
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(1);
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
room.difficultyConfig.monster.movesPerPlayerMove = 0;
room._moveMonster();
room._moveMonster();
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
});
});
describe('expiration / rankings', () => {
test('expired match ranks living players by proximity to exit', () => {
const room = makeRoom();
room.start();
const a = room.occupants.get('a');
const b = room.occupants.get('b');
const [ex, ey] = room.dungeon.exit;
a.moveTo(ex - 1, ey); // closer
b.moveTo(ex - 5, ey); // farther
room.walkable[ey][ex - 1] = true;
room.walkable[ey][ex - 5] = true;
room.expire('hard_ceiling');
room.finalize();
expect(room.winnerId).toBe('a');
expect(room.playerStates.get('a').placement).toBe(1);
expect(room.playerStates.get('b').placement).toBe(2);
});
test('all dead without escape ends match', () => {
const room = makeRoom();
room.start();
for (const id of room.playerStates.keys()) {
room._killPlayer(id, 'monster');
}
expect(room.status).toBe('finished');
expect(room.endReason).toBe('all_dead');
});
});
describe('serialization', () => {
test('toGameState includes players, monster, treasure, exit', () => {
const room = makeRoom();
room.start();
const state = room.toGameState('a');
expect(state.players.length).toBe(2);
expect(state.players.find(p => p.id === 'a').you).toBe(true);
expect(state.monster).toBeTruthy();
expect(state.exit).toEqual(room.dungeon.exit);
});
test('MatchState serializes entrants for persistence', () => {
const room = makeRoom();
room.start();
room.queueMove('a', 1, 0);
room.resolveTick();
room.expire('hard_ceiling');
room.finalize();
const rows = MatchState.toEntrantRows(room);
expect(rows.length).toBe(2);
expect(rows[0]).toHaveProperty('match_id', room.id);
expect(rows[0]).toHaveProperty('placement');
expect(rows[0]).toHaveProperty('score');
});
});
describe('determinism', () => {
test('same seed produces identical dungeon and initial state', () => {
const r1 = makeRoom();
const r2 = makeRoom();
expect(r1.dungeon.entrance).toEqual(r2.dungeon.entrance);
expect(r1.dungeon.exit).toEqual(r2.dungeon.exit);
expect(r1.seedHash).toEqual(r2.seedHash);
expect(r1.monster.x).toBe(r2.monster.x);
expect(r1.monster.y).toBe(r2.monster.y);
});
});
});
describe('MatchEngine', () => {
test('starts the room and ticks', () => {
const room = new MatchRoom({
entrants: { a: { userId: 1, name: 'A' } },
seed: '0000000000000000000000000000000000000000000000000000000000000000'
});
const engine = new MatchEngine({ room, tickMs: 1000 });
expect(engine.start()).toBe(true);
expect(room.status).toBe('active');
engine.stop();
});
test('onFinish fires when room finishes', () => {
const room = new MatchRoom({
entrants: { a: { userId: 1, name: 'A' } },
seed: '0000000000000000000000000000000000000000000000000000000000000000'
});
const finish = jest.fn();
const engine = new MatchEngine({ room, onFinish: finish });
engine.start();
// Walk to exit.
const a = room.occupants.get('a');
const [ex, ey] = room.dungeon.exit;
a.moveTo(ex - 1, ey);
room.walkable[ey][ex] = true;
room.walkable[ey][ex - 1] = true;
room.queueMove('a', 1, 0);
engine.tick();
expect(finish).toHaveBeenCalledWith(room);
engine.stop();
});
});
+178
View File
@@ -0,0 +1,178 @@
const MatchScheduler = require('../src/network/matchScheduler');
const { deriveBlockMatchSeed } = MatchScheduler;
const MatchQueue = require('../src/network/matchQueue');
describe('MatchScheduler', () => {
let scheduler = null;
let manager = null;
afterEach(() => {
// Stop the scheduler tail and every handed-off engine so Jest cannot leak handles.
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;
return {
entries,
async join(e) {
e.queueEntryId = nextId++;
this.entries.push(e);
return { success: true };
},
async drain(economy, max) {
const filtered = this.entries.filter(e => e.economy === economy);
if (filtered.length < 2) return null;
const count = Math.min(max, filtered.length);
const drained = filtered.slice(0, count);
this.entries = this.entries.filter(e => !drained.includes(e));
return { entries: drained };
},
length(economy) { return this.entries.length; }
};
}
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) { engines.set(id, engine); },
expireBlockDeadlines: jest.fn(),
onTick() {},
onFinish() {},
expire() {}
};
}
test('does nothing when disabled', async () => {
process.env.MATCH_ENABLED = 'false';
const queue = makeQueue();
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
await scheduler.onBlock(100);
expect(manager.attached.length).toBe(0);
});
test('does nothing when fewer than 2 players are queued', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
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);
});
test('drains free queue into a match when 2+ players queued', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
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);
expect(manager.attached.length).toBe(1);
const { room, entrants } = manager.attached[0];
expect(room.economy).toBe('free');
expect(entrants.length).toBe(2);
expect(queue.length('free')).toBe(0);
});
test('hands off the engine without starting it (real countdown, no head-start)', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
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];
// 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');
});
test('operator-selected last-alive ruleset creates a real PvP match', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, rulesetId: 'last-alive' });
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];
expect(room.ruleset.id).toBe('last-alive');
expect(room.pvpCombat).toBe(true);
expect(room.variant).toBe('pvp');
expect(room.snapshot().ruleset).toEqual(expect.objectContaining({
id: 'last-alive',
winCondition: 'last-alive'
}));
});
test('block-derived match seeds are reproducible and lock the durable entrant set', () => {
const input = {
blockHash: 'ab'.repeat(32),
blockHeight: 123,
economy: 'crypto_race',
rulesetId: 'race',
queueEntryIds: [12, 3]
};
const first = deriveBlockMatchSeed(input);
const reordered = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 12] });
const changedEntrant = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 13] });
expect(first).toEqual(reordered);
expect(first.seed).toMatch(/^[0-9a-f]{64}$/);
expect(first.derivation.queueEntryIds).toEqual(['3', '12']);
expect(changedEntrant.seed).not.toBe(first.seed);
});
test('production paid queues do not drain without a canonical block hash', async () => {
const oldNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
const drain = jest.spyOn(queue, 'drain');
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
try {
await scheduler._drainEconomy('credits_prestige', 100, null);
expect(drain).not.toHaveBeenCalled();
expect(manager.attached).toHaveLength(0);
} finally {
if (oldNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = oldNodeEnv;
}
});
test('each advancing block is offered once to active-room deadline processing', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
await scheduler.onBlock(100);
await scheduler.onBlock(100);
await scheduler.onBlock(101);
// getblockcount is normalized to its zero-based canonical header exactly once.
expect(manager.expireBlockDeadlines.mock.calls).toEqual([[99], [100]]);
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Money / atomic-unit arithmetic tests (Phase 1.1).
*
* These specifically exercise the large-amount cases where the old float path
* (`base * multiplier`, `sum + Number(x)`) silently loses precision above 2^53.
*/
const money = require('../src/money/atomic');
describe('money.toBig / toSafe', () => {
test('parses string, number and bigint', () => {
expect(money.toBig('100000000000')).toBe(100000000000n);
expect(money.toBig(5)).toBe(5n);
expect(money.toBig(7n)).toBe(7n);
});
test('rejects non-integer numbers and junk strings', () => {
expect(() => money.toBig(1.5)).toThrow();
expect(() => money.toBig('1.5')).toThrow();
expect(() => money.toBig('abc')).toThrow();
expect(() => money.toBig(Infinity)).toThrow();
});
test('toSafe narrows to number when exact, keeps string when too large', () => {
expect(money.toSafe(200000000000n)).toBe(200000000000);
// 10^18 atomic > MAX_SAFE_INTEGER (~9.007e15) -> must stay a string, not a lossy number
expect(money.toSafe(1000000000000000000n)).toBe('1000000000000000000');
});
});
describe('money.mulByDecimal (exact, no float)', () => {
test('whole multipliers', () => {
expect(money.mulByDecimal(100000000000n, 2)).toBe(200000000000n);
expect(money.mulByDecimal(100000000000n, 3)).toBe(300000000000n);
});
test('fractional multipliers with half-up rounding', () => {
expect(money.mulByDecimal(100000000000n, 1.5)).toBe(150000000000n);
expect(money.mulByDecimal(50000000000n, 1.5)).toBe(75000000000n);
expect(money.mulByDecimal(3n, 1.5)).toBe(5n); // 4.5 -> 5 (half up)
expect(money.mulByDecimal(1n, 2.5)).toBe(3n); // 2.5 -> 3 (half up)
expect(money.mulByDecimal(0n, 3)).toBe(0n);
});
test('large amounts stay exact where float would drift', () => {
// 9,007,199,254,740,993 is 2^53 + 1: not representable as a float64.
const big = 9007199254740993n;
expect(money.mulByDecimal(big, 3)).toBe(27021597764222979n);
// Sanity: the naive float computation is wrong, proving why BigInt matters.
expect(Number(big) * 3).not.toBe(27021597764222979);
});
test('rejects invalid multipliers', () => {
expect(() => money.mulByDecimal(1n, -1)).toThrow();
expect(() => money.mulByDecimal(1n, 'x')).toThrow();
});
});
describe('money.sum / add (exact)', () => {
test('sums large atomic amounts without precision loss', () => {
const vals = ['9007199254740991', '9007199254740991', '9007199254740991'];
expect(money.sum(vals)).toBe(27021597764222973n); // exact
// The naive float reduce drifts away from the exact integer total.
const floatSum = vals.reduce((a, b) => a + Number(b), 0);
expect(BigInt(floatSum)).not.toBe(money.sum(vals));
});
test('add coerces mixed types', () => {
expect(money.add('100', 50)).toBe(150n);
expect(money.add(7n, '3')).toBe(10n);
});
});
describe('money.format', () => {
test('formats with currency decimals and trims trailing zeros', () => {
expect(money.format(150000000000n, 11)).toBe('1.5');
expect(money.format(100000000000n, 11)).toBe('1');
expect(money.format(1n, 11)).toBe('0.00000000001');
expect(money.format(0n, 11)).toBe('0');
});
});
+41
View File
@@ -0,0 +1,41 @@
const MovementManager = require('../src/game/movementManager');
function makeChaseGame() {
// Minimal game stub with player + monster and deterministic chase
return {
player: { x: 1, y: 1, getState() { return { x: this.x, y: this.y }; } },
monster: { x: 5, y: 1, getState() { return { x: this.x, y: this.y }; } },
movePlayer(dx, dy) {
this.player.x += dx; this.player.y += dy; return { status: 'moved' };
},
moveMonster() { // simple horizontal chase
if (this.monster.x > this.player.x) this.monster.x--; else if (this.monster.x < this.player.x) this.monster.x++;
if (this.monster.y > this.player.y) this.monster.y--; else if (this.monster.y < this.player.y) this.monster.y++;
},
getState() { return { player: { x: this.player.x, y: this.player.y }, monster: { x: this.monster.x, y: this.monster.y } }; }
};
}
describe('Monster chase integration (MovementManager hook)', () => {
test('monster advances toward player each move', () => {
const activeGames = new Map();
const socketId = 'sock-monster';
const game = makeChaseGame();
activeGames.set(socketId, game);
const emitted = [];
const io = { to: () => ({ emit: (evt, data) => emitted.push({ evt, data }) }) };
const debugManager = { getCurrentBlockHeight: () => 999 };
// Provide postMoveHook similar to server wiring
const mm = new MovementManager({ activeGames, io, debugManager, moveCooldown: 0, postMoveHook: ({ game }) => game.moveMonster() });
// Player moves right 3 times; monster should close distance
const startDistance = game.monster.x - game.player.x;
for (let i = 0; i < 3; i++) {
mm.handleMove(socketId, { direction: 'right' });
}
const endDistance = game.monster.x - game.player.x;
expect(endDistance).toBeLessThan(startDistance); // monster got closer
// Ensure we emitted 3 updates
expect(emitted.filter(e => e.evt === 'game_update').length).toBe(3);
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Monster collision tests (Phase 3.3).
*
* With movesPerPlayerMove > 1 (e.g. the casino preset's 1.5x speed) the monster takes
* multiple steps per turn. Collision must be checked after EACH sub-step, or the monster
* can step onto the player's tile and off again between checks, phasing through the player
* and missing the catch (a player-favorable bug that lowers the house edge).
*/
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
function openGame() {
// Default-size dungeon so the digger generates validly; we then override the map and
// positions with a known fully-open grid so the scenario is deterministic.
const game = new Game('sock-test', { id: 1 });
const FL = "'1";
game.dungeon.map = Array.from({ length: 7 }, () => Array(7).fill(FL));
game.gameConfig.primaryFloor = FL;
game.gameConfig.secondaryFloor = "'2";
game.gameState = 'active';
game.seededRNG = () => 0; // 0 < chaseAggressiveness => always chase
return game;
}
describe('Monster collision with multi-step movement', () => {
test('two-room fallback spawns the monster deterministically on a distant passable tile', () => {
const FL = "'1";
const map = Array.from({ length: 7 }, (_, y) => Array.from({ length: 7 }, (_, x) =>
(x === 0 || y === 0 || x === 6 || y === 6) ? '#' : FL));
const dungeon = {
map,
rooms: [],
entrance: [1, 1],
exit: [5, 5],
treasure: [4, 4],
torches: []
};
const generatedFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
const generate = jest.spyOn(DungeonGenerator, 'generate').mockReturnValue(dungeon);
const previousLevels = process.env.DUNGEON_LEVELS;
process.env.DUNGEON_LEVELS = '1';
try {
const game = new Game('fallback-spawn', { id: 1 }, { width: 7, height: 7, cryptoType: 'XMR' });
expect(game.monster.getState()).toMatchObject({ x: 5, y: 4 });
expect(game.dungeon.map[game.monster.y][game.monster.x]).toBe(FL);
expect(game.monster.isAt(game.player.x, game.player.y)).toBe(false);
expect(game.monster.isAt(game.dungeon.exit[0], game.dungeon.exit[1])).toBe(false);
expect(game.monster.isAt(game.dungeon.treasure[0], game.dungeon.treasure[1])).toBe(false);
expect(DungeonGenerator.layoutFingerprint(game.dungeon)).toBe(generatedFingerprint);
} finally {
generate.mockRestore();
if (previousLevels === undefined) delete process.env.DUNGEON_LEVELS;
else process.env.DUNGEON_LEVELS = previousLevels;
}
});
test('2-step monster adjacent to the player catches it (no phasing through)', () => {
const game = openGame();
game.difficultyConfig.monster.movesPerPlayerMove = 2;
game.difficultyConfig.monster.chaseAggressiveness = 1.0;
game.monsterMoveAccumulator = 0;
game.player.moveTo(3, 3);
game.monster.moveTo(3, 4); // directly adjacent
game.monster.lastKnownPlayerX = null;
game.monster.lastKnownPlayerY = null;
const res = game.moveMonster();
expect(game.gameState).toBe('lost');
expect(res.event).toBe('monster_caught');
});
test('single-step monster moving onto the player still catches (sanity)', () => {
const game = openGame();
game.difficultyConfig.monster.movesPerPlayerMove = 1;
game.difficultyConfig.monster.chaseAggressiveness = 1.0;
game.monsterMoveAccumulator = 0;
game.player.moveTo(3, 3);
game.monster.moveTo(3, 4);
game.moveMonster();
expect(game.gameState).toBe('lost');
});
});
+33
View File
@@ -0,0 +1,33 @@
const MovementManager = require('../src/game/movementManager');
// Minimal fake game implementing movePlayer & getState
function makeGame() {
return {
player: { x: 5, y: 5, getState() { return { x: this.x, y: this.y }; } },
movePlayer(dx, dy) {
this.player.x += dx;
this.player.y += dy;
return { status: 'moved' };
},
getState() { return { player: { x: this.player.x, y: this.player.y } }; }
};
}
describe('MovementManager', () => {
test('applies movement and rate limits subsequent rapid moves', () => {
const activeGames = new Map();
const socketId = 'sock1';
const game = makeGame();
activeGames.set(socketId, game);
const emitted = [];
const io = { to: () => ({ emit: (evt, data) => emitted.push({ evt, data }) }) };
const debugManager = { getCurrentBlockHeight: () => 123 };
const mm = new MovementManager({ activeGames, io, debugManager, moveCooldown: 100 });
mm.handleMove(socketId, { direction: 'right' });
mm.handleMove(socketId, { direction: 'right' }); // should be rate-limited
expect(game.player.x).toBe(6); // only first move applied
expect(emitted.filter(e => e.evt === 'game_update').length).toBe(1);
});
});
+86
View File
@@ -0,0 +1,86 @@
// Multi-level descent: a run spans `maxDepth` levels, each a fresh deterministic dungeon, treasure
// only in the vault (final level), and only the final exit escapes. Verifies the mechanic + that
// every level stays reproducible from the one committed seed (provably fair across the descent).
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
const { levelSeed } = require('../src/game/provablyFair');
const SAVE = {};
const KEYS = ['DUNGEON_LEVELS', 'CRYPTO_TYPE', 'DIFFICULTY_PRESET', 'NETWORK_TUNING_DISABLED', 'PAYMENTS_ENABLED', 'GAME_MODE'];
beforeEach(() => { KEYS.forEach(k => { SAVE[k] = process.env[k]; delete process.env[k]; }); process.env.DIFFICULTY_PRESET = 'normal'; });
afterEach(() => { KEYS.forEach(k => { if (SAVE[k] === undefined) delete process.env[k]; else process.env[k] = SAVE[k]; }); });
const user = () => ({ id: 0, username: 'test', endGame() {} });
describe('multi-level descent', () => {
test('descends through levels; treasure only in the vault; stays active until the final exit', () => {
process.env.DUNGEON_LEVELS = '3';
const game = new Game('sock', user(), {});
expect(game.maxDepth).toBe(3);
expect(game.depth).toBe(1);
expect(game.gameState).toBe('active');
expect(game.dungeon.treasure).toBeNull(); // level 1/3: no treasure
game._descend();
expect(game.depth).toBe(2);
expect(game.gameState).toBe('active'); // descending is NOT winning
expect(game.dungeon.treasure).toBeNull(); // level 2/3: still no treasure
expect(game.dungeon.entrance).toBeTruthy();
expect(game.dungeon.exit).toBeTruthy();
game._descend();
expect(game.depth).toBe(3);
expect(game.dungeon.treasure).not.toBeNull(); // the vault: treasure present
});
test('every level is reproducible from the committed seed + depth (provably fair)', () => {
process.env.DUNGEON_LEVELS = '3';
process.env.CRYPTO_TYPE = 'WOW';
const game = new Game('sock', user(), {});
const seed = game.gameProof.seed;
// Level 1 == regenerate from the master seed (unchanged single-level behaviour).
const regen1 = DungeonGenerator.regenerateFromSeed(levelSeed(seed, 1), 'WOW');
expect(game.dungeon.map).toEqual(regen1.map);
game._descend(); // level 2
const regen2 = DungeonGenerator.regenerateFromSeed(levelSeed(seed, 2), 'WOW');
expect(game.dungeon.map).toEqual(regen2.map);
// Deeper levels are salted → a different layout from level 1.
expect(regen2.map).not.toEqual(regen1.map);
});
test('single-level default: an untuned network runs one level and its exit escapes', () => {
process.env.CRYPTO_TYPE = 'TESTNET'; // unknown → no level tuning → maxDepth 1
const game = new Game('sock', user(), {});
expect(game.maxDepth).toBe(1);
expect(game.dungeon.treasure).not.toBeNull(); // single level IS the vault
// Walk onto the exit → escaped (won), not descend.
const [ex, ey] = game.dungeon.exit;
game.player.moveTo(ex - 1, ey); // stand next to the exit on a floor cell if possible
// Force the player adjacent by teleporting onto an exit-adjacent floor, then step in. If the
// left neighbour isn't floor, just teleport onto the exit-1 and assert via a direct exit check.
game.player.moveTo(ex, ey); // already on exit: re-derive win via a no-op-ish move
// A fresh move INTO the exit from a neighbour: place on a known floor neighbour of the exit.
const map = game.dungeon.map;
const nbrs = [[ex - 1, ey], [ex + 1, ey], [ex, ey - 1], [ex, ey + 1]];
const floor = nbrs.find(([x, y]) => map[y] && (map[y][x] === "'1" || map[y][x] === "'2"));
if (floor) {
game.player.moveTo(floor[0], floor[1]);
const res = game.movePlayer(ex - floor[0], ey - floor[1]);
expect(res.event).toBe('escaped');
expect(game.gameState).toBe('won');
} else {
// Degenerate layout (exit fully walled): the descent path is still covered by other tests.
expect(game.maxDepth).toBe(1);
}
});
test('DUNGEON_LEVELS env override sets the run length', () => {
process.env.DUNGEON_LEVELS = '5';
const game = new Game('sock', user(), {});
expect(game.maxDepth).toBe(5);
});
});
+60
View File
@@ -0,0 +1,60 @@
// Guards the per-network difficulty tuning: cryptoType must set LEVELS (multi-level depth ∝ block
// time), size + monster must stay at the fair preset, block-time ordering must hold, and operator
// env overrides must still win.
const {
DIFFICULTY_PRESETS, NETWORK_TUNING, applyNetworkTuning, getDifficultyConfig
} = require('../src/game/difficultyConfig');
const casino = () => ({ ...DIFFICULTY_PRESETS.casino, presetName: 'casino' });
const normal = () => ({ ...DIFFICULTY_PRESETS.normal, presetName: 'normal' });
// Isolate env mutations per test.
const SAVE = {};
const KEYS = ['NETWORK_TUNING_DISABLED', 'DUNGEON_WIDTH', 'DUNGEON_LEVELS', 'MONSTER_SPEED', 'DIFFICULTY_PRESET', 'CRYPTO_TYPE', 'PAYMENTS_ENABLED', 'GAME_MODE'];
beforeEach(() => { KEYS.forEach(k => { SAVE[k] = process.env[k]; delete process.env[k]; }); });
afterEach(() => { KEYS.forEach(k => { if (SAVE[k] === undefined) delete process.env[k]; else process.env[k] = SAVE[k]; }); });
describe('per-network difficulty tuning (multi-level)', () => {
test('tuning sets LEVELS ∝ block time, leaving size + monster at the preset', () => {
const w = applyNetworkTuning(casino(), 'WOW');
expect(w.levels).toBe(NETWORK_TUNING.WOW.levels);
// Size and monster are untouched: depth is the pacing lever, not size/monster-speed.
expect(w.dungeon.width).toBe(DIFFICULTY_PRESETS.casino.dungeon.width);
expect(w.monster.movesPerPlayerMove).toBe(DIFFICULTY_PRESETS.casino.monster.movesPerPlayerMove);
});
test('levels are non-decreasing with block time, BTC the deepest', () => {
const lv = (n) => applyNetworkTuning(casino(), n).levels;
expect(lv('GRIN')).toBeLessThanOrEqual(lv('XMR'));
expect(lv('XMR')).toBeLessThanOrEqual(lv('LTC'));
expect(lv('LTC')).toBeLessThanOrEqual(lv('WOW'));
expect(lv('WOW')).toBeLessThan(lv('BTC'));
expect(lv('GRIN')).toBe(1); // fast chain = single level
expect(lv('BTC')).toBeGreaterThan(1); // slow chain descends
});
test('applies to every preset (levels is preset-agnostic pacing)', () => {
expect(applyNetworkTuning(normal(), 'WOW').levels).toBe(NETWORK_TUNING.WOW.levels);
});
test('getDifficultyConfig surfaces levels; defaults to 1 for an unknown/untuned network', () => {
process.env.DIFFICULTY_PRESET = 'casino';
expect(getDifficultyConfig('BTC').levels).toBe(NETWORK_TUNING.BTC.levels);
expect(getDifficultyConfig('DOGE').levels).toBe(1);
});
test('operator env overrides win: DUNGEON_LEVELS + DUNGEON_WIDTH beat the tuning', () => {
process.env.DIFFICULTY_PRESET = 'casino';
process.env.DUNGEON_LEVELS = '3';
process.env.DUNGEON_WIDTH = '99';
const cfg = getDifficultyConfig('BTC');
expect(cfg.levels).toBe(3); // env beats BTC's 8
expect(cfg.dungeon.width).toBe(99);
});
test('NETWORK_TUNING_DISABLED=true → no levels tuning (single level)', () => {
process.env.NETWORK_TUNING_DISABLED = 'true';
process.env.DIFFICULTY_PRESET = 'casino';
expect(getDifficultyConfig('BTC').levels).toBe(1);
});
});
+20
View File
@@ -0,0 +1,20 @@
const noStore = require('../src/middleware/noStore');
describe('authenticated API cache policy', () => {
test('sets explicit no-store headers before continuing', () => {
const headers = {};
const response = {
setHeader: jest.fn((name, value) => { headers[name] = value; })
};
const next = jest.fn();
noStore({}, response, next);
expect(headers).toEqual({
'Cache-Control': 'no-store, max-age=0',
Pragma: 'no-cache',
Expires: '0'
});
expect(next).toHaveBeenCalledTimes(1);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Admission is refused while no blockchain node is reachable.
*
* A run's lifetime is measured in blocks and its entry block comes from the last SUCCESSFUL poll.
* With every node down that value goes stale silently, so a game admitted during an outage was
* anchored to an already-past block and killed as a timeout on the first recovered poll: the
* player lost a run, and in paid mode an entry, to our infrastructure. Refuse up front instead.
*/
const SocketHandlers = require('../src/network/socketHandlers');
function handlers({ chainHealthy, shuttingDown = false }) {
const h = Object.create(SocketHandlers.prototype);
h._isShuttingDown = shuttingDown;
h._admissionsInFlight = new Set();
h.debugManager = { CONSOLE_LOGGING: false, isChainHealthy: () => chainHealthy };
h.broadcastManager = { sendStatusUpdate: jest.fn() };
return h;
}
function socket() {
const emitted = [];
return { id: 's1', emitted, emit: (event, payload) => emitted.push({ event, payload }) };
}
describe('admission while no node is reachable', () => {
test('refuses the admission and never runs the task', async () => {
const h = handlers({ chainHealthy: false });
const s = socket();
const task = jest.fn();
await h._runAdmission(s, 'auto_start', task);
expect(task).not.toHaveBeenCalled();
const refusal = s.emitted.find(e => e.event === 'chain_unavailable');
expect(refusal).toBeDefined();
expect(refusal.payload).toMatchObject({ code: 'NODE_UNREACHABLE', kind: 'auto_start' });
// The player must be told nothing was taken from them.
expect(refusal.payload.message).toMatch(/nothing was charged/i);
expect(h.broadcastManager.sendStatusUpdate).toHaveBeenCalled();
});
test('runs the admission normally when a node is reachable', async () => {
const h = handlers({ chainHealthy: true });
const s = socket();
const task = jest.fn().mockResolvedValue('started');
await expect(h._runAdmission(s, 'join_queue', task)).resolves.toBe('started');
expect(task).toHaveBeenCalledTimes(1);
expect(s.emitted).toHaveLength(0);
});
test('gates every admission kind, including paid intake', async () => {
for (const kind of ['auto_start', 'play_free', 'join_queue', 'early_entry',
'request_payment', 'match_queue']) {
const h = handlers({ chainHealthy: false });
const s = socket();
const task = jest.fn();
await h._runAdmission(s, kind, task);
expect(task).not.toHaveBeenCalled();
expect(s.emitted.some(e => e.event === 'chain_unavailable')).toBe(true);
}
});
test('shutdown still takes precedence over the node check', async () => {
const h = handlers({ chainHealthy: false, shuttingDown: true });
h._emitShutdownAdmissionRefusal = jest.fn();
const s = socket();
await h._runAdmission(s, 'auto_start', jest.fn());
expect(h._emitShutdownAdmissionRefusal).toHaveBeenCalled();
expect(s.emitted.some(e => e.event === 'chain_unavailable')).toBe(false);
});
test('paid intake is refused when the WALLET is unreachable, but credits/free are not', async () => {
// Not a failover: two wallet-rpc processes over one wallet file can spend the same outputs.
// The safe response to a wallet outage is a clean refusal of intake only.
const h = handlers({ chainHealthy: true });
h.walletService = { isHealthy: false };
const paid = socket();
await h._runAdmission(paid, 'request_payment', jest.fn());
const refusal = paid.emitted.find(e => e.event === 'payment_unavailable');
expect(refusal).toBeDefined();
expect(refusal.payload).toMatchObject({ code: 'WALLET_UNREACHABLE' });
for (const kind of ['auto_start', 'play_free', 'join_queue']) {
const s = socket();
const task = jest.fn().mockResolvedValue('ok');
await expect(h._runAdmission(s, kind, task)).resolves.toBe('ok');
expect(task).toHaveBeenCalled();
}
});
test('a runtime without the health probe is left alone (fires only on an explicit false)', async () => {
const h = handlers({ chainHealthy: true });
h.debugManager = { CONSOLE_LOGGING: false }; // no isChainHealthy at all
const s = socket();
const task = jest.fn().mockResolvedValue('ok');
await expect(h._runAdmission(s, 'auto_start', task)).resolves.toBe('ok');
expect(task).toHaveBeenCalled();
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Pillar 5: global cross-server chat over nostr, layered on the ChatProvider seam.
*/
const NostrChatProvider = require('../src/network/chat/NostrChatProvider');
const { buildChatProvider } = require('../src/network/chat');
const { createBridgeSigner } = require('../src/network/chat/nostr/bridgeSigner');
const { loadNostrTools } = require('../src/utils/nostrLoader');
function fakeLocal() {
return {
initialize: jest.fn().mockResolvedValue(),
publish: jest.fn().mockResolvedValue(),
getHistory: jest.fn().mockResolvedValue(['history']),
shutdown: jest.fn().mockResolvedValue()
};
}
function fakeTransport() {
return {
publish: jest.fn().mockResolvedValue(),
subscribe: jest.fn(function (filters, onEvent) { this.filters = filters; this.onEvent = onEvent; }),
close: jest.fn()
};
}
let _n = 0;
function fakeSigner(pubkey = 'ab'.repeat(32)) {
return { pubkey, sign: (tpl) => ({ ...tpl, id: `evt-${++_n}`, pubkey, sig: 'sig' }) };
}
describe('NostrChatProvider decorates the local provider', () => {
test('publish always delivers locally; getHistory/shutdown delegate', async () => {
const local = fakeLocal();
const p = new NostrChatProvider({ local }); // no transport/signer
await p.publish({ scope: 'global', username: 'bob', text: 'hi' });
expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({ text: 'hi' }));
expect(await p.getHistory({ scope: 'global' })).toEqual(['history']);
await p.shutdown();
expect(local.shutdown).toHaveBeenCalled();
});
test('global message is signed and fanned out to the relay', async () => {
const local = fakeLocal(), transport = fakeTransport(), signer = fakeSigner();
const p = new NostrChatProvider({ local, transport, signer, channelTag: 'wowngeon-global', kind: 1 });
await p.publish({ scope: 'global', username: 'bob', text: 'gm', ts: 1_700_000_000_000 });
expect(local.publish).toHaveBeenCalled();
expect(transport.publish).toHaveBeenCalledTimes(1);
const ev = transport.publish.mock.calls[0][0];
expect(ev.kind).toBe(1);
expect(ev.content).toBe('gm');
expect(ev.tags).toEqual(expect.arrayContaining([['t', 'wowngeon-global'], ['n', 'bob']]));
expect(ev.created_at).toBe(1_700_000_000); // ms -> s
});
test('room-scoped and remote messages are NOT fanned out', async () => {
const local = fakeLocal(), transport = fakeTransport(), signer = fakeSigner();
const p = new NostrChatProvider({ local, transport, signer });
await p.publish({ scope: 'tavern:1', username: 'x', text: 'local only' });
await p.publish({ scope: 'global', username: 'x', text: 'echo', remote: true });
expect(local.publish).toHaveBeenCalledTimes(2);
expect(transport.publish).not.toHaveBeenCalled();
});
});
describe('NostrChatProvider receives remote messages', () => {
function connected() {
const local = fakeLocal(), transport = fakeTransport(), signer = fakeSigner();
const p = new NostrChatProvider({ local, transport, signer, now: () => 1_700_000_000_000 });
return { local, transport, signer, p };
}
test('a remote event is delivered locally-only, escaped, never re-published', async () => {
const { local, transport, p } = connected();
await p.initialize();
expect(transport.subscribe).toHaveBeenCalled();
p.onEvent = transport.onEvent; // convenience
transport.onEvent({ id: 'r1', pubkey: 'cc'.repeat(32), created_at: 1_700_000_000, content: '<b>hi</b>', tags: [['t', 'wowngeon-global'], ['n', 'alice']] });
expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({
scope: 'global', username: 'alice', text: '&lt;b&gt;hi&lt;/b&gt;', remote: true
}));
expect(transport.publish).not.toHaveBeenCalled(); // no echo loop
});
test('our own echoes and duplicate ids are dropped', async () => {
const { local, transport, signer, p } = connected();
await p.initialize();
transport.onEvent({ id: 'mine', pubkey: signer.pubkey, content: 'x', tags: [] }); // our pubkey -> drop
transport.onEvent({ id: 'dup', pubkey: 'cc'.repeat(32), content: 'first', tags: [] });
transport.onEvent({ id: 'dup', pubkey: 'cc'.repeat(32), content: 'again', tags: [] }); // same id -> drop
expect(local.publish).toHaveBeenCalledTimes(1);
});
test('remote delivery is rate-limited', async () => {
const local = fakeLocal(), transport = fakeTransport();
const p = new NostrChatProvider({ local, transport, signer: fakeSigner(), maxRemotePerMin: 2, now: () => 1000 });
await p.initialize();
for (let i = 0; i < 5; i++) transport.onEvent({ id: `x${i}`, pubkey: 'dd'.repeat(32), content: 'spam', tags: [] });
expect(local.publish).toHaveBeenCalledTimes(2); // capped
});
});
describe('buildChatProvider (opt-in seam)', () => {
const local = fakeLocal();
test('disabled -> returns the local provider unchanged (behavior-preserving)', () => {
expect(buildChatProvider({ local, env: {} })).toBe(local);
});
test('scope=local -> returns the plain local provider', () => {
expect(buildChatProvider({ local, env: { NOSTR_CHAT_ENABLED: 'true', NOSTR_CHAT_SCOPE: 'local' } })).toBe(local);
});
test('scope=global -> wraps in a NostrChatProvider', () => {
const p = buildChatProvider({ local, env: { NOSTR_CHAT_ENABLED: 'true', NOSTR_RELAYS: 'wss://relay.smirk.cash' } });
expect(p).toBeInstanceOf(NostrChatProvider);
});
});
describe('bridge signer (real nostr-tools round-trip)', () => {
test('null key -> no signer; a real key signs a verifiable event', () => {
expect(createBridgeSigner(null)).toBeNull();
const tools = loadNostrTools();
const sk = tools.generateSecretKey();
const hex = Buffer.from(sk).toString('hex');
const signer = createBridgeSigner(hex);
expect(signer.pubkey).toBe(tools.getPublicKey(sk));
const ev = signer.sign({ kind: 1, tags: [['t', 'wowngeon-global']], content: 'hello' });
expect(tools.verifyEvent(ev)).toBe(true);
expect(ev.content).toBe('hello');
});
});
+118
View File
@@ -0,0 +1,118 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const appRequire = require('./helpers/appRequire');
const dotenv = appRequire('dotenv');
const ROOT = path.join(__dirname, '..');
function parseExample(name) {
const contents = fs.readFileSync(path.join(ROOT, 'src', name), 'utf8');
return Object.fromEntries(contents.split(/\r?\n/).flatMap(line => {
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
return match ? [[match[1], match[2]]] : [];
}));
}
function preflightExample(name) {
const contents = fs.readFileSync(path.join(ROOT, 'src', name), 'utf8');
const env = {
...dotenv.parse(contents),
DB_PASSWORD: '6f4d9b8270e1c35aa96d2408b75f13ce59a0472d7e86c914',
ADMIN_API_KEY: 'b12f078ce49d6a3510ef7b29d8435ca6982e04f17bd6593c',
WALLET_RPC_PASSWORD: '8ce2b970a64d1f35e0897c42db6513fa724eb960d18c53a7'
};
return spawnSync(process.execPath, [path.join(ROOT, 'src', 'scripts', 'preflight.js')], {
cwd: ROOT,
env,
encoding: 'utf8',
timeout: 10000
});
}
describe('operated product environment templates', () => {
test('play.wowne.ro is exactly credits-only Wownero prestige with every payout path off', () => {
const env = parseExample('.env.mainnet.example');
expect(env).toEqual(expect.objectContaining({
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
HOSTED_BY: 'https://play.wowne.ro',
OPERATOR_NAME: 'Such Software',
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
OPERATOR_CONTACT_LABEL: 'apps@such.software',
LEGAL_POLICY_VERSION: '2026-07-22-v1',
TERMS_EFFECTIVE_DATE: '2026-07-22',
MINIMUM_AGE: '18',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
CRYPTO_TYPE: 'WOW',
MONERO_NETWORK: 'mainnet',
FREE_PLAY_ENABLED: 'true',
GAME_MODE: 'PAID_CREDITS',
PAYMENT_MODES: 'credits',
DIRECT_PAYMENT_ENABLED: 'false',
CREDITS_ENABLED: 'true',
PAYOUTS_ENABLED: 'false',
DIRECT_PAYOUTS_ENABLED: 'false',
CREDITS_PAYOUTS_ENABLED: 'false',
ALLOW_MAINNET_PAYOUTS: 'false',
SOLO_ENABLED: 'true',
TAVERN_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'false',
MATCH_PAYOUTS_ENABLED: 'false'
}));
});
test('monerogue.app is exactly direct-only stagenet 2x/3x solo testing', () => {
const env = parseExample('.env.stagenet.example');
expect(env).toEqual(expect.objectContaining({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet',
HOSTED_BY: 'https://monerogue.app',
OPERATOR_NAME: 'Such Software',
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
OPERATOR_CONTACT_LABEL: 'apps@such.software',
LEGAL_POLICY_VERSION: '2026-07-23-v2',
TERMS_EFFECTIVE_DATE: '2026-07-23',
MINIMUM_AGE: '18',
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
CRYPTO_TYPE: 'XMR',
MONERO_NETWORK: 'stagenet',
FREE_PLAY_ENABLED: 'true',
GAME_MODE: 'PAID_SINGLE',
PAYMENT_MODES: 'direct',
DIRECT_PAYMENT_ENABLED: 'true',
CREDITS_ENABLED: 'false',
PAYOUTS_ENABLED: 'true',
DIRECT_PAYOUTS_ENABLED: 'true',
CREDITS_PAYOUTS_ENABLED: 'false',
DIRECT_PAYOUT_ESCAPE: '2.0',
DIRECT_PAYOUT_TREASURE: '3.0',
ALLOW_MAINNET_PAYOUTS: 'false',
SOLO_ENABLED: 'true',
TAVERN_ENABLED: 'true',
MATCH_ENABLED: 'true',
MATCH_CRYPTO_RACE_ENABLED: 'false',
MATCH_PAYOUTS_ENABLED: 'false'
}));
expect(BigInt(env.PAYOUT_MAX_PER_GAME)).toBeGreaterThanOrEqual(
3n * BigInt(env.DIRECT_GAME_PRICE)
);
expect(env.COSMETIC_PRODUCTS).toBeUndefined();
});
test('the generic MIT self-hosting template does not opt into a Such Software profile', () => {
const env = parseExample('.env.example');
expect(env.OPERATED_PRODUCT_PROFILE).toBeUndefined();
});
test.each([
['.env.mainnet.example', 'paid modes: credits'],
['.env.stagenet.example', 'paid modes: direct']
])('%s passes the real production preflight after only secret substitution', (name, summary) => {
const result = preflightExample(name);
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
expect(result.stdout).toContain('Production configuration preflight passed');
expect(result.stdout).toContain(summary);
});
});
+35
View File
@@ -0,0 +1,35 @@
const {
selectPublicPaidModeDescriptors
} = require('../src/config/operatedProductProfiles');
const descriptors = Object.freeze({
PAID_SINGLE: Object.freeze({ enabled: true, name: 'Paid Single Game' }),
PAID_CREDITS: Object.freeze({ enabled: true, name: 'Credits Package' })
});
describe('operated-product public paid-mode contract', () => {
test('play.wowne.ro publishes credits and omits direct entry', () => {
const result = selectPublicPaidModeDescriptors({
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige'
}, descriptors);
expect(result).toEqual({ PAID_CREDITS: descriptors.PAID_CREDITS });
expect(result).not.toHaveProperty('PAID_SINGLE');
});
test('monerogue.app publishes direct entry and omits purchased credits', () => {
const result = selectPublicPaidModeDescriptors({
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
}, descriptors);
expect(result).toEqual({ PAID_SINGLE: descriptors.PAID_SINGLE });
expect(result).not.toHaveProperty('PAID_CREDITS');
});
test('generic MIT deployments retain both legacy API descriptors', () => {
const result = selectPublicPaidModeDescriptors({}, descriptors);
expect(result).toEqual(descriptors);
expect(result).not.toBe(descriptors);
});
});
+195
View File
@@ -0,0 +1,195 @@
const SessionManager = require('../src/network/sessionManager');
function makeDb(initialGames, initialCredits = 0) {
const games = new Map(initialGames.map(game => [game.id, { ...game }]));
const users = new Map([[7, initialCredits]]);
const refundReasons = new Set();
const query = jest.fn(async (sql, params = []) => {
if (/SELECT g\.id[\s\S]*FROM games g[\s\S]*g\.status = 'active'/i.test(sql)) {
return {
rows: Array.from(games.values())
.filter(game => game.status === 'active')
.map(game => ({ id: game.id }))
};
}
if (/FROM games g[\s\S]*g\.id = \$1[\s\S]*FOR UPDATE/i.test(sql)) {
const game = games.get(params[0]);
return { rows: game ? [{ ...game }] : [] };
}
if (/SELECT 1 FROM credit_transactions/i.test(sql)) {
return { rows: refundReasons.has(params[1]) ? [{}] : [] };
}
if (/UPDATE users SET credits = credits \+/i.test(sql)) {
const next = (users.get(params[1]) || 0) + Number(params[0]);
users.set(params[1], next);
return { rows: [{ credits: next }], rowCount: 1 };
}
if (/INSERT INTO credit_transactions/i.test(sql)) {
refundReasons.add(params[2]);
return { rows: [], rowCount: 1 };
}
if (/UPDATE games SET status = \$1/i.test(sql)) {
const game = games.get(params[1]);
if (game) game.status = params[0];
return { rows: [], rowCount: game ? 1 : 0 };
}
return { rows: [], rowCount: 0 };
});
return {
query,
withTransaction: jest.fn(async callback => callback({ query })),
credits: userId => users.get(userId),
game: id => games.get(id),
refundReasons
};
}
function manager(db, currentConfiguredCost = 99) {
return new SessionManager({
db,
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: { creditsPerGameCost: currentConfiguredCost }
});
}
describe('orphan game recovery debit evidence', () => {
test('does not mint a refund for a pre-created active row when entry charging never committed', async () => {
const db = makeDb([{
id: 1,
user_id: 7,
game_mode: 'PAID_CREDITS',
payment_id: null,
status: 'active',
entry_consumed_at: null,
entry_credits_spent: 5
}], 10);
const result = await manager(db).recoverOrphanedGames();
expect(result).toEqual({
ok: true,
scanned: 1,
finalized: 1,
refunded: 0,
creditsRefunded: 0,
unresolved: []
});
expect(db.credits(7)).toBe(10);
expect(db.game(1).status).toBe('expired');
expect(db.refundReasons.size).toBe(0);
});
test('restores the immutable debit snapshot, not the current configured entry cost', async () => {
const db = makeDb([{
id: 2,
user_id: 7,
game_mode: 'PAID_CREDITS',
payment_id: null,
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: 3
}], 4);
const recovery = manager(db, 99);
const first = await recovery.recoverOrphanedGames();
const second = await recovery.recoverOrphanedGames();
expect(first).toEqual({
ok: true,
scanned: 1,
finalized: 1,
refunded: 1,
creditsRefunded: 3,
unresolved: []
});
expect(second).toEqual({
ok: true,
scanned: 0,
finalized: 0,
refunded: 0,
creditsRefunded: 0,
unresolved: []
});
expect(db.credits(7)).toBe(7);
expect(db.game(2).status).toBe('refunded');
expect(db.refundReasons).toEqual(new Set(['orphan_game_refunded:2']));
});
test('does not silently convert a linked direct payment into credits', async () => {
const db = makeDb([{
id: 3,
user_id: 7,
game_mode: 'PAID_SINGLE',
payment_id: 77,
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: null
}], 1);
const result = await manager(db).recoverOrphanedGames();
expect(result.refunded).toBe(0);
expect(db.credits(7)).toBe(1);
expect(db.game(3).status).toBe('expired');
});
test('fails closed with explicit scan semantics when the orphan query fails', async () => {
const db = {
query: jest.fn().mockRejectedValue(new Error('database unavailable')),
withTransaction: jest.fn()
};
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
ok: false,
scope: 'orphaned_solo_games',
scanFailed: true,
scanned: 0,
resolved: 0,
unresolved: []
}
});
expect(db.withTransaction).not.toHaveBeenCalled();
});
test('finishes other rows but rejects startup while any orphan remains unresolved', async () => {
const db = makeDb([{
id: 4,
user_id: 7,
game_mode: 'PAID_CREDITS',
status: 'active',
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
entry_credits_spent: 2
}, {
id: 5,
user_id: 7,
game_mode: 'FREE',
status: 'active',
entry_consumed_at: null,
entry_credits_spent: null
}], 0);
const baseTransaction = db.withTransaction;
let attempts = 0;
db.withTransaction = jest.fn(async callback => {
attempts += 1;
if (attempts === 1) throw new Error('refund transaction failed');
return baseTransaction(callback);
});
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
recovery: {
scope: 'orphaned_solo_games',
scanFailed: false,
scanned: 2,
resolved: 1,
unresolved: [{ type: 'game', id: '4' }]
}
});
expect(db.game(4).status).toBe('active');
expect(db.game(5).status).toBe('expired');
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Pack registry: the engine for multi-pack, interchangeable render environments (products).
*/
describe('pack registry', () => {
let RK;
beforeEach(() => { jest.resetModules(); RK = require('../html/js/render/packRegistry'); });
function seed() {
RK.registerPack({ id: 'topA', label: 'A', projection: 'topdown', assets: { a: 1 } });
RK.registerPack({ id: 'isoFree', label: 'IsoFree', projection: 'iso', assets: { i: 1 } });
RK.registerPack({ id: 'isoPrem', label: 'IsoPrem', projection: 'iso', assets: { i: 2 } });
}
test('registers packs and lists them by projection', () => {
seed();
expect(RK.packsForProjection('iso').map(p => p.id).sort()).toEqual(['isoFree', 'isoPrem']);
expect(RK.getPackDef('topA').assets).toEqual({ a: 1 });
expect(RK.registerPack({ id: 'x' })).toBeNull(); // no projection -> rejected
});
test('unlockedPacks filters by canUsePack', () => {
seed();
RK.canUsePack = (id) => id !== 'isoPrem';
expect(RK.unlockedPacks('iso').map(p => p.id)).toEqual(['isoFree']);
});
test('active pack = saved-if-unlocked, else first unlocked; assets resolve', () => {
seed();
RK.canUsePack = () => true;
expect(RK.activePackId('iso')).toBe('isoFree'); // default = first unlocked
expect(RK.setActivePack('isoPrem')).toBe(true);
expect(RK.activePackId('iso')).toBe('isoPrem'); // saved choice sticks
expect(RK.activePackAssets('iso')).toEqual({ i: 2 });
});
test('a saved pack that later locks falls back; setActivePack refuses a locked pack', () => {
seed();
RK.canUsePack = () => true;
RK.setActivePack('isoPrem');
RK.canUsePack = (id) => id !== 'isoPrem'; // access lost
expect(RK.activePackId('iso')).toBe('isoFree'); // graceful fallback
expect(RK.setActivePack('isoPrem')).toBe(false); // refused
});
test('a projection with no packs resolves to null', () => {
seed();
expect(RK.activePackId('3d')).toBeNull();
expect(RK.activePackAssets('3d')).toBeNull();
});
});
+332
View File
@@ -0,0 +1,332 @@
const SocketHandlers = require('../src/network/socketHandlers');
const PaymentHandlers = require('../src/network/paymentHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
const ENV_KEYS = [
'NODE_ENV',
'LEGAL_POLICY_VERSION',
'PAID_ACKNOWLEDGEMENT_REQUIRED',
'MATCH_ENABLED',
'MATCH_CRYPTO_RACE_ENABLED',
'MATCH_PAYOUTS_ENABLED'
];
function manager(overrides = {}) {
return {
cryptoType: 'WOW',
currencyLabel: 'WOW',
network: 'mainnet',
isTestNetwork: false,
paymentsEnabled: false,
directModeEnabled: false,
creditsModeEnabled: false,
freePlayEnabled: true,
payoutsEnabled: false,
gameMode: 'PAID_CREDITS',
isPayoutEnabledForMode: () => false,
...overrides
};
}
function acknowledgement(gameModeManager, overrides = {}) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: false,
...overrides
};
}
function socket() {
return {
id: 'socket-paid-flow',
handshake: { address: '127.0.0.1', headers: {} },
emit: jest.fn()
};
}
function baseContext(gameModeManager = manager()) {
return {
gameModeManager,
sessionManager: null,
broadcastManager: { sendStatusUpdate: jest.fn() },
rateLimiter: {
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
recordAttempt: jest.fn().mockResolvedValue(undefined)
},
_consumeFairnessAttempt: jest.fn().mockReturnValue({ proofVersion: 2 }),
activeGames: new Map(),
connectionHandler: { getUserBySocket: jest.fn().mockReturnValue({ serverId: 'socket-paid-flow' }) },
debugManager: { getCurrentBlockHeight: jest.fn().mockReturnValue(100), CONSOLE_LOGGING: false },
io: { to: jest.fn(() => ({ emit: jest.fn() })) }
};
}
describe('current paid acknowledgement gates every value-consuming socket flow', () => {
let previous;
beforeEach(() => {
previous = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
process.env.NODE_ENV = 'test';
process.env.LEGAL_POLICY_VERSION = 'paid-flow-current-v1';
process.env.PAID_ACKNOWLEDGEMENT_REQUIRED = 'false';
process.env.MATCH_ENABLED = 'true';
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
delete process.env.MATCH_PAYOUTS_ENABLED;
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
});
test('invoice request rejects a stale policy before fairness, wallet, or database work', async () => {
const client = socket();
const ctx = baseContext();
ctx.paymentHandlers = { handlePaymentRequest: jest.fn() };
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
type: 'credits_package',
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx.paymentHandlers.handlePaymentRequest).not.toHaveBeenCalled();
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
code: 'PAID_ACK_VERSION', policyVersion: 'paid-flow-current-v1'
}));
});
test('invoice request accepts the current policy even with intake state reported off', async () => {
const client = socket();
const ctx = baseContext();
ctx.paymentHandlers = { handlePaymentRequest: jest.fn().mockResolvedValue(undefined) };
const ack = acknowledgement(ctx.gameModeManager);
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
type: 'credits_package', legalAcknowledgement: ack
});
expect(ctx.paymentHandlers.handlePaymentRequest).toHaveBeenCalledWith(client, {
type: 'credits_package', legalAcknowledgement: ack
});
});
test('auto-start rejects stale policy before consuming a fairness offer or credit', async () => {
const client = socket();
const ctx = baseContext();
ctx.gameManager = { createGameForUser: jest.fn() };
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.gameManager.createGameForUser).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test('auto-start with current policy reaches existing-credit consumption while intake is off', async () => {
const client = socket();
const gameModeManager = manager({
canUserStartGame: jest.fn().mockResolvedValue({
allowed: true,
effectiveMode: 'PAID_CREDITS',
useCredits: true
}),
requiresPayoutAddressForMode: jest.fn().mockReturnValue(false),
processGameStart: jest.fn().mockResolvedValue({ success: true, creditsRemaining: 4 })
});
const ctx = baseContext(gameModeManager);
const game = {
id: 'game-1',
dbId: null,
getState: jest.fn().mockReturnValue({}),
getProofCommitment: jest.fn().mockReturnValue('commitment')
};
ctx.gameManager = { createGameForUser: jest.fn().mockResolvedValue(game) };
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
legalAcknowledgement: acknowledgement(gameModeManager)
});
expect(gameModeManager.canUserStartGame).toHaveBeenCalledWith(client.id);
expect(gameModeManager.processGameStart).toHaveBeenCalledWith(client.id, game.id, { forceFree: false });
});
test('queued-credit entry rejects stale policy before queue eligibility or debit', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleGameQueue: jest.fn() };
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.queueHandler.handleGameQueue).not.toHaveBeenCalled();
});
test('queued-credit entry accepts the current policy while invoice intake is off', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleGameQueue: jest.fn().mockResolvedValue(undefined) };
ctx.queueManager = { getPlayerIndex: jest.fn().mockReturnValue(-1) };
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.queueHandler.handleGameQueue).toHaveBeenCalledWith(
client,
expect.any(Function),
expect.objectContaining({ fairnessProof: { proofVersion: 2 } })
);
});
test('early entry rejects stale policy before queue eligibility or credit debit', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleEarlyEntry: jest.fn() };
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
expect(ctx.queueHandler.handleEarlyEntry).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('early_entry_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test('early entry accepts current policy while invoice intake is off', async () => {
const client = socket();
const ctx = baseContext();
ctx.queueHandler = { handleEarlyEntry: jest.fn().mockResolvedValue({ success: true }) };
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.queueHandler.handleEarlyEntry).toHaveBeenCalledWith(
client,
expect.any(Function),
{ fairnessProof: { proofVersion: 2 } }
);
});
test('paid PvP rejects stale policy before a credit or ticket is escrowed', async () => {
const client = socket();
const ctx = baseContext();
ctx.matchQueue = {
isEnabled: jest.fn().mockReturnValue(true),
enqueue: jest.fn(),
leave: jest.fn()
};
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
userId: 1, socketId: client.id, sessionToken: 'session'
});
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
action: 'join', economy: 'crypto_race',
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
});
expect(ctx.matchQueue.enqueue).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
});
test.each(['credits_prestige', 'crypto_race'])(
'paid PvP %s accepts the current policy while new invoice intake is off',
async economy => {
const client = socket();
const ctx = baseContext();
ctx.matchQueue = {
isEnabled: jest.fn().mockReturnValue(true),
enqueue: jest.fn().mockResolvedValue({ success: true, position: 1 }),
leave: jest.fn()
};
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
userId: 1, socketId: client.id, sessionToken: 'session'
});
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
action: 'join', economy,
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
});
expect(ctx.matchQueue.enqueue).toHaveBeenCalledWith(
expect.objectContaining({ userId: 1, economy }),
{ economy }
);
}
);
test('continuation cache is canonical, capped, expires, and clears on failed selection', async () => {
const gameModeManager = manager({
gameMode: 'FREE',
getCosmeticProduct: jest.fn().mockReturnValue(null)
});
const handler = new PaymentHandlers({
io: { to: () => ({ emit: jest.fn() }) },
gameModeManager,
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: { sendStatusUpdate: jest.fn() },
sessionManager: null
});
const ack = acknowledgement(gameModeManager);
try {
handler._rememberPendingCommerceAcknowledgement('canonical', {
...ack,
ignored: 'never retained'
});
expect(handler._getPendingCommerceAcknowledgement('canonical')).toEqual(ack);
expect(Object.keys(handler._getPendingCommerceAcknowledgement('canonical'))).toEqual([
'policyVersion', 'ageEligible', 'termsRead', 'riskAccepted', 'testnetUnderstood'
]);
for (let index = 0; index < 300; index += 1) {
handler._rememberPendingCommerceAcknowledgement(`socket-${index}`, ack);
}
expect(handler.pendingCommerceAcknowledgement.size).toBe(256);
const expiresAt = handler.pendingCommerceAcknowledgement.get('socket-299').expiresAt;
const now = jest.spyOn(Date, 'now').mockReturnValue(expiresAt);
expect(handler._getPendingCommerceAcknowledgement('socket-299')).toBeNull();
now.mockRestore();
handler._rememberPendingCommerceAcknowledgement('failed', ack);
handler.pendingEntryFairness.set('failed', { proofVersion: 2 });
await handler.createAndShowPaymentRequest({ id: 'failed' }, {
paymentType: 'bogus',
legalAcknowledgement: ack
});
expect(handler._getPendingCommerceAcknowledgement('failed')).toBeNull();
expect(handler.pendingEntryFairness.has('failed')).toBe(false);
} finally {
handler.dispose();
}
});
test('disconnect clears server-side acknowledgement and fairness continuation state', () => {
const client = socket();
const clearPendingCommerceAcknowledgement = jest.fn();
const pendingEntryFairness = new Map([[client.id, { proofVersion: 2 }]]);
const ctx = {
fairnessOffers: { discardSocket: jest.fn() },
paymentHandlers: { clearPendingCommerceAcknowledgement, pendingEntryFairness },
connectionHandler: { handleDisconnect: jest.fn() }
};
SocketHandlers.prototype.handleDisconnect.call(ctx, client);
expect(clearPendingCommerceAcknowledgement).toHaveBeenCalledWith(client.id);
expect(pendingEntryFairness.has(client.id)).toBe(false);
});
});
+233
View File
@@ -0,0 +1,233 @@
const GameModeManager = require('../src/game/gameModeManager');
const { hashSeed } = require('../src/game/provablyFair');
const ENV_KEYS = [
'NODE_ENV', 'REQUIRE_PAID_FAIRNESS_V2', 'GAME_MODE', 'SINGLE_GAME_PRICE',
'CREDITS_PACKAGE_PRICE', 'CREDITS_PER_GAME', 'CREDITS_PAYOUT_ENABLED',
'CREDITS_PAYOUTS_ENABLED'
];
function configManager() {
return {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'XMR', decimals: 12 },
modes: {
direct: { enabled: true, price: 1000n },
credits: { enabled: false, creditsPerGame: 1, packages: [] }
},
payouts: {
enabled: false,
rules: {
direct: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } }
}
},
preferences: { preferCreditsFirst: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: jest.fn() }
};
}
function makeManager(db, provider = null) {
const providers = provider ? { getProvider: jest.fn(() => provider) } : { getProvider: jest.fn(() => null) };
return new GameModeManager(
db,
{ createPaymentRequest: jest.fn() },
{ CONSOLE_LOGGING: false },
configManager(),
providers
);
}
describe('paid fairness invoice durability', () => {
let previousEnv;
let logSpy;
beforeEach(() => {
previousEnv = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
process.env.NODE_ENV = 'production';
process.env.REQUIRE_PAID_FAIRNESS_V2 = 'false';
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
for (const key of ENV_KEYS) {
if (previousEnv[key] === undefined) delete process.env[key];
else process.env[key] = previousEnv[key];
}
});
test('production cannot disable v2 and a new manager restores the invoice-bound proof', async () => {
const serverSeed = '12'.repeat(32);
const proof = {
proofVersion: 2,
offerId: 'offer-before-payment',
offerIssuedAt: 1700000000123,
serverSeed,
commitment: hashSeed(serverSeed),
clientSeed: '34'.repeat(32)
};
let insertParams;
let insertSql;
const expiresAt = new Date('2030-01-01T00:00:00.000Z');
const db = {
query: jest.fn(async (sql, params = []) => {
if (/SELECT \* FROM users WHERE id/i.test(sql)) return { rows: [{ id: 7 }] };
if (/UPDATE payments SET status = 'expired'/i.test(sql)) return { rows: [] };
if (/INSERT INTO payments/i.test(sql)) {
insertSql = sql;
insertParams = params;
return { rows: [{ id: 55, expires_at: expiresAt }] };
}
return { rows: [] };
})
};
const provider = {
createInvoice: jest.fn(async () => ({
address: 'stagenet-address',
addressIndex: 9,
invoiceId: 'invoice-55',
expiresAt
}))
};
const firstProcess = makeManager(db, provider);
expect(firstProcess._requiresPaidFairnessV2()).toBe(true);
const request = await firstProcess.createPaymentRequest('old-socket', 'single_game', {
userId: 7,
reuseExisting: false,
fairnessProof: proof
});
expect(request.fairnessProof).toEqual(proof);
// PostgreSQL's extended protocol rejects one placeholder when it is inferred as both
// VARCHAR (the destination column) and TEXT (a CASE expression). Keep both uses explicit
// and identical; this is exercised end-to-end by the stagenet invoice canary.
expect(insertSql).toMatch(/\$12::varchar\(64\)/i);
expect(insertSql).toMatch(/CASE WHEN \$12::varchar\(64\) IS NULL/i);
expect(insertParams.slice(10, 16)).toEqual([
2,
proof.offerId,
new Date(proof.offerIssuedAt),
proof.commitment,
proof.serverSeed,
proof.clientSeed
]);
// Simulate a process restart: only fields reconstructed from the payment row remain.
const durableRow = {
id: 55,
subaddress: 'stagenet-address',
expected_amount: '1000',
payment_type: 'single_game',
status: 'pending',
expires_at: expiresAt,
fairness_proof_version: insertParams[10],
fairness_offer_id: insertParams[11],
fairness_offer_issued_at: insertParams[12],
fairness_commitment: insertParams[13],
fairness_server_seed: insertParams[14],
fairness_client_seed: insertParams[15],
fairness_bound_at: new Date(),
fairness_consumed_at: null
};
const restarted = makeManager({ query: jest.fn() });
expect(restarted._mapPaymentRowToRequest(durableRow, 'single_game', null).fairnessProof)
.toEqual(proof);
});
test('entry claim atomically verifies and consumes the exact invoice proof', async () => {
const serverSeed = 'ab'.repeat(32);
const offerIssuedAt = 1700000000999;
const payment = {
id: 90,
user_id: 7,
fairness_proof_version: 2,
fairness_offer_id: 'restart-proof',
fairness_offer_issued_at: new Date(offerIssuedAt),
fairness_commitment: hashSeed(serverSeed),
fairness_server_seed: serverSeed,
fairness_client_seed: 'cd'.repeat(32),
fairness_bound_at: new Date(),
fairness_consumed_at: null
};
const calls = [];
const client = {
query: jest.fn(async (sql, params = []) => {
calls.push({ sql, params });
if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] };
if (/SELECT proof_version/i.test(sql)) {
return { rows: [{
proof_version: 2,
fairness_offer_id: payment.fairness_offer_id,
fairness_offer_issued_at: payment.fairness_offer_issued_at,
proof_commitment: payment.fairness_commitment,
server_seed: payment.fairness_server_seed,
client_seed: payment.fairness_client_seed
}] };
}
if (/UPDATE games/i.test(sql)) return { rows: [{ id: 1 }], rowCount: 1 };
if (/UPDATE payments[\s\S]*fairness_consumed_at/i.test(sql)) {
return { rows: [{ id: payment.id }], rowCount: 1 };
}
if (/UPDATE users/i.test(sql)) return { rows: [], rowCount: 1 };
return { rows: [], rowCount: 0 };
})
};
const db = { withTransaction: jest.fn(async callback => callback(client)) };
const manager = makeManager(db);
await expect(manager._processGameStartWithPayment(
{ id: 7, payout_address: null },
{ id: payment.id },
'game-proof-seed'
)).resolves.toEqual(expect.objectContaining({ success: true, paymentId: payment.id }));
const gameUpdate = calls.find(call => /UPDATE games/i.test(call.sql));
const paymentConsume = calls.find(call => /UPDATE payments[\s\S]*fairness_consumed_at/i.test(call.sql));
expect(gameUpdate.sql).toContain('entry_consumed_at = NOW()');
expect(paymentConsume.sql).toContain('fairness_consumed_at IS NULL');
});
test('tampering with the persisted game proof aborts before entry consumption', async () => {
const serverSeed = 'ef'.repeat(32);
const payment = {
id: 91,
user_id: 7,
fairness_proof_version: 2,
fairness_offer_id: 'tamper-proof',
fairness_offer_issued_at: new Date(1700000001234),
fairness_commitment: hashSeed(serverSeed),
fairness_server_seed: serverSeed,
fairness_client_seed: '01'.repeat(32),
fairness_bound_at: new Date(),
fairness_consumed_at: null
};
const client = {
query: jest.fn(async sql => {
if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] };
if (/SELECT proof_version/i.test(sql)) {
return { rows: [{
proof_version: 2,
fairness_offer_id: payment.fairness_offer_id,
fairness_offer_issued_at: payment.fairness_offer_issued_at,
proof_commitment: payment.fairness_commitment,
server_seed: payment.fairness_server_seed,
client_seed: 'ff'.repeat(32)
}] };
}
return { rows: [], rowCount: 0 };
})
};
const manager = makeManager({ withTransaction: jest.fn(async callback => callback(client)) });
await expect(manager._processGameStartWithPayment(
{ id: 7, payout_address: null },
{ id: payment.id },
'tampered-game'
)).rejects.toMatchObject({ code: 'PAYMENT_FAIRNESS_MISMATCH' });
expect(client.query.mock.calls.some(([sql]) => /UPDATE games/i.test(sql))).toBe(false);
});
});
+643
View File
@@ -0,0 +1,643 @@
'use strict';
const crypto = require('crypto');
const MatchScheduler = require('../src/network/matchScheduler');
const MatchQueue = require('../src/network/matchQueue');
const SocketHandlers = require('../src/network/socketHandlers');
const {
PAID_SEED_VERSION,
buildPaidEntrantFreeze,
deriveFutureBlockMatchSeed
} = require('../src/network/matchFairness');
const FUTURE_HASH = 'ab'.repeat(32);
function entrant(queueEntryId, userId = queueEntryId) {
return {
queueEntryId,
userId,
socketId: `socket-${userId}`,
sessionToken: `session-${userId}`,
createdAt: queueEntryId
};
}
class DurablePaidQueueDouble {
constructor(entries = []) {
this.initialized = true;
this.queues = {
free: [],
credits_prestige: entries.slice(),
crypto_race: []
};
this.frozen = [];
this.freezeCalls = [];
this.cancelled = [];
}
async refundUnavailableQueues() {}
isEconomyAvailable() { return true; }
isFinancialRecoveryReady() { return true; }
async freezePaidMatch(spec) {
const queue = this.queues[spec.economy];
if (!queue || queue.length < spec.minPlayers) return null;
const entries = queue.slice(0, spec.maxPlayers);
const freeze = buildPaidEntrantFreeze({
freezeBlockHeight: spec.freezeBlockHeight,
targetBlockHeight: spec.targetBlockHeight,
economy: spec.economy,
rulesetId: spec.rulesetId,
queueEntryIds: entries.map(entry => entry.queueEntryId)
});
const group = {
id: crypto.randomUUID(),
economy: spec.economy,
variant: spec.variant,
rulesetId: spec.rulesetId,
difficultyPreset: spec.difficultyPreset,
maxPlayers: spec.maxPlayers,
targetBlockHeight: spec.targetBlockHeight,
freezeCommitment: freeze.freezeCommitment,
precommitTipHeight: null,
precommitVerifiedAt: null,
freeze,
entries,
status: 'pending'
};
this.freezeCalls.push(spec);
this.frozen.push(group);
this.queues[spec.economy] = queue.slice(entries.length);
return group;
}
async verifyFrozenPrecommit(id, commitment, tip) {
const group = this.frozen.find(candidate => candidate.id === id
&& candidate.status === 'pending'
&& candidate.freezeCommitment === commitment
&& candidate.targetBlockHeight > tip);
if (!group) return { verified: false };
group.precommitTipHeight = tip;
group.precommitVerifiedAt = new Date().toISOString();
return {
verified: true,
observedTipHeight: tip,
verifiedAt: group.precommitVerifiedAt
};
}
async listFrozenPaidMatches(throughBlockHeight) {
return this.frozen.filter(group => group.status === 'pending'
&& (throughBlockHeight == null || group.targetBlockHeight <= throughBlockHeight));
}
noteFrozenStarted(id) {
const group = this.frozen.find(candidate => candidate.id === id);
if (group) group.status = 'starting';
}
async cancelFrozenMatch(id) {
const group = this.frozen.find(candidate => candidate.id === id && candidate.status === 'pending');
if (!group) return { claimed: false, resolved: true, refunded: 0 };
group.status = 'cancelled';
this.cancelled.push(...group.entries.map(entry => entry.queueEntryId));
return { claimed: true, resolved: true, refunded: group.entries.length };
}
async refundEntries(entries) {
const ids = new Set((entries || []).map(entry => String(entry.queueEntryId)));
const group = this.frozen.find(candidate => candidate.entries.some(
entry => ids.has(String(entry.queueEntryId))
));
if (group && group.status !== 'cancelled') {
group.status = 'cancelled';
this.cancelled.push(...group.entries.map(entry => entry.queueEntryId));
}
return { attempted: ids.size, resolved: ids.size, failed: 0 };
}
async drain(economy, maxPlayers, minPlayers) {
const queue = this.queues[economy] || [];
if (queue.length < minPlayers) return null;
const entries = queue.slice(0, maxPlayers);
this.queues[economy] = queue.slice(entries.length);
return { entries };
}
}
function managerDouble() {
return {
attached: [],
engines: new Map(),
db: null,
attach(room, entries) { this.attached.push({ room, entries }); },
setEngine(id, engine) { this.engines.set(id, engine); },
onTick() {},
onFinish() {}
};
}
describe('paid PvP future-block entrant freeze', () => {
const schedulers = [];
beforeEach(() => {
process.env.MATCH_ENABLED = 'true';
});
afterEach(async () => {
await Promise.all(schedulers.splice(0).map(scheduler => scheduler.shutdown()));
delete process.env.MATCH_ENABLED;
});
function schedulerFor(queue, manager, provider, countProvider) {
const scheduler = new MatchScheduler({
matchQueue: queue,
matchManager: manager,
blockEntropyProvider: provider,
blockCountProvider: countProvider,
paidEntropyDelayBlocks: 2,
paidEntropyConfirmations: 2,
rulesetId: 'last-alive',
maxPlayers: 2,
countdownMs: 10 ** 9
});
schedulers.push(scheduler);
return scheduler;
}
test('delay=2/conf=2 freezes FIFO at H, reads no hash through target, then starts from the stable exact header', async () => {
const queue = new DurablePaidQueueDouble([entrant(12), entrant(3), entrant(99)]);
const manager = managerDouble();
let daemonCount = 101;
const entropyCalls = [];
const scheduler = schedulerFor(queue, manager, async height => {
entropyCalls.push(height);
if (height === 100) throw new Error('the freeze-block hash must never be requested');
return height === 102
? { block_header: { height, hash: FUTURE_HASH } }
: null;
}, async () => daemonCount);
// DebugManager emits block count 101, whose actual canonical tip header is height 100.
await scheduler.onBlock(101);
expect(entropyCalls).toEqual([]);
expect(queue.frozen).toHaveLength(1);
expect(queue.frozen[0].entries.map(entry => entry.queueEntryId)).toEqual([12, 3]);
expect(queue.frozen[0].freeze.queueEntryIds).toEqual(['3', '12']);
expect(manager.attached).toHaveLength(0);
daemonCount = 102;
await scheduler.onBlock(102); // H+1
daemonCount = 103;
await scheduler.onBlock(103); // target exists, but only confirmation one
expect(entropyCalls).toEqual([]);
expect(manager.attached).toHaveLength(0);
daemonCount = 104;
await scheduler.onBlock(104); // target+1: confirmation two
expect(entropyCalls).toEqual([102, 102, 102]);
expect(manager.attached).toHaveLength(1);
const { room, entries } = manager.attached[0];
const expected = deriveFutureBlockMatchSeed({
blockHash: FUTURE_HASH,
blockHeight: 102,
freeze: queue.frozen[0].freeze
});
expect(entries.map(entry => entry.queueEntryId)).toEqual([12, 3]);
expect(room.seed).toBe(expected.seed);
expect(room.seedDerivation).toEqual(expect.objectContaining(expected.derivation));
expect(room.seedDerivation.version).toBe(PAID_SEED_VERSION);
expect(room.seedDerivation.blockHash).toBe(FUTURE_HASH);
expect(room.seedDerivation).toEqual(expect.objectContaining({
entropyDelayBlocks: 2,
entropyConfirmations: 2,
minimumConfirmedTipHeight: 103,
precommitTipHeight: 100
}));
expect(room.fairnessProof(false)).toEqual(expect.objectContaining({
blockHeight: 102,
blockHash: FUTURE_HASH,
freezeBlockHeight: 100,
targetBlockHeight: 102,
entropyDelayBlocks: 2,
entropyConfirmations: 2,
minimumConfirmedTipHeight: 103,
precommitTipHeight: 100
}));
});
test('a delayed freeze commit that crosses its target performs zero hash/attach work and refunds once', async () => {
const queue = new DurablePaidQueueDouble([entrant(1), entrant(2)]);
const manager = managerDouble();
manager.matchPayoutService = { collectEntryTickets: jest.fn() };
const entropy = jest.fn();
const scheduler = schedulerFor(queue, manager, entropy, async () => 203);
await scheduler.onBlock(101); // freeze H=100, target=102, post-commit tip already 202
expect(manager.attached).toHaveLength(0);
expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled();
expect(entropy).not.toHaveBeenCalled();
expect(queue.freezeCalls).toHaveLength(1);
expect(queue.cancelled).toEqual([1, 2]);
await scheduler.onBlock(101);
expect(queue.cancelled).toEqual([1, 2]);
});
test('strict count failure cannot activate and the due unverified freeze is refunded without a hash read', async () => {
const queue = new DurablePaidQueueDouble([entrant(21), entrant(22)]);
const manager = managerDouble();
const entropy = jest.fn();
const scheduler = schedulerFor(queue, manager, entropy, async () => {
throw new Error('daemon unavailable');
});
await scheduler.onBlock(301);
await scheduler.onBlock(302);
await scheduler.onBlock(303);
expect(manager.attached).toHaveLength(0);
expect(entropy).not.toHaveBeenCalled();
expect(queue.cancelled).toEqual([21, 22]);
});
test('a header reorg between the two pre-activation reads refunds without gameplay', async () => {
const queue = new DurablePaidQueueDouble([entrant(41), entrant(42)]);
const manager = managerDouble();
let daemonCount = 201;
const hashes = [FUTURE_HASH, 'cd'.repeat(32)];
const entropy = jest.fn(async height => ({
block_header: { height, hash: hashes.shift() || hashes.at(-1) }
}));
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
await scheduler.onBlock(201); // H=200, target=202
daemonCount = 204;
await scheduler.onBlock(204);
expect(manager.attached).toHaveLength(0);
expect(manager.engines.size).toBe(0);
expect(queue.cancelled).toEqual([41, 42]);
expect(entropy).toHaveBeenCalledTimes(2);
});
test('a daemon response for the wrong height cannot activate the paid freeze', async () => {
const queue = new DurablePaidQueueDouble([entrant(45), entrant(46)]);
const manager = managerDouble();
let daemonCount = 211;
const entropy = jest.fn(async () => ({
block_header: { height: 999, hash: FUTURE_HASH }
}));
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
await scheduler.onBlock(211); // H=210, target=212
daemonCount = 214;
await scheduler.onBlock(214);
expect(manager.attached).toHaveLength(0);
expect(queue.frozen[0].status).toBe('pending');
expect(entropy).toHaveBeenCalledTimes(1);
});
test('a post-activation reorg aborts/refunds before engine attach', async () => {
const queue = new DurablePaidQueueDouble([entrant(51), entrant(52)]);
const manager = managerDouble();
manager.matchPayoutService = { collectEntryTickets: jest.fn() };
let daemonCount = 301;
const hashes = [FUTURE_HASH, FUTURE_HASH, 'ef'.repeat(32)];
const entropy = jest.fn(async height => ({
block_header: { height, hash: hashes.shift() }
}));
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
await scheduler.onBlock(301);
daemonCount = 304;
await scheduler.onBlock(304);
expect(manager.attached).toHaveLength(0);
expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled();
expect(manager.engines.size).toBe(0);
expect(queue.cancelled).toEqual([51, 52]);
expect(entropy).toHaveBeenCalledTimes(3);
});
test('legacy v1 pending freezes are cancelled before any target lookup', async () => {
const queue = new DurablePaidQueueDouble();
const entries = [entrant(61), entrant(62)];
queue.frozen.push({
id: crypto.randomUUID(),
economy: 'credits_prestige',
rulesetId: 'last-alive',
targetBlockHeight: 401,
freezeCommitment: '0'.repeat(64),
freeze: {
version: 'future-block-freeze-v1', freezeBlockHeight: 400,
targetBlockHeight: 401, economy: 'credits_prestige', rulesetId: 'last-alive',
queueEntryIds: ['61', '62'], freezeCommitment: '0'.repeat(64)
},
entries,
status: 'pending'
});
const entropy = jest.fn();
const scheduler = schedulerFor(queue, managerDouble(), entropy, async () => 403);
await scheduler.onBlock(402);
expect(entropy).not.toHaveBeenCalled();
expect(queue.cancelled).toEqual([61, 62]);
});
test('the default provider fetches the exact canonical header height disclosed in proofs', async () => {
const rpc = { getBlockByHeight: jest.fn().mockResolvedValue({ hash: FUTURE_HASH }) };
const scheduler = new MatchScheduler({
matchQueue: new DurablePaidQueueDouble(),
matchManager: managerDouble(),
debugManager: { rpcService: rpc },
rulesetId: 'last-alive'
});
schedulers.push(scheduler);
await scheduler.blockEntropyProvider(808);
expect(rpc.getBlockByHeight).toHaveBeenCalledWith(808);
});
});
describe('MatchQueue durable freeze/refund transactions', () => {
beforeEach(() => { process.env.MATCH_ENABLED = 'true'; });
afterEach(() => { delete process.env.MATCH_ENABLED; });
test('writes pending match, exact queue links, and entrants in one freeze transaction', async () => {
const calls = [];
const entries = [entrant(61, 6), entrant(62, 7)];
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('INSERT INTO matches')) return { rows: [{ id: params[0] }], rowCount: 1 };
if (sql.includes('UPDATE match_queue_entries')) {
return {
rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
}
};
const queue = new MatchQueue({ db });
queue._queues.credits_prestige = entries.slice();
const frozen = await queue.freezePaidMatch({
economy: 'credits_prestige',
minPlayers: 2,
maxPlayers: 2,
freezeBlockHeight: 600,
targetBlockHeight: 601,
rulesetId: 'last-alive',
variant: 'pvp',
difficultyPreset: 'race'
});
expect(frozen.entries.map(entry => entry.queueEntryId)).toEqual([61, 62]);
expect(queue.length('credits_prestige')).toBe(0);
const pending = calls.find(call => call.sql.includes('INSERT INTO matches'));
const links = calls.find(call => call.sql.includes('UPDATE match_queue_entries'));
expect(pending.sql).toContain("'pending'");
expect(pending.params[6]).toBe(frozen.freeze.freezeCommitment);
expect(pending.params[8]).toBe(601);
expect(links.params[1]).toEqual(['61', '62']);
expect(calls.filter(call => call.sql.includes('INSERT INTO match_entrants'))).toHaveLength(2);
});
test('records the fresh post-commit daemon tip only while the target is still future', async () => {
const calls = [];
const verifiedAt = new Date('2026-07-21T12:00:00Z');
const db = {
async withTransaction(fn) {
return fn({
async query(sql, params) {
calls.push({ sql, params });
return {
rows: [{
id: params[0], start_block_height: 502,
entropy_precommit_tip_height: params[2],
entropy_precommit_verified_at: verifiedAt
}],
rowCount: 1
};
}
});
},
query: jest.fn()
};
const queue = new MatchQueue({ db });
const proof = await queue.verifyFrozenPrecommit(
crypto.randomUUID(), 'ab'.repeat(32), 500
);
expect(proof).toEqual(expect.objectContaining({
verified: true, targetBlockHeight: 502, observedTipHeight: 500
}));
expect(calls[0].sql).toContain('start_block_height > $3');
expect(calls[0].sql).toContain('entropy_precommit_tip_height IS NULL');
expect(calls[0].sql).toContain("status = 'pending'");
expect(db.query).not.toHaveBeenCalled();
});
test('startup cancels v1 and crash-before-verification freezes; a failed cancellation retries', async () => {
const canonical = buildPaidEntrantFreeze({
freezeBlockHeight: 800,
targetBlockHeight: 802,
economy: 'credits_prestige',
rulesetId: 'last-alive',
queueEntryIds: [81, 82]
});
const queue = new MatchQueue({ db: {} });
queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([
{
id: 'v2-unverified', freeze: canonical,
freezeCommitment: canonical.freezeCommitment,
precommitTipHeight: null, precommitVerifiedAt: null
},
{
id: 'v1-legacy', freeze: { ...canonical, version: 'future-block-freeze-v1' },
freezeCommitment: canonical.freezeCommitment,
precommitTipHeight: 800, precommitVerifiedAt: new Date()
}
]);
const attempts = new Map();
queue.cancelFrozenMatch = jest.fn(async id => {
attempts.set(id, (attempts.get(id) || 0) + 1);
if (id === 'v2-unverified' && attempts.get(id) === 1) {
return { resolved: false };
}
return { resolved: true };
});
const first = await queue.cancelUnverifiedPaidFreezes(
'match_cancel', { failOnError: false }
);
const second = await queue.cancelUnverifiedPaidFreezes(
'match_cancel', { failOnError: false }
);
expect(first).toEqual(expect.objectContaining({ ok: false, failed: 1 }));
expect(second).toEqual(expect.objectContaining({ ok: true, failed: 0 }));
expect(attempts.get('v2-unverified')).toBe(2);
expect(attempts.get('v1-legacy')).toBe(2);
});
test('a lost freeze COMMIT acknowledgement adopts the exact durable envelope once', async () => {
const entries = [entrant(63, 9), entrant(64, 10)];
let pendingParams = null;
const db = {
async withTransaction(fn) {
await fn({
async query(sql, params) {
if (sql.includes('INSERT INTO matches')) {
pendingParams = params;
return { rows: [{ id: params[0] }], rowCount: 1 };
}
if (sql.includes('UPDATE match_queue_entries')) {
return {
rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })),
rowCount: entries.length
};
}
return { rows: [{ id: 1 }], rowCount: 1 };
}
});
throw new Error('commit acknowledgement lost');
},
async query(sql) {
expect(sql).toContain("m.status = 'pending'");
const dungeon = JSON.parse(pendingParams[7]);
return {
rows: entries.map(entry => ({
id: pendingParams[0],
economy: 'credits_prestige',
variant: 'pvp',
ruleset_id: 'last-alive',
difficulty_preset: 'race',
max_players: 2,
seed_hash: pendingParams[6],
start_block_height: 611,
dungeon,
queue_entry_id: entry.queueEntryId,
user_id: entry.userId,
socket_id: entry.socketId,
session_token: entry.sessionToken,
created_at: new Date(entry.createdAt).toISOString()
})),
rowCount: entries.length
};
}
};
const queue = new MatchQueue({ db });
queue._queues.credits_prestige = entries.slice();
const frozen = await queue.freezePaidMatch({
economy: 'credits_prestige',
minPlayers: 2,
maxPlayers: 2,
freezeBlockHeight: 610,
targetBlockHeight: 611,
rulesetId: 'last-alive',
variant: 'pvp',
difficultyPreset: 'race'
});
expect(frozen).toEqual(expect.objectContaining({
id: pendingParams[0],
freezeCommitment: pendingParams[6]
}));
expect(queue.length('credits_prestige')).toBe(0);
});
test('shutdown cancellation claims a pending freeze and refunds each anchor exactly once', async () => {
const freeze = buildPaidEntrantFreeze({
freezeBlockHeight: 700,
targetBlockHeight: 701,
economy: 'credits_prestige',
rulesetId: 'last-alive',
queueEntryIds: [71, 72]
});
let pending = true;
let creditsRefunded = 0;
let ledgerRows = 0;
const db = {
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('UPDATE matches')) {
if (!pending) return { rows: [], rowCount: 0 };
pending = false;
return {
rows: [{ economy: 'credits_prestige', dungeon: { match_fairness_freeze: freeze } }],
rowCount: 1
};
}
if (sql.includes('UPDATE match_queue_entries')) {
return {
rows: [
{ id: 71, user_id: 7, escrow_amount: '1', race_entry_lot_id: null },
{ id: 72, user_id: 8, escrow_amount: '1', race_entry_lot_id: null }
],
rowCount: 2
};
}
if (sql.includes('UPDATE users')) {
creditsRefunded += 1;
return { rows: [{ credits: 10 }], rowCount: 1 };
}
if (sql.includes('INSERT INTO credit_transactions')) {
ledgerRows += 1;
return { rows: [], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
}
});
}
};
const queue = new MatchQueue({ db });
const freezeId = crypto.randomUUID();
queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([{
id: freezeId, economy: 'credits_prestige', entries: [entrant(71), entrant(72)]
}]);
await queue.shutdown();
await queue.shutdown();
expect(queue.listFrozenPaidMatches).toHaveBeenCalledTimes(2);
expect({ creditsRefunded, ledgerRows }).toEqual({ creditsRefunded: 2, ledgerRows: 2 });
});
test('orderly server shutdown waits for scheduler, then refunds freezes, then stops matches', async () => {
const order = [];
const context = {
beginShutdown: jest.fn(),
drainShutdownProducers: jest.fn(async () => { order.push('producers'); }),
debugManager: { CONSOLE_LOGGING: false },
tavernManager: null,
matchScheduler: { shutdown: jest.fn(async () => { order.push('scheduler'); }) },
matchQueue: { shutdown: jest.fn(async () => { order.push('queue'); }) },
matchManager: { shutdown: jest.fn(() => { order.push('manager'); }) },
spectatorManager: null,
chatHandler: null,
connectionHandler: null,
rateLimiter: null,
memoryManager: null,
sessionManager: null,
suspendedGameManager: null,
paymentHandlers: null,
activeGames: new Map()
};
await SocketHandlers.prototype.shutdown.call(context);
expect(order).toEqual(['producers', 'scheduler', 'queue', 'manager']);
});
});
+38
View File
@@ -0,0 +1,38 @@
const PaymentConfigManager = require('../src/config/paymentConfig');
const GameModeManager = require('../src/game/gameModeManager');
describe('PaymentConfigManager atomic-unit precision', () => {
const originalPrice = process.env.DIRECT_GAME_PRICE;
afterEach(() => {
if (originalPrice === undefined) delete process.env.DIRECT_GAME_PRICE;
else process.env.DIRECT_GAME_PRICE = originalPrice;
});
test('parses integer environment values above Number.MAX_SAFE_INTEGER exactly', () => {
process.env.DIRECT_GAME_PRICE = '100000000000000001';
const config = new PaymentConfigManager({
logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }
}).getConfig();
expect(config.modes.direct.price).toBe(100000000000000001n);
});
test('game mode snapshot and public transport preserve large atomic prices exactly', () => {
process.env.DIRECT_GAME_PRICE = '100000000000000001';
const manager = new PaymentConfigManager({
logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }
});
const gmm = new GameModeManager(
{ query: jest.fn() },
{},
{ CONSOLE_LOGGING: false },
manager
);
expect(gmm.singleGamePrice).toBe('100000000000000001');
expect(gmm.getGameModeInfo().singleGamePrice).toBe('100000000000000001');
expect(gmm.calculatePayout('PAID_SINGLE').amount).toBe('200000000000000002');
});
});
+268
View File
@@ -0,0 +1,268 @@
const PaymentHandlers = require('../src/network/paymentHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
function legalAcknowledgement(gameModeManager) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: disclosure.service.isTestNetwork === true
};
}
function makeMocks() {
const user = { id: 'user-1', clientId: 'client1', payout_address: 'wow1address' };
return {
io: { to: () => ({ emit: () => {} }) },
gameModeManager: {
gameMode: 'PAID_SINGLE',
cryptoType: 'WOW',
currencyDecimals: 11,
singleGamePrice: 1000,
creditsPayoutEnabled: false,
formatAtomicHuman: jest.fn(() => '0.001'),
getOrCreateUser: jest.fn(async () => user),
createPaymentRequest: async () => ({
id: 'pid123',
address: 'addr',
amount: 1000,
amountFormatted: '0.001',
expiresAt: new Date()
})
},
walletService: {
startPaymentMonitoring: jest.fn(),
stopPaymentMonitoring: jest.fn()
},
debugManager: { getCurrentBlockHeight: () => 123, CONSOLE_LOGGING: false },
queueManager: {
getUserBySocket: () => user,
getPlayerIndex: () => -1,
getQueueLength: () => 0,
addPlayer: jest.fn(),
markConfirmed: jest.fn(),
startGameImmediately: jest.fn(() => false)
},
broadcastManager: { sendStatusUpdate: jest.fn() }
};
}
describe('PaymentHandlers basic', () => {
let ph;
afterEach(() => {
if (ph && typeof ph.dispose === 'function') {
ph.dispose();
}
ph = null;
});
test('createAndShowPaymentRequest emits payment_created', async () => {
const mocks = makeMocks();
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock1' }, {
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(emitted.payment_created).toBeDefined();
expect(emitted.payment_created.paymentId).toBe('pid123');
// No paymentProviders registry on the mock -> legacy fallback to direct wallet monitoring.
expect(mocks.walletService.startPaymentMonitoring).toHaveBeenCalled();
});
test('monitoring routes through a routed gateway provider instead of the wallet', async () => {
const mocks = makeMocks();
const gateway = { id: 'wowcheckout', startWatch: jest.fn(), stopWatch: jest.fn() };
mocks.gameModeManager.paymentProviders = { getProvider: jest.fn(() => gateway) };
mocks.io.to = () => ({ emit: () => {} });
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock2' }, {
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW');
expect(gateway.startWatch).toHaveBeenCalled();
expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native
});
test('direct payout invoice is not created before a required payout address exists', async () => {
const mocks = makeMocks();
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
mocks.gameModeManager.getOrCreateUser.mockResolvedValue({ id: 'user-1', payout_address: null });
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
mocks.gameModeManager.createPaymentRequest = jest.fn();
ph = new PaymentHandlers(mocks);
await ph.handlePaymentRequest({ id: 'sock3' }, {
type: 'single_game',
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
expect(emitted.payment_error).toEqual(expect.objectContaining({ code: 'PAYOUT_ADDRESS_REQUIRED' }));
});
test.each(['single_game', 'credits_package'])(
'payout-disabled %s invoice intake is not halted by an unknown/low wallet reserve',
async (paymentType) => {
const mocks = makeMocks();
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(() => false);
mocks.gameModeManager.alertService = {
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'low' })
};
mocks.gameModeManager.createPaymentRequest = jest.fn(mocks.gameModeManager.createPaymentRequest);
ph = new PaymentHandlers(mocks);
await ph.handlePaymentRequest({ id: `no-payout-${paymentType}` }, {
type: paymentType,
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.alertService.checkBalanceForGameStart).not.toHaveBeenCalled();
expect(mocks.gameModeManager.createPaymentRequest).toHaveBeenCalledTimes(1);
}
);
test('payout-bearing direct invoice remains fail closed under a critical reserve', async () => {
const mocks = makeMocks();
const emitted = {};
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(mode => mode === 'PAID_SINGLE');
mocks.gameModeManager.alertService = {
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'critical' })
};
mocks.gameModeManager.createPaymentRequest = jest.fn();
ph = new PaymentHandlers(mocks);
await ph.handlePaymentRequest({ id: 'payout-stagenet' }, {
type: 'single_game',
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
expect(emitted.balance_critical).toEqual(expect.objectContaining({ message: 'critical' }));
});
test('product grant transaction failure emits review-required and never confirmation success', async () => {
const mocks = makeMocks();
const emitted = {};
const socket = { id: 'product-failure', emit: (event, data) => { emitted[event] = data; } };
mocks.gameModeManager.processProductPaymentConfirmation = jest.fn()
.mockRejectedValue(new Error('receipt insert failed'));
mocks.queueManager.removePlayer = jest.fn();
ph = new PaymentHandlers(mocks);
ph.socketPaymentMap.set(socket.id, {
address: 'addr', paymentId: 'pid123', amount: 1000,
paymentType: 'credits_package', package: { credits: 10 }
});
await ph._handlePaymentStatus(socket, { id: 'pid123', address: 'addr', amount: 1000 }, null, {
confirmed: true,
complete: true,
amount: 1000,
confirmations: 2,
receipts: []
});
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
code: 'PAYMENT_RECEIPT_REJECTED'
}));
expect(emitted.payment_confirmed).toBeUndefined();
expect(mocks.queueManager.removePlayer).toHaveBeenCalledWith(socket.id);
});
test('single-game payment queue preserves the consumed two-phase fairness proof', async () => {
const mocks = makeMocks();
let statusCallback;
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
statusCallback = callback;
});
const fairnessProof = {
proofVersion: 2,
offerId: 'offer-paid',
serverSeed: 'a'.repeat(64),
commitment: 'b'.repeat(64),
clientSeed: 'c'.repeat(64)
};
const durableProof = {
...fairnessProof,
offerId: 'offer-persisted-on-invoice',
offerIssuedAt: 1700000000000
};
mocks.gameModeManager.createPaymentRequest = jest.fn(async () => ({
id: 'pid123',
address: 'addr',
amount: 1000,
amountFormatted: '0.001',
expiresAt: new Date(),
fairnessProof: durableProof
}));
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock4', emit: jest.fn() }, {
fairnessProof,
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({
paymentId: 'pid123',
fairnessProof: durableProof
}));
});
test('production refuses to queue an unbound paid invoice', async () => {
const mocks = makeMocks();
let statusCallback;
const emitted = {};
mocks.gameModeManager._requiresPaidFairnessV2 = jest.fn(() => true);
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
statusCallback = callback;
});
const socket = {
id: 'sock-unbound',
emit: (event, data) => { emitted[event] = data; }
};
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest(socket, {
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
expect(mocks.queueManager.addPlayer).not.toHaveBeenCalled();
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
code: 'PAYMENT_FAIRNESS_UNBOUND'
}));
});
test('fairness proof survives a payout-address prompt before monitoring starts', async () => {
const mocks = makeMocks();
let hasAddress = false;
let statusCallback;
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
mocks.gameModeManager.getOrCreateUser.mockImplementation(async () => ({
id: 'user-1',
payout_address: hasAddress ? 'wow1address' : null
}));
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
statusCallback = callback;
});
const fairnessProof = { proofVersion: 2, offerId: 'address-offer', clientSeed: 'd'.repeat(64) };
ph = new PaymentHandlers(mocks);
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() }, {
fairnessProof,
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
});
expect(statusCallback).toBeUndefined();
hasAddress = true;
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() });
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({ fairnessProof }));
});
});
+149
View File
@@ -0,0 +1,149 @@
const PaymentConfigManager = require('../src/config/paymentConfig');
const GameModeManager = require('../src/game/gameModeManager');
const ENV_KEYS = [
'GAME_MODE',
'PAYMENTS_ENABLED',
'PAYMENT_MODES',
'DIRECT_PAYMENT_ENABLED',
'CREDITS_ENABLED',
'FREE_PLAY_ENABLED',
'PAYOUTS_ENABLED',
'DIRECT_PAYOUTS_ENABLED',
'CREDITS_PAYOUTS_ENABLED',
'PAYOUT_MAX_PER_GAME',
'CREDITS_PAYOUT_MAX'
];
describe('production payment policy wiring', () => {
let saved;
beforeEach(() => {
saved = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
for (const key of ENV_KEYS) delete process.env[key];
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
test('PAYOUTS_ENABLED=false remains authoritative with both per-mode flags true', () => {
process.env.PAYMENTS_ENABLED = 'true';
process.env.PAYMENT_MODES = 'direct,credits';
process.env.PAYOUTS_ENABLED = 'false';
process.env.DIRECT_PAYOUTS_ENABLED = 'true';
process.env.CREDITS_PAYOUTS_ENABLED = 'true';
process.env.FREE_PLAY_ENABLED = 'false';
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const manager = new GameModeManager(
{ query: jest.fn(), withTransaction: jest.fn() },
{ validateAddress: jest.fn() },
{ CONSOLE_LOGGING: false },
configManager
);
expect(configManager.getConfig().payouts.enabled).toBe(false);
expect(manager.payoutsEnabled).toBe(false);
expect(manager.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
expect(manager.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
expect(manager.getGameModeInfo()).toEqual(expect.objectContaining({
payoutsEnabled: false,
directPayoutsEnabled: false,
creditsPayoutsEnabled: false,
freePlayEnabled: false
}));
});
test('PAYMENTS_ENABLED=false remains authoritative over every per-mode enable', () => {
process.env.PAYMENTS_ENABLED = 'false';
process.env.PAYMENT_MODES = 'direct,credits';
process.env.DIRECT_PAYMENT_ENABLED = 'true';
process.env.CREDITS_ENABLED = 'true';
const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig();
expect(config.paymentsEnabled).toBe(false);
expect(config.modes.direct.enabled).toBe(false);
expect(config.modes.credits.enabled).toBe(false);
});
test('invoice creation refuses before database or wallet work when the master switch is off', async () => {
process.env.PAYMENTS_ENABLED = 'false';
process.env.PAYMENT_MODES = 'direct,credits';
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const db = { query: jest.fn(), withTransaction: jest.fn() };
const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() };
const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager);
await expect(manager.createPaymentRequest('socket-disabled', 'single_game'))
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
await expect(manager.createPaymentRequest('socket-disabled', 'credits_package'))
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
expect(db.query).not.toHaveBeenCalled();
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
});
test('per-product switches cannot be bypassed through the generic invoice method', async () => {
process.env.PAYMENTS_ENABLED = 'true';
process.env.PAYMENT_MODES = 'direct';
process.env.DIRECT_PAYMENT_ENABLED = 'true';
process.env.CREDITS_ENABLED = 'false';
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const db = { query: jest.fn(), withTransaction: jest.fn() };
const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() };
const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager);
expect(manager.isPaymentIntakeEnabled('single_game')).toBe(true);
expect(manager.isPaymentIntakeEnabled('credits_package')).toBe(false);
await expect(manager.createPaymentRequest('socket-disabled', 'credits_package'))
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
expect(db.query).not.toHaveBeenCalled();
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
});
test('a contradictory legacy GAME_MODE cannot misreport the enabled payment policy', () => {
process.env.PAYMENTS_ENABLED = 'true';
process.env.GAME_MODE = 'PAID_SINGLE';
process.env.PAYMENT_MODES = 'credits';
process.env.DIRECT_PAYMENT_ENABLED = 'false';
process.env.CREDITS_ENABLED = 'true';
process.env.PAYOUTS_ENABLED = 'false';
process.env.DIRECT_PAYOUTS_ENABLED = 'false';
process.env.CREDITS_PAYOUTS_ENABLED = 'false';
const manager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
const gameModes = new GameModeManager(
{ query: jest.fn(), withTransaction: jest.fn() },
{ validateAddress: jest.fn() },
{ CONSOLE_LOGGING: false },
manager
);
expect(manager.getConfig().modes.direct.enabled).toBe(false);
expect(manager.getConfig().modes.credits.enabled).toBe(true);
expect(manager.getLegacyGameMode()).toBe('PAID_CREDITS');
expect(gameModes.getGameModeInfo()).toEqual(expect.objectContaining({
mode: 'PAID_CREDITS',
directModeEnabled: false,
creditsModeEnabled: true,
payoutsEnabled: false
}));
expect(gameModes.getPublicPreferredPaymentMode({
preferred_payment_mode: 'direct'
})).toBe('credits');
});
test('global per-game payout cap is an outer bound for direct and credit wins', () => {
process.env.PAYMENT_MODES = 'direct,credits';
process.env.PAYOUT_MAX_PER_GAME = '123456';
const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig();
expect(config.payouts.rules.direct.maxPayout).toBe(123456n);
expect(config.payouts.rules.credits.maxPayout).toBe(123456n);
});
});
@@ -0,0 +1,71 @@
const { buildProviderRegistry, parseRouting } = require('../src/payments/providers');
const fakeWallet = { createPaymentRequest: async () => ({}), checkPaymentStatus: async () => ({}), processBatchPayout: async () => ({}) };
const CHECKOUT = { // full provider set (BTC/LTC) + xmrcheckout + wowcheckout
BTCPAY_URL: 'http://192.0.2.33', BTCPAY_STORE_ID: 'b', BTCPAY_API_KEY: 'k',
XMRCHECKOUT_URL: 'http://192.0.2.33:8080', XMRCHECKOUT_STORE_ID: 'x', XMRCHECKOUT_API_KEY: 'k',
WOWCHECKOUT_URL: 'http://192.0.2.33:8180', WOWCHECKOUT_STORE_ID: 'w', WOWCHECKOUT_API_KEY: 'k'
};
describe('parseRouting', () => {
test('parses JSON and compact forms, ignores garbage', () => {
expect(parseRouting('{"btc":"btcpay","xmr":"xmrcheckout"}')).toEqual({ BTC: 'btcpay', XMR: 'xmrcheckout' });
expect(parseRouting('BTC:btcpay, XMR:xmrcheckout')).toEqual({ BTC: 'btcpay', XMR: 'xmrcheckout' });
expect(parseRouting('')).toEqual({});
expect(parseRouting('{not json')).toEqual({});
});
});
describe('buildProviderRegistry', () => {
test('native-only (no gateway env): every chain routes to native, behavior-preserving', () => {
const reg = buildProviderRegistry({ env: {}, walletService: fakeWallet });
expect(reg.getProvider('WOW').id).toBe('native-monero');
expect(reg.getProvider('XMR').id).toBe('native-monero');
expect(reg.getProvider('BTC').id).toBe('native-monero'); // default provider, no routing entry
});
test('real BTCPay + native wallet: BTC/LTC -> btcpay, XMR/WOW stay native', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'u', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k' },
walletService: fakeWallet
});
expect(reg.getProvider('BTC').id).toBe('btcpay');
expect(reg.getProvider('LTC').id).toBe('btcpay');
expect(reg.getProvider('XMR').id).toBe('native-monero');
expect(reg.getProvider('WOW').id).toBe('native-monero');
});
test('full LAN infra: BTC/LTC -> btcpay, XMR -> xmrcheckout, WOW -> wowcheckout', () => {
const reg = buildProviderRegistry({ env: CHECKOUT, walletService: fakeWallet });
expect(reg.getProvider('BTC').id).toBe('btcpay');
expect(reg.getProvider('LTC').id).toBe('btcpay');
expect(reg.getProvider('XMR').id).toBe('xmrcheckout'); // dedicated gateway wins over native
expect(reg.getProvider('WOW').id).toBe('wowcheckout');
});
test('a checkout gateway wins over the native wallet for its chain', () => {
const reg = buildProviderRegistry({
env: { XMRCHECKOUT_URL: 'http://192.0.2.33:8080', XMRCHECKOUT_STORE_ID: 'x', XMRCHECKOUT_API_KEY: 'k' },
walletService: fakeWallet
});
expect(reg.getProvider('XMR').id).toBe('xmrcheckout');
expect(reg.getProvider('WOW').id).toBe('native-monero'); // no wowcheckout -> native
});
test('BTCPAY_CHAINS widens the real BTCPay gateway', () => {
const reg = buildProviderRegistry({
env: { BTCPAY_URL: 'u', BTCPAY_STORE_ID: 's', BTCPAY_API_KEY: 'k', BTCPAY_CHAINS: 'btc,ltc,xmr' },
walletService: null
});
expect(reg.getProvider('XMR').id).toBe('btcpay');
});
test('PAYMENT_ROUTING override wins over defaults', () => {
const reg = buildProviderRegistry({
env: { ...CHECKOUT, PAYMENT_ROUTING: 'XMR:native-monero' },
walletService: fakeWallet
});
expect(reg.getProvider('XMR').id).toBe('native-monero'); // overrode the xmrcheckout default
expect(reg.getProvider('WOW').id).toBe('wowcheckout'); // untouched
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Proves GameModeManager.createPaymentRequest routes invoice creation through the payment-provider
* registry: a configured gateway is used, and with the default registry it delegates to the native
* walletService (byte-for-byte legacy path).
*/
const GameModeManager = require('../src/game/gameModeManager');
function mockDb() {
return {
query: jest.fn(async (sql) => {
const s = String(sql);
if (/FROM users WHERE id/i.test(s)) return { rows: [{ id: 7, credits: 0, total_credits_purchased: 0 }] };
if (/INSERT INTO payments/i.test(s)) return { rows: [{ id: 101, expires_at: null }] };
return { rows: [] }; // reuse lookup, expire-stale, everything else
}),
withTransaction: jest.fn(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }))
};
}
const mockDebug = { CONSOLE_LOGGING: false };
const mockConfig = {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: { direct: { enabled: true, price: 100000000000n }, credits: { enabled: false, packages: [] } },
payouts: { rules: { direct: { multipliers: {} }, credits: { enabled: false, multipliers: {} } } },
preferences: {}
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
};
describe('createPaymentRequest routes through the provider registry', () => {
test('a configured gateway provider is used for invoice creation', async () => {
const gateway = {
id: 'xmrcheckout',
createInvoice: jest.fn(async () => ({ invoiceId: 'inv-uuid', address: 'GATEWAYADDR', addressIndex: null, expiresAt: null })),
getProvider() { return this; }
};
const registry = { getProvider: jest.fn(() => gateway) };
const walletService = { createPaymentRequest: jest.fn() };
const gmm = new GameModeManager(mockDb(), walletService, mockDebug, mockConfig, registry);
const res = await gmm.createPaymentRequest('sock1', 'single_game', { userId: 7 });
expect(registry.getProvider).toHaveBeenCalledWith(gmm.cryptoType);
expect(gateway.createInvoice).toHaveBeenCalledWith(
expect.objectContaining({ chain: gmm.cryptoType, userId: 7, orderId: 'sock1' })
);
expect(res.address).toBe('GATEWAYADDR');
expect(res.invoiceId).toBe('inv-uuid');
expect(walletService.createPaymentRequest).not.toHaveBeenCalled(); // routed away from native
});
test('the default registry delegates to the native walletService (legacy path preserved)', async () => {
const walletService = {
createPaymentRequest: jest.fn(async () => ({ address: 'NATIVEADDR', addressIndex: 3, expiresAt: null }))
};
// No injected registry -> constructor builds one whose only provider wraps this walletService.
const gmm = new GameModeManager(mockDb(), walletService, mockDebug, mockConfig);
const res = await gmm.createPaymentRequest('sock2', 'single_game', { userId: 7 });
expect(walletService.createPaymentRequest).toHaveBeenCalledWith(expect.anything(), expect.any(String), 7, 'sock2');
expect(res.address).toBe('NATIVEADDR');
expect(res.invoiceId).toBe('NATIVEADDR'); // native invoices keyed by subaddress
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Modular payment-provider system (Pillar 3, reframed around BTCPay).
*/
const { PaymentProviderRegistry } = require('../src/payments/providers/paymentProvider');
const BTCPayProvider = require('../src/payments/providers/btcpayProvider');
const NativeMoneroProvider = require('../src/payments/providers/nativeMoneroProvider');
function recordingFetch(routes) {
const calls = [];
const fn = async (url, opts) => {
const method = (opts && opts.method) || 'GET';
let body = null;
try { body = opts && opts.body ? JSON.parse(opts.body) : null; } catch (_) {}
calls.push({ url, method, body });
for (const r of routes) {
if (r.method === method && r.match.test(url)) {
return { ok: r.ok !== false, status: r.status || 200, text: async () => JSON.stringify(r.body) };
}
}
return { ok: false, status: 404, text: async () => '{}' };
};
fn.calls = calls;
return fn;
}
describe('PaymentProviderRegistry routing', () => {
const btc = new BTCPayProvider({ baseUrl: 'x', storeId: 's', apiKey: 'k', chains: ['BTC', 'LTC', 'GRIN'], fetchImpl: async () => ({}) });
const native = new NativeMoneroProvider({ walletService: {}, chains: ['XMR', 'WOW'] });
test('routes each chain to its configured provider', () => {
const reg = new PaymentProviderRegistry().register(btc).register(native)
.setRouting({ BTC: 'btcpay', LTC: 'btcpay', GRIN: 'btcpay', XMR: 'native-monero', WOW: 'native-monero' });
expect(reg.getProvider('BTC').id).toBe('btcpay');
expect(reg.getProvider('GRIN').id).toBe('btcpay');
expect(reg.getProvider('WOW').id).toBe('native-monero');
});
test('a chain routed to a provider that cannot serve it falls back to one that can', () => {
const reg = new PaymentProviderRegistry().register(btc).register(native).setRouting({ XMR: 'btcpay' });
expect(reg.getProvider('XMR').id).toBe('native-monero'); // btcpay(chains) excludes XMR here
});
});
describe('BTCPayProvider (Greenfield)', () => {
test('creates an invoice priced in the chain and reads the address from payment-methods', async () => {
const fetchImpl = recordingFetch([
{ method: 'POST', match: /\/stores\/store1\/invoices$/, body: { id: 'inv1', checkoutLink: 'https://pay/inv1', expirationTime: 1700 } },
{ method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, body: [{ paymentMethod: 'BTC', destination: 'bc1qaddr', paymentLink: 'bitcoin:bc1qaddr' }] }
]);
const p = new BTCPayProvider({ baseUrl: 'https://btcpay.lan/', storeId: 'store1', apiKey: 'KEY', fetchImpl });
const inv = await p.createInvoice({ chain: 'BTC', amountAtomic: 100000000n, description: 'entry', orderId: 'o1' });
expect(inv.invoiceId).toBe('inv1');
expect(inv.address).toBe('bc1qaddr');
const post = fetchImpl.calls.find(c => c.method === 'POST');
expect(post.url).toBe('https://btcpay.lan/api/v1/stores/store1/invoices');
expect(post.body.currency).toBe('BTC');
expect(post.body.amount).toBe('1'); // 1e8 atomic / 1e8 = 1 BTC
});
test('maps invoice status to the contract', async () => {
const mk = (status) => new BTCPayProvider({
baseUrl: 'x', storeId: 's', apiKey: 'k',
fetchImpl: recordingFetch([{ method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status } }])
});
expect((await mk('Settled').getInvoiceStatus('inv1')).complete).toBe(true);
expect((await mk('New').getInvoiceStatus('inv1')).status).toBe('pending');
expect((await mk('Expired').getInvoiceStatus('inv1')).status).toBe('expired');
});
test('errors clearly when unconfigured', async () => {
const p = new BTCPayProvider({ fetchImpl: async () => ({}) });
await expect(p.getInvoiceStatus('x')).rejects.toThrow(/not configured/);
});
describe('getWalletStatus (raw shape the confirmation callback consumes)', () => {
const mk = (invStatus, methods, chains) => new BTCPayProvider({
baseUrl: 'x', storeId: 's', apiKey: 'k', chains,
fetchImpl: recordingFetch([
{ method: 'GET', match: /\/invoices\/inv1$/, body: { id: 'inv1', status: invStatus } },
{ method: 'GET', match: /\/invoices\/inv1\/payment-methods$/, body: methods }
])
});
test('Settled -> confirmed+complete with paid/required in atomic units (XMR, 12 decimals)', async () => {
const st = await mk('Settled', [{ cryptoCode: 'XMR', destination: 'a', totalPaid: '1.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1');
expect(st.confirmed).toBe(true);
expect(st.complete).toBe(true);
expect(st.in_mempool).toBe(false);
expect(st.amount).toBe(1500000000000); // 1.5 * 1e12
expect(st.required).toBe(1500000000000);
});
test('WOW uses 11 decimals', async () => {
const st = await mk('Settled', [{ cryptoCode: 'WOW', totalPaid: '1.5', amount: '1.5' }], ['WOW']).getWalletStatus('inv1');
expect(st.amount).toBe(150000000000); // 1.5 * 1e11
});
test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => {
const st = await mk('Settled', [{
cryptoCode: 'XMR',
totalPaid: '10000.000000000001',
amount: '10000.000000000001'
}], ['XMR']).getWalletStatus('inv1');
expect(st.amount).toBe('10000000000000001');
expect(st.required).toBe('10000000000000001');
});
test('Processing -> in_mempool, not confirmed', async () => {
const st = await mk('Processing', [{ cryptoCode: 'XMR', totalPaid: '0.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1');
expect(st.in_mempool).toBe(true);
expect(st.confirmed).toBe(false);
expect(st.complete).toBe(false);
expect(st.amount).toBe(500000000000);
});
test('New -> pending, no amount', async () => {
const st = await mk('New', [], ['XMR']).getWalletStatus('inv1');
expect(st.confirmed).toBe(false);
expect(st.in_mempool).toBe(false);
expect(st.amount).toBe(0);
});
test('Expired -> terminal flag so the watcher stops', async () => {
const st = await mk('Expired', [], ['XMR']).getWalletStatus('inv1');
expect(st._terminal).toBe(true);
expect(st.complete).toBe(false);
});
});
});
describe('NativeMoneroProvider wraps walletRPCService', () => {
const wallet = {
createPaymentRequest: jest.fn(async () => ({ address: 'Wo3xaddr' })),
checkPaymentStatus: jest.fn(async () => ({ confirmed: true, complete: true, amount: 100000000000, confirmations: 3 })),
processBatchPayout: jest.fn(async () => ({ tx_hash_list: ['tx1'] }))
};
test('supports only XMR/WOW; delegates invoice + status + payout', async () => {
const np = new NativeMoneroProvider({ walletService: wallet });
expect(np.supportsChain('WOW')).toBe(true);
expect(np.supportsChain('BTC')).toBe(false);
const inv = await np.createInvoice({ chain: 'WOW', amountAtomic: '100000000000', userId: 1 });
expect(inv.address).toBe('Wo3xaddr');
const st = await np.getInvoiceStatus('Wo3xaddr');
expect(st.status).toBe('paid');
expect(st.complete).toBe(true);
const po = await np.sendPayout({ chain: 'WOW', address: 'Wo3ydest', amountAtomic: '50000000000' });
expect(po.txids).toEqual(['tx1']);
expect(wallet.processBatchPayout).toHaveBeenCalledWith([{ address: 'Wo3ydest', amount: '50000000000' }]);
});
test('startWatch passes the RAW wallet status through untouched (native path preserved)', () => {
let captured;
const w = { startPaymentMonitoring: jest.fn((addr, cb) => { captured = cb; }) };
const np = new NativeMoneroProvider({ walletService: w });
const seen = [];
np.startWatch('Wo3xaddr', (s) => seen.push(s), 2000);
expect(w.startPaymentMonitoring).toHaveBeenCalledWith('Wo3xaddr', expect.any(Function), 2000);
const raw = { in_mempool: true, confirmed: false, complete: false, amount: 42, required: 100, confirmations: 0 };
captured(raw);
expect(seen[0]).toBe(raw); // same object, not normalized: callback sees exactly what the wallet emitted
});
});
+128
View File
@@ -0,0 +1,128 @@
const fs = require('fs');
const path = require('path');
const GameModeManager = require('../src/game/gameModeManager');
function chainReceipt(txHash, outputId, addressIndex, amount) {
return {
evidenceType: 'chain_output',
evidenceId: `${txHash}:${outputId}`,
providerId: 'native-monero',
txHash,
outputId,
addressIndex,
amount: String(amount),
confirmed: true
};
}
function receiptDb(rows) {
const payments = new Map(rows.map(row => [row.id, { provider_id: 'native-monero', status: 'pending', ...row }]));
const receipts = [];
const client = {
query: jest.fn(async (sql, params = []) => {
if (/SELECT id, status, expected_amount/i.test(sql)) {
const payment = payments.get(params[0]);
return { rows: payment ? [{ ...payment }] : [] };
}
if (/INSERT INTO payment_receipts/i.test(sql)) {
const candidate = {
payment_id: params[0], provider_id: params[1], evidence_type: params[2],
evidence_id: params[3], tx_hash: params[4], output_id: params[5],
address_index: params[6], amount: params[7]
};
const occupied = receipts.some(receipt => receipt.provider_id === candidate.provider_id
&& receipt.evidence_id === candidate.evidence_id);
if (!occupied) receipts.push(candidate);
return { rows: [], rowCount: occupied ? 0 : 1 };
}
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
const owned = receipts.filter(receipt => receipt.payment_id === params[0]
&& receipt.provider_id === params[1]);
const total = owned.reduce((sum, receipt) => sum + BigInt(receipt.amount), 0n);
const hashes = owned.map(receipt => receipt.tx_hash).filter(Boolean).sort();
return { rows: [{ total: total.toString(), primary_tx_hash: hashes[0] || null }] };
}
if (/UPDATE payments/i.test(sql)) {
const payment = payments.get(params[0]);
if (!payment || payment.status !== 'pending') return { rows: [] };
payment.status = 'confirmed';
payment.received_amount = params[1];
payment.tx_hash = params[2];
return { rows: [{ id: payment.id }] };
}
return { rows: [], rowCount: 0 };
})
};
return {
withTransaction: jest.fn(async callback => callback(client)),
payments,
receipts
};
}
describe('durable incoming payment receipts', () => {
test('one Monero transaction can confirm distinct invoice subaddresses, not the same output twice', async () => {
const hash = 'a'.repeat(64);
const outputOne = '1'.repeat(64);
const outputTwo = '2'.repeat(64);
const db = receiptDb([
{ id: 1, expected_amount: '100', address_index: 5 },
{ id: 2, expected_amount: '100', address_index: 6 },
{ id: 3, expected_amount: '100', address_index: 5 }
]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
await expect(manager.confirmSingleGamePayment(1, '100', [chainReceipt(hash, outputOne, 5, 100)]))
.resolves.toEqual({ updated: true });
await expect(manager.confirmSingleGamePayment(2, '100', [chainReceipt(hash, outputTwo, 6, 100)]))
.resolves.toEqual({ updated: true });
await expect(manager.confirmSingleGamePayment(3, '100', [chainReceipt(hash, outputOne, 5, 100)]))
.rejects.toThrow(/do not cover/i);
expect(db.payments.get(1).status).toBe('confirmed');
expect(db.payments.get(2).status).toBe('confirmed');
expect(db.payments.get(3).status).toBe('pending');
expect(db.receipts).toHaveLength(2);
});
test('several confirmed top-ups can exactly cover one invoice', async () => {
const db = receiptDb([{ id: 4, expected_amount: '100', address_index: 9 }]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
const result = await manager.confirmSingleGamePayment(4, '100', [
chainReceipt('b'.repeat(64), '3'.repeat(64), 9, 40),
chainReceipt('c'.repeat(64), '4'.repeat(64), 9, 60)
]);
expect(result.updated).toBe(true);
expect(db.receipts).toHaveLength(2);
expect(db.payments.get(4).received_amount).toBe('100');
});
test('distinct outputs in one transaction to one subaddress sum once despite replay', async () => {
const hash = 'd'.repeat(64);
const first = chainReceipt(hash, '5'.repeat(64), 12, 40);
const second = chainReceipt(hash, '6'.repeat(64), 12, 60);
const db = receiptDb([{ id: 5, expected_amount: '100', address_index: 12 }]);
const manager = Object.create(GameModeManager.prototype);
manager.db = db;
const result = await manager.confirmSingleGamePayment(5, '140', [first, second, { ...first }]);
expect(result).toEqual({ updated: true });
expect(db.receipts).toHaveLength(2);
expect(db.payments.get(5).received_amount).toBe('100');
});
test('migration removes txid-only uniqueness and installs the output-scoped forward guard', () => {
const sql = fs.readFileSync(path.join(__dirname, '../src/migrations/037_payment_receipt_evidence.sql'), 'utf8');
expect(sql).toMatch(/DROP INDEX IF EXISTS idx_payments_tx_hash_unique/i);
expect(sql).not.toMatch(/CREATE\s+UNIQUE\s+INDEX[\s\S]*?ON\s+payments\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
expect(sql).toMatch(/ON\s+payment_receipts\s*\(\s*provider_id\s*,\s*evidence_id\s*\)/i);
expect(sql).not.toMatch(/ON\s+payment_receipts\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
expect(sql).not.toMatch(/UNIQUE\s*\(subaddress\)/i);
expect(sql).toMatch(/evidence_id\s*=\s*tx_hash\s*\|\|\s*':'\s*\|\|\s*output_id/i);
expect(sql).toMatch(/OLD\.status IS DISTINCT FROM 'confirmed'/i);
expect(sql).toMatch(/receipt_total < NEW\.expected_amount/i);
});
});
+585
View File
@@ -0,0 +1,585 @@
const PaymentRefundService = require('../src/services/paymentRefundService');
function clone(value) {
return value == null ? value : { ...value };
}
class Mutex {
constructor() {
this.locked = false;
this.waiters = [];
}
async acquire() {
if (!this.locked) {
this.locked = true;
return () => this.release();
}
return new Promise(resolve => this.waiters.push(resolve));
}
release() {
const next = this.waiters.shift();
if (next) {
next(() => this.release());
} else {
this.locked = false;
}
}
}
/** Minimal transactional DB double implementing only the refund service's SQL. */
class FakeRefundDb {
constructor({ payment, user, grant = null, raceLot = null, packEntitlements = [], refund = null }) {
this.payment = clone(payment);
this.user = clone(user);
this.grant = clone(grant);
this.raceLot = clone(raceLot);
this.packEntitlements = packEntitlements.map(clone);
this.raceTransactions = [];
this.refund = clone(refund);
this.creditTransactions = [];
this.paymentUpdateCount = 0;
this.nextRefundId = refund?.id ? Number(refund.id) + 1 : 1;
this.paymentLock = new Mutex();
}
async getClient() {
const db = this;
let unlock = null;
return {
async query(text, params = []) {
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (sql === 'begin') return { rows: [] };
if (sql === 'commit' || sql === 'rollback') {
unlock?.();
unlock = null;
return { rows: [] };
}
if (sql.includes('select p.*') && sql.includes('from payments p')) {
unlock = await db.paymentLock.acquire();
return { rows: db.payment && Number(db.payment.id) === Number(params[0]) ? [clone(db.payment)] : [] };
}
if (sql.includes('from payment_refunds') && sql.includes('for update')) {
return { rows: db.refund ? [clone(db.refund)] : [] };
}
if (sql.includes('select id, credits, race_entries') && sql.includes('from users')) {
return { rows: db.user && Number(db.user.id) === Number(params[0]) ? [clone(db.user)] : [] };
}
if (sql.includes('from payment_entitlement_grants') && sql.includes('for update')) {
return { rows: db.grant ? [clone(db.grant)] : [] };
}
if (sql.startsWith('select id from games')) return { rows: [] };
if (sql.startsWith('update users') && sql.includes('credits = credits -')) {
db.user.credits = (BigInt(db.user.credits) - BigInt(params[0])).toString();
db.user.total_credits_purchased = (BigInt(db.user.total_credits_purchased) - BigInt(params[1])).toString();
db.user.race_entries -= Number(params[2]);
db.user.premium_level = params[3];
return { rows: [clone(db.user)] };
}
if (sql.startsWith('insert into credit_transactions')) {
db.creditTransactions.push({
user_id: params[0],
amount: params[1],
reason: params[2],
balance_after: params[3],
transaction_type: 'refund'
});
return { rows: [] };
}
if (sql.startsWith('insert into race_entry_transactions')) {
db.raceTransactions.push({ delta: params[1], payment_id: params[3] });
return { rows: [] };
}
if (sql.startsWith('update payment_entitlement_grants')) {
if (sql.includes("status = 'needs_review'")) {
db.grant.status = 'needs_review';
db.grant.needs_review_at = new Date();
} else {
db.grant.status = 'reversed';
db.grant.credits_reversed = params[1];
db.grant.purchase_progress_reversed = params[2];
db.grant.race_entries_reversed = params[3];
db.grant.reversed_at = new Date();
}
return { rows: [clone(db.grant)], rowCount: 1 };
}
if (sql.includes('from race_entry_lots')) return { rows: db.raceLot ? [clone(db.raceLot)] : [] };
if (sql.startsWith('update race_entry_lots')) {
if (!db.raceLot || db.raceLot.refunded_at != null
|| db.raceLot.remaining_entries !== db.raceLot.original_entries) return { rows: [], rowCount: 0 };
db.raceLot.remaining_entries = 0;
db.raceLot.refunded_at = new Date();
return { rows: [{ id: db.raceLot.id }], rowCount: 1 };
}
if (sql.includes('from user_pack_entitlements') && sql.includes('for update')) {
return { rows: db.packEntitlements.map(clone) };
}
if (sql.startsWith('delete from user_pack_entitlements')) {
const wanted = new Set(params[1]);
const removed = db.packEntitlements.filter(row => wanted.has(row.pack_id)
&& String(row.metadata?.paymentId) === String(params[2]));
db.packEntitlements = db.packEntitlements.filter(row => !removed.includes(row));
return { rows: removed.map(row => ({ pack_id: row.pack_id })), rowCount: removed.length };
}
if (sql.startsWith('update payments')) {
db.payment.status = 'refunded';
db.paymentUpdateCount += 1;
return { rows: [] };
}
if (sql.startsWith('insert into payment_refunds')) {
const needsReview = sql.includes("'needs_review'");
db.refund = {
id: db.nextRefundId++,
payment_id: params[0],
user_id: params[1],
status: needsReview ? 'needs_review' : params[2],
amount: needsReview ? params[2] : params[3],
payout_address: needsReview ? params[3] : params[4],
credits_deducted: needsReview ? '0' : params[5],
purchase_progress_deducted: needsReview ? '0' : params[6],
race_entries_deducted: needsReview ? 0 : params[7],
packs_revoked: needsReview ? [] : JSON.parse(params[8]),
premium_level_restored: needsReview ? null : params[9],
reason: needsReview ? params[4] : params[10],
tx_hash: null,
error_message: needsReview ? params[5] : null,
requested_at: needsReview || params[2] === 'requested' ? new Date() : null,
processing_started_at: null,
completed_at: null,
needs_review_at: needsReview ? new Date() : null
};
return { rows: [clone(db.refund)] };
}
throw new Error(`Unexpected transaction SQL: ${sql}`);
},
release() {
unlock?.();
unlock = null;
}
};
}
async query(text, params = []) {
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'processing'")) {
if (!this.refund || Number(this.refund.payment_id) !== Number(params[0]) || this.refund.status !== 'requested') {
return { rows: [] };
}
this.refund.status = 'processing';
this.refund.processing_started_at = new Date();
this.refund.error_message = null;
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'completed'")) {
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
return { rows: [] };
}
this.refund.status = 'completed';
this.refund.tx_hash = params[1];
this.refund.completed_at = new Date();
this.refund.error_message = null;
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'needs_review'")) {
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
return { rows: [] };
}
this.refund.status = 'needs_review';
this.refund.tx_hash = params[1] || this.refund.tx_hash;
this.refund.error_message = params[2];
this.refund.needs_review_at = new Date();
return { rows: [clone(this.refund)] };
}
if (sql.startsWith('select *') && sql.includes('from payment_refunds')) {
return { rows: this.refund && Number(this.refund.payment_id) === Number(params[0]) ? [clone(this.refund)] : [] };
}
throw new Error(`Unexpected pool SQL: ${sql}`);
}
}
function fixture(overrides = {}) {
return new FakeRefundDb({
payment: {
id: 42,
user_id: 7,
expected_amount: '1000',
received_amount: '900',
credits_purchased: '6',
status: 'confirmed',
description: 'Credits purchase',
...overrides.payment
},
user: {
id: 7,
credits: '6',
race_entries: 0,
total_credits_purchased: '6',
premium_level: 'free',
payout_address: '5'.repeat(95),
...overrides.user
},
grant: overrides.grant === undefined ? {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '6',
purchase_progress_granted: '6',
race_entries_granted: 0,
packs_granted: [],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
} : overrides.grant,
raceLot: overrides.raceLot || null,
packEntitlements: overrides.packEntitlements || [],
refund: overrides.refund || null
});
}
describe('PaymentRefundService', () => {
test('the global payout gate refuses an automatic refund before any durable or wallet side effect', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({
db,
walletService,
logger,
isSendEnabled: () => false
});
await expect(service.refundPayment({ paymentId: 42, sendFunds: true }))
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.refund).toBeNull();
expect(db.paymentUpdateCount).toBe(0);
});
test('a kill switch raised after the refund claim prevents the wallet call and strands safely for review', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const isSendEnabled = jest.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const service = new PaymentRefundService({
db,
walletService,
logger,
isSendEnabled
});
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(isSendEnabled).toHaveBeenCalledTimes(2);
});
const txHash = 'a'.repeat(64);
const logger = { error: jest.fn() };
beforeEach(() => logger.error.mockClear());
test('concurrent transfer requests deduct and broadcast exactly once', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const results = await Promise.all([
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true }),
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true })
]);
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
expect(db.paymentUpdateCount).toBe(1);
expect(db.payment.description).toBe('Credits purchase');
expect(db.user.credits).toBe('0');
expect(db.user.total_credits_purchased).toBe('0');
expect(db.creditTransactions).toHaveLength(1);
expect(db.creditTransactions[0].amount).toBe('-6');
expect(db.refund.status).toBe('completed');
expect(db.refund.amount).toBe('900');
expect(db.refund.tx_hash).toBe(txHash);
expect(results.filter(result => result.existing)).toHaveLength(1);
});
test('reverses exact purchase progress and a premium tier granted solely by the payment', async () => {
const Entitlements = require('../src/multiplayer/entitlements');
const db = fixture({
user: {
credits: '6',
total_credits_purchased: '6',
premium_level: 'supporter'
},
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '6',
purchase_progress_granted: '6',
race_entries_granted: 0,
packs_granted: [],
premium_level_granted: 'supporter',
premium_level_previous: 'free',
status: 'active'
}
});
const before = Entitlements.snapshotForUser(db.user);
expect(before.packs['generated-skins']).toBe(true);
const service = new PaymentRefundService({ db, walletService: null, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: false });
const after = Entitlements.snapshotForUser(db.user);
expect(result.refund.status).toBe('recorded');
expect(db.user.credits).toBe('0');
expect(db.user.total_credits_purchased).toBe('0');
expect(db.user.premium_level).toBe('free');
expect(after.packs['generated-skins']).toBe(false);
expect(db.grant).toEqual(expect.objectContaining({
status: 'reversed',
credits_reversed: '6',
purchase_progress_reversed: '6'
}));
expect(db.refund).toEqual(expect.objectContaining({
credits_deducted: '6',
purchase_progress_deducted: '6',
premium_level_restored: 'free'
}));
});
test('changed purchase progress routes the whole refund to review without revoking benefits', async () => {
const db = fixture({ user: { credits: '6', total_credits_purchased: '5' } });
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('progression cannot be reversed safely');
expect(db.user.credits).toBe('6');
expect(db.user.total_credits_purchased).toBe('5');
expect(db.payment.status).toBe('confirmed');
expect(walletService.processPayout).not.toHaveBeenCalled();
});
test('an unconsumed direct entry with unscoped purchase progress cannot auto-refund', async () => {
const db = fixture({
payment: { payment_type: 'single_game', credits_purchased: '0' },
user: { credits: '0', total_credits_purchased: '1' },
grant: null
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('purchase progression requires manual reconciliation');
expect(db.user.total_credits_purchased).toBe('1');
expect(db.payment.status).toBe('confirmed');
expect(walletService.processPayout).not.toHaveBeenCalled();
});
test('record-only requests are durable, terminal, and idempotent', async () => {
const db = fixture();
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(first.refund.status).toBe('recorded');
expect(second.refund.status).toBe('recorded');
expect(second.existing).toBe(true);
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(1);
expect(db.creditTransactions).toHaveLength(1);
});
test('ambiguous wallet failure moves to needs_review and is never retried', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockRejectedValue(new Error('timeout after transfer submission'))
};
const service = new PaymentRefundService({ db, walletService, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: true });
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(first.refund.status).toBe('needs_review');
expect(first.refund.error_message).toContain('timeout');
expect(second.refund.status).toBe('needs_review');
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
expect(db.paymentUpdateCount).toBe(1);
expect(db.creditTransactions).toHaveLength(1);
});
test('success without a valid transaction hash is treated as ambiguous', async () => {
const db = fixture();
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('valid transaction hash');
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
});
test('consumed purchased credits require review and no wallet transfer', async () => {
const db = fixture({ user: { credits: '5' } });
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('already been consumed');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.credits).toBe('5');
expect(db.payment.status).toBe('confirmed');
expect(db.creditTransactions).toHaveLength(0);
});
test('unused race tickets and payment-owned packs reverse exactly once', async () => {
const db = fixture({
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '0',
purchase_progress_granted: '0',
race_entries_granted: 2,
packs_granted: [{ id: 'kenney-3d-characters' }],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
},
raceLot: { id: 8, original_entries: 2, remaining_entries: 2, refunded_at: null },
packEntitlements: [{
pack_id: 'kenney-3d-characters',
source: 'product_purchase',
metadata: { paymentId: 42 }
}]
});
const service = new PaymentRefundService({ db, walletService: null, logger });
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
const second = await service.refundPayment({ paymentId: 42, sendFunds: false });
expect(first.refund.status).toBe('recorded');
expect(second.existing).toBe(true);
expect(db.user.race_entries).toBe(0);
expect(db.raceLot.remaining_entries).toBe(0);
expect(db.raceLot.refunded_at).toBeTruthy();
expect(db.packEntitlements).toHaveLength(0);
expect(db.raceTransactions).toHaveLength(1);
expect(db.grant.status).toBe('reversed');
});
test('an escrowed race ticket makes the whole refund needs_review', async () => {
const db = fixture({
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
grant: {
payment_id: 42,
user_id: 7,
source: 'product_confirmation',
credits_granted: '0',
purchase_progress_granted: '0',
race_entries_granted: 2,
packs_granted: [],
premium_level_granted: null,
premium_level_previous: null,
status: 'active'
},
raceLot: { id: 8, original_entries: 2, remaining_entries: 1, refunded_at: null }
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.error_message).toContain('consumed or escrowed');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.race_entries).toBe(2);
expect(db.raceLot.remaining_entries).toBe(1);
});
test('a pending unpaid invoice cannot deduct credits or send its expected amount', async () => {
const db = fixture({
payment: { status: 'pending', received_amount: '0' }
});
const walletService = {
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
};
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('needs_review');
expect(result.refund.amount).toBe('0');
expect(result.refund.error_message).toContain('confirmed payment');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.user.credits).toBe('6');
expect(db.creditTransactions).toHaveLength(0);
});
test('a stranded processing row requires review and is never resent', async () => {
const db = fixture({
payment: { status: 'refunded' },
refund: {
id: 11,
payment_id: 42,
user_id: 7,
status: 'processing',
amount: '900',
payout_address: '5'.repeat(95),
credits_deducted: '4',
reason: 'Admin refund',
tx_hash: null,
error_message: null,
requested_at: new Date(),
processing_started_at: new Date()
}
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.refund.status).toBe('processing');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(0);
expect(db.creditTransactions).toHaveLength(0);
});
test('a legacy refunded payment is imported without repeating unknown side effects', async () => {
const db = fixture({
payment: { status: 'refunded', received_amount: '900' }
});
const walletService = { processPayout: jest.fn() };
const service = new PaymentRefundService({ db, walletService, logger });
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
expect(result.legacyImported).toBe(true);
expect(result.existing).toBe(true);
expect(result.refund.status).toBe('recorded');
expect(result.refund.amount).toBe('900');
expect(walletService.processPayout).not.toHaveBeenCalled();
expect(db.paymentUpdateCount).toBe(0);
expect(db.user.credits).toBe('6');
expect(db.creditTransactions).toHaveLength(0);
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* 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]).toBeNull(); // no valid transaction hash was observed
expect(reviewCall[1][3]).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 = \$4/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 durably records every committed liability', () => {
test('quarantines a liability when the paying identity cannot be resolved', async () => {
const { gmm, db } = buildGmm();
db.withTransaction.mockImplementation(async (cb) => cb(db));
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, /* no user_id */ game_mode: 'PAID_SINGLE', payout_address: 'addr' }] }) // game record
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
.mockResolvedValueOnce({ rows: [{ id: 88 }], rowCount: 1 }); // needs_review liability
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 88, status: 'needs_review' }));
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(inserted).toBeDefined();
expect(inserted[1]).toContain('solo_winner_identity_review');
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_liability_needs_review', 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.withTransaction.mockImplementation(async (cb) => cb(db));
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.withTransaction.mockImplementation(async (cb) => cb(db));
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();
});
});
+165
View File
@@ -0,0 +1,165 @@
const QueueManager = require('../src/network/queueManager');
const PaymentHandlers = require('../src/network/paymentHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
function legalAcknowledgement(gameModeManager) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: disclosure.service.isTestNetwork === true
};
}
/**
* Integration-style tests for the payment confirmation flow.
*
* 1. A payment that confirms AFTER a block event (entry still unconfirmed) results
* in an immediate game start without waiting for the next block.
* 2. (Phase 0.1 security gate) A payment that is confirmed but UNDERPAID
* (received < expected) must NOT start a game or grant anything.
*/
const blockHeight = 100;
const socketId = 'sock-int';
/**
* Build a fresh PaymentHandlers + QueueManager harness with controllable wallet
* monitoring. Returns the pieces the tests need.
*/
function buildHarness() {
const events = [];
const activeGames = new Map();
const io = {
to: () => ({ emit: (event, data) => events.push({ event, data }) })
};
const broadcastManager = {
sendStatusUpdate: (id, type, message) => events.push({ event: 'status', data: { type, message } }),
broadcastChatMessage: () => {}
};
const debugManager = {
CONSOLE_LOGGING: false,
getCurrentBlockHeight: () => blockHeight
};
const userObj = { id: socketId, clientId: 'client-int', payout_address: 'wow1address' };
const getUserBySocket = (id) => (id === socketId ? userObj : null);
const createGameForUser = (user) => {
const game = {
getState: () => ({ player: { x: 0, y: 0 }, lighting: {}, torches: [], started: true })
};
activeGames.set(user.id, game);
return game;
};
const gameModeManager = {
gameMode: 'PAID_SINGLE',
cryptoType: 'WOW',
currencyDecimals: 11,
singleGamePrice: 1000,
creditsPayoutEnabled: false,
formatAtomicHuman: () => '0.001',
getOrCreateUser: async () => ({ id: socketId, payout_address: 'wow1address', clientId: 'client-int' }),
createPaymentRequest: async () => ({
id: 'pay-1',
address: 'addr-1',
amount: 1000,
amountFormatted: '0.001',
expiresAt: new Date(Date.now() + 60000)
}),
processGameStart: async () => ({ success: true, effectiveMode: 'PAID_SINGLE' }),
db: {
query: async (sql) => {
if (sql && sql.includes('UPDATE payments') && sql.includes('RETURNING')) {
return { rows: [{ id: 'pay-1' }] };
}
return { rows: [] };
}
}
};
let monitorCallback;
const walletService = {
startPaymentMonitoring: (address, cb) => { monitorCallback = cb; },
stopPaymentMonitoring: () => {}
};
const queueManager = new QueueManager({
debugManager, broadcastManager, io, createGameForUser, getUserBySocket,
activeGames, gameModeManager, consoleLogging: false
});
const paymentHandlers = new PaymentHandlers({
io, gameModeManager, walletService, debugManager, queueManager, broadcastManager
});
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
return {
events, queueManager, paymentHandlers, fakeSocket, gameModeManager,
getMonitorCallback: () => monitorCallback
};
}
describe('Payment flow integration: confirm-after-block immediate start', () => {
test('unconfirmed at block tick then confirmed triggers immediate game', async () => {
const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness();
await paymentHandlers.createAndShowPaymentRequest(fakeSocket, {
legalAcknowledgement: legalAcknowledgement(gameModeManager)
});
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
// Simulate mempool detection first
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1000, confirmations: 0 });
expect(queueManager.getQueueLength()).toBe(1);
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
// Block tick BEFORE confirmation (should skip unconfirmed)
await queueManager.startGamesForWaiting(blockHeight);
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
expect(queueManager.getQueueLength()).toBe(1);
// Confirmation after the block. `complete: true` means the received amount covers
// the expected amount: required for a game to start (Phase 0.1 gate).
await monitorCallback({ in_mempool: true, confirmed: true, complete: true, amount: 1000, required: 1000, confirmations: 1 });
const gameStarts = events.filter(e => e.event === 'game_start');
expect(gameStarts.length).toBe(1);
expect(gameStarts[0].data.blockHeight).toBe(blockHeight);
expect(queueManager.getQueueLength()).toBe(0);
});
test('confirmed but UNDERPAID does not start a game or grant anything', async () => {
const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness();
await paymentHandlers.createAndShowPaymentRequest(fakeSocket, {
legalAcknowledgement: legalAcknowledgement(gameModeManager)
});
const monitorCallback = getMonitorCallback();
expect(monitorCallback).toBeDefined();
// Mempool detection queues the player (queueing is gated on confirmation downstream)
await monitorCallback({ in_mempool: true, confirmed: false, amount: 1, confirmations: 0 });
// Confirmed on-chain, but only 1 of 1000 atomic units received → complete is false.
await monitorCallback({ in_mempool: true, confirmed: true, complete: false, amount: 1, required: 1000, confirmations: 1 });
// No game must start, and the user must be warned about the underpayment.
expect(events.filter(e => e.event === 'game_start').length).toBe(0);
expect(events.filter(e => e.event === 'payment_underpaid').length).toBe(1);
const underpaid = events.find(e => e.event === 'payment_underpaid');
expect(underpaid.data.received).toBe(1);
expect(underpaid.data.required).toBe(1000);
expect(underpaid.data.shortfall).toBe(999);
// Repeated underpaid polls must not spam the user (notify once).
await monitorCallback({ in_mempool: true, confirmed: true, complete: false, amount: 1, required: 1000, confirmations: 2 });
expect(events.filter(e => e.event === 'payment_underpaid').length).toBe(1);
});
});
+729
View File
@@ -0,0 +1,729 @@
/**
* Comprehensive Payment and Payout Tests
* Deep testing of all payment flows, credits, payouts, and edge cases
*/
const GameModeManager = require('../src/game/gameModeManager');
// Mock dependencies
const createMockDb = () => {
const mockClient = {
query: jest.fn().mockResolvedValue({ rows: [] })
};
return {
query: jest.fn(),
// Mock withTransaction to execute the callback with a shared mock client
// so tests can set up client.query expectations
withTransaction: jest.fn().mockImplementation(async (callback) => {
return callback(mockClient);
}),
_mockClient: mockClient // Expose for test setup
};
};
const createMockWalletService = () => ({
createPaymentRequest: jest.fn(),
processPayout: jest.fn(),
startPaymentMonitoring: jest.fn(),
stopPaymentMonitoring: jest.fn()
});
const createMockDebugManager = () => ({
CONSOLE_LOGGING: false,
getCurrentBlockHeight: () => 12345
});
const createMockPaymentConfig = (overrides = {}) => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [
{ id: 'small', credits: 10, price: '500000000000', bonus: 0 },
{ id: 'medium', credits: 25, price: '1000000000000', bonus: 2 }
] }
},
payouts: {
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: true, allowInFreeMode: true, allowInCreditsMode: true },
...overrides
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
describe('Payment & Payout Comprehensive Tests', () => {
let gmm;
let mockDb;
let mockWalletService;
let mockDebugManager;
let mockPaymentConfig;
beforeEach(() => {
jest.clearAllMocks();
mockDb = createMockDb();
mockWalletService = createMockWalletService();
mockDebugManager = createMockDebugManager();
mockPaymentConfig = createMockPaymentConfig();
gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfig);
});
afterEach(() => {
gmm?.shutdown();
});
describe('Credit Deduction - Negative Balance Prevention', () => {
test('should fail gracefully when user has insufficient credits', async () => {
const user = { id: 1, credits: 0 };
gmm.creditsPerGameCost = 1;
// The credits update should return 0 rows because WHERE credits >= 1 fails
// (when user.credits = 0 and we require >= 1)
mockDb.query.mockResolvedValueOnce({ rows: [] }); // UPDATE users ... WHERE credits >= 1 - returns empty!
const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123');
expect(result.success).toBe(false);
expect(result.reason).toBe('Insufficient credits');
expect(result.creditsRequired).toBe(1);
});
test('should succeed when user has exactly enough credits', async () => {
const user = { id: 1, credits: 1 };
gmm.creditsPerGameCost = 1;
// All queries inside withTransaction go through client.query
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ credits: 0 }] }) // UPDATE users ... WHERE credits >= 1
.mockResolvedValueOnce({ rows: [] }) // UPDATE games
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123');
expect(result.success).toBe(true);
expect(result.creditsRemaining).toBe(0);
expect(result.creditsSpent).toBe(1);
});
test('should deduct correct amount for multi-credit games', async () => {
const user = { id: 1, credits: 5 };
gmm.creditsPerGameCost = 2;
// All queries inside withTransaction go through client.query
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ credits: 3 }] }) // UPDATE users
.mockResolvedValueOnce({ rows: [] }) // UPDATE games
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
const result = await gmm._processGameStartWithCredits(user, 'socket1', 'game123');
expect(result.success).toBe(true);
expect(result.creditsRemaining).toBe(3);
expect(result.creditsSpent).toBe(2);
});
});
describe('Payout Calculation', () => {
test('direct mode escape payout calculated correctly', () => {
gmm.singleGamePrice = 100000000000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false });
expect(result.amount).toBe(200000000000);
expect(result.multiplier).toBe(2);
expect(result.base).toBe(100000000000);
});
test('direct mode treasure payout calculated correctly', () => {
gmm.singleGamePrice = 100000000000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: true });
expect(result.amount).toBe(300000000000);
expect(result.multiplier).toBe(3);
});
test('credits mode uses base value not single game price', () => {
gmm.singleGamePrice = 100000000000;
gmm.creditsPayoutBaseValue = 50000000000;
gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false });
expect(result.amount).toBe(100000000000); // 50000000000 * 2
expect(result.base).toBe(50000000000);
});
test('zero multiplier returns zero payout', () => {
gmm.creditPayoutMultipliers = { escape: 0, escapeWithTreasure: 0 };
gmm.creditsPayoutBaseValue = 100000000000;
const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false });
expect(result.amount).toBe(0);
});
test('fractional multipliers work correctly', () => {
gmm.singleGamePrice = 100000000000;
gmm.directPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2.5 };
const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false });
expect(result.amount).toBe(150000000000);
});
});
describe('Mixed Mode Payment Selection', () => {
beforeEach(() => {
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = true;
gmm.creditsPerGameCost = 1;
});
test('prefers credits when user has credits and preferCreditsFirst is true', async () => {
gmm.preferCreditsFirst = true;
const user = { id: 1, credits: 5, payout_address: 'wow1addr' };
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(true);
expect(result.useCredits).toBe(true);
expect(result.effectiveMode).toBe('PAID_CREDITS');
});
test('uses direct payment when user has no credits', async () => {
gmm.preferCreditsFirst = true;
const user = { id: 1, credits: 0, payout_address: 'wow1addr' };
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
// Has confirmed payment
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 123 }] }); // confirmed payment query
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(true);
expect(result.paymentId).toBe(123);
expect(result.effectiveMode).toBe('PAID_SINGLE');
});
test('denies when no credits and no confirmed payment', async () => {
const user = { id: 1, credits: 0, payout_address: 'wow1addr' };
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no confirmed payment
// getPaymentOptionsForUser will be called
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser again
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
const result = await gmm.canUserStartGame('socket1');
expect(result.allowed).toBe(false);
expect(result.action).toBe('choose_payment');
});
});
describe('Credits Package Confirmation', () => {
test('adds credits from package info correctly', async () => {
// Step 1: db.query runs the SELECT payment lookup (has user_id, status='pending')
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: '', status: 'pending' }]
});
// Step 2: withTransaction runs these client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) // snapshot product promise
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // mark payment confirmed
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) // lock user
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15 }] })
.mockResolvedValueOnce({ rows: [] }) // credit transaction
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) // entitlement grant
.mockResolvedValueOnce({ rows: [] }); // active pack grants
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(15);
expect(result.newBalance).toBe(15);
});
test('parses credits from payment description as fallback', async () => {
// Step 1: db.query runs the SELECT payment lookup with description
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: 'Wowngeon 25 credits package (WOW)', status: 'pending' }]
});
// Step 2: withTransaction runs these client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 25, total_credits_purchased: 25 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [] });
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(25); // Parsed from description
});
test('defaults to 10 credits when no info available', async () => {
// Step 1: db.query runs the SELECT payment lookup with non-matching description
mockDb.query.mockResolvedValueOnce({
rows: [{ user_id: 1, description: 'Some payment', status: 'pending' }]
});
// Step 2: withTransaction runs these client.query calls:
mockDb._mockClient.query
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 10, total_credits_purchased: 10 }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [] });
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
expect(result.success).toBe(true);
expect(result.creditsAdded).toBe(10); // Default fallback
});
});
describe('Game Completion & Payout Flow', () => {
beforeEach(() => {
gmm.gameMode = 'PAID_SINGLE';
gmm.singleGamePrice = 100000000000;
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
// Completion owns a real transaction; route every statement through the ordered
// root query mock so these tests exercise completion + obligation atomically.
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
});
test('processes winning game with payout correctly', async () => {
// Game record lookup
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
// Game update
mockDb.query.mockResolvedValueOnce({ rows: [] });
// Check for existing payout
mockDb.query.mockResolvedValueOnce({ rows: [] });
// User lookup
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
});
// INSERT payout (pending record) - returns new payout id
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 50 });
expect(result.success).toBe(true);
expect(result.payout).toBeDefined();
expect(result.payout.status).toBe('queued');
expect(result.payout.payoutId).toBe(999);
expect(result.payout.amount).toBe(200000000000);
expect(result.payout.multiplier).toBe(2);
// processPayout is NOT called directly: payout is batched via _scheduleBatchPayout
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('treasure bonus applied correctly to payout', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] });
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
});
// INSERT payout (pending record)
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 30 });
expect(result.payout.status).toBe('queued');
expect(result.payout.amount).toBe(300000000000); // 3x for treasure
expect(result.payout.multiplier).toBe(3);
expect(result.payout.treasure).toBe(true);
});
test('no payout for losing game', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
const result = await gmm.completeGame('socket1', 'game123', false, false, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('no payout when user has no payout address', async () => {
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({
rows: [{ id: 1, payout_address: null }] // No address
});
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1001 }], rowCount: 1 }); // review liability
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
expect(result.success).toBe(true);
expect(result.payout).toEqual(expect.objectContaining({
payoutId: 1001,
status: 'needs_review'
}));
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('prevents duplicate payout for same game', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
// Existing payout found
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(result.reason).toBe('Payout already processed');
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
});
test('uses recorded payment_mode not current gameMode', async () => {
// Current global mode is PAID_SINGLE
gmm.gameMode = 'PAID_SINGLE';
// But the game was started in credits mode
gmm.creditsPayoutEnabled = true;
gmm.creditsPayoutBaseValue = 50000000000;
gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 };
mockDb.query.mockResolvedValueOnce({
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
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
});
// INSERT payout (pending record)
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999 }]
});
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
// Should use credits multipliers, not direct
expect(result.payout.status).toBe('queued');
expect(result.payout.amount).toBe(75000000000); // 50000000000 * 1.5
expect(result.payout.multiplier).toBe(1.5);
});
});
describe('Payment Request Creation', () => {
test('creates single_game payment request correctly', async () => {
const user = { id: 1, credits: 0 };
gmm.singleGamePrice = 100000000000;
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
address: 'wow1paymentaddr',
expiresAt: new Date()
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999, expires_at: new Date() }]
}); // INSERT payment
const result = await gmm.createPaymentRequest('socket1', 'single_game');
expect(result.address).toBe('wow1paymentaddr');
expect(result.amount).toBe(100000000000);
expect(result.paymentType).toBe('single_game');
expect(mockWalletService.createPaymentRequest).toHaveBeenCalledWith(
100000000000,
expect.stringContaining('single game'),
1,
'socket1'
);
});
test('creates credits_package payment request correctly', async () => {
const user = { id: 1, credits: 0 };
gmm.creditsPackagePrice = 500000000000;
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
address: 'wow1creditspayment',
expiresAt: new Date()
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1000, expires_at: new Date() }]
}); // INSERT payment
const result = await gmm.createPaymentRequest('socket1', 'credits_package');
expect(result.address).toBe('wow1creditspayment');
expect(result.paymentType).toBe('credits_package');
expect(result.package).toBeDefined();
});
test('creates cosmetic_pack payment request with durable product grants', async () => {
const user = { id: 1, credits: 0 };
gmm.configSnapshot.products = {
cosmetic: [{
id: 'pack_3d',
label: '3D Character Pack',
price: 250000000000n,
grants: { packs: ['kenney-3d-characters'] }
}]
};
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
address: 'wow1packpayment',
expiresAt: new Date()
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1001, expires_at: new Date() }]
}); // INSERT payment
const result = await gmm.createPaymentRequest('socket1', 'cosmetic_pack', { productId: 'pack_3d' });
expect(result.address).toBe('wow1packpayment');
expect(result.paymentType).toBe('cosmetic_pack');
expect(result.productId).toBe('pack_3d');
expect(result.grants.packs).toEqual(['kenney-3d-characters']);
expect(mockWalletService.createPaymentRequest).toHaveBeenCalledWith(
250000000000,
expect.stringContaining('3D Character Pack'),
1,
'socket1'
);
const insertCall = mockDb.query.mock.calls.find(([sql]) => /INSERT INTO payments/i.test(sql));
expect(insertCall[1][8]).toBe('pack_3d');
expect(JSON.parse(insertCall[1][9]).packs[0].id).toBe('kenney-3d-characters');
});
test('reuses existing pending payment request', async () => {
const user = { id: 1, credits: 0 };
gmm.singleGamePrice = 100000000000;
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
// Existing pending payment
mockDb.query.mockResolvedValueOnce({
rows: [{
id: 555,
subaddress: 'wow1existingaddr',
expected_amount: '100000000000',
payment_type: 'single_game',
status: 'pending',
expires_at: new Date(Date.now() + 1800000)
}]
});
const result = await gmm.createPaymentRequest('socket1', 'single_game', { reuseExisting: true });
expect(result.id).toBe(555);
expect(result.address).toBe('wow1existingaddr');
expect(result.reused).toBe(true);
// Should not create a new payment
expect(mockWalletService.createPaymentRequest).not.toHaveBeenCalled();
});
test('throws on invalid payment type', async () => {
const user = { id: 1, credits: 0 };
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
await expect(gmm.createPaymentRequest('socket1', 'invalid_type'))
.rejects.toThrow(/Invalid payment type/);
});
});
describe('Game Mode Info', () => {
test('returns complete game mode info', () => {
gmm.gameMode = 'PAID_SINGLE';
gmm.cryptoType = 'WOW';
gmm.singleGamePrice = 100000000000;
gmm.paymentsEnabled = true;
gmm.directModeEnabled = true;
gmm.creditsModeEnabled = false;
const info = gmm.getGameModeInfo();
expect(info.mode).toBe('PAID_SINGLE');
expect(info.cryptoType).toBe('WOW');
expect(info.singleGamePrice).toBe(100000000000);
expect(info.paymentsEnabled).toBe(true);
expect(info.directModeEnabled).toBe(true);
expect(info.creditsModeEnabled).toBe(false);
expect(info.earlyEntry).toBeDefined();
expect(info.payoutMultipliers).toBeDefined();
});
test('includes testnet warning for XMR stagenet', () => {
gmm.cryptoType = 'XMR';
gmm.network = 'stagenet';
gmm.isTestNetwork = true;
const info = gmm.getGameModeInfo();
expect(info.isTestNetwork).toBe(true);
expect(info.testnetWarning).toContain('STAGENET');
});
test('no testnet warning for WOW (only has mainnet)', () => {
gmm.cryptoType = 'WOW';
gmm.network = 'mainnet';
gmm.isTestNetwork = false;
const info = gmm.getGameModeInfo();
expect(info.isTestNetwork).toBe(false);
expect(info.testnetWarning).toBeNull();
});
});
describe('User Stats', () => {
test('returns user statistics from database function', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{
total_games: 100,
games_won: 45,
win_rate: 0.45,
total_paid: 5000000000000,
total_won: 6000000000000,
net_profit: 1000000000000,
credits_remaining: 15
}]
});
const stats = await gmm.getUserStats('socket1');
expect(stats.total_games).toBe(100);
expect(stats.games_won).toBe(45);
expect(stats.net_profit).toBe(1000000000000);
});
test('returns default stats when none found', async () => {
mockDb.query.mockResolvedValueOnce({ rows: [] });
const stats = await gmm.getUserStats('socket1');
expect(stats.total_games).toBe(0);
expect(stats.games_won).toBe(0);
expect(stats.net_profit).toBe(0);
});
});
});
describe('Edge Cases and Error Handling', () => {
let gmm;
let mockDb;
let mockWalletService;
beforeEach(() => {
mockDb = createMockDb();
mockWalletService = createMockWalletService();
gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig());
});
afterEach(() => {
gmm?.shutdown();
});
test('handles payout insert failure gracefully', async () => {
gmm.gameMode = 'PAID_SINGLE';
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
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({
rows: [{ id: 1, payout_address: 'wow1testaddress' }]
});
// INSERT payout fails
mockDb.query.mockRejectedValueOnce(new Error('Database connection lost'));
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
// The payout insert and game completion share one transaction. A failed obligation
// therefore fails/rolls back completion instead of leaving a completed unpaid win.
expect(result.success).toBe(false);
expect(result.payout).toBeUndefined();
expect(result.error).toBeDefined();
});
test('handles database error during credits deduction', async () => {
const user = { id: 1, credits: 5 };
// _processGameStartWithCredits uses withTransaction, so error must come from there
mockDb.withTransaction.mockRejectedValueOnce(new Error('Database connection lost'));
await expect(gmm._processGameStartWithCredits(user, 'socket1', 'game123'))
.rejects.toThrow('Database connection lost');
});
test('handles payment creation when wallet is unavailable', async () => {
const user = { id: 1 };
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
mockWalletService.createPaymentRequest.mockRejectedValueOnce(
new Error('RPC connection refused')
);
await expect(gmm.createPaymentRequest('socket1', 'single_game'))
.rejects.toThrow();
});
});
+171
View File
@@ -0,0 +1,171 @@
const GameModeManager = require('../src/game/gameModeManager');
const {
PAYOUT_ADMISSION_LOCK_KEY,
reservePayoutCapacity
} = require('../src/services/payoutAdmissionService');
describe('serialized payout-liability admission', () => {
const oldCritical = process.env.BALANCE_CRITICAL;
beforeEach(() => { process.env.BALANCE_CRITICAL = '50'; });
afterAll(() => {
if (oldCritical === undefined) delete process.env.BALANCE_CRITICAL;
else process.env.BALANCE_CRITICAL = oldCritical;
});
function capacityClient() {
const calls = [];
return {
calls,
query: jest.fn(async (sql, params) => {
calls.push({ sql, params });
if (/AS payout_rows/i.test(sql)) {
return { rows: [{ payout_rows: '100', solo_commitments: '200', match_commitments: '300' }] };
}
return { rows: [] };
})
};
}
test('requires unlocked balance for outstanding + new liability + reserve under one advisory lock', async () => {
const client = capacityClient();
const walletService = { isHealthy: true, getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '1050' }) };
const result = await reservePayoutCapacity({
client,
walletService,
newLiability: '400',
gameModeManager: null
});
expect(result.required).toBe(1050n);
expect(result.outstanding).toBe(600n);
expect(client.calls[0]).toEqual(expect.objectContaining({
params: [PAYOUT_ADMISSION_LOCK_KEY]
}));
expect(client.calls[0].sql).toMatch(/pg_advisory_xact_lock/i);
});
test('fails closed one atomic unit below exact committed coverage', async () => {
const client = capacityClient();
await expect(reservePayoutCapacity({
client,
walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1049' }) },
newLiability: '400'
})).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_INSUFFICIENT' });
});
test('counts only match liabilities that can still owe a payout', async () => {
const client = capacityClient();
await reservePayoutCapacity({
client,
walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1050' }) },
newLiability: '400'
});
const liabilityQuery = client.calls.find(({ sql }) => /AS payout_rows/i.test(sql)).sql;
expect(liabilityQuery).toMatch(
/FROM matches m[\s\S]*m\.status IN \('starting', 'active', 'finished'\)[\s\S]*NOT EXISTS/i
);
expect(liabilityQuery).not.toMatch(/m\.status IN \([^)]*'cancelled'/i);
});
function creditManager({ payoutEligible, getBalance }) {
const calls = [];
const client = {
query: jest.fn(async (sql) => {
calls.push(sql);
if (/AS payout_rows/i.test(sql)) {
return { rows: [{ payout_rows: '0', solo_commitments: '0', match_commitments: '0' }] };
}
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
return { rows: [{ credits: 1, total_credits_purchased: 2, payout_address: 'addr' }], rowCount: 1 };
}
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
return { rows: [{ id: 9 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
manager.walletService = { isHealthy: true, getBalance };
manager.creditsPerGameCost = 1;
manager.requiresPayoutAddressForMode = () => payoutEligible;
manager._computePayoutSnapshot = () => ({
eligible: payoutEligible,
escapeAmount: '200',
treasureAmount: '300',
escapeMult: 2,
treasureMult: 3,
terms: { version: 1, eligible: payoutEligible }
});
return { manager, calls };
}
test('existing-credit payout game is denied before debit when balance is unknown', async () => {
const { manager, calls } = creditManager({
payoutEligible: true,
getBalance: jest.fn().mockRejectedValue(new Error('wallet offline'))
});
await expect(manager._processGameStartWithCredits(
{ id: 1, credits: 2, payout_address: 'addr' }, 'sock', 'seed'
)).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_UNVERIFIED' });
expect(calls.some(sql => /credits = credits -/i.test(sql))).toBe(false);
});
test('payout-disabled prestige start stays open and never reads wallet balance', async () => {
const getBalance = jest.fn().mockRejectedValue(new Error('wallet offline'));
const { manager } = creditManager({ payoutEligible: false, getBalance });
const result = await manager._processGameStartWithCredits(
{ id: 1, credits: 2, payout_address: null }, 'sock', 'seed'
);
expect(result.success).toBe(true);
expect(getBalance).not.toHaveBeenCalled();
});
test('authoritative credit start rejects a missing required payout address before debit', async () => {
const { manager, calls } = creditManager({ payoutEligible: true, getBalance: async () => ({ unlocked_balance: '1000' }) });
const result = await manager._processGameStartWithCredits(
{ id: 1, credits: 2, payout_address: null }, 'sock', 'seed'
);
expect(result).toEqual(expect.objectContaining({ success: false }));
expect(calls).toEqual([]);
});
test('authoritative direct-payment claim rechecks the locked user payout address', async () => {
const calls = [];
const client = {
query: jest.fn(async (sql) => {
calls.push(sql);
if (/SELECT \* FROM payments/i.test(sql)) {
return { rows: [{ id: 4, user_id: 1, status: 'confirmed' }], rowCount: 1 };
}
if (/SELECT id, payout_address[\s\S]*FROM users/i.test(sql)) {
return { rows: [{ id: 1, payout_address: null }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
})
};
const manager = Object.create(GameModeManager.prototype);
manager.db = { withTransaction: async callback => callback(client) };
manager._computePayoutSnapshot = () => ({
eligible: false, escapeAmount: '0', treasureAmount: '0', terms: {}
});
manager._requiresPaidFairnessV2 = () => false;
manager.requiresPayoutAddressForMode = () => true;
const result = await manager._processGameStartWithPayment(
{ id: 1, payout_address: 'stale-client-cache' }, { id: 4 }, 'seed'
);
expect(result).toEqual(expect.objectContaining({ success: false }));
expect(calls.some(sql => /UPDATE games/i.test(sql))).toBe(false);
expect(calls.some(sql => /UPDATE payments/i.test(sql))).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
const fs = require('fs');
const path = require('path');
describe('browser payout disclosures', () => {
test('renders server-computed outcomes and never recreates multiplier amounts', () => {
const paymentUi = fs.readFileSync(
path.join(__dirname, '../html/js/ui/paymentUI.js'),
'utf8'
);
const help = fs.readFileSync(
path.join(__dirname, '../html/js/ui/helpModal.js'),
'utf8'
);
expect(paymentUi).toContain('this.config?.payoutOutcomes?.[mode]');
expect(paymentUi).toContain('configured cap applied');
expect(paymentUi).not.toMatch(/parseFloat\([^\n]+\)\s*\*\s*[^\n]*multiplier/i);
expect(help).not.toMatch(/Escape pays[\s\S]{0,80}×/);
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* Payout retry service tests (Phase 1.4).
*
* Guards two previously-broken behaviours:
* - the "already on-chain" path counts user stats exactly once (the old NOT EXISTS guard
* checked the same row it had just marked completed, so it never counted), and
* - a successful retry stores tx_hash + marks completed + counts stats in ONE transaction
* (no non-atomic tx_hash write that could strand a tx_hash on a non-completed row).
*/
const PayoutRetryService = require('../src/payments/payoutRetryService');
function makeHarness() {
const client = { query: jest.fn().mockResolvedValue({ rows: [] }) };
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(client))
};
return { client, db };
}
function svcWith(db, wallet) {
return new PayoutRetryService({
db, walletService: wallet, debugManager: { CONSOLE_LOGGING: false }, maxRetries: 3
});
}
const findCall = (mockFn, re) => mockFn.mock.calls.find(c => re.test(c[0]));
describe('PayoutRetryService.retryPayout', () => {
test('already-on-chain: marks completed and counts stats exactly once', async () => {
const { client, db } = makeHarness();
client.query
.mockResolvedValueOnce({ rows: [{ id: 5 }] }) // payouts transition (did transition)
.mockResolvedValue({ rows: [] }); // users stat update
const wallet = {
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: true, confirmations: 3 }),
processPayout: jest.fn()
};
await svcWith(db, wallet).retryPayout({ id: 5, user_id: 1, amount: '100', tx_hash: 'tx5', status: 'failed', retry_count: 0 });
// Transition guarded by `status <> 'completed'` (so it counts once).
expect(findCall(client.query, /UPDATE payouts[\s\S]*status <> 'completed'/i)).toBeDefined();
// Stats were counted.
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined();
// Did NOT re-send funds (already on chain).
expect(wallet.processPayout).not.toHaveBeenCalled();
});
test('already-on-chain but already completed: does NOT double-count stats', async () => {
const { client, db } = makeHarness();
client.query
.mockResolvedValueOnce({ rows: [] }) // payouts transition affected 0 rows (already completed)
.mockResolvedValue({ rows: [] });
const wallet = {
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: true, confirmations: 9 }),
processPayout: jest.fn()
};
await svcWith(db, wallet).retryPayout({ id: 5, user_id: 1, amount: '100', tx_hash: 'tx5', status: 'failed', retry_count: 0 });
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeUndefined();
});
test('successful retry: stores tx_hash, completes, counts stats in one transaction', async () => {
const { client, db } = makeHarness();
client.query
.mockResolvedValueOnce({ rows: [{ id: 7 }] }) // completion transition
.mockResolvedValue({ rows: [] });
const wallet = {
checkTransactionStatus: jest.fn(),
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'd'.repeat(64), fee: 10 })
};
await svcWith(db, wallet).retryPayout({
id: 7,
user_id: 1,
amount: '100',
payout_address: 'wow1addr',
status: 'processing',
retry_count: 0,
last_error: 'not enough unlocked money to transfer'
});
// tx_hash + completion happen in the SAME statement (atomic).
const completion = findCall(client.query, /UPDATE payouts[\s\S]*tx_hash[\s\S]*status = 'completed'/i);
expect(completion).toBeDefined();
expect(completion[1]).toContain('d'.repeat(64));
// No standalone pre-transaction tx_hash write on db.query.
expect(findCall(db.query, /UPDATE payouts SET tx_hash = \$1, fee = \$2 WHERE id/i)).toBeUndefined();
// Stats counted once.
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined();
});
test('ambiguous failure without a tx hash is never sent again automatically', async () => {
const { db } = makeHarness();
const wallet = { checkTransactionStatus: jest.fn(), processPayout: jest.fn() };
await svcWith(db, wallet).retryPayout({
id: 8,
user_id: 1,
amount: '100',
payout_address: 'wow1addr',
status: 'processing',
retry_count: 1,
last_error: 'RPC timeout after broadcast'
});
expect(wallet.processPayout).not.toHaveBeenCalled();
const review = findCall(db.query, /SET status = 'needs_review'/i);
expect(review).toBeDefined();
});
test('a recorded transaction hash that is temporarily not found is never resent', async () => {
const { db } = makeHarness();
const wallet = {
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: false }),
processPayout: jest.fn()
};
await svcWith(db, wallet).retryPayout({
id: 11,
user_id: 1,
amount: '100',
payout_address: 'wow1addr',
tx_hash: 'possibly-broadcast-tx',
status: 'processing',
retry_count: 1
});
expect(wallet.processPayout).not.toHaveBeenCalled();
expect(findCall(db.query, /SET status = 'needs_review'/i)).toBeDefined();
});
});
describe('PayoutRetryService.processRetries', () => {
test('stale processing claims are quarantined and never selected for resend', async () => {
const db = {
query: jest.fn().mockResolvedValue({ rows: [{ id: 44 }], rowCount: 1 }),
withTransaction: jest.fn(async (fn) => fn({
query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 })
}))
};
const wallet = { processPayout: jest.fn() };
const service = new PayoutRetryService({
db,
walletService: wallet,
staleProcessingMs: 60000,
debugManager: { CONSOLE_LOGGING: false }
});
service.retryPayout = jest.fn();
await service.processRetries();
const recovery = findCall(db.query, /status = 'needs_review'[\s\S]*status = 'processing'/i);
expect(recovery).toBeDefined();
expect(service.retryPayout).not.toHaveBeenCalled();
expect(wallet.processPayout).not.toHaveBeenCalled();
});
test('claims failed rows as processing in the same transaction before dispatch', async () => {
const candidate = {
id: 9,
user_id: 1,
payout_address: 'wow1addr',
amount: '100',
status: 'processing',
retry_count: 0,
last_error: 'insufficient unlocked balance'
};
const client = { query: jest.fn() };
client.query
.mockResolvedValueOnce({ rows: [{ ...candidate, status: 'failed' }] })
.mockResolvedValueOnce({ rows: [candidate] });
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn(async (fn) => fn(client))
};
const service = svcWith(db, { processPayout: jest.fn() });
service.retryPayout = jest.fn().mockResolvedValue();
await service.processRetries();
expect(findCall(client.query, /SET status = 'processing'/i)).toBeDefined();
expect(service.retryPayout).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: 'processing' }));
});
test('master predicate prevents both scan and dispatch', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [] }), withTransaction: jest.fn() };
const wallet = { processPayout: jest.fn() };
const service = new PayoutRetryService({ db, walletService: wallet, isEnabled: () => false });
await service.processRetries();
await service.retryPayout({ id: 10 });
expect(db.withTransaction).not.toHaveBeenCalled();
expect(wallet.processPayout).not.toHaveBeenCalled();
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* Payout snapshot tests (Phase 1.2).
*
* The payout terms are snapshotted onto the game at start. completeGame must pay from
* that snapshot, so a mid-game config change can't alter an in-flight game's payout.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockDb = () => {
const db = {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn()
};
db.withTransaction.mockImplementation(async (cb) => cb(db));
return db;
};
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: {
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm() {
const db = createMockDb();
const gmm = new GameModeManager(
db,
{ processPayout: jest.fn(), processBatchPayout: jest.fn() },
{ CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 },
createMockPaymentConfig()
);
gmm._scheduleBatchPayout = jest.fn(); // don't actually schedule
return { gmm, db };
}
describe('Payout uses the snapshot recorded at game start', () => {
test('pays the snapshot amount, not the live-config amount', async () => {
const { gmm, db } = buildGmm();
// Snapshot recorded at start: escape pays 123456 atomic (deliberately unlike the
// live 2x = 200000000000), treasure pays 999999.
db.query
.mockResolvedValueOnce({ rows: [{
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
.mockResolvedValueOnce({ rows: [] }) // game completion UPDATE
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] }) // user lookup
.mockResolvedValueOnce({ rows: [{ id: 999 }] }); // INSERT payout
// Even if live config is mutated after start, the snapshot must win.
gmm.directPayoutMultipliers = { escape: 9, escapeWithTreasure: 9 };
const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 10 });
expect(result.payout).toBeDefined();
expect(result.payout.amount).toBe('123456');
});
test('falls back to live calculation for legacy games without a snapshot', async () => {
const { gmm, db } = buildGmm();
db.query
.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' }] })
.mockResolvedValueOnce({ rows: [{ id: 999 }] });
const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 10 });
expect(result.payout.amount).toBe(200000000000); // live 2x
});
test('no payout for a PAID_SINGLE win when direct payouts are disabled (no-payout instance)', async () => {
const { gmm, db } = buildGmm();
gmm.directPayoutEnabled = false; // e.g. mainnet legitimacy: sell entry, never pay out
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // game record
.mockResolvedValue({ rows: [] }); // completion UPDATE + anything else
const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 10 });
expect(result.success).toBe(true);
expect(result.payout == null || result.payout === undefined).toBe(true); // no payout queued
// No payout INSERT happened.
const insertedPayout = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(insertedPayout).toBeUndefined();
});
test('master payout switch prevents payout creation even when direct payouts are enabled', async () => {
const { gmm, db } = buildGmm();
gmm.payoutsEnabled = false;
gmm.directPayoutEnabled = true;
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] })
.mockResolvedValue({ rows: [] });
const result = await gmm.completeGame('socket1', 'game-master-off', true, true, { moves: 10 });
expect(result.success).toBe(true);
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined();
expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled();
});
test('a start-time eligible liability survives a mid-game master kill switch', async () => {
const { gmm, db } = buildGmm();
gmm.payoutsEnabled = false; // dispatch is paused after the game committed its terms
db.query
.mockResolvedValueOnce({ rows: [{
id: 1,
user_id: 1,
status: 'active',
game_mode: 'PAID_SINGLE',
payout_address: 'wow1addr',
payout_eligible: true,
payout_terms: {
version: 1,
eligible: true,
escapeAmount: '222',
treasureAmount: '333',
escapeMultiplier: 2,
treasureMultiplier: 3,
minAmount: '1',
maxAmount: '1000'
}
}] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] })
.mockResolvedValueOnce({ rows: [{ id: 901 }], rowCount: 1 });
const result = await gmm.completeGame('socket1', 'committed-game', true, false, {});
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 901, amount: '222' }));
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeDefined();
});
test('a start-time ineligible game never gains a payout after policy is enabled', async () => {
const { gmm, db } = buildGmm();
gmm.payoutsEnabled = true;
gmm.directPayoutEnabled = true;
db.query
.mockResolvedValueOnce({ rows: [{
id: 2,
user_id: 1,
status: 'active',
game_mode: 'PAID_SINGLE',
payout_eligible: false,
payout_terms: { version: 1, mode: 'PAID_SINGLE', eligible: false }
}] })
.mockResolvedValueOnce({ rows: [] });
const result = await gmm.completeGame('socket1', 'ineligible-game', true, true, {});
expect(result.success).toBe(true);
expect(result.payout).toBeNull();
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined();
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Phase 2, per-player nostr identity: verify client-signed chat events, relay them under the
* player's own npub, and let a premium subscription unlock cosmetic tiers.
*/
const { verifyChatEvent } = require('../src/utils/verifyChatEvent');
const { loadNostrTools } = require('../src/utils/nostrLoader');
const NostrChatProvider = require('../src/network/chat/NostrChatProvider');
const ChatProvider = require('../src/network/chat/ChatProvider');
const Entitlements = require('../src/multiplayer/entitlements');
const tools = loadNostrTools();
function signedEvent({ sk, content = 'gm', tag = 'wowngeon-global', kind = 1, created_at } = {}) {
return tools.finalizeEvent({
kind,
created_at: created_at || Math.floor(Date.now() / 1000),
tags: [['t', tag]],
content
}, sk);
}
describe('verifyChatEvent', () => {
const sk = tools.generateSecretKey();
const pub = tools.getPublicKey(sk);
test('accepts an authentic event signed by the expected npub', () => {
const v = verifyChatEvent(signedEvent({ sk, content: 'hello world' }), { expectedPubkey: pub });
expect(v.ok).toBe(true);
expect(v.pubkey).toBe(pub);
expect(v.content).toBe('hello world');
});
test('rejects a valid event signed by a DIFFERENT key (impersonation)', () => {
const otherPub = tools.getPublicKey(tools.generateSecretKey());
const v = verifyChatEvent(signedEvent({ sk }), { expectedPubkey: otherPub });
expect(v.ok).toBe(false);
expect(v.reason).toBe('pubkey-mismatch');
});
test('rejects a tampered event (content changed after signing)', () => {
const ev = signedEvent({ sk, content: 'original' });
ev.content = 'tampered';
expect(verifyChatEvent(ev, { expectedPubkey: pub }).reason).toBe('id-mismatch');
});
test('rejects the wrong channel tag', () => {
const v = verifyChatEvent(signedEvent({ sk, tag: 'some-other-room' }), { expectedPubkey: pub });
expect(v.reason).toBe('wrong-channel');
});
test('rejects a stale event', () => {
const v = verifyChatEvent(signedEvent({ sk, created_at: Math.floor(Date.now() / 1000) - 9999 }), { expectedPubkey: pub });
expect(v.reason).toBe('expired');
});
test('rejects wrong kind and over-length content', () => {
expect(verifyChatEvent(signedEvent({ sk, kind: 4 }), { expectedPubkey: pub }).reason).toBe('wrong-kind');
expect(verifyChatEvent(signedEvent({ sk, content: 'x'.repeat(400) }), { expectedPubkey: pub, maxLen: 280 }).reason).toBe('too-long');
});
test('requires an expected pubkey', () => {
expect(verifyChatEvent(signedEvent({ sk }), {}).reason).toBe('no-expected-pubkey');
});
});
describe('relaySignedEvent', () => {
function fakeLocal() {
return { initialize: jest.fn().mockResolvedValue(), publish: jest.fn().mockResolvedValue(), getHistory: jest.fn(), shutdown: jest.fn() };
}
function fakeTransport() {
return { publish: jest.fn().mockResolvedValue(), subscribe: jest.fn(function (f, cb) { this.onEvent = cb; }), close: jest.fn() };
}
test('NostrChatProvider delivers in-game AND relays the pre-signed event without re-signing', async () => {
const local = fakeLocal(), transport = fakeTransport();
const p = new NostrChatProvider({ local, transport, signer: { pubkey: 'aa'.repeat(32), sign: () => { throw new Error('should not sign'); } } });
const event = { id: 'evt-42', pubkey: 'bb'.repeat(32), sig: 'x', kind: 1, content: 'gm', tags: [] };
await p.relaySignedEvent({ event, username: 'alice', text: 'gm', ts: 1700000000000 });
expect(local.publish).toHaveBeenCalledWith(expect.objectContaining({ scope: 'global', username: 'alice', text: 'gm' }));
expect(transport.publish).toHaveBeenCalledWith(event); // the client-signed event, verbatim
});
test('the relayed event echoing back from the relay is deduped (not re-delivered)', async () => {
const local = fakeLocal(), transport = fakeTransport();
const p = new NostrChatProvider({ local, transport, signer: { pubkey: 'aa'.repeat(32), sign: () => ({}) } });
await p.initialize();
const event = { id: 'evt-echo', pubkey: 'bb'.repeat(32), sig: 'x', kind: 1, content: 'gm', tags: [] };
await p.relaySignedEvent({ event, username: 'alice', text: 'gm' });
local.publish.mockClear();
transport.onEvent(event); // relay echoes our own published event back
expect(local.publish).not.toHaveBeenCalled(); // markSeen dropped it
});
test('the base ChatProvider relaySignedEvent just delivers locally (no relay)', async () => {
const base = new ChatProvider();
base.publish = jest.fn().mockResolvedValue();
await base.relaySignedEvent({ event: { id: 'x' }, username: 'a', text: 'hi' });
expect(base.publish).toHaveBeenCalledWith(expect.objectContaining({ scope: 'global', username: 'a', text: 'hi' }));
expect(base.publish.mock.calls[0][0].event).toBeUndefined(); // signed event not forwarded to a non-relay provider
});
});
describe('premium subscription unlocks cosmetic tiers', () => {
test('an active premium sub sets the tier and unlocks tier-1 packs: no purchase needed', () => {
const free = Entitlements.snapshotForUser({ credits: 0, total_credits_purchased: 0 });
expect(free.tier).toBe(0);
expect(free.packs['generated-skins']).toBe(false); // tier-1 pack locked for free
const premium = Entitlements.snapshotForUser({ subscription_tier: 'premium', total_credits_purchased: 0 });
expect(premium.level).toBe('premium');
expect(premium.tier).toBe(2);
expect(premium.packs['generated-skins']).toBe(true); // tier-1 pack unlocked by the sub
expect(premium.premium).toBe(true);
});
test('buying credits still does NOT put you on the premium ladder', () => {
const snap = Entitlements.snapshotForUser({ subscription_tier: 'credits', total_credits_purchased: 50 });
expect(snap.level).toBe('free');
expect(snap.tier).toBe(0);
});
test('the higher of stored level and subscription tier wins', () => {
const snap = Entitlements.snapshotForUser({ premium_level: 'supporter', subscription_tier: 'operator' });
expect(snap.level).toBe('operator');
expect(snap.tier).toBe(3);
});
});
+93
View File
@@ -0,0 +1,93 @@
const {
normalizeLeaderboardBoard,
normalizeLeaderboardPeriod,
prestigeCutoff,
queryPrestigeLeaderboard,
querySoloLeaderboard,
soloLeaderboardPolicy
} = require('../src/network/prestigeLeaderboard');
describe('prestige leaderboard periods', () => {
const now = new Date('2026-07-21T16:00:00.000Z');
test.each([
['week', '2026-07-14T16:00:00.000Z'],
['month', '2026-06-21T16:00:00.000Z'],
['all', null],
['unexpected', null]
])('%s resolves to the exact cutoff', (period, expected) => {
const cutoff = prestigeCutoff(period, now);
expect(cutoff?.toISOString() || null).toBe(expected);
});
test('unknown periods normalize to all', () => {
expect(normalizeLeaderboardPeriod('YEAR')).toBe('all');
});
test.each([
['pleb', 'pleb'],
['CHAMPIONS', 'champions'],
[' prestige ', 'prestige'],
[undefined, 'pleb'],
[null, 'pleb'],
['', null],
['all', null],
['unexpected', null]
])('leaderboard board %p has an explicit, non-mixing API interpretation', (input, expected) => {
expect(normalizeLeaderboardBoard(input)).toBe(expected);
});
test('operated profile policies classify Champions from server identity', () => {
expect(soloLeaderboardPolicy('such-play-wow-prestige', 'champions')).toEqual({
gameModes: ['PAID_CREDITS'],
excludeMatchGenerated: true
});
expect(soloLeaderboardPolicy('such-monerogue-stagenet', 'champions')).toEqual({
gameModes: ['PAID_SINGLE'],
excludeMatchGenerated: true
});
expect(soloLeaderboardPolicy(null, 'champions')).toEqual({
gameModes: ['PAID_SINGLE', 'PAID_CREDITS'],
excludeMatchGenerated: false
});
expect(soloLeaderboardPolicy('such-play-wow-prestige', 'pleb')).toEqual({
gameModes: ['FREE'],
excludeMatchGenerated: false
});
});
test('operated solo query excludes both durable match markers', async () => {
const db = { query: jest.fn().mockResolvedValue({ rows: [] }) };
await querySoloLeaderboard(db, {
profileId: 'such-play-wow-prestige',
board: 'champions',
period: 'week',
limit: 17,
now
});
const [sql, params] = db.query.mock.calls[0];
expect(sql).toContain("LEFT(COALESCE(g.outcome, ''), 6) <> 'match_'");
expect(sql).toContain('source_match.id::text = g.dungeon_seed');
expect(sql).toContain('g.game_mode = ANY($2::varchar[])');
expect(params[0].toISOString()).toBe('2026-07-14T16:00:00.000Z');
expect(params[1]).toEqual(['PAID_CREDITS']);
expect(params[2]).toBe(true);
expect(params[3]).toBe(17);
});
test.each(['week', 'month', 'all'])('queries match rows with a strict boundary for %s', async (period) => {
const db = { query: jest.fn().mockResolvedValue({ rows: [] }) };
await queryPrestigeLeaderboard(db, { period, limit: 17, now });
const [sql, params] = db.query.mock.calls[0];
expect(sql).toContain("m.economy = 'credits_prestige'");
expect(sql).toContain('me.placement = 1');
expect(sql).toContain('m.winner_user_id = me.user_id');
expect(sql).toContain("m.ruleset_id <> 'coop-escape'");
expect(sql).not.toContain('me.escaped');
expect(sql).toContain('m.ended_at > $1::timestamptz');
expect(params[0]?.toISOString() || null).toBe(prestigeCutoff(period, now)?.toISOString() || null);
expect(params[1]).toBe(17);
});
});
+44
View File
@@ -0,0 +1,44 @@
const ProductGrants = require('../src/payments/productGrants');
describe('product grants', () => {
test('derives credit grants from credit package fields when grants are absent', () => {
const grants = ProductGrants.normalizeProductGrants({ id: 'small', credits: 10, bonus: 2 });
expect(grants).toMatchObject({
credits: 12,
packs: [],
premiumLevel: null
});
});
test('normalizes bundled pack grants and premium tier', () => {
const grants = ProductGrants.normalizeProductGrants({
id: 'bundle',
credits: 10,
grants: {
credits: 15,
packs: ['generated-skins', { id: 'kenney-3d-characters' }, 'bad-pack'],
premiumLevel: 'Supporter'
}
});
expect(grants.credits).toBe(15);
expect(grants.packs.map(p => p.id)).toEqual(['generated-skins', 'kenney-3d-characters']);
expect(grants.premiumLevel).toBe('supporter');
});
test('public summaries omit internal source/expiry fields', () => {
const summary = ProductGrants.publicGrantSummary({
credits: 0,
packs: [{ id: 'iso-dungeon', source: 'operator', expiresAt: '2030-01-01' }],
premiumLevel: null
});
expect(summary).toEqual({
credits: 0,
raceEntries: 0,
packs: ['iso-dungeon'],
premiumLevel: null
});
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Provably-fair determinism tests (Phase 0.2).
*
* The committed seed must deterministically generate the dungeon and drive monster
* behaviour, so that:
* - the same seed always reproduces the exact same dungeon (verifiability), and
* - the server cannot craft a different/unfair dungeon after seeing the seed.
*/
const DungeonGenerator = require('../src/game/dungeon');
const Monster = require('../src/game/monster');
const { createSeededRNG, hashSeed, verifyGame, deriveSeed } = require('../src/game/provablyFair');
const SEED_A = 'a'.repeat(64);
const SEED_B = 'b'.repeat(64);
describe('Provably fair: deterministic dungeon generation', () => {
test('same seed regenerates an identical dungeon layout', () => {
const d1 = DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW');
const d2 = DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW');
expect(DungeonGenerator.layoutFingerprint(d1))
.toBe(DungeonGenerator.layoutFingerprint(d2));
expect(d1.entrance).toEqual(d2.entrance);
expect(d1.exit).toEqual(d2.exit);
expect(d1.treasure).toEqual(d2.treasure);
expect(d1.map).toEqual(d2.map);
});
test('regeneration is stable even when interleaved with another seed (no global RNG bleed)', () => {
const a1 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
// Generate a different seed's dungeon in between: must not affect SEED_A's output.
DungeonGenerator.regenerateFromSeed(SEED_B, 'WOW');
const a2 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
expect(a1).toBe(a2);
});
test('different seeds produce different layouts', () => {
const fpA = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_A, 'WOW'));
const fpB = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(SEED_B, 'WOW'));
expect(fpA).not.toBe(fpB);
});
test('verifyGame confirms the seed/commitment relationship', () => {
const commitment = hashSeed(SEED_A);
expect(verifyGame(SEED_A, commitment).valid).toBe(true);
expect(verifyGame(SEED_B, commitment).valid).toBe(false);
});
});
describe('Provably fair: 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" };
for (let i = 0; i < 25; i++) {
const seed = (i.toString(16).padStart(2, '0')).repeat(32);
const d = DungeonGenerator.regenerateFromSeed(seed, 'WOW');
expect(DungeonGenerator.isReachable(d.map, d.entrance, d.exit, cfg)).toBe(true);
}
});
test('isReachable returns false when the target is walled off', () => {
const cfg = { primaryFloor: "'1", secondaryFloor: "'2" };
// 3x3 all-floor map, but wall off the target corner.
const map = [
["'1", "'1", "'1"],
["'1", "'1", '#'],
["'1", '#', "'1"] // (2,2) is isolated by walls at (2,1) and (1,2)
];
expect(DungeonGenerator.isReachable(map, [0, 0], [2, 2], cfg)).toBe(false);
expect(DungeonGenerator.isReachable(map, [0, 0], [1, 0], cfg)).toBe(true);
});
});
describe('Provably fair: deterministic monster movement', () => {
// A small open room so the monster always has somewhere to wander.
function buildDungeon() {
const map = Array.from({ length: 7 }, () => Array(7).fill("'1"));
for (let i = 0; i < 7; i++) { map[0][i] = '#'; map[6][i] = '#'; map[i][0] = '#'; map[i][6] = '#'; }
return { map };
}
test('wandering is reproducible from the same seeded RNG sequence', () => {
const dungeon = buildDungeon();
const run = () => {
const rng = createSeededRNG(SEED_A);
const m = new Monster(3, 3, { visionRange: 5 });
const path = [];
for (let i = 0; i < 20; i++) {
m.moveTowardPlayer(null, dungeon, rng); // no player => wander using rng
path.push([m.x, m.y]);
}
return path;
};
expect(run()).toEqual(run());
});
test('different seeds yield different wander paths', () => {
const dungeon = buildDungeon();
const walk = (seed) => {
const rng = createSeededRNG(seed);
const m = new Monster(3, 3, { visionRange: 5 });
const path = [];
for (let i = 0; i < 20; i++) {
m.moveTowardPlayer(null, dungeon, rng);
path.push([m.x, m.y]);
}
return JSON.stringify(path);
};
expect(walk(SEED_A)).not.toBe(walk(SEED_B));
});
});
+227
View File
@@ -0,0 +1,227 @@
'use strict';
const vm = require('vm');
const {
CAPTURE_RULESET_IDS,
DEFAULT_SEED,
assertCaptureSafety,
assertCaptureStateEnvironment,
capturePageHtml,
capturePresentation,
captureResultSummary,
captureStateHash,
captureTraceHeader,
createCaptureRoom,
parseArgs,
planBotMove,
validateOptions
} = require('../src/scripts/pvp-capture');
describe('deterministic PvP capture harness', () => {
function options(extra = {}) {
return validateOptions(parseArgs([
'--confirm-local-free-only',
'--no-video',
...extra.args || []
]));
}
test('requires an explicit acknowledgement and can never run in production', () => {
const safe = options();
expect(() => assertCaptureSafety({ NODE_ENV: 'development' }, { ...safe, confirmed: false }))
.toThrow('--confirm-local-free-only');
expect(() => assertCaptureSafety({ NODE_ENV: 'production' }, safe))
.toThrow('NODE_ENV=development or NODE_ENV=test');
expect(() => assertCaptureSafety({ NODE_ENV: 'development', PAYOUTS_ENABLED: 'true' }, safe))
.toThrow('money-enabled environments');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', MATCH_CRYPTO_RACE_ENABLED: '1' }, safe))
.toThrow('MATCH_CRYPTO_RACE_ENABLED');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', PAYMENTS_ENABLED: 'true' }, safe))
.toThrow('PAYMENTS_ENABLED');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', CREDITS_PAYOUT_ENABLED: 'true' }, safe))
.toThrow('CREDITS_PAYOUT_ENABLED');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', WALLET_RPC_URL: 'http://127.0.0.1:18083' }, safe))
.toThrow('wallet-configured environments');
expect(assertCaptureSafety({ NODE_ENV: 'test' }, safe)).toBe(true);
});
test('refuses inherited gameplay tuning so a seed has one authoritative replay', () => {
expect(() => assertCaptureStateEnvironment({ DIFFICULTY_PRESET: 'casino' }))
.toThrow('inherited gameplay tuning: DIFFICULTY_PRESET');
expect(() => assertCaptureSafety({ NODE_ENV: 'test', DUNGEON_WIDTH: '70' }, options()))
.toThrow('inherited gameplay tuning: DUNGEON_WIDTH');
expect(() => assertCaptureSafety({ NODE_ENV: 'development', MONSTER_SPEED: '2' }, options()))
.toThrow('inherited gameplay tuning: MONSTER_SPEED');
expect(assertCaptureStateEnvironment({})).toBe(true);
});
test('accepts only local renderer/capture options and a fixed-width seed', () => {
expect(parseArgs(['--help']).help).toBe(true);
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--mode', '3d'
]))).toThrow('network-loaded renderers are forbidden');
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--seed', '1234'
]))).toThrow('64 hexadecimal');
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--ruleset', 'crypto-race'
]))).toThrow('--ruleset must be one of');
expect(() => validateOptions(parseArgs([
'--confirm-local-free-only', '--camera', 'wallet'
]))).toThrow('--camera must be action or focus');
expect(() => parseArgs(['--url', 'https://example.com'])).toThrow('Unknown option');
});
test('creates every built-in multiplayer ruleset deterministically with no money state', () => {
const config = options();
const first = createCaptureRoom(config);
const second = createCaptureRoom(config);
expect(config.seed).toBe(DEFAULT_SEED);
expect(first.economy).toBe('free');
expect(first.ruleset.id).toBe('race');
expect(first.ruleset.economy.model).toBe('free');
expect(first.entryFeeAtomic).toBe(0);
expect(first.potAtomic).toBe(0);
expect(first.houseFeeAtomic).toBe(0);
expect(Array.from(first.occupants.keys())).toEqual(['bot-1', 'bot-2', 'bot-3', 'bot-4']);
expect(first.seedHash).toBe(second.seedHash);
expect(first.dungeon.map).toEqual(second.dungeon.map);
expect(first.dungeon.exit).toEqual(second.dungeon.exit);
expect(first.dungeon.treasure).toEqual(second.dungeon.treasure);
for (const rulesetId of CAPTURE_RULESET_IDS) {
const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] }));
expect(room.ruleset.id).toBe(rulesetId);
expect(room.economy).toBe('free');
expect(room.ruleset.economy.model).toBe('free');
expect(room.entryFeeAtomic).toBe(0);
expect(room.potAtomic).toBe(0);
expect(room.houseFeeAtomic).toBe(0);
}
});
test.each(CAPTURE_RULESET_IDS)('%s uses deterministic bot inputs and authoritative state', rulesetId => {
const config = options({ args: ['--ruleset', rulesetId, '--ticks', '30'] });
const rooms = [createCaptureRoom(config), createCaptureRoom(config)];
rooms.forEach(room => room.start());
for (let tick = 0; tick < 30; tick++) {
for (const room of rooms) {
const state = room.toGameState();
for (const botId of room.occupants.keys()) {
const move = planBotMove(botId, state);
if (Math.abs(move.dx) + Math.abs(move.dy) === 1) {
room.queueMove(botId, move.dx, move.dy);
}
}
room.resolveTick();
}
expect(captureStateHash(rooms[0].toGameState()))
.toBe(captureStateHash(rooms[1].toGameState()));
}
});
test('last-alive bots pursue a rival instead of following the race exit', () => {
const move = planBotMove('bot-1', {
tick: 1,
ruleset: { id: 'last-alive' },
visibleTiles: [["'1", "'1", "'1"]],
exit: [0, 0],
players: [
{ id: 'bot-1', x: 0, y: 0, alive: true, finished: false },
{ id: 'bot-2', x: 2, y: 0, alive: true, finished: false }
]
});
expect(move).toEqual({ dx: 1, dy: 0 });
});
test('presentation and final copy are ruleset-correct, including cooperative results', () => {
expect(capturePresentation('last-alive')).toEqual(expect.objectContaining({
title: 'LAST ALIVE', activeNoun: 'STANDING'
}));
expect(captureResultSummary({
ruleset: { id: 'score-attack' }, winnerId: 'bot-2', tickCount: 55
}, [{ id: 'bot-2', name: 'Nyx', score: 912 }])).toEqual(expect.objectContaining({
headline: 'NYX TOPS THE BOARD',
detail: expect.stringMatching(/912 POINTS.*FREE EXHIBITION.*NO CASH PRIZES/)
}));
expect(captureResultSummary({
ruleset: { id: 'coop-escape' }, winnerId: 'bot-1', tickCount: 40
}, [
{ id: 'bot-1', escaped: true },
{ id: 'bot-2', escaped: true }
])).toEqual(expect.objectContaining({
headline: 'TEAM ESCAPED TOGETHER', cooperative: true
}));
expect(captureResultSummary({
ruleset: { id: 'last-alive' }, winnerId: 'bot-2', tickCount: 80
}, [
{ id: 'bot-1', name: 'Rook', alive: true, finished: false },
{ id: 'bot-2', name: 'Nyx', alive: true, finished: false }
])).toEqual(expect.objectContaining({ headline: 'NYX LEADS AT THE BELL', winnerStatus: 'LEADER' }));
expect(captureResultSummary({
ruleset: { id: 'race' }, winnerId: 'bot-1', tickCount: 10, endReason: 'capture_limit'
}, [{ id: 'bot-1', name: 'Rook' }])).toEqual(expect.objectContaining({
headline: 'ROOK LEADS AT THE BELL', winnerStatus: 'LEADER'
}));
expect(captureResultSummary({
ruleset: { id: 'last-alive' }, winnerId: 'bot-1', tickCount: 10, endReason: 'all_dead'
}, [{ id: 'bot-1', name: 'Rook', alive: false }])).toEqual(expect.objectContaining({
headline: 'ROOK SURVIVED LONGEST', winnerStatus: 'LAST OUT'
}));
});
test('capture page uses local assets, a spectator focus camera, and no payment UI', () => {
const html = capturePageHtml();
expect(html).toContain('focusPlayerId:cameraFocus');
expect(html).toContain("cameraMode=query.get('camera')||'action'");
expect(html).toContain('id="roster"');
expect(html).toContain('actionFocusUntil');
expect(html).toContain('function composeCamera');
expect(html).toContain("if(finalWinnerId&&player.id===finalWinnerId)classes.push('winner')");
expect(html).toContain("finalWinnerId=outcome.cooperative?null:(payload.winnerId||null)");
expect(html).toContain("if(finalWinnerId)document.body.classList.add('finale');else document.body.classList.remove('finale')");
expect(html).toContain("if(renderer.name==='iso')return Math.max(cover");
expect(html).toContain('baseFocus.x-previousFocus.x');
expect(html).not.toContain('chosen.x-previousFocus.x');
expect(html).toContain('DETERMINISTIC REPLAY ID');
expect(html).not.toContain('VERIFIED REPLAY');
expect(html).toContain('FREE EXHIBITION • NO CASH PRIZES');
expect(html).toContain('id="roster" role="list"');
expect(html).toContain('id="result" role="status" aria-live="polite" aria-atomic="true"');
expect(html).toContain("slice(0,portrait?3:4)");
expect(html).toContain("cover=Math.max(stage.clientWidth/(canvas.width||stage.clientWidth)");
expect(html).toContain('#result{position:absolute;z-index:30;left:20px;right:20px;bottom:18px');
expect(html).toContain("if(entity.kind==='player')entity.label=null");
expect(html).toContain('controls:false');
expect(html).toContain('/socket.io/socket.io.js');
expect(html).not.toMatch(/https?:\/\/(?!127\.0\.0\.1)/);
expect(html).not.toContain('crypto_race');
expect(html).not.toContain('payment');
const inlineScripts = Array.from(
html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi),
match => match[1]
);
expect(inlineScripts.length).toBeGreaterThanOrEqual(2);
for (const source of inlineScripts) {
expect(() => new vm.Script(source)).not.toThrow();
}
});
test('parses the camera viewport as replay provenance', () => {
const config = options({ args: ['--viewport', '1080x1920', '--camera', 'focus'] });
const room = createCaptureRoom(config);
const trace = captureTraceHeader(room, capturePresentation(room.ruleset), config);
expect(config.viewport).toEqual({ width: 1080, height: 1920 });
expect(config.camera).toBe('focus');
expect(trace.camera).toEqual({
mode: 'focus',
focus: 'bot-1',
renderer: 'tiles',
viewport: { width: 1080, height: 1920 }
});
});
});
+143
View File
@@ -0,0 +1,143 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadMatchCopy() {
const context = {};
context.window = context;
vm.createContext(context);
vm.runInContext(
fs.readFileSync(path.join(__dirname, '../html/js/ui/matchUiCopy.js'), 'utf8'),
context
);
return context.MatchUiCopy;
}
describe('multiplayer UI copy contract', () => {
const copy = loadMatchCopy();
test.each([
['race', 'racer', 'Reach the exit'],
['last-alive', 'contender', 'Stay alive'],
['score-attack', 'delver', 'highest score'],
['coop-escape', 'teammate', 'Work together']
])('%s exposes its own objective and participant language', (id, participant, objective) => {
const mode = copy.normalize({ id });
expect(mode.participant).toBe(participant);
expect(mode.objective).toContain(objective);
});
test.each([
['race', '🏆 You escaped first!'],
['last-alive', '🏆 You are the last delver standing!'],
['score-attack', '🏆 You set the high score!']
])('%s renders a mode-specific competitive victory', (id, expected) => {
const result = copy.finalResult({
ruleset: { id },
winnerId: 'me',
players: [{ id: 'me', placement: 1, score: 812 }]
}, 'me');
expect(result.won).toBe(true);
expect(result.text).toContain(expected);
if (id === 'score-attack') expect(result.text).toContain('score 812');
});
test('co-op reports one team outcome and never promotes placement #1 as an individual winner', () => {
const result = copy.finalResult({
ruleset: { id: 'coop-escape' },
reason: 'all_escaped',
winnerId: null,
players: [
{ id: 'me', placement: 2, escaped: true },
{ id: 'ally', placement: 1, escaped: true }
]
}, 'me');
expect(result.won).toBe(true);
expect(result.text).toContain('🤝 Team escaped!');
expect(result.text).toContain('you reached safety');
expect(result.text).not.toMatch(/placed|winner/i);
});
test('live summaries use the active mode rather than race-only placement copy', () => {
expect(copy.liveSummary({
ruleset: { id: 'last-alive' },
players: [{ id: 'me', alive: true }, { id: 'out', alive: false }]
}, 'me')).toBe('1 contender still in');
expect(copy.liveSummary({
ruleset: { id: 'coop-escape' },
players: [{ id: 'me', escaped: true, finished: true }, { id: 'ally', alive: true }]
}, 'me')).toBe('1/2 safe · 1 still exploring');
});
});
describe('public multiplayer controls and boards', () => {
const matchHtml = fs.readFileSync(path.join(__dirname, '../html/match.html'), 'utf8');
const tavernHtml = fs.readFileSync(path.join(__dirname, '../html/tavern.html'), 'utf8');
const matchClient = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8');
const socketHandlers = fs.readFileSync(path.join(__dirname, '../html/js/network/socketHandlers.js'), 'utf8');
const helpModal = fs.readFileSync(path.join(__dirname, '../html/js/ui/helpModal.js'), 'utf8');
const leaderboard = fs.readFileSync(path.join(__dirname, '../html/js/ui/leaderboard.js'), 'utf8');
const paymentUi = fs.readFileSync(path.join(__dirname, '../html/js/ui/paymentUI.js'), 'utf8');
const designDoc = fs.readFileSync(path.join(__dirname, '../docs/TAVERN_AND_MULTIPLAYER.md'), 'utf8');
test('match and Tavern D-pads are named groups activated through native click', () => {
expect(matchHtml).toMatch(/id="matchDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/);
expect(tavernHtml).toMatch(/id="tavernDpad"[^>]*role="group"[^>]*aria-label="Tavern movement controls"/);
expect(tavernHtml).toMatch(/id="spectatorDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/);
expect(matchClient).toContain("b.addEventListener('click'");
expect(tavernHtml).toContain("b.addEventListener('click'");
});
test('anonymous Tavern admission has explicit connection, pending, and retry states', () => {
expect(tavernHtml).toContain('Walk in anonymously');
expect(tavernHtml).toContain('No account required');
expect(tavernHtml).toMatch(/id="joinPanel"[^>]*>[\s\S]*id="joinBtn"[^>]*disabled/);
expect(tavernHtml).toContain("joinPanel.addEventListener('submit'");
expect(tavernHtml).toContain("socket.on('disconnect'");
expect(tavernHtml).toContain("socket.on('connect_error'");
expect(tavernHtml).toContain('Preparing your anonymous session');
expect(tavernHtml).toContain('!socket.connected || !joinReady');
expect(tavernHtml).toContain('The tavern did not respond');
});
test('Prestige is a distinct public board with an explicit non-mixing disclosure', () => {
expect(leaderboard).toContain('data-board="prestige"');
expect(leaderboard).toContain('Credit-entry competitive PvP scores only');
expect(leaderboard).toContain('never mixed with Free or the Hall of Champions');
});
test('arena renders only server-advertised economies, never locked out-of-profile cards', () => {
expect(matchClient).toContain('availableKeys.forEach(function (key)');
expect(matchClient).not.toContain('Object.keys(ECONOMIES).forEach(function (key)');
expect(matchClient).not.toContain("avail.textContent = locked ? 'NOT ENABLED'");
expect(tavernHtml).toContain('if (!economies[key]) return;');
});
test('Wownero-facing solo and leaderboard copy uses entry/credits, not stake claims', () => {
expect(socketHandlers).toContain('Choose your entry');
expect(socketHandlers).not.toContain('Choose your stakes');
expect(socketHandlers).toContain("Buy credits with ' + currency");
expect(helpModal).toContain('Prefer a free entry?');
expect(helpModal).not.toContain('Prefer no stakes?');
expect(leaderboard).toContain("this._profileId === 'such-play-wow-prestige'");
expect(leaderboard).toContain('Credit-entry solo scores only');
expect(leaderboard).toContain('historical crypto-match results recorded while that mode was enabled');
expect(leaderboard).not.toContain('crypto-stakes');
});
test('direct-entry payment copy never claims it adds a spendable credit', () => {
expect(socketHandlers).toContain('Buy a single entry or credits with ');
expect(socketHandlers).toContain('SocketHandlers._directModeEnabled');
expect(paymentUi).toContain('Single-game entry');
expect(paymentUi).toContain('no spendable credit is added');
expect(paymentUi).not.toContain('Single game <span');
expect(paymentUi).not.toContain('Counts as 1 credit toward cosmetic unlocks');
});
test('operator docs require restart for mode/ruleset changes and do not promise future combat', () => {
expect(designDoc).toContain('restart the service');
expect(designDoc).toContain('does not hot-reload match modes or rulesets');
expect(designDoc).not.toMatch(/PvP combat (?:remains|is planned)/i);
});
});
+48
View File
@@ -0,0 +1,48 @@
const QueueManager = require('../src/network/queueManager');
function buildManager() {
const captured = [];
const game = {
id: 'game-id',
dbId: 1,
getState: () => ({}),
getProofCommitment: () => ({ commitment: 'c'.repeat(64) })
};
const user = { id: 'socket-a', clientId: 'client-a' };
const io = { to: jest.fn().mockReturnValue({ emit: jest.fn() }) };
const manager = new QueueManager({
debugManager: { getCurrentBlockHeight: () => 10 },
broadcastManager: {},
io,
createGameForUser: jest.fn(async (_user, options) => { captured.push(options); return game; }),
getUserBySocket: () => user,
activeGames: new Map(),
gameModeManager: { processGameStart: jest.fn().mockResolvedValue({ success: true }) },
consoleLogging: false
});
return { manager, captured, user };
}
describe('all queue start paths preserve the consumed fairness proof', () => {
const fairnessProof = { offerId: 'offer', serverSeed: 'a'.repeat(64), clientSeed: '01' };
test('next-block start', async () => {
const { manager, captured } = buildManager();
manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof });
await manager.startGamesForWaiting(11);
expect(captured[0].fairnessProof).toBe(fairnessProof);
});
test('confirmed-payment immediate start', async () => {
const { manager, captured } = buildManager();
manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof });
await manager.startGameImmediately('socket-a', 11);
expect(captured[0].fairnessProof).toBe(fairnessProof);
});
test('early entry start', async () => {
const { manager, captured, user } = buildManager();
await manager.startEarlyGame('socket-a', user, 11, { fairnessProof });
expect(captured[0]).toEqual(expect.objectContaining({ earlyEntry: true, fairnessProof }));
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Reconnect-proof rate limiting tests (Phase 2.2).
*/
const RateLimiter = require('../src/network/rateLimiter');
const { clientIp, stableId } = require('../src/network/rateLimitContext');
const SocketHandlers = require('../src/network/socketHandlers');
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
function legalAcknowledgement(gameModeManager = null) {
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
return {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: disclosure.service.isTestNetwork === true
};
}
describe('rateLimitContext.stableId', () => {
test('prefers the session stable user id over the socket id', () => {
const sm = { sessions: new Map([['sockA', { id: 42 }]]) };
expect(stableId({ id: 'sockA' }, sm)).toBe('u:42');
});
test('falls back to socket id when no session', () => {
const sm = { sessions: new Map() };
expect(stableId({ id: 'sockZ' }, sm)).toBe('s:sockZ');
expect(stableId({ id: 'sockZ' }, undefined)).toBe('s:sockZ');
});
});
describe('rateLimitContext.clientIp', () => {
afterEach(() => { delete process.env.TRUST_PROXY; });
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 the rightmost (proxy-appended) X-Forwarded-For hop when TRUST_PROXY=true', () => {
process.env.TRUST_PROXY = 'true';
// 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');
});
});
describe('reconnect cannot bypass the limit (stable-id keyed)', () => {
test('two sockets for the same user share the limit', async () => {
const rl = new RateLimiter({ limits: { 'game:start': { window: 60000, max: 2 } } });
const sm = { sessions: new Map([['sockA', { id: 7 }], ['sockB', { id: 7 }]]) };
const idA = stableId({ id: 'sockA' }, sm); // u:7
const idB = stableId({ id: 'sockB' }, sm); // u:7 (reconnected, new socket)
expect(idA).toBe(idB);
await rl.recordAttempt(idA, 'game:start', '1.2.3.4');
await rl.recordAttempt(idA, 'game:start', '1.2.3.4'); // at max
const afterReconnect = await rl.checkLimit(idB, 'game:start', '1.2.3.4');
expect(afterReconnect.allowed).toBe(false); // blocked despite the new socket id
rl.shutdown();
});
test('chat:message is now IP-limited', () => {
const rl = new RateLimiter();
expect(rl._shouldApplyIpLimit('chat:message')).toBe(true);
rl.shutdown();
});
test('IP limit blocks a fresh anonymous identity from the same IP', async () => {
const rl = new RateLimiter({ limits: { 'game:start': { window: 60000, max: 1 } } });
// Two different "users" (e.g. cleared token) but same IP.
await rl.recordAttempt('u:1', 'game:start', '5.5.5.5');
const other = await rl.checkLimit('u:2', 'game:start', '5.5.5.5');
expect(other.allowed).toBe(false); // IP bucket already exhausted
rl.shutdown();
});
});
describe('payment creation limit is enforced at the socket boundary', () => {
test('blocks the fourth request before wallet or database work', async () => {
const rateLimiter = new RateLimiter({
limits: { 'payment:create': { window: 60000, max: 3 } }
});
const downstream = jest.fn().mockResolvedValue(undefined);
const socket = {
id: 'payment-socket',
handshake: { address: '192.0.2.20', headers: {} },
emit: jest.fn()
};
const context = {
rateLimiter,
sessionManager: { sessions: new Map([[socket.id, { id: 91 }]]) },
paymentHandlers: { handlePaymentRequest: downstream }
};
for (let i = 0; i < 4; i += 1) {
await SocketHandlers.prototype.handlePaymentRequest.call(
context,
socket,
{
type: 'credits_package',
packageId: 'small',
legalAcknowledgement: legalAcknowledgement()
}
);
}
expect(downstream).toHaveBeenCalledTimes(3);
expect(socket.emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({
code: 'RATE_LIMITED'
}));
rateLimiter.shutdown();
});
});
describe('match queue join throttling', () => {
test('bounds joins but never blocks an escrow-releasing leave', async () => {
const rateLimiter = new RateLimiter({
limits: { 'game:queue': { window: 60000, max: 1 } }
});
const socket = {
id: 'match-socket',
handshake: { address: '192.0.2.30', headers: {} },
emit: jest.fn()
};
const enqueue = jest.fn().mockResolvedValue({ success: true });
const leave = jest.fn().mockResolvedValue({ success: true });
const context = {
rateLimiter,
sessionManager: { sessions: new Map([[socket.id, { id: 92 }]]) },
matchQueue: { isEnabled: () => true, enqueue, leave },
_resolveMatchSession: jest.fn().mockResolvedValue({
userId: 92,
socketId: socket.id,
sessionToken: 'test-token'
}),
debugManager: { CONSOLE_LOGGING: false }
};
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
action: 'join', economy: 'free'
});
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
action: 'join', economy: 'free'
});
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
action: 'leave', economy: 'free'
});
expect(enqueue).toHaveBeenCalledTimes(1);
expect(leave).toHaveBeenCalledTimes(1);
expect(socket.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({
code: 'RATE_LIMITED'
}));
rateLimiter.shutdown();
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Rate-limiter storage-key resolution.
*
* Keys are `${id}:${action}`, and the id itself contains colons: `u:<userId>` / `s:<socketId>` from
* rateLimitContext.stableId, plus raw IPv6 addresses in ipStorage. The old key parser stripped only
* the first `:`-delimited segment, so `u:123:chat:message` resolved to the action `123:chat:message`
* and matched nothing. Both consequences were silent:
*
* - `cleanup()` skips entries whose limit it cannot resolve, so the ONLY registered memory
* reclamation freed nothing and both Maps grew for the lifetime of the process;
* - `_recordSingleAttempt` never reset an expired window, so counts accumulated forever.
*/
const RateLimiter = require('../src/network/rateLimiter');
describe('rate limiter key resolution', () => {
let limiter;
beforeEach(() => { limiter = new RateLimiter(); });
afterEach(() => { if (limiter.shutdown) limiter.shutdown(); });
test.each([
['stable user id', 'u:123:chat:message', 'chat:message'],
['socket-scoped id', 's:AbCdEf123456:game:queue', 'game:queue'],
['bare IPv4', '203.0.113.9:chat:message', 'chat:message'],
['IPv6 (colon-dense)', '2001:db8:85a3::8a2e:370:7334:chat:message', 'chat:message']
])('resolves the configured limit for a %s key', (_label, key, action) => {
expect(limiter._getLimitFromKey(key)).toBe(limiter.limits[action]);
});
test('returns undefined for a key with no configured action', () => {
expect(limiter._getLimitFromKey('u:123:not:a:real:action')).toBeUndefined();
});
test('cleanup() actually frees expired entries for realistic keys', () => {
const key = 'u:456:chat:message';
const window = limiter.limits['chat:message'].window;
limiter.storage.set(key, { count: 9, firstAttempt: Date.now() - window - 1000 });
limiter.ipStorage.set('2001:db8::1:chat:message', {
count: 4, firstAttempt: Date.now() - window - 1000
});
limiter.cleanup();
expect(limiter.storage.has(key)).toBe(false);
expect(limiter.ipStorage.size).toBe(0);
});
test('cleanup() leaves entries whose window is still open', () => {
const key = 'u:789:chat:message';
limiter.storage.set(key, { count: 1, firstAttempt: Date.now() });
limiter.cleanup();
expect(limiter.storage.has(key)).toBe(true);
});
test('an expired window resets the count instead of accumulating forever', () => {
const action = 'chat:message';
const key = `u:321:${action}`;
const window = limiter.limits[action].window;
limiter.storage.set(key, { count: 999, firstAttempt: Date.now() - window - 1000 });
limiter._recordSingleAttempt(key, Date.now());
expect(limiter.storage.get(key).count).toBe(1);
});
});
+284
View File
@@ -0,0 +1,284 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const sourceBuilder = path.join(__dirname, '..', 'scripts', 'deploy', 'build-release-artifact.sh');
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
env: { ...process.env, TZ: 'UTC' },
stdio: options.stdio || ['ignore', 'pipe', 'pipe']
});
}
function write(root, relative, contents, mode = 0o644) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents, { mode });
}
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-test-'));
write(root, 'scripts/deploy/build-release-artifact.sh', fs.readFileSync(sourceBuilder), 0o755);
write(root, 'src/index.js', "'use strict';\n");
write(root, 'src/package.json', JSON.stringify({
name: 'fixture',
version: '1.0.0',
scripts: {
start: 'node index.js',
preflight: 'node scripts/preflight.js',
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js',
test: 'jest',
'capture:pvp': 'node scripts/pvp-capture.js',
'canary:stagenet': 'node scripts/stagenet-financial-canary.js',
'db:create': 'node scripts/setup_db.js'
}
}) + '\n');
write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n');
write(root, 'src/scripts/preflight.js', "'use strict';\n");
write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n");
write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n");
write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n");
write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n");
write(root, 'src/scripts/smoke.js', "throw new Error('source-only smoke helper');\n");
write(root, 'src/jest.config.js', "module.exports = {};\n");
write(root, 'src/sim/simulate.js', "throw new Error('source-only simulator');\n");
write(root, 'src/.env.example', 'DB_PASSWORD=CHANGE_ME\n');
write(root, 'src/.env.production.example', 'ADMIN_API_KEY=CHANGE_ME\n');
write(root, 'html/index.html', '<!doctype html>\n');
write(root, 'LICENSE', 'fixture license\n');
write(root, '.env.example', 'DB_PASSWORD=CHANGE_ME\n');
write(root, 'README.md', 'source documentation only\n');
write(root, 'docs/internal.md', 'operator documentation only\n');
write(root, 'test/runtime-exclusion.test.js', "throw new Error('must not ship');\n");
write(root, 'scripts/deploy/example.service', '[Service]\nExecStart=/bin/false\n');
write(root, 'scripts/deploy/migrate-monero-stagenet-wallet.sh', '#!/bin/sh\nexit 1\n', 0o755);
write(root, '.gitignore', 'out*/\n');
run('git', ['init', '-q'], { cwd: root });
run('git', ['config', 'user.name', 'Release Test'], { cwd: root });
run('git', ['config', 'user.email', 'release-test@example.invalid'], { cwd: root });
run('git', ['add', '.'], { cwd: root });
run('git', ['commit', '-qm', 'fixture'], { cwd: root });
return root;
}
function build(root, output, releaseId = 'candidate-1') {
return run(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
'--release-id', releaseId,
'--output-dir', output
], { cwd: root });
}
function buildAsync(root, output, releaseId = 'candidate-1') {
return new Promise(resolve => {
const child = require('child_process').spawn(
path.join(root, 'scripts/deploy/build-release-artifact.sh'),
['--release-id', releaseId, '--output-dir', output],
{
cwd: root,
env: { ...process.env, TZ: 'UTC' },
stdio: ['ignore', 'pipe', 'pipe']
}
);
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('close', status => resolve({ status, stdout, stderr }));
});
}
describe('release artifact builder', () => {
const roots = [];
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('builds byte-identical, runtime-only artifacts with provenance markers', () => {
const root = fixture();
roots.push(root);
const first = path.join(root, 'out-a');
const second = path.join(root, 'out-b');
const output = build(root, first);
build(root, second);
const artifactA = path.join(first, 'candidate-1.tar.gz');
const artifactB = path.join(second, 'candidate-1.tar.gz');
expect(fs.readFileSync(artifactA).equals(fs.readFileSync(artifactB))).toBe(true);
expect(output).toMatch(/^release_id=candidate-1$/m);
expect(output).toMatch(/^commit=[0-9a-f]{40}$/m);
expect(output).toMatch(/^source_sha256=[0-9a-f]{64}$/m);
expect(output).toMatch(/^artifact_sha256=[0-9a-f]{64}$/m);
const members = run('tar', ['-tzf', artifactA]).trim().split('\n');
expect(members).toContain('candidate-1/.release-source-sha256');
expect(members).toContain('candidate-1/.release-commit');
expect(members).toContain('candidate-1/.release-package-lock-sha256');
expect(members).toContain('candidate-1/.release-files.sha256');
expect(members).toContain('candidate-1/LICENSE');
expect(members).toContain('candidate-1/src/index.js');
expect(members).toContain('candidate-1/src/scripts/preflight.js');
expect(members).toContain('candidate-1/src/scripts/migrate-disposable-clone.js');
expect(members.some(member => member.includes('/.git/'))).toBe(false);
expect(members.some(member => member.includes('/node_modules/'))).toBe(false);
expect(members.some(member => /\/(?:\.env)$/.test(member))).toBe(false);
expect(members.some(member => member.startsWith('candidate-1/docs/'))).toBe(false);
expect(members.some(member => member.startsWith('candidate-1/test/'))).toBe(false);
expect(members.some(member => member.startsWith('candidate-1/scripts/'))).toBe(false);
expect(members.some(member => member.endsWith('.service'))).toBe(false);
expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh');
expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false);
expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false);
expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js');
expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js');
expect(members).not.toContain('candidate-1/src/scripts/setup_db.js');
expect(members).not.toContain('candidate-1/src/scripts/smoke.js');
expect(members).not.toContain('candidate-1/src/jest.config.js');
expect(members.some(member => member.startsWith('candidate-1/src/sim/'))).toBe(false);
const allowedTopLevel = new Set([
'.release-commit',
'.release-files.sha256',
'.release-id',
'.release-package-lock-sha256',
'.release-source-sha256',
'LICENSE',
'html',
'src'
]);
const actualTopLevel = new Set(
members
.filter(member => member.startsWith('candidate-1/'))
.map(member => member.slice('candidate-1/'.length).split('/')[0])
.filter(Boolean)
);
expect(actualTopLevel).toEqual(allowedTopLevel);
run('sha256sum', ['--check', path.join(first, 'candidate-1.tar.gz.sha256')], { cwd: first });
const extracted = path.join(root, 'extracted');
fs.mkdirSync(extracted);
run('tar', ['-xzf', artifactA, '-C', extracted]);
run('sha256sum', ['--check', '.release-files.sha256'], {
cwd: path.join(extracted, 'candidate-1')
});
const runtimePackage = JSON.parse(fs.readFileSync(
path.join(extracted, 'candidate-1/src/package.json'), 'utf8'
));
expect(runtimePackage.scripts).toEqual({
start: 'node index.js',
preflight: 'node scripts/preflight.js',
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js'
});
});
test.each([
['tracked modification', root => write(root, 'src/index.js', '// dirty\n')],
['untracked file', root => write(root, 'untracked.txt', 'not reviewed\n')]
])('refuses a dirty worktree: %s', (_label, dirty) => {
const root = fixture();
roots.push(root);
dirty(root);
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('worktree is dirty');
});
test('refuses tracked symlinks', () => {
const root = fixture();
roots.push(root);
fs.symlinkSync('index.js', path.join(root, 'src', 'linked.js'));
run('git', ['add', '.'], { cwd: root });
run('git', ['commit', '-qm', 'add symlink'], { cwd: root });
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('tracked symlink, gitlink, or unsafe file mode');
});
test('refuses tracked runtime environment files', () => {
const root = fixture();
roots.push(root);
write(root, '.env.production', 'SECRET=value\n');
run('git', ['add', '-f', '.env.production'], { cwd: root });
run('git', ['commit', '-qm', 'add forbidden env'], { cwd: root });
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('tracked non-example environment file is forbidden');
});
test('refuses unsafe release IDs and existing output', () => {
const root = fixture();
roots.push(root);
const output = path.join(root, 'out');
let result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
'--release-id', '../escape', '--output-dir', output
], { cwd: root, encoding: 'utf8' });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('release ID must be');
build(root, output, 'candidate-1');
result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
'--release-id', 'candidate-1', '--output-dir', output
], { cwd: root, encoding: 'utf8' });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('refusing to overwrite');
});
test.each([
['missing', scripts => { delete scripts.preflight; }],
['empty', scripts => { scripts.preflight = ' '; }],
['non-string', scripts => { scripts.preflight = { command: 'node scripts/preflight.js' }; }]
])('refuses a %s required runtime package script', (_label, mutate) => {
const root = fixture();
roots.push(root);
const manifestPath = path.join(root, 'src/package.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
mutate(manifest.scripts);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
run('git', ['add', 'src/package.json'], { cwd: root });
run('git', ['commit', '-qm', 'break required runtime script'], { cwd: root });
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
cwd: root,
encoding: 'utf8'
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('required runtime package script is absent or empty: preflight');
});
test('a losing concurrent build cannot remove the winning same-ID publication', async () => {
const root = fixture();
roots.push(root);
const output = path.join(root, 'out');
const results = await Promise.all([
buildAsync(root, output),
buildAsync(root, output),
buildAsync(root, output),
buildAsync(root, output)
]);
expect(results.filter(result => result.status === 0)).toHaveLength(1);
expect(results.filter(result => result.status !== 0)).toHaveLength(3);
const artifact = path.join(output, 'candidate-1.tar.gz');
const checksum = `${artifact}.sha256`;
expect(fs.existsSync(artifact)).toBe(true);
expect(fs.existsSync(checksum)).toBe(true);
run('sha256sum', ['--check', checksum], { cwd: output });
});
});
+64
View File
@@ -0,0 +1,64 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
loadReleaseIdentity,
readBoundedRegularFile
} = require('../src/config/releaseIdentity');
describe('immutable release identity', () => {
let root;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-identity-'));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
function writeIdentity(commit = 'a'.repeat(40), id = `git-${commit.slice(0, 12)}`) {
fs.writeFileSync(path.join(root, '.release-id'), `${id}\n`, { mode: 0o444 });
fs.writeFileSync(path.join(root, '.release-commit'), `${commit}\n`, { mode: 0o444 });
}
test('returns the matching artifact release ID and commit', () => {
writeIdentity();
expect(loadReleaseIdentity({ releaseRoot: root, production: true })).toEqual({
verified: true,
id: `git-${'a'.repeat(12)}`,
commit: 'a'.repeat(40)
});
});
test('fails production closed when identity files disagree', () => {
writeIdentity('b'.repeat(40), `git-${'c'.repeat(12)}`);
expect(() => loadReleaseIdentity({ releaseRoot: root, production: true }))
.toThrow('production release identity is absent or invalid');
});
test('fails production closed when either identity file is absent', () => {
expect(() => loadReleaseIdentity({ releaseRoot: root, production: true }))
.toThrow('production release identity is absent or invalid');
});
test('returns an explicit unverified identity outside production', () => {
expect(loadReleaseIdentity({ releaseRoot: root, production: false })).toEqual({
verified: false,
id: null,
commit: null
});
});
test('refuses symlinks and oversized identity files', () => {
const target = path.join(root, 'target');
fs.writeFileSync(target, 'a'.repeat(40));
const linked = path.join(root, 'linked');
fs.symlinkSync(target, linked);
expect(() => readBoundedRegularFile(linked)).toThrow();
const oversized = path.join(root, 'oversized');
fs.writeFileSync(oversized, 'x'.repeat(129));
expect(() => readBoundedRegularFile(oversized)).toThrow('unsafe shape');
});
});
+199
View File
@@ -0,0 +1,199 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadRenderKit() {
const context = {
console,
window: {
console,
localStorage: {
getItem: () => null,
setItem: () => {}
}
}
};
context.window.window = context.window;
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/assetPacks.js'), 'utf8'),
context
);
const RK = context.window.RK;
RK.normalizeAppearance = input => {
if (input && input.appearance && !input.avatar) input = input.appearance;
if (typeof input === 'string') input = { avatar: input };
input = input || {};
return {
avatar: input.avatar || 'default',
tint: input.tint || 'none',
equipment: input.equipment || { body: 'none', head: 'none', shield: 'none', weapon: 'none' },
colors: input.colors
};
};
RK.appearance = input => ({ label: (input && input.avatar) || 'Character' });
RK.SKINS = {
'monero-knight': { id: 'monero-knight', label: 'Monero Knight', pack: 'generated-skins' }
};
RK.isSkin = id => !!RK.SKINS[id];
RK.CHAR_TINTS = {
none: { id: 'none', label: 'Natural', color: null },
gold: { id: 'gold', label: 'Gold', color: '#d29922' }
};
RK.CHARS = { 'char-ranger': { id: 'char-ranger', label: 'Ranger', frame: 432 } };
RK.isChar = id => !!RK.CHARS[id];
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'),
context
);
RK.TileRenderer = function TileRenderer() { this.name = 'tiles'; };
RK.IsoRenderer = function IsoRenderer() { this.name = 'iso'; };
RK.ThreeRenderer = function ThreeRenderer() { this.name = '3d'; };
RK.THREE = { THREE: {}, GLTFLoader: function GLTFLoader() {} };
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'),
context
);
RK.RENDER_MODE_TEST_UNLOCKS = false;
return RK;
}
describe('render pack visual resolver', () => {
test('resolves iso and 3D visuals through pack entitlements', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
const locked3d = RK.avatarVisuals.resolve(
{ avatar: 'kenney-survivor-male' },
{ projection: '3d' }
);
expect(locked3d).toMatchObject({
projection: '3d',
kind: 'model3d',
pack: 'kenney-3d-characters',
allowed: false
});
expect(locked3d.model.url).toContain('survivorMaleB.glb');
RK.setEntitlementSnapshot({ totalCreditsPurchased: 1 });
const iso = RK.avatarVisuals.resolve({ avatar: 'char-ranger' }, { projection: 'iso' });
expect(iso).toMatchObject({ projection: 'iso', kind: 'iso', pack: 'iso-dungeon', allowed: true });
expect(iso.character.idle).toContain('Male_3_Idle0.png');
const compat = RK.resolveAppearance(
{ appearance: { avatar: 'kenney-survivor-female' } },
'3d'
);
expect(compat.model.url).toContain('survivorFemaleA.glb');
});
test('iso floor uses a flat tile instead of a raised block tile', () => {
const RK = loadRenderKit();
expect(RK.isoAssets.tiles.floor).toContain('planks_S.png');
expect(RK.isoAssets.tiles.floor).not.toContain('stone_S.png');
expect(RK.isoAssets.tiles.wall).toContain('stoneWall_S.png'); // full wall from the rich set
expect(RK.isoAssets.tiles.window).toContain('stoneWallWindow_S.png'); // real iso window now
expect(RK.isoAssets.tiles.table).toContain('tableShortChairs_S.png'); // distinct furniture art
});
test('iso pack treats imported Male files as directions, not class variants', () => {
const RK = loadRenderKit();
expect(RK.isoAssets.avatar).toBe('char-villager');
expect(RK.isoAssets.characters['char-villager'].idle).toContain('Male_3_Idle0.png');
expect(RK.isoAssets.characters['char-merchant']).toBeUndefined();
expect(RK.isoAssets.characters['char-wizard']).toBeUndefined();
expect(RK.isoAssets.characters['char-goblin']).toBeUndefined();
// Sprites are angled clockwise from NE: 0=NE,2=SE,4=SW,6=NW (verified against live movement).
// Facing follows visual movement (up->NE, down->SW, left->NW, right->SE).
expect(RK.isoAssets.directions.up.idle).toContain('Male_0_Idle0.png');
expect(RK.isoAssets.directions.right.idle).toContain('Male_2_Idle0.png');
expect(RK.isoAssets.directions.down.idle).toContain('Male_4_Idle0.png');
expect(RK.isoAssets.directions.left.idle).toContain('Male_6_Idle0.png');
});
test('projection visuals expose the saved base tint as a render color', () => {
const RK = loadRenderKit();
const visual = RK.avatarVisuals.resolve(
{ avatar: 'char-ranger', colors: { base: 'gold' }, tint: 'gold' },
{ projection: 'iso' }
);
expect(RK.avatarVisuals.tintColorFor(visual.appearance)).toBe('#d29922');
});
test('renderer factory falls back when premium modes are locked', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
expect(RK.canUseMode('iso')).toBe(false);
expect(RK.createRenderer('iso', {}, {}).name).toBe('tiles');
RK.setEntitlementSnapshot({ totalCreditsPurchased: 1 });
expect(RK.canUseMode('iso')).toBe(true);
expect(RK.createRenderer('iso', {}, {}).name).toBe('iso');
});
test('temporary render test unlocks bypass all premium gates for QA', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
// With test unlocks off, premium modes and packs are gated by entitlements
expect(RK.canUseMode('iso')).toBe(false);
expect(RK.canUsePack('iso-dungeon')).toBe(false);
// With test unlocks on, ALL premium modes and packs are available for QA
RK.RENDER_MODE_TEST_UNLOCKS = true;
expect(RK.canUseMode('iso')).toBe(true); // 'fancy' is no longer a mode; it's a topdown pack
expect(RK.canUseMode('3d')).toBe(true);
expect(RK.canUsePack('iso-dungeon')).toBe(true);
expect(RK.canUsePack('kenney-3d-characters')).toBe(true);
expect(RK.canUsePack('generated-skins')).toBe(true);
// Free modes and packs are always available regardless
RK.RENDER_MODE_TEST_UNLOCKS = false;
expect(RK.canUseMode('tiles')).toBe(true);
expect(RK.canUseMode('ascii')).toBe(true); // ASCII is the free accessible fallback
});
test('render pack code stays out of payment and account APIs', () => {
const files = [
'../html/js/render/assetPacks.js',
'../html/js/render/avatarVisuals.js',
'../html/js/render/renderModes.js',
'../html/js/render/isoRenderer.js',
'../html/js/render/threeRenderer.js',
'../html/js/render/tileRenderer.js'
];
const source = files.map(file => fs.readFileSync(path.join(__dirname, file), 'utf8')).join('\n');
[
'PaymentUI',
'request_payment',
'payment_',
'wallet',
'subaddress',
'/api/',
'user_id',
'socket.emit'
].forEach(forbidden => {
expect(source).not.toContain(forbidden);
});
});
});
test('ASCII is the free accessible fallback, not premium-gated', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
RK.RENDER_MODE_TEST_UNLOCKS = false;
const asciiMeta = RK.modeMeta('ascii');
expect(asciiMeta.premium).toBe(false);
expect(RK.canUseMode('ascii')).toBe(true);
});
test('render mode test unlocks default to false in production', () => {
const RK = loadRenderKit();
expect(RK.RENDER_MODE_TEST_UNLOCKS).toBe(false);
});
+119
View File
@@ -0,0 +1,119 @@
const scene = require('../html/js/render/sceneModel');
describe('sceneFromTavern (render kit adapter)', () => {
test('maps layout chars to tile kinds and occupants to entities', () => {
const appearance = {
avatar: 'char-ranger',
tint: 'teal',
equipment: { body: 'mail', head: 'hood', shield: 'round', weapon: 'bow' }
};
const state = {
cols: 3, rows: 2,
layout: ['#=@', 'T..'],
occupants: [{ id: 'a', x: 2, y: 0, avatar: 'green', appearance, facing: 'left', name: 'Al' }]
};
const s = scene.sceneFromTavern(state, 'a');
expect(s.cols).toBe(3);
expect(s.rows).toBe(2);
expect(s.grid[0]).toEqual(['wall', 'bar', 'floor']); // '@' is walkable floor
expect(s.grid[1]).toEqual(['table', 'floor', 'floor']);
expect(s.entities).toHaveLength(1);
expect(s.entities[0]).toMatchObject({ id: 'a', x: 2, y: 0, kind: 'avatar', avatar: 'char-ranger', appearance, facing: 'left', label: 'Al', you: true });
expect(s.entities[0].color).toBe(scene.AVATAR_COLORS.default);
});
test('you-flag is false for other occupants and unknown avatars fall back to default', () => {
const s = scene.sceneFromTavern(
{ cols: 1, rows: 1, layout: ['.'], occupants: [{ id: 'b', x: 0, y: 0, avatar: 'zzz' }] },
'a'
);
expect(s.entities[0].you).toBe(false);
expect(s.entities[0].color).toBe(scene.AVATAR_COLORS.default);
});
test('unknown / out-of-legend characters are treated as walls (solid)', () => {
const s = scene.sceneFromTavern({ cols: 2, rows: 1, layout: ['?~'], occupants: [] });
expect(s.grid[0]).toEqual(['wall', 'wall']);
});
test('uses walkability when a designed room has no ASCII layout', () => {
const s = scene.sceneFromTavern({
cols: 3,
rows: 2,
layout: null,
walkable: [
[true, false, true],
[false, true, true]
],
occupants: []
});
expect(s.grid).toEqual([
['floor', 'wall', 'floor'],
['wall', 'floor', 'floor']
]);
});
});
describe('sceneFromGameState (dungeon adapter)', () => {
const api = require('../html/js/render/sceneModel.js');
test('converts visible tiles, entities, and lighting into a dungeon Scene', () => {
const scene = api.sceneFromGameState({
visibleTiles: [['#', "'1", '>'], ["'1", "'1", "'1"]],
exploredTiles: [['#', "'1", '>'], ["'1", "'1", "'1"]],
lighting: [[0, 0.5, 0], [0, 0, 0]],
entrance: [0, 1],
exit: [2, 0],
treasure: null,
player: { x: 1, y: 1, facing: 'down' },
monster: { x: 2, y: 1 },
items: {},
isSpectating: true
});
expect(scene.isDungeon).toBe(true);
expect(scene.cols).toBe(3);
expect(scene.rows).toBe(2);
expect(scene.grid[0]).toEqual(['wall', 'floor', 'exit']);
expect(scene.grid[1]).toEqual(['floor', 'floor', 'floor']);
expect(scene.lightGrid[0][1]).toBeCloseTo(0.5, 1);
// Entities: entrance, exit, monster, player (no treasure)
const kinds = scene.entities.map(e => e.kind);
expect(kinds).toContain('feature');
expect(kinds).toContain('monster');
expect(kinds).toContain('player');
// Player entity should be marked as not-you for spectators
const player = scene.entities.find(e => e.kind === 'player');
expect(player.you).toBe(false);
});
test('treasure entity appears when treasure position is set', () => {
const scene = api.sceneFromGameState({
visibleTiles: [["'1", '$']],
entrance: [0, 0],
exit: [1, 0],
treasure: [1, 0],
player: { x: 0, y: 0 },
monster: null,
items: {},
isSpectating: false
});
const treasure = scene.entities.find(e => e.id === 'treasure');
expect(treasure).toBeTruthy();
expect(treasure.char).toContain('$');
});
test('unexplored tiles become dark', () => {
const scene = api.sceneFromGameState({
visibleTiles: [["'1", undefined]],
exploredTiles: [["'1", undefined]],
lighting: [[0, 0]],
player: { x: 0, y: 0 },
monster: null,
items: {},
isSpectating: true
});
expect(scene.grid[0][1]).toBe('dark');
expect(scene.lightGrid[0][1]).toBe(0);
});
});
+204
View File
@@ -0,0 +1,204 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const crypto = require('crypto');
function loadRenderModes(runtime, preloadedThree = null) {
const window = {
console,
WOWNGEON_RUNTIME: runtime,
localStorage: {
getItem: () => null,
setItem: () => {},
},
RK: {
TileRenderer: function TileRenderer() {},
THREE: preloadedThree,
},
};
window.window = window;
const source = fs.readFileSync(
path.join(__dirname, '../html/js/render/renderModes.js'),
'utf8',
);
vm.runInNewContext(source, { window, console });
return window.RK;
}
function fakeElement(tagName) {
const classes = new Set();
const element = {
tagName,
children: [],
className: '',
textContent: '',
title: '',
attributes: {},
appendChild(child) { this.children.push(child); return child; },
setAttribute(name, value) { this.attributes[name] = String(value); },
classList: {
add(name) { classes.add(name); },
remove(name) { classes.delete(name); },
contains(name) { return classes.has(name); },
},
};
Object.defineProperty(element, 'innerHTML', {
set() { element.children = []; },
});
return element;
}
function loadPickerWithRuntimeDisabled() {
const picker = fakeElement('div');
const head = fakeElement('head');
const document = {
head,
createElement: fakeElement,
getElementById(id) {
if (id === 'renderPicker') return picker;
if (id === 'rp-style') return head.children.find(child => child.id === 'rp-style') || null;
return null;
},
addEventListener() {},
};
const window = {
console,
document,
WOWNGEON_RUNTIME: { rendererCdnEnabled: false },
localStorage: { getItem: () => null, setItem: () => {} },
RK: { TileRenderer: function TileRenderer() {} },
};
window.window = window;
const context = { window, document, console };
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'),
context,
);
window.RK.entitlements.premium = true;
const focusGameplay = jest.fn();
window.RK.SPGame = {
mode: () => 'tiles',
setMode: () => true,
focusGameplay,
};
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'),
context,
);
window.RK.rebuildRenderPicker();
return { picker, RK: window.RK, focusGameplay };
}
describe('optional renderer CDN policy', () => {
test('external renderer loading defaults closed', () => {
const RK = loadRenderModes(undefined);
expect(RK.externalRenderersEnabled()).toBe(false);
expect(RK.isModeRuntimeAvailable('3d')).toBe(false);
expect(RK.modeAvailability('3d')).toMatchObject({
usable: false,
runtimeAvailable: false,
reason: 'runtime_unavailable',
});
expect(RK.canUseMode('3d')).toBe(false);
const callback = jest.fn();
RK.ensureThree(callback);
expect(callback).toHaveBeenCalledWith(false);
});
test('a locally preloaded renderer remains usable with CDN loading disabled', () => {
const localThree = {
THREE: { REVISION: 'local-test' },
GLTFLoader: function GLTFLoader() {},
};
const RK = loadRenderModes({ rendererCdnEnabled: false }, localThree);
RK.entitlements.premium = true;
expect(RK.canUseMode('3d')).toBe(true);
const callback = jest.fn();
RK.ensureThree(callback);
expect(callback).toHaveBeenCalledWith(true);
});
test('the pinned same-origin renderer is available without enabling CDN execution', () => {
const RK = loadRenderModes({
rendererLocalEnabled: true,
rendererLocalVersion: '0.160.0',
rendererCdnEnabled: false,
});
RK.entitlements.premium = true;
expect(RK.externalRenderersEnabled()).toBe(false);
expect(RK.localRenderersEnabled()).toBe(true);
expect(RK.isModeRuntimeAvailable('3d')).toBe(true);
expect(RK.canUseMode('3d')).toBe(true);
expect(RK.threeModuleSources()).toEqual({
library: '/vendor/three/0.160.0/three.module.min.js',
loader: '/vendor/three/0.160.0/addons/loaders/GLTFLoader.js',
skeleton: '/vendor/three/0.160.0/addons/utils/SkeletonUtils.js',
});
});
test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => {
const { picker } = loadPickerWithRuntimeDisabled();
const modeButtons = picker.children[0].children;
const threeButton = modeButtons.find(button => button.textContent.startsWith('3D'));
expect(threeButton.textContent).toContain('⛔');
expect(threeButton.textContent).not.toContain('🔒');
expect(threeButton.title).toMatch(/unavailable on this server/i);
expect(threeButton.title).not.toMatch(/unlock with credits/i);
expect(threeButton.attributes['aria-disabled']).toBe('true');
});
test('homepage renderer selection immediately returns keyboard focus to gameplay', () => {
const { picker, focusGameplay } = loadPickerWithRuntimeDisabled();
const tiledButton = picker.children[0].children.find(button => button.textContent === 'Tiled');
tiledButton.onclick();
expect(focusGameplay).toHaveBeenCalledTimes(1);
});
test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => {
const serverSource = fs.readFileSync(
path.join(__dirname, '../src/index.js'),
'utf8',
);
expect(serverSource).toContain('RENDERER_CDN_ENABLED');
expect(serverSource).toMatch(/if \(rendererCdnEnabled\) \{[\s\S]*scriptSources\.push\('https:\/\/cdn\.jsdelivr\.net'\)/);
expect(serverSource).not.toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
});
test('browser pages use the pinned same-origin jQuery 3.7.1 asset', () => {
const asset = fs.readFileSync(path.join(__dirname, '../html/js/lib/jquery-3.7.1.min.js'));
const homepage = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8');
const debugPage = fs.readFileSync(path.join(__dirname, '../html/debug.html'), 'utf8');
expect(asset.toString('utf8', 0, 100)).toContain('jQuery v3.7.1');
expect(crypto.createHash('sha256').update(asset).digest('hex'))
.toBe('3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260');
expect(homepage).toContain('js/lib/jquery-3.7.1.min.js');
expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js');
expect(homepage + debugPage).not.toContain('jquery-3.4.1');
});
test('server and renderer pages publish the exact same-origin Three.js build', () => {
const packageJson = require('../src/package.json');
const serverSource = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
const pages = ['index.html', 'tavern.html', 'match.html']
.map(name => fs.readFileSync(path.join(__dirname, '../html', name), 'utf8'))
.join('\n');
expect(packageJson.dependencies.three).toBe('0.160.0');
expect(serverSource).toContain("const rendererLocalVersion = '0.160.0'");
expect(serverSource).toContain("'examples/jsm'");
expect(serverSource).toContain("'node_modules/three'");
expect(pages.split('"three":"/vendor/three/0.160.0/three.module.min.js"'))
.toHaveLength(4);
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Multi-node daemon failover and graceful degradation.
*
* Two defects motivated this:
* - the stock configuration pointed PRIMARY and FALLBACK at the SAME host, so what looked like
* redundancy was none, and only two endpoints were ever supported;
* - `failoverActive` was a one-way latch: once the process moved to a backup it stayed there
* until restart, even after the preferred node recovered.
*
* The contract now: one logical call tries every configured node before failing, the preferred node
* is periodically re-tested, and when nothing answers the service reports UNHEALTHY and throws
* rather than silently serving a stale cached height.
*/
const RpcService = require('../src/rpc/rpcService');
const A = 'http://node-a.test';
const B = 'http://node-b.test';
const C = 'http://node-c.test';
/** http.post stub that succeeds only for endpoints listed in `up`. */
function httpFor(up) {
return {
post: jest.fn(async (url) => {
const endpoint = up.find((e) => url.startsWith(e));
if (!endpoint) throw new Error(`unreachable ${url}`);
return { data: { result: { count: 4242 } } };
})
};
}
function service(http, extra = {}) {
return new RpcService({
http,
endpoints: [A, B, C],
identityRequired: false,
...extra
});
}
describe('daemon endpoint failover', () => {
test('accepts an ordered list of endpoints and prefers the first', () => {
const svc = service(httpFor([A, B, C]));
expect(svc.endpoints).toEqual([A, B, C]);
expect(svc.currentEndpoint).toBe(A);
expect(svc.failoverActive).toBe(false);
});
test('deduplicates endpoints: the stock PRIMARY == FALLBACK config is one node, not two', () => {
const same = 'http://127.0.0.1:34568';
const svc = new RpcService({
http: httpFor([same]),
primaryEndpoint: same,
fallbackEndpoint: same,
identityRequired: false
});
expect(svc.endpoints).toEqual([same]);
});
test('parses a comma-separated RPC_ENDPOINTS list and keeps PRIMARY/FALLBACK', () => {
const svc = new RpcService({
http: httpFor([A]),
env: {
RPC_ENDPOINTS: `${A}, ${B}`,
PRIMARY_RPC_ENDPOINT: C,
FALLBACK_RPC_ENDPOINT: C,
NODE_ENV: 'test'
},
identityRequired: false
});
expect(svc.endpoints).toEqual([A, B, C]);
});
test('walks past dead nodes to a live one within a SINGLE call', async () => {
const svc = service(httpFor([C])); // A and B are down
await expect(svc.makeRPCCall('getblockcount')).resolves.toMatchObject({ count: 4242 });
expect(svc.currentEndpoint).toBe(C);
expect(svc.failoverActive).toBe(true);
expect(svc.healthy).toBe(true);
});
test('sticks to the working node on subsequent calls', async () => {
const http = httpFor([B, C]);
const svc = service(http, { preferredRetryMs: 10 * 60 * 1000 });
await svc.makeRPCCall('getblockcount');
expect(svc.currentEndpoint).toBe(B);
http.post.mockClear();
await svc.makeRPCCall('getblockcount');
expect(http.post).toHaveBeenCalledTimes(1); // no re-probing of the dead primary
expect(http.post.mock.calls[0][0]).toContain('node-b');
});
test('returns to the preferred node once it recovers', async () => {
const up = [B];
const http = {
post: jest.fn(async (url) => {
if (!up.some((e) => url.startsWith(e))) throw new Error('unreachable');
return { data: { result: { count: 4242 } } };
})
};
const svc = service(http, { preferredRetryMs: 1000 }); // floor is 1s; see the constructor
await svc.makeRPCCall('getblockcount');
expect(svc.currentEndpoint).toBe(B);
up.push(A); // primary comes back
svc._preferredProbedAt -= 5000; // ...and the re-test interval elapses
await svc.makeRPCCall('getblockcount');
expect(svc.currentEndpoint).toBe(A);
expect(svc.failoverActive).toBe(false);
});
test('throws and reports unhealthy when NO node answers', async () => {
const svc = service(httpFor([]));
await expect(svc.makeRPCCall('getblockcount')).rejects.toThrow();
expect(svc.healthy).toBe(false);
});
test('getBlockHeight degrades to the last known height without claiming health', async () => {
const http = httpFor([A]);
const svc = service(http);
expect(await svc.getBlockHeight()).toBe(4242);
http.post.mockRejectedValue(new Error('all nodes offline'));
expect(await svc.getBlockHeight()).toBe(4242); // cached value for non-financial UI
expect(svc.healthy).toBe(false); // ...but never reported as live
});
test('getBlockCountStrict refuses to substitute the cached height', async () => {
const http = httpFor([A]);
const svc = service(http);
await svc.getBlockCountStrict();
http.post.mockRejectedValue(new Error('all nodes offline'));
await expect(svc.getBlockCountStrict()).rejects.toThrow();
});
test('healthCheck probes every node and adopts the first healthy one', async () => {
const svc = service(httpFor([C]));
const health = await svc.healthCheck();
expect(health.endpoints).toHaveLength(3);
expect(health.healthy).toBe(true);
expect(health.healthyCount).toBe(1);
expect(svc.currentEndpoint).toBe(C);
});
test('healthCheck reports unhealthy when every node is down', async () => {
const health = await service(httpFor([])).healthCheck();
expect(health.healthy).toBe(false);
expect(health.healthyCount).toBe(0);
});
});

Some files were not shown because too many files have changed in this diff Show More