mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 15:18:33 +02:00
Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
Disabled by default; enable with MATCH_ENABLED=true.
Highlights:
- New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
- Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
- MatchScheduler drains queues on every new block and starts 2–32 player races.
- MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
forfeit/AFK handling, and end-of-match persistence.
- Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
payout is queued through the existing batch payout/retry service.
- Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
crypto → Hall of Champions.
- Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
- Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
- Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
race_entry_transactions, users.race_entries, and payouts.match_id.
Docs:
- New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
- Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
and src/.env.
Tests: 40 new unit tests across 7 suites, all passing.
Known follow-ups for real-world validation:
- Full integration test with Postgres + Socket.IO.
- Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
- Browser render-kit verification in /match.html and tavern spectator.
- Crypto-race payout end-to-end with real wallet-rpc.
158 lines
6.1 KiB
JavaScript
158 lines
6.1 KiB
JavaScript
/**
|
|
* MatchPayoutService — crypto-race pot accounting and winner payout.
|
|
*
|
|
* On match start: convert held tickets to consumed entries and lock the pot.
|
|
* On match end: calculate house fee, create one pending payout for the winner,
|
|
* then schedule batch processing via the existing GameModeManager path.
|
|
*
|
|
* This service does not send on-chain transactions itself; it reuses the existing
|
|
* payout retry / batching machinery in GameModeManager.
|
|
*/
|
|
|
|
const { normalizeError } = require('../utils/errors');
|
|
|
|
class MatchPayoutService {
|
|
constructor({ db, walletService, gameModeManager, debugManager } = {}) {
|
|
this.db = db;
|
|
this.walletService = walletService;
|
|
this.gameModeManager = gameModeManager;
|
|
this.debugManager = debugManager;
|
|
}
|
|
|
|
_log(...args) {
|
|
if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
|
|
}
|
|
|
|
/**
|
|
* Called by MatchScheduler when a crypto_race match is created.
|
|
* Atomically converts each entrant's queue_join ticket into a consumed match ticket,
|
|
* computes pot/house fee, and stores it on the match row.
|
|
*/
|
|
async collectEntryTickets(room, entrants) {
|
|
if (!this.db || room.economy !== 'crypto_race') return;
|
|
|
|
const entryFee = this._entryFee();
|
|
const houseFeePercent = this._houseFeePercent();
|
|
const pot = entryFee * entrants.length;
|
|
const houseFee = Math.floor((pot * houseFeePercent) / 100);
|
|
|
|
await this.db.withTransaction(async (client) => {
|
|
for (const entrant of entrants) {
|
|
// Convert the held ticket to consumed.
|
|
await client.query(`
|
|
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, match_id)
|
|
VALUES ($1, 0, (
|
|
SELECT race_entries FROM users WHERE id = $1
|
|
), 'match_start', $2)
|
|
`, [entrant.userId, room.id]);
|
|
|
|
// Update match_entrant to consumed.
|
|
await client.query(`
|
|
UPDATE match_entrants
|
|
SET entry_consumed = TRUE
|
|
WHERE match_id = $1 AND user_id = $2
|
|
`, [room.id, entrant.userId]);
|
|
}
|
|
|
|
// Lock pot/house fee on the match row.
|
|
await client.query(`
|
|
UPDATE matches
|
|
SET entry_fee_atomic = $1,
|
|
pot_atomic = $2,
|
|
house_fee_atomic = $3,
|
|
house_fee_percent = $4
|
|
WHERE id = $5
|
|
`, [entryFee, pot, houseFee, houseFeePercent, room.id]);
|
|
});
|
|
|
|
room.entryFeeAtomic = entryFee;
|
|
room.potAtomic = pot;
|
|
room.houseFeeAtomic = houseFee;
|
|
room.houseFeePercent = houseFeePercent;
|
|
|
|
this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}) for match ${room.id}`);
|
|
}
|
|
|
|
/**
|
|
* Called by MatchManager when a crypto_race match finishes.
|
|
* Creates one pending payout for the winner and hands it to the batch processor.
|
|
*/
|
|
async payoutWinner(room) {
|
|
if (!this.db || room.economy !== 'crypto_race' || !room.winnerId) return;
|
|
|
|
const winnerState = room.playerStates.get(room.winnerId);
|
|
if (!winnerState || !winnerState.userId) return;
|
|
|
|
const matchRow = await this.db.query(`
|
|
SELECT pot_atomic, house_fee_atomic
|
|
FROM matches
|
|
WHERE id = $1
|
|
`, [room.id]);
|
|
if (matchRow.rows.length === 0) return;
|
|
|
|
const pot = BigInt(matchRow.rows[0].pot_atomic || 0);
|
|
const houseFee = BigInt(matchRow.rows[0].house_fee_atomic || 0);
|
|
const winnerAmount = pot - houseFee;
|
|
|
|
if (winnerAmount <= 0) {
|
|
this._log(`[MatchPayoutService] no winner payout for match ${room.id} (pot=${pot} fee=${houseFee})`);
|
|
return;
|
|
}
|
|
|
|
// Resolve payout address from user record.
|
|
const userResult = await this.db.query(`
|
|
SELECT id, payout_address
|
|
FROM users
|
|
WHERE id = $1
|
|
LIMIT 1
|
|
`, [winnerState.userId]);
|
|
const user = userResult.rows[0];
|
|
if (!user || !user.payout_address) {
|
|
this._log(`[MatchPayoutService] winner has no payout address; match ${room.id} payout deferred`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.db.withTransaction(async (client) => {
|
|
// Guard against double payout.
|
|
const existing = await client.query(`
|
|
SELECT id FROM payouts
|
|
WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed')
|
|
FOR UPDATE
|
|
`, [room.id]);
|
|
if (existing.rows.length > 0) {
|
|
throw Object.assign(new Error('Payout already exists for this match'), { code: 'PAYOUT_EXISTS' });
|
|
}
|
|
|
|
await client.query(`
|
|
INSERT INTO payouts (user_id, match_id, payout_address, amount, multiplier, reason, status, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
`, [user.id, room.id, user.payout_address, winnerAmount.toString(), 0, 'match_winner', 'pending']);
|
|
});
|
|
|
|
// Trigger the existing batch payout processor.
|
|
if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
|
this.gameModeManager._scheduleBatchPayout();
|
|
}
|
|
|
|
this._log(`[MatchPayoutService] queued winner payout ${winnerAmount} for match ${room.id}`);
|
|
} catch (err) {
|
|
const normalized = normalizeError(err, 'Failed to queue match payout');
|
|
this._log('[MatchPayoutService] payout error:', normalized.message);
|
|
}
|
|
}
|
|
|
|
_entryFee() {
|
|
const fee = this.gameModeManager?.singleGamePrice || process.env.MATCH_ENTRY_FEE_ATOMIC || 5000000000;
|
|
const parsed = Number(fee);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
}
|
|
|
|
_houseFeePercent() {
|
|
const v = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT);
|
|
return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 5;
|
|
}
|
|
}
|
|
|
|
module.exports = MatchPayoutService;
|