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.
333 lines
14 KiB
JavaScript
333 lines
14 KiB
JavaScript
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);
|
|
});
|
|
});
|