mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
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.
644 lines
26 KiB
JavaScript
644 lines
26 KiB
JavaScript
'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']);
|
|
});
|
|
});
|