Files
wownerogue/test/soloLiabilityAtomic.test.js
jwinterm fad2a695bd 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.
2026-07-27 08:23:01 -04:00

162 lines
6.8 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const GameModeManager = require('../src/game/gameModeManager');
function paymentConfig() {
return {
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100n, requiresAddress: true },
credits: { enabled: true, creditsPerGame: 1, requiresAddress: true, packages: [{ id: 'p', credits: 1, price: 100n }] }
},
payouts: {
enabled: true,
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1n, maxPayout: 1000n },
credits: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, baseValue: 100n, minPayout: 1n, maxPayout: 1000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on() {} }
};
}
function managerWith(db) {
const manager = new GameModeManager(
db,
{ processPayout: jest.fn(), processBatchPayout: jest.fn() },
{ CONSOLE_LOGGING: false },
paymentConfig()
);
manager._scheduleBatchPayout = jest.fn();
return manager;
}
describe('solo liability commitment', () => {
test('credit debit and immutable payout terms are written in the same transaction', async () => {
const calls = [];
const client = {
async query(sql, params) {
calls.push({ sql, params });
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
return { rows: [{ credits: 4, total_credits_purchased: 5 }], rowCount: 1 };
}
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
return { rows: [{ id: 10 }], rowCount: 1 };
}
return { rows: [], rowCount: 1 };
}
};
const db = {
query: jest.fn(),
withTransaction: jest.fn(async callback => callback(client))
};
const manager = managerWith(db);
const result = await manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'seed');
expect(result.success).toBe(true);
expect(db.withTransaction).toHaveBeenCalledTimes(1);
const commitment = calls.find(call => /payout_committed_at = NOW\(\)/i.test(call.sql));
expect(commitment).toBeDefined();
expect(commitment.params[6]).toBe(true);
expect(JSON.parse(commitment.params[7])).toEqual(expect.objectContaining({
version: 2,
mode: 'PAID_CREDITS',
eligible: true,
escapeAmount: '200',
treasureAmount: '300',
minAmount: '1',
maxAmount: '1000'
}));
});
test('a missing game row aborts before a credit ledger entry can be committed', async () => {
const calls = [];
const client = {
async query(sql) {
calls.push(sql);
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
return { rows: [{ credits: 4 }], rowCount: 1 };
}
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
return { rows: [], rowCount: 0 };
}
return { rows: [], rowCount: 1 };
}
};
const manager = managerWith({ query: jest.fn(), withTransaction: async callback => callback(client) });
await expect(manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'missing'))
.rejects.toMatchObject({ code: 'GAME_ROW_REQUIRED' });
expect(calls.some(sql => /INSERT INTO credit_transactions/i.test(sql))).toBe(false);
});
test('completion rolls back if its payout obligation cannot be inserted', async () => {
const durable = { status: 'active', payout: false };
const gameRow = {
id: 5,
user_id: 1,
game_mode: 'PAID_SINGLE',
status: 'active',
payout_address: 'addr',
payout_eligible: true,
payout_terms: {
version: 1,
eligible: true,
escapeAmount: '200',
treasureAmount: '300',
escapeMultiplier: 2,
treasureMultiplier: 3,
minAmount: '1',
maxAmount: '1000'
}
};
const db = {
query: jest.fn(),
async withTransaction(callback) {
const tx = { status: durable.status, payout: durable.payout };
const client = {
async query(sql) {
if (/FROM games[\s\S]*FOR UPDATE/i.test(sql)) return { rows: [gameRow], rowCount: 1 };
if (/UPDATE games[\s\S]*completed_at = NOW/i.test(sql)) {
tx.status = 'won';
return { rows: [], rowCount: 1 };
}
if (/SELECT id, status FROM payouts/i.test(sql)) return { rows: [], rowCount: 0 };
if (/SELECT id, payout_address FROM users/i.test(sql)) return { rows: [{ id: 1, payout_address: 'addr' }], rowCount: 1 };
if (/INSERT INTO payouts/i.test(sql)) throw new Error('payout insert failed');
return { rows: [], rowCount: 1 };
}
};
const value = await callback(client);
durable.status = tx.status;
durable.payout = tx.payout;
return value;
}
};
const manager = managerWith(db);
const result = await manager.completeGame('sock', 'seed', true, false, {});
expect(result.success).toBe(false);
expect(durable).toEqual({ status: 'active', payout: false });
});
test('database identity guards cover every payout status and commitments are immutable', () => {
const migration = fs.readFileSync(
path.join(__dirname, '../src/migrations/032_solo_liability_invariants.sql'),
'utf8'
);
expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_game[\s\S]*WHERE game_id IS NOT NULL;/i);
expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_match[\s\S]*WHERE match_id IS NOT NULL;/i);
expect(migration).not.toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_(?:game|match)[\s\S]{0,160}status IN/i);
expect(migration).toMatch(/reject_game_payout_commitment_mutation/i);
});
});