mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
merge: 5-pillar 10x foundation (credits/catalog, chain profiles, modular payments, ruleset/PvP, nostr chat)
Lands the feature branch feat/credits-catalog-foundation. All five pillars are
behavior-preserving and dormant until their env flips on — native XMR/WOW stays
the live default, and BTCPay gateways, nostr global chat, and PvP rulesets are
code-ready seams.
P1 credits + operator-owned cosmetic catalog + three-way unlock
P2 ChainProfile registry for BTC/LTC/XMR/WOW/GRIN
P3 modular payment providers — BTCPay + xmrcheckout/wowcheckout (Greenfield) +
native Monero, invoice creation AND monitoring wired
P4 Ruleset engine — gameplay as data; race preserved, PvP last-alive / score /
co-op modes added on the match engine
P5 NostrChatProvider — global cross-server chat over relay.smirk.cash
Suite 55 suites / 391 tests green.
This commit is contained in:
@@ -44,6 +44,30 @@
|
||||
RK.setEntitlementSnapshot = function (data) {
|
||||
data = data || {};
|
||||
var prev = RK.entitlements || {};
|
||||
|
||||
// Consume the server-authoritative catalog (migration 024). This retires the hardcoded
|
||||
// client pack list as the source of truth: operator-added packs appear, and existing packs
|
||||
// pick up server definitions (label/projection/tier). Merged (not replaced) so any client
|
||||
// fallback a render mode relies on is preserved if the operator's catalog omits it.
|
||||
if (Array.isArray(data.catalog) && data.catalog.length) {
|
||||
var merged = {};
|
||||
for (var k in RK.PACKS) merged[k] = RK.PACKS[k];
|
||||
for (var ci = 0; ci < data.catalog.length; ci++) {
|
||||
var c = data.catalog[ci];
|
||||
if (!c || !c.id) continue;
|
||||
var ex = merged[c.id] || {};
|
||||
merged[c.id] = {
|
||||
id: c.id,
|
||||
label: c.label || ex.label || c.id,
|
||||
premium: c.premium != null ? !!c.premium : !!ex.premium,
|
||||
projection: c.projection || ex.projection || null,
|
||||
tier: c.tier != null ? c.tier : ex.tier,
|
||||
unlockMinCredits: c.unlockMinCredits != null ? c.unlockMinCredits : ex.unlockMinCredits,
|
||||
unlock: ex.unlock
|
||||
};
|
||||
}
|
||||
RK.PACKS = merged;
|
||||
}
|
||||
var hasTotal = data.totalCreditsPurchased != null || data.total_credits_purchased != null;
|
||||
var hasPremium = data.premium != null;
|
||||
var hasPacks = !!data.packs;
|
||||
|
||||
@@ -200,7 +200,10 @@ const PaymentUI = {
|
||||
if (this.config.directModeEnabled) {
|
||||
const price = this.config.singleGamePriceFormatted || this.formatPrice(this.config.singleGamePrice);
|
||||
const multipliers = this.config.payoutMultipliers?.direct || { escape: 2, escapeWithTreasure: 3 };
|
||||
const directPayoutsEnabled = this.config.directPayoutsEnabled !== false;
|
||||
// Payout-honest: only show multipliers when payouts are actually on (default off, not on).
|
||||
const directPayoutsEnabled = this.config.directPayoutsEnabled != null
|
||||
? !!this.config.directPayoutsEnabled
|
||||
: !!this.config.payoutsEnabled;
|
||||
|
||||
let payoutInfo = '';
|
||||
if (directPayoutsEnabled) {
|
||||
@@ -214,12 +217,13 @@ const PaymentUI = {
|
||||
$container.append(`
|
||||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
|
||||
<div style="margin-bottom:8px;">
|
||||
<strong style="color:#f0f0f0;">⚡ Single Game</strong>
|
||||
<strong style="color:#f0f0f0;">⚡ Single game <span style="color:#a78bfa;font-size:0.8em;">· 1 credit</span></strong>
|
||||
<span style="float:right;color:#fbbf24;font-weight:bold;">${price} ${currency}</span>
|
||||
</div>
|
||||
<div style="font-size:0.85em;color:#aaa;margin-bottom:10px;">
|
||||
<div style="font-size:0.85em;color:#aaa;margin-bottom:6px;">
|
||||
${payoutInfo}
|
||||
</div>
|
||||
<div style="font-size:0.78em;color:#888;margin-bottom:10px;">Counts as 1 credit toward cosmetic unlocks.</div>
|
||||
<button class="mode-option" data-mode="PAID_SINGLE" data-action="pay_direct"
|
||||
style="width:100%;padding:8px;background:#3730a3;border:none;color:#fff;cursor:pointer;border-radius:4px;">
|
||||
💰 Pay ${price} ${currency} to Play
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* ChainProfile registry — the 10× foundation stone for multi-chain (Pillar 3).
|
||||
*
|
||||
* A single, data-driven source for everything chain-specific that ISN'T an RPC call: money units
|
||||
* (decimals / atomic divisor), the block-timing model (mean block time — which difficulty must
|
||||
* scale to), the adapter family (which wallet/daemon adapter implements it), and the payment-URI
|
||||
* scheme. The adapters (daemon/wallet clients, address validators) plug in per family and read
|
||||
* their params from here.
|
||||
*
|
||||
* The set is exactly what the Smirk wallet supports — btc, ltc, xmr, wow, grin (see
|
||||
* smirk-monorepo packages/core/src/types.ts AssetType) — all proof-of-work / high-variance, so the
|
||||
* "a random block ends your run" mechanic is fair on every one. Adding a chain = one entry here
|
||||
* plus a family adapter, never a fork.
|
||||
*/
|
||||
|
||||
const DEFAULT_DECIMALS = 12;
|
||||
|
||||
// family: 'monero' (subaddress + transfer_split), 'utxo' (BTC/LTC-style), 'mimblewimble' (Grin).
|
||||
const PROFILES = Object.freeze({
|
||||
WOW: Object.freeze({ id: 'WOW', symbol: 'WOW', decimals: 11, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'wownero' }),
|
||||
XMR: Object.freeze({ id: 'XMR', symbol: 'XMR', decimals: 12, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'monero' }),
|
||||
BTC: Object.freeze({ id: 'BTC', symbol: 'BTC', decimals: 8, meanBlockTimeMs: 600000, family: 'utxo', uriScheme: 'bitcoin' }),
|
||||
LTC: Object.freeze({ id: 'LTC', symbol: 'LTC', decimals: 8, meanBlockTimeMs: 150000, family: 'utxo', uriScheme: 'litecoin' }),
|
||||
GRIN: Object.freeze({ id: 'GRIN', symbol: 'GRIN', decimals: 9, meanBlockTimeMs: 60000, family: 'mimblewimble', uriScheme: 'grin' })
|
||||
});
|
||||
|
||||
function normalizeId(cryptoType) {
|
||||
return String(cryptoType || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a chain profile. Unknown/unset falls back to a WOW-shaped default (11 decimals, ~2 min)
|
||||
* so callers never crash on a mis-set CRYPTO_TYPE. Returns a frozen profile object.
|
||||
*/
|
||||
function getProfile(cryptoType) {
|
||||
const id = normalizeId(cryptoType);
|
||||
if (PROFILES[id]) return PROFILES[id];
|
||||
return Object.freeze({ id: id || 'WOW', symbol: id || 'WOW', decimals: DEFAULT_DECIMALS, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'monero' });
|
||||
}
|
||||
|
||||
function decimalsFor(cryptoType) {
|
||||
return getProfile(cryptoType).decimals;
|
||||
}
|
||||
|
||||
function meanBlockTimeMsFor(cryptoType) {
|
||||
return getProfile(cryptoType).meanBlockTimeMs;
|
||||
}
|
||||
|
||||
function familyFor(cryptoType) {
|
||||
return getProfile(cryptoType).family;
|
||||
}
|
||||
|
||||
function uriSchemeFor(cryptoType) {
|
||||
return getProfile(cryptoType).uriScheme;
|
||||
}
|
||||
|
||||
/** Atomic divisor as a BigInt (10 ** decimals) — safe for ETH-18-scale amounts (none here, but correct). */
|
||||
function atomicDivisor(cryptoType) {
|
||||
return 10n ** BigInt(decimalsFor(cryptoType));
|
||||
}
|
||||
|
||||
function isSupported(cryptoType) {
|
||||
return !!PROFILES[normalizeId(cryptoType)];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_DECIMALS,
|
||||
PROFILES,
|
||||
getProfile,
|
||||
decimalsFor,
|
||||
meanBlockTimeMsFor,
|
||||
familyFor,
|
||||
uriSchemeFor,
|
||||
atomicDivisor,
|
||||
isSupported
|
||||
};
|
||||
@@ -18,6 +18,7 @@ const paymentConfig = require('../config/paymentConfig');
|
||||
const money = require('../money/atomic');
|
||||
const Entitlements = require('../multiplayer/entitlements');
|
||||
const ProductGrants = require('../payments/productGrants');
|
||||
const { buildProviderRegistry } = require('../payments/providers');
|
||||
|
||||
// A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it
|
||||
// is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero
|
||||
@@ -31,12 +32,18 @@ const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW dependi
|
||||
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
|
||||
|
||||
class GameModeManager {
|
||||
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) {
|
||||
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null, paymentProviders = null) {
|
||||
this.db = databaseManager;
|
||||
this.walletService = walletRPCService;
|
||||
this.debugManager = debugManager;
|
||||
this.paymentConfigManager = paymentConfigManager || null;
|
||||
|
||||
// Modular payment providers (Pillar 3). Defaults to a registry whose only member is the
|
||||
// native Monero/Wownero provider wrapping this walletService — so with no gateway env set
|
||||
// every chain routes to the existing wallet-RPC path and behavior is unchanged. Injectable
|
||||
// for tests. See src/payments/providers/index.js + the btcpay-infra-topology memo.
|
||||
this.paymentProviders = paymentProviders || buildProviderRegistry({ walletService: walletRPCService });
|
||||
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
||||
|
||||
@@ -490,6 +497,67 @@ class GameModeManager {
|
||||
return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a direct/single_game entry as "buy 1 credit and immediately spend it on this game":
|
||||
* the balance nets to zero but total_credits_purchased advances, so a direct payment unlocks the
|
||||
* SAME tier/threshold cosmetics as buying credits (the first step of unifying everything to
|
||||
* credits). Idempotent by the caller — the single_game confirmation runs once per payment via
|
||||
* its status='pending' -> 'confirmed' guard. Returns { totalCreditsPurchased, balance,
|
||||
* entitlements } or null.
|
||||
*/
|
||||
async recordDirectEntryPurchase(socketId) {
|
||||
try {
|
||||
const user = await this.getOrCreateUser(socketId, { create: false });
|
||||
if (!user || user.id == null) return null;
|
||||
let totalCreditsPurchased = 0;
|
||||
let balance = 0;
|
||||
let premiumLevel = null;
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const upd = await client.query(
|
||||
`UPDATE users
|
||||
SET total_credits_purchased = COALESCE(total_credits_purchased, 0) + 1
|
||||
WHERE id = $1
|
||||
RETURNING total_credits_purchased, credits, premium_level`,
|
||||
[user.id]
|
||||
);
|
||||
if (!upd.rows.length) return;
|
||||
totalCreditsPurchased = Number(upd.rows[0].total_credits_purchased || 0);
|
||||
balance = Number(upd.rows[0].credits || 0);
|
||||
premiumLevel = upd.rows[0].premium_level || null;
|
||||
// Audit trail: bought 1 credit + spent it on this game (net 0 balance).
|
||||
await client.query(
|
||||
`INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, 1, 'direct_entry', $2, 'purchase'),
|
||||
($1, -1, 'game_entry', $2, 'spend')`,
|
||||
[user.id, balance]
|
||||
);
|
||||
});
|
||||
|
||||
let packGrants = [];
|
||||
try {
|
||||
const g = await this.db.query(
|
||||
`SELECT pack_id, source, granted_at, expires_at FROM user_pack_entitlements
|
||||
WHERE user_id = $1 AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[user.id]
|
||||
);
|
||||
packGrants = g.rows || [];
|
||||
} catch (_) { packGrants = []; }
|
||||
|
||||
const entitlements = Entitlements.snapshotForUser({
|
||||
id: user.id,
|
||||
credits: balance,
|
||||
total_credits_purchased: totalCreditsPurchased,
|
||||
premium_level: premiumLevel
|
||||
}, packGrants);
|
||||
|
||||
return { totalCreditsPurchased, balance, entitlements };
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to record direct entry purchase');
|
||||
console.error('❌ recordDirectEntryPurchase error:', normalized.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _findReusablePayment(userId, paymentType, productId = null) {
|
||||
if (!userId) return null;
|
||||
const result = await this.db.query(`
|
||||
@@ -1153,13 +1221,31 @@ class GameModeManager {
|
||||
`, [user.id]);
|
||||
const expiredAddresses = expiredResult.rows.map(r => r.subaddress);
|
||||
|
||||
// Create payment request using wallet RPC with correct parameters
|
||||
const paymentResult = await this.walletService.createPaymentRequest(
|
||||
amount,
|
||||
description,
|
||||
user.id,
|
||||
socketId
|
||||
);
|
||||
// Create the payment request through the routed provider. The native provider (default
|
||||
// for every chain when no gateway env is set) delegates to walletService.createPaymentRequest,
|
||||
// so this is byte-for-byte the legacy path for the shipped single-chain config — same
|
||||
// subaddress, same walletService monitoring maps. A BTCPay/xmrcheckout/wowcheckout gateway
|
||||
// takes over only when the operator routes this.cryptoType to it.
|
||||
const provider = this.paymentProviders && this.paymentProviders.getProvider(this.cryptoType);
|
||||
let paymentResult;
|
||||
if (provider) {
|
||||
const invoice = await provider.createInvoice({
|
||||
chain: this.cryptoType,
|
||||
amountAtomic: amount,
|
||||
description,
|
||||
userId: user.id,
|
||||
orderId: socketId
|
||||
});
|
||||
paymentResult = {
|
||||
address: invoice.address,
|
||||
addressIndex: invoice.addressIndex ?? (invoice.raw && invoice.raw.addressIndex) ?? null,
|
||||
expiresAt: invoice.expiresAt,
|
||||
invoiceId: invoice.invoiceId || invoice.address
|
||||
};
|
||||
} else {
|
||||
// Defensive fallback: registry somehow empty -> exact legacy call.
|
||||
paymentResult = await this.walletService.createPaymentRequest(amount, description, user.id, socketId);
|
||||
}
|
||||
|
||||
const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000);
|
||||
|
||||
@@ -1186,6 +1272,7 @@ class GameModeManager {
|
||||
return {
|
||||
id: insertedRow?.id,
|
||||
address: paymentResult.address,
|
||||
invoiceId: paymentResult.invoiceId || paymentResult.address,
|
||||
amount: amount,
|
||||
amountFormatted: this.formatAtomicHuman(amount, 4),
|
||||
currency: this.cryptoType,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const ChainProfile = require('../../chain/chainProfile');
|
||||
|
||||
const DEFAULT_DECIMALS = 12;
|
||||
|
||||
const parseAtomicEnvValue = (val, fallback) => {
|
||||
@@ -17,13 +19,9 @@ const parseAtomicEnvValue = (val, fallback) => {
|
||||
return Math.trunc(num);
|
||||
};
|
||||
|
||||
const inferCurrencyDecimals = (symbol) => {
|
||||
if (!symbol) return DEFAULT_DECIMALS;
|
||||
const normalized = symbol.toUpperCase();
|
||||
if (normalized === 'WOW') return 11;
|
||||
if (normalized === 'XMR') return 12;
|
||||
return DEFAULT_DECIMALS;
|
||||
};
|
||||
// Delegates to the ChainProfile registry — one source for all chains (WOW 11, XMR 12, BTC/LTC 8,
|
||||
// GRIN 9; unknown -> 12). Previously WOW/XMR were hardcoded and everything else silently got 12.
|
||||
const inferCurrencyDecimals = (symbol) => ChainProfile.decimalsFor(symbol);
|
||||
|
||||
const getDecimalDivisor = (decimals = DEFAULT_DECIMALS) => {
|
||||
const normalized = Number.isFinite(decimals) ? decimals : DEFAULT_DECIMALS;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Ruleset — gameplay as a data object (Pillar 4).
|
||||
*
|
||||
* A Ruleset captures everything that makes one game mode different from another: the world it
|
||||
* generates, the entities in it, how many players, how someone wins, the clock, and the economy.
|
||||
* The match engine READS a ruleset instead of hardcoding rules, so new modes (PvP Last-Alive,
|
||||
* Score Attack, Co-op) are data — not new subsystems — and operators/players can later author their
|
||||
* own lobbies by supplying a ruleset object.
|
||||
*
|
||||
* defineRuleset normalizes a partial spec to a complete, frozen ruleset with safe defaults. The
|
||||
* defaults reproduce the classic single-dungeon escape so an empty spec is a sane baseline.
|
||||
*/
|
||||
|
||||
const WIN = {
|
||||
FIRST_TO_EXIT: 'first-to-exit', // first player out the exit wins, ends immediately (classic race)
|
||||
LAST_ALIVE: 'last-alive', // last player still alive-and-in-play wins (PvP / battle royale)
|
||||
HIGH_SCORE: 'high-score', // highest score when the match ends (score attack)
|
||||
ALL_ESCAPE: 'all-escape' // co-op: everyone must reach the exit
|
||||
};
|
||||
|
||||
const ECONOMY = { FREE: 'free', CREDITS_PRESTIGE: 'credits_prestige', CRYPTO_RACE: 'crypto_race' };
|
||||
|
||||
const num = (v, d) => (Number.isFinite(v) ? v : d);
|
||||
const clampInt = (v, lo, hi, d) => {
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d;
|
||||
};
|
||||
|
||||
function defineRuleset(spec = {}) {
|
||||
const s = spec || {};
|
||||
const world = s.world || {};
|
||||
const entities = s.entities || {};
|
||||
const players = s.players || {};
|
||||
const winCondition = s.winCondition || {};
|
||||
const timing = s.timing || {};
|
||||
const economy = s.economy || {};
|
||||
|
||||
const rs = {
|
||||
id: String(s.id || 'custom'),
|
||||
label: String(s.label || s.id || 'Custom'),
|
||||
mode: s.mode || 'race', // 'solo' | 'race' | 'pvp' | 'coop' — a coarse family label
|
||||
world: Object.freeze({
|
||||
difficultyPreset: world.difficultyPreset || 'normal',
|
||||
maps: clampInt(world.maps, 1, 10, 1) // reserved for multi-level; engine uses 1 today
|
||||
}),
|
||||
entities: Object.freeze({
|
||||
monster: entities.monster !== false, // default true
|
||||
monsterCount: clampInt(entities.monsterCount, 0, 8, entities.monster === false ? 0 : 1),
|
||||
pvpCombat: !!entities.pvpCombat // stepping onto a rival kills them instead of being blocked
|
||||
}),
|
||||
players: Object.freeze({
|
||||
min: clampInt(players.min, 1, 32, 1),
|
||||
max: clampInt(players.max, 1, 32, 4)
|
||||
}),
|
||||
winCondition: Object.freeze({
|
||||
type: Object.values(WIN).includes(winCondition.type) ? winCondition.type : WIN.FIRST_TO_EXIT
|
||||
}),
|
||||
timing: Object.freeze({
|
||||
tickMs: clampInt(timing.tickMs, 50, 5000, 250),
|
||||
minDurationMs: clampInt(timing.minDurationMs, 0, 600000, 20000),
|
||||
hardCeilingMs: clampInt(timing.hardCeilingMs, 1000, 3600000, 240000),
|
||||
blockDeadline: timing.blockDeadline !== false
|
||||
}),
|
||||
economy: Object.freeze({
|
||||
model: Object.values(ECONOMY).includes(economy.model) ? economy.model : ECONOMY.FREE,
|
||||
houseFeePercent: num(economy.houseFeePercent, 0),
|
||||
payoutMultipliers: Object.freeze({
|
||||
escape: num(economy.payoutMultipliers && economy.payoutMultipliers.escape, 2),
|
||||
escapeWithTreasure: num(economy.payoutMultipliers && economy.payoutMultipliers.escapeWithTreasure, 3)
|
||||
})
|
||||
}),
|
||||
metadata: Object.freeze({ ...(s.metadata || {}) })
|
||||
};
|
||||
// normalize min<=max
|
||||
if (rs.players.min > rs.players.max) {
|
||||
rs.players = Object.freeze({ min: rs.players.max, max: rs.players.max });
|
||||
}
|
||||
return Object.freeze(rs);
|
||||
}
|
||||
|
||||
module.exports = { defineRuleset, WIN, ECONOMY };
|
||||
@@ -0,0 +1,14 @@
|
||||
const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
|
||||
const { resolveWinCondition } = require('./winConditions');
|
||||
const { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts } = require('./registry');
|
||||
|
||||
module.exports = {
|
||||
defineRuleset,
|
||||
WIN,
|
||||
ECONOMY,
|
||||
resolveWinCondition,
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
rulesetFromMatchOpts
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
|
||||
|
||||
/**
|
||||
* Built-in rulesets. `race` reproduces the current match behavior exactly (so nothing changes for
|
||||
* existing matches). `last-alive` and `score-attack` are the new PvP / score modes the match engine
|
||||
* now supports as data. `solo-classic` describes the live single-player escape for catalog/parity —
|
||||
* the single-player engine isn't refactored to consume it yet, so it's a descriptor, not wired.
|
||||
*/
|
||||
const BUILTINS = {
|
||||
'solo-classic': defineRuleset({
|
||||
id: 'solo-classic', label: 'Classic Escape', mode: 'solo',
|
||||
players: { min: 1, max: 1 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
economy: { model: ECONOMY.FREE }
|
||||
}),
|
||||
'race': defineRuleset({
|
||||
id: 'race', label: 'Escape Race', mode: 'race',
|
||||
world: { difficultyPreset: 'race' }, // matches DEFAULT_MATCH_PRESET (falls through difficultyConfig, preserved)
|
||||
players: { min: 2, max: 8 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
economy: { model: ECONOMY.CRYPTO_RACE }
|
||||
}),
|
||||
'last-alive': defineRuleset({
|
||||
id: 'last-alive', label: 'Last Alive', mode: 'pvp',
|
||||
world: { difficultyPreset: 'normal' },
|
||||
entities: { monster: true, pvpCombat: true }, // step onto a rival to strike them down
|
||||
players: { min: 2, max: 8 },
|
||||
winCondition: { type: WIN.LAST_ALIVE }
|
||||
}),
|
||||
'score-attack': defineRuleset({
|
||||
id: 'score-attack', label: 'Score Attack', mode: 'race',
|
||||
players: { min: 1, max: 8 },
|
||||
winCondition: { type: WIN.HIGH_SCORE }
|
||||
}),
|
||||
'coop-escape': defineRuleset({
|
||||
id: 'coop-escape', label: 'Co-op Escape', mode: 'coop',
|
||||
entities: { pvpCombat: false },
|
||||
players: { min: 2, max: 8 },
|
||||
winCondition: { type: WIN.ALL_ESCAPE }
|
||||
})
|
||||
};
|
||||
|
||||
function getRuleset(id, overrides = null) {
|
||||
const base = BUILTINS[id] || BUILTINS.race;
|
||||
if (!overrides) return base;
|
||||
return defineRuleset({ ...base, ...overrides, id: base.id, label: base.label });
|
||||
}
|
||||
|
||||
function listRulesets() {
|
||||
return Object.values(BUILTINS).map(r => ({
|
||||
id: r.id, label: r.label, mode: r.mode,
|
||||
players: r.players, winCondition: r.winCondition.type, pvpCombat: r.entities.pvpCombat
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the legacy MatchRoom option bag ({economy, variant, difficultyPreset, maxPlayers}) to a
|
||||
* ruleset that preserves the current race behavior — win condition stays first-to-exit regardless
|
||||
* of `variant` (which was a stored-but-unused label), so wiring the engine to rulesets changes
|
||||
* nothing until a caller explicitly asks for a different ruleset by id.
|
||||
*/
|
||||
function rulesetFromMatchOpts(opts = {}) {
|
||||
return defineRuleset({
|
||||
id: 'race',
|
||||
label: 'Escape Race',
|
||||
mode: 'race',
|
||||
world: { difficultyPreset: opts.difficultyPreset || 'race' },
|
||||
players: { min: 2, max: opts.maxPlayers || 8 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
economy: { model: opts.economy || ECONOMY.FREE }
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts };
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Win-condition strategies. Each is a small object the MatchRoom consults at three decision points,
|
||||
* so the room no longer hardcodes "first to the exit wins":
|
||||
*
|
||||
* onExit(room, id) — a player reached the exit. Decide whether that ends the match / sets a winner.
|
||||
* onDeath(room) — a player just died. Decide whether the match is now over.
|
||||
* rank(room) — final ordering (best-first) used by finalize() for placement + winnerId.
|
||||
*
|
||||
* FIRST_TO_EXIT reproduces the classic race behavior byte-for-byte (so existing matches are
|
||||
* unchanged); LAST_ALIVE and HIGH_SCORE are the new PvP / score modes.
|
||||
*/
|
||||
const { WIN } = require('./Ruleset');
|
||||
|
||||
function manhattanToExit(room, occ, onlyIfAlive, state) {
|
||||
const exit = room.dungeon.exit;
|
||||
if (!exit || !occ) return Infinity;
|
||||
if (onlyIfAlive && (!state || !state.alive)) return Infinity;
|
||||
return Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]);
|
||||
}
|
||||
|
||||
function buildRanked(room, onlyIfAlive) {
|
||||
return Array.from(room.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = room.occupants.get(id);
|
||||
return { id, state, occ, dist: manhattanToExit(room, occ, onlyIfAlive, state) };
|
||||
});
|
||||
}
|
||||
|
||||
const FIRST_TO_EXIT = {
|
||||
onExit(room, id) {
|
||||
if (!room.winnerId) {
|
||||
room.winnerId = id;
|
||||
room.endReason = 'escaped';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
onDeath(room) {
|
||||
if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') {
|
||||
room.endReason = 'all_dead';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
// Verbatim port of the original finalize() sort.
|
||||
rank(room) {
|
||||
const preWinner = room.winnerId;
|
||||
const ranked = buildRanked(room, true);
|
||||
ranked.sort((a, b) => {
|
||||
if (preWinner) {
|
||||
const aw = a.id === preWinner ? 1 : 0;
|
||||
const bw = b.id === preWinner ? 1 : 0;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
const finishedA = a.state.finished ? 1 : 0;
|
||||
const finishedB = b.state.finished ? 1 : 0;
|
||||
if (finishedA !== finishedB) return finishedB - finishedA;
|
||||
const aliveA = a.state.alive ? 1 : 0;
|
||||
const aliveB = b.state.alive ? 1 : 0;
|
||||
if (aliveA !== aliveB) return aliveB - aliveA;
|
||||
if (a.dist !== b.dist) return a.dist - b.dist;
|
||||
const tA = a.state.hasTreasure ? 1 : 0;
|
||||
const tB = b.state.hasTreasure ? 1 : 0;
|
||||
if (tA !== tB) return tB - tA;
|
||||
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
};
|
||||
|
||||
// Last player still alive-and-in-play wins. Reaching the exit means you SURVIVED (safe) but does
|
||||
// not instantly win; the match ends when at most one contender remains.
|
||||
const LAST_ALIVE = {
|
||||
_maybeEnd(room) {
|
||||
const started = room.playerStates.size;
|
||||
if (started >= 2 && room.activePlayerCount <= 1 && room.status === 'active') {
|
||||
room.status = 'finished';
|
||||
room.endReason = room.activePlayerCount === 1 ? 'last_alive' : 'all_dead';
|
||||
}
|
||||
},
|
||||
onExit(room /*, id */) { LAST_ALIVE._maybeEnd(room); },
|
||||
onDeath(room) { LAST_ALIVE._maybeEnd(room); },
|
||||
rank(room) {
|
||||
// in-play (last standing) > escaped survivor > dead-later > dead-earlier; then treasure, id.
|
||||
const tier = (s) => (s.alive && !s.finished ? 3 : (s.escaped ? 2 : (s.alive ? 1 : 0)));
|
||||
const ranked = buildRanked(room, false);
|
||||
ranked.sort((a, b) => {
|
||||
const ta = tier(a.state), tb = tier(b.state);
|
||||
if (ta !== tb) return tb - ta;
|
||||
// among the dead, whoever died later ranks higher (survived longer)
|
||||
const da = a.state.deathOrder || 0, db = b.state.deathOrder || 0;
|
||||
if (da !== db) return db - da;
|
||||
const tA = a.state.hasTreasure ? 1 : 0, tB = b.state.hasTreasure ? 1 : 0;
|
||||
if (tA !== tB) return tB - tA;
|
||||
if (a.dist !== b.dist) return a.dist - b.dist;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
};
|
||||
|
||||
// Highest score when everyone is resolved (or the clock expires). No instant win on exit.
|
||||
const HIGH_SCORE = {
|
||||
onExit(/* room, id */) { /* escaping just finishes the player; scoring decides the winner */ },
|
||||
onDeath(room) {
|
||||
if (room.activePlayerCount === 0 && room.status === 'active') {
|
||||
room.endReason = 'resolved';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
rank(room) {
|
||||
const pre = (s, dist) => {
|
||||
let v = 0;
|
||||
if (s.escaped) v += 200;
|
||||
if (s.hasTreasure) v += 200;
|
||||
v += Math.max(0, 300 - Math.max(s.moves - 30, 0) * 3);
|
||||
if (!s.escaped && Number.isFinite(dist)) v += Math.max(0, 100 - dist * 2);
|
||||
return v;
|
||||
};
|
||||
const ranked = buildRanked(room, false);
|
||||
ranked.sort((a, b) => {
|
||||
const sa = pre(a.state, a.dist), sb = pre(b.state, b.dist);
|
||||
if (sa !== sb) return sb - sa;
|
||||
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
};
|
||||
|
||||
// ALL_ESCAPE (co-op) — a lightweight variant of race: the match ends when everyone still alive has
|
||||
// escaped (or all died). Ranking falls back to the race ordering.
|
||||
const ALL_ESCAPE = {
|
||||
onExit(room) {
|
||||
if (room.activePlayerCount === 0 && room.finishCount > 0 && room.status === 'active') {
|
||||
room.endReason = 'all_escaped';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
onDeath(room) {
|
||||
if (room.activePlayerCount === 0 && room.status === 'active') {
|
||||
room.endReason = room.finishCount > 0 ? 'all_escaped' : 'all_dead';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
rank(room) { return FIRST_TO_EXIT.rank(room); }
|
||||
};
|
||||
|
||||
const STRATEGIES = {
|
||||
[WIN.FIRST_TO_EXIT]: FIRST_TO_EXIT,
|
||||
[WIN.LAST_ALIVE]: LAST_ALIVE,
|
||||
[WIN.HIGH_SCORE]: HIGH_SCORE,
|
||||
[WIN.ALL_ESCAPE]: ALL_ESCAPE
|
||||
};
|
||||
|
||||
function resolveWinCondition(type) {
|
||||
return STRATEGIES[type] || FIRST_TO_EXIT;
|
||||
}
|
||||
|
||||
module.exports = { resolveWinCondition, FIRST_TO_EXIT, LAST_ALIVE, HIGH_SCORE, ALL_ESCAPE };
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Migration 024: operator-owned cosmetic catalog (the 10× foundation stone).
|
||||
--
|
||||
-- Replaces the split-brain, hand-synced pack definitions that live in BOTH
|
||||
-- src/multiplayer/entitlements.js (server PACKS) and
|
||||
-- html/js/render/assetPacks.js (client RK.PACKS)
|
||||
-- with a single server-authoritative table the operator owns. The server snapshot and the
|
||||
-- client renderer both read from this (the client gets it in the entitlement/config payload),
|
||||
-- so an operator can define a new skin/tile pack, its price, tier, and unlock rule without
|
||||
-- editing any JS.
|
||||
--
|
||||
-- UNLOCK MODEL (fixes the current "any purchase unlocks EVERY premium pack" short-circuit in
|
||||
-- entitlements.js snapshotForUser). A user owns a pack if ANY of:
|
||||
-- 1. an explicit row in user_pack_entitlements (a grant or a direct cosmetic purchase), OR
|
||||
-- 2. unlock_min_credits IS NOT NULL AND users.total_credits_purchased >= unlock_min_credits, OR
|
||||
-- 3. the user's tier >= this pack's tier (ordered ladder; 0 = free).
|
||||
-- This makes "buy 1 credit → unlock skin A, buy the 10-pack → unlock pack B" expressible:
|
||||
-- skin A: unlock_min_credits = 1 | pack B: unlock_min_credits = 10 (or grant_only + a
|
||||
-- per-product grant on the 10-pack). Tiers cover blanket "supporter unlocks everything at
|
||||
-- tier ≤ N" if the operator wants it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cosmetic_catalog (
|
||||
pack_id VARCHAR(64) PRIMARY KEY, -- 'iso-dungeon', 'generated-skins', …
|
||||
label VARCHAR(120) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL DEFAULT 'render-pack', -- 'render-pack' | 'skin' | 'tile'
|
||||
projection VARCHAR(24), -- 'topdown' | 'iso' | '3d' | NULL (any)
|
||||
tier INTEGER NOT NULL DEFAULT 0, -- 0 = free; ordered ladder (1,2,…)
|
||||
unlock_min_credits BIGINT, -- lifetime-spend threshold; NULL = not credit-unlockable
|
||||
grant_only BOOLEAN NOT NULL DEFAULT FALSE,-- true = only via explicit grant/purchase
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}', -- asset urls, render-mode binding, etc.
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT cosmetic_catalog_tier_nonneg CHECK (tier >= 0),
|
||||
CONSTRAINT cosmetic_catalog_min_credits_nonneg CHECK (unlock_min_credits IS NULL OR unlock_min_credits >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cosmetic_catalog_active ON cosmetic_catalog(active, sort_order);
|
||||
|
||||
-- Seed the four packs that exist today so nothing regresses on first migration. These mirror the
|
||||
-- current hardcoded definitions (roguelike-interior free; the rest premium, credit-unlockable at
|
||||
-- 1 lifetime credit — matching today's {kind:'credits_purchase', minTotalCreditsPurchased:1}).
|
||||
INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, sort_order) VALUES
|
||||
('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 0, NULL, FALSE, 0),
|
||||
('generated-skins', 'Premium Generated Skins', 'render-pack', 'topdown', 1, 1, FALSE, 1),
|
||||
('iso-dungeon', 'Kenney Isometric Dungeon', 'render-pack', 'iso', 1, 1, FALSE, 2),
|
||||
('kenney-3d-characters', 'Kenney Animated 3D Chars', 'render-pack', '3d', 1, 1, FALSE, 3)
|
||||
ON CONFLICT (pack_id) DO NOTHING;
|
||||
@@ -22,6 +22,7 @@ const DungeonGenerator = require('../game/dungeon');
|
||||
const Monster = require('../game/monster');
|
||||
const { createGameProof, createSeededRNG, seedToInt } = require('../game/provablyFair');
|
||||
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('../game/difficultyConfig');
|
||||
const { defineRuleset, getRuleset, rulesetFromMatchOpts, resolveWinCondition } = require('../game/rulesets');
|
||||
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -46,10 +47,19 @@ class MatchRoom extends Room {
|
||||
// so pass a minimal placeholder and then overwrite the map state.
|
||||
super({ id, type: 'match', layout: ['.'], solidOccupants: true, maxOccupants: opts.maxPlayers || 4 });
|
||||
|
||||
this.economy = opts.economy || 'free';
|
||||
this.variant = opts.variant || 'race';
|
||||
this.difficultyPreset = opts.difficultyPreset || DEFAULT_MATCH_PRESET;
|
||||
this.maxPlayers = Math.max(2, Math.min(32, opts.maxPlayers || 4));
|
||||
// Resolve the ruleset (Pillar 4). Precedence: an explicit ruleset object, then a rulesetId
|
||||
// from the registry, else one synthesized from the legacy opts — which preserves the classic
|
||||
// race behavior exactly, so nothing changes until a caller asks for a different ruleset.
|
||||
this.ruleset = opts.ruleset ? defineRuleset(opts.ruleset)
|
||||
: (opts.rulesetId ? getRuleset(opts.rulesetId, opts.rulesetOverrides) : rulesetFromMatchOpts(opts));
|
||||
this.winCondition = resolveWinCondition(this.ruleset.winCondition.type);
|
||||
this.pvpCombat = this.ruleset.entities.pvpCombat;
|
||||
this._deathCounter = 0; // increments per death → last-alive tiebreak (died later ranks higher)
|
||||
|
||||
this.economy = opts.economy || this.ruleset.economy.model;
|
||||
this.variant = opts.variant || (this.pvpCombat ? 'pvp' : 'race');
|
||||
this.difficultyPreset = opts.difficultyPreset || this.ruleset.world.difficultyPreset || DEFAULT_MATCH_PRESET;
|
||||
this.maxPlayers = Math.max(2, Math.min(32, opts.maxPlayers || this.ruleset.players.max || 4));
|
||||
this.cryptoType = opts.cryptoType || process.env.CRYPTO_TYPE || 'WOW';
|
||||
this.startBlockHeight = opts.startBlockHeight || null;
|
||||
this.createdAt = Date.now();
|
||||
@@ -69,8 +79,9 @@ class MatchRoom extends Room {
|
||||
// Generate the shared dungeon
|
||||
this._generateDungeon();
|
||||
|
||||
// Spawn players at entrance, monster in configured room
|
||||
this._spawnMonster();
|
||||
// Spawn players at entrance, and the shared monster when the ruleset includes one.
|
||||
this.monster = null;
|
||||
if (this.ruleset.entities.monster) this._spawnMonster();
|
||||
this._resetEntrants(opts.entrants || {});
|
||||
|
||||
// Shared treasure state
|
||||
@@ -79,9 +90,9 @@ class MatchRoom extends Room {
|
||||
// Movement queue for current tick: occupantId -> { dx, dy }
|
||||
this.moveQueue = new Map();
|
||||
|
||||
// Timer / deadline bookkeeping
|
||||
this.minDurationMs = 20000; // 20s floor before next-block expiry can fire
|
||||
this.hardCeilingMs = 240000; // 4m absolute max
|
||||
// Timer / deadline bookkeeping (from the ruleset; defaults reproduce the classic values).
|
||||
this.minDurationMs = this.ruleset.timing.minDurationMs; // floor before next-block expiry can fire (20s)
|
||||
this.hardCeilingMs = this.ruleset.timing.hardCeilingMs; // absolute max (4m)
|
||||
this.startedAt = null;
|
||||
this.endedAt = null;
|
||||
this.endReason = null;
|
||||
@@ -309,7 +320,19 @@ class MatchRoom extends Room {
|
||||
} else if (!this.isWalkable(p.to.x, p.to.y)) {
|
||||
reason = 'blocked';
|
||||
} else if (this.solidOccupants && this.isOccupied(p.to.x, p.to.y, p.id)) {
|
||||
reason = 'occupied';
|
||||
// PvP: stepping onto a living rival strikes them down; the attacker holds position.
|
||||
if (this.pvpCombat) {
|
||||
const victimId = this._livingOccupantIdAt(p.to.x, p.to.y, p.id);
|
||||
if (victimId) {
|
||||
this._killPlayer(victimId, p.id);
|
||||
events.push({ type: 'player_death', id: victimId, killedBy: p.id, tick: this.tickCount });
|
||||
reason = 'attack';
|
||||
} else {
|
||||
reason = 'occupied'; // only a corpse / non-target is here
|
||||
}
|
||||
} else {
|
||||
reason = 'occupied';
|
||||
}
|
||||
} else if (destCount.get(`${p.to.x},${p.to.y}`) > 1) {
|
||||
reason = 'collision_pileup';
|
||||
}
|
||||
@@ -399,6 +422,19 @@ class MatchRoom extends Room {
|
||||
return tile === "'1" || tile === "'2" || tile === 0 || tile === '>' || tile === '$M';
|
||||
}
|
||||
|
||||
// The id of a living, in-play occupant standing on (x,y), excluding `excludeId`. Used for PvP
|
||||
// strikes (a corpse or a finished/escaped player is not a valid target).
|
||||
_livingOccupantIdAt(x, y, excludeId) {
|
||||
for (const occ of this.occupants.values()) {
|
||||
if (occ.id === excludeId) continue;
|
||||
if (occ.x === x && occ.y === y) {
|
||||
const s = this.playerStates.get(occ.id);
|
||||
if (s && s.alive && !s.finished) return occ.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_checkResolution() {
|
||||
const events = [];
|
||||
const exit = this.dungeon.exit;
|
||||
@@ -422,15 +458,12 @@ class MatchRoom extends Room {
|
||||
events.push({ type: 'treasure_pickup', id: occ.id, x: occ.x, y: occ.y, tick: this.tickCount });
|
||||
}
|
||||
|
||||
// Exit reached.
|
||||
// Exit reached. The ruleset's win condition decides what that means (race: first out
|
||||
// wins & ends; last-alive: you survived but the match continues; score: just finished).
|
||||
if (exit && occ.x === exit[0] && occ.y === exit[1]) {
|
||||
this._finishPlayer(occ.id, true);
|
||||
events.push({ type: 'player_exit', id: occ.id, hasTreasure: state.hasTreasure, tick: this.tickCount });
|
||||
if (!this.winnerId) {
|
||||
this.winnerId = occ.id;
|
||||
this.endReason = 'escaped';
|
||||
this.status = 'finished';
|
||||
}
|
||||
this.winCondition.onExit(this, occ.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +476,7 @@ class MatchRoom extends Room {
|
||||
if (!state || !state.alive) return;
|
||||
state.alive = false;
|
||||
state.killedBy = killedBy;
|
||||
state.deathOrder = ++this._deathCounter; // later deaths rank higher in last-alive
|
||||
|
||||
// Drop treasure at corpse if carrying.
|
||||
if (state.hasTreasure && this.treasure) {
|
||||
@@ -452,11 +486,8 @@ class MatchRoom extends Room {
|
||||
state.hasTreasure = false;
|
||||
}
|
||||
|
||||
if (this.activePlayerCount === 0 && this.finishCount === 0 && this.status === 'active') {
|
||||
// Everyone died without anyone escaping.
|
||||
this.endReason = 'all_dead';
|
||||
this.status = 'finished';
|
||||
}
|
||||
// The ruleset decides whether this death ends the match (race: all-dead; last-alive: one left).
|
||||
this.winCondition.onDeath(this);
|
||||
}
|
||||
|
||||
_finishPlayer(id, escaped) {
|
||||
@@ -529,38 +560,10 @@ class MatchRoom extends Room {
|
||||
finalize() {
|
||||
if (this.status !== 'finished') return this.playerStates;
|
||||
|
||||
const exit = this.dungeon.exit;
|
||||
// A winner already designated by first-to-escape (_checkResolution) must remain the
|
||||
// winner: the first player out wins the race regardless of treasure/speed tiebreakers
|
||||
// among later finishers. When no one escaped (timeout / all-dead) preWinner is null and
|
||||
// ranking alone decides.
|
||||
const preWinner = this.winnerId;
|
||||
const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = this.occupants.get(id);
|
||||
const dist = exit && occ && state.alive ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
|
||||
return { id, state, occ, dist };
|
||||
});
|
||||
|
||||
ranked.sort((a, b) => {
|
||||
// Designated escape-winner always ranks first (placement #1 == crypto payout target).
|
||||
if (preWinner) {
|
||||
const aw = a.id === preWinner ? 1 : 0;
|
||||
const bw = b.id === preWinner ? 1 : 0;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
const finishedA = a.state.finished ? 1 : 0;
|
||||
const finishedB = b.state.finished ? 1 : 0;
|
||||
if (finishedA !== finishedB) return finishedB - finishedA;
|
||||
const aliveA = a.state.alive ? 1 : 0;
|
||||
const aliveB = b.state.alive ? 1 : 0;
|
||||
if (aliveA !== aliveB) return aliveB - aliveA;
|
||||
if (a.dist !== b.dist) return a.dist - b.dist;
|
||||
const tA = a.state.hasTreasure ? 1 : 0;
|
||||
const tB = b.state.hasTreasure ? 1 : 0;
|
||||
if (tA !== tB) return tB - tA;
|
||||
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
// The ruleset's win condition provides the final ordering (best-first). FIRST_TO_EXIT
|
||||
// reproduces the classic ranking (escape-winner first, then finished > alive > closer-to-
|
||||
// exit > treasure > fewer-moves); last-alive/high-score order by their own rules.
|
||||
const ranked = this.winCondition.rank(this);
|
||||
|
||||
for (let i = 0; i < ranked.length; i++) {
|
||||
const r = ranked[i];
|
||||
@@ -568,10 +571,9 @@ class MatchRoom extends Room {
|
||||
r.state.score = this._calculateScore(r.state, i + 1, r.dist);
|
||||
}
|
||||
|
||||
// Winner consistency across ALL end paths (escape / timeout / all-dead): the crypto
|
||||
// payout target (winnerId) and the leaderboard's placement #1 always name the SAME
|
||||
// player. finalize() is the single source of truth — it runs after expire() in every
|
||||
// path, so it reconciles any provisional winner expire() may have set.
|
||||
// Winner consistency across ALL end paths: the payout target (winnerId) and the
|
||||
// leaderboard's placement #1 always name the SAME player. finalize() is the single source
|
||||
// of truth — it runs after expire() in every path, reconciling any provisional winner.
|
||||
if (ranked.length > 0) this.winnerId = ranked[0].id;
|
||||
|
||||
return this.playerStates;
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
const Appearance = require('./appearance');
|
||||
|
||||
const PACKS = Object.freeze({
|
||||
// Built-in default catalog. This is the FALLBACK used when the DB `cosmetic_catalog` table
|
||||
// (migration 024) isn't available (fresh/partly-migrated dev DBs, unit tests). In production the
|
||||
// operator-owned DB catalog is loaded by CatalogService and passed into snapshotForUser(); this
|
||||
// object just mirrors the four seed packs so nothing regresses without the table.
|
||||
//
|
||||
// Each entry: { id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }.
|
||||
// tier — 0 = free tier; ordered ladder (1,2,3) for premium subscriptions.
|
||||
// unlockMinCredits — lifetime-spend threshold to unlock, or null (not credit-unlockable).
|
||||
// grantOnly — only obtainable via an explicit grant/purchase.
|
||||
// premium — derived convenience flag: true unless the pack is free (no gate at all).
|
||||
const DEFAULT_CATALOG = Object.freeze({
|
||||
'roguelike-interior': Object.freeze({
|
||||
id: 'roguelike-interior',
|
||||
label: 'Kenney Roguelike Interior',
|
||||
premium: false,
|
||||
projection: 'topdown'
|
||||
id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack',
|
||||
projection: 'topdown', tier: 0, unlockMinCredits: null, grantOnly: false, premium: false
|
||||
}),
|
||||
'generated-skins': Object.freeze({
|
||||
id: 'generated-skins',
|
||||
label: 'Premium Generated Skins',
|
||||
premium: true,
|
||||
projection: 'topdown',
|
||||
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
|
||||
id: 'generated-skins', label: 'Premium Generated Skins', kind: 'render-pack',
|
||||
projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
|
||||
}),
|
||||
'iso-dungeon': Object.freeze({
|
||||
id: 'iso-dungeon',
|
||||
label: 'Kenney Isometric Dungeon',
|
||||
premium: true,
|
||||
projection: 'iso',
|
||||
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
|
||||
id: 'iso-dungeon', label: 'Kenney Isometric Dungeon', kind: 'render-pack',
|
||||
projection: 'iso', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
|
||||
}),
|
||||
'kenney-3d-characters': Object.freeze({
|
||||
id: 'kenney-3d-characters',
|
||||
label: 'Kenney Animated 3D Characters',
|
||||
premium: true,
|
||||
projection: '3d',
|
||||
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
|
||||
id: 'kenney-3d-characters', label: 'Kenney Animated 3D Characters', kind: 'render-pack',
|
||||
projection: '3d', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
|
||||
})
|
||||
});
|
||||
|
||||
// Ordered premium-tier ladder. Buying credits does NOT put you on this ladder (that was the old
|
||||
// bug — any purchase → level 'credits' → every premium pack unlocked). 'credits' maps to tier 0.
|
||||
const TIER_OF = Object.freeze({ free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 });
|
||||
|
||||
// Backwards-compatible alias: productGrants.js and older callers validate pack ids against PACKS.
|
||||
const PACKS = DEFAULT_CATALOG;
|
||||
// Legacy: the set of premium level names (still referenced by a couple of callers).
|
||||
const PREMIUM_LEVELS = new Set(['credits', 'supporter', 'premium', 'operator']);
|
||||
|
||||
function asNumber(v) {
|
||||
@@ -37,68 +43,107 @@ function asNumber(v) {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function normalizePackGrants(grants = []) {
|
||||
function tierForLevel(level) {
|
||||
const t = TIER_OF[String(level || '').toLowerCase()];
|
||||
return t == null ? 0 : t;
|
||||
}
|
||||
|
||||
function isFreePack(pack) {
|
||||
return asNumber(pack.tier) === 0 && (pack.unlockMinCredits == null) && !pack.grantOnly;
|
||||
}
|
||||
|
||||
function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) {
|
||||
const out = {};
|
||||
if (Array.isArray(grants)) {
|
||||
for (const g of grants) {
|
||||
const id = g && (g.pack_id || g.packId || g.id);
|
||||
if (id && PACKS[id]) out[id] = true;
|
||||
if (id && catalog[id]) out[id] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (grants && typeof grants === 'object') {
|
||||
for (const id of Object.keys(grants)) {
|
||||
if (PACKS[id] && grants[id]) out[id] = true;
|
||||
if (catalog[id] && grants[id]) out[id] = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not.
|
||||
function levelForUser(user = {}) {
|
||||
const raw = String(user.premium_level || user.premiumLevel || '').trim().toLowerCase();
|
||||
if (raw) return raw;
|
||||
return asNumber(user.total_credits_purchased || user.totalCreditsPurchased) > 0 ? 'credits' : 'free';
|
||||
if (raw && tierForLevel(raw) > 0) return raw;
|
||||
return 'free';
|
||||
}
|
||||
|
||||
function snapshotForUser(user = {}, packGrants = []) {
|
||||
// A pack is unlocked for a user if ANY of: it's free, an explicit grant, the user's lifetime
|
||||
// spend crosses the pack's threshold, or the user's premium tier is >= the pack's tier.
|
||||
function snapshotForUser(user = {}, packGrants = [], catalog = DEFAULT_CATALOG) {
|
||||
catalog = catalog && typeof catalog === 'object' ? catalog : DEFAULT_CATALOG;
|
||||
const totalCreditsPurchased = asNumber(user.total_credits_purchased || user.totalCreditsPurchased);
|
||||
const credits = asNumber(user.credits);
|
||||
const level = levelForUser(user);
|
||||
const explicit = normalizePackGrants(packGrants);
|
||||
const hasCreditUnlock = totalCreditsPurchased > 0;
|
||||
const hasPremiumLevel = PREMIUM_LEVELS.has(level);
|
||||
const packs = {};
|
||||
const userTier = tierForLevel(level);
|
||||
const explicit = normalizePackGrants(packGrants, catalog);
|
||||
|
||||
for (const id of Object.keys(PACKS)) {
|
||||
const pack = PACKS[id];
|
||||
if (!pack.premium) {
|
||||
packs[id] = true;
|
||||
continue;
|
||||
}
|
||||
const unlock = pack.unlock || {};
|
||||
const unlockedByCredits = unlock.kind === 'credits_purchase'
|
||||
&& hasCreditUnlock
|
||||
&& totalCreditsPurchased >= (unlock.minTotalCreditsPurchased || 1);
|
||||
packs[id] = !!explicit[id] || unlockedByCredits || hasPremiumLevel;
|
||||
const packs = {};
|
||||
let anyPremiumUnlocked = false;
|
||||
for (const id of Object.keys(catalog)) {
|
||||
const pack = catalog[id];
|
||||
const tier = asNumber(pack.tier);
|
||||
const minCredits = pack.unlockMinCredits;
|
||||
const free = isFreePack(pack);
|
||||
const unlocked = free
|
||||
|| !!explicit[id]
|
||||
|| (minCredits != null && totalCreditsPurchased >= asNumber(minCredits))
|
||||
|| (tier > 0 && userTier >= tier);
|
||||
packs[id] = unlocked;
|
||||
if (!free && unlocked) anyPremiumUnlocked = true;
|
||||
}
|
||||
|
||||
const premiumPackUnlocked = Object.keys(packs).some(id => PACKS[id].premium && packs[id]);
|
||||
return {
|
||||
premium: hasCreditUnlock || hasPremiumLevel || premiumPackUnlocked,
|
||||
level: level === 'free' && hasCreditUnlock ? 'credits' : level,
|
||||
premium: userTier > 0 || anyPremiumUnlocked,
|
||||
level,
|
||||
tier: userTier,
|
||||
credits,
|
||||
totalCreditsPurchased,
|
||||
packs
|
||||
packs,
|
||||
catalog: catalogSummary(catalog)
|
||||
};
|
||||
}
|
||||
|
||||
// Display-facing catalog the client renders (labels/projection/gating) — replaces the hardcoded
|
||||
// client RK.PACKS so the operator owns the catalog from one place (the DB).
|
||||
function catalogSummary(catalog = DEFAULT_CATALOG) {
|
||||
const out = [];
|
||||
for (const id of Object.keys(catalog)) {
|
||||
const p = catalog[id];
|
||||
out.push({
|
||||
id: p.id || id,
|
||||
label: p.label || id,
|
||||
kind: p.kind || 'render-pack',
|
||||
projection: p.projection || null,
|
||||
tier: asNumber(p.tier),
|
||||
premium: !isFreePack(p),
|
||||
unlockMinCredits: p.unlockMinCredits == null ? null : asNumber(p.unlockMinCredits)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Gating reads the user's computed snapshot (which already contains every catalog pack). Unknown
|
||||
// packs default to the built-in catalog's free-check (deny premium, allow free).
|
||||
function canUsePack(entitlements = {}, packId) {
|
||||
const pack = PACKS[packId];
|
||||
if (!pack) return false;
|
||||
if (!pack.premium) return true;
|
||||
if (!packId) return true;
|
||||
// A full snapshot's packs map is authoritative (the three-way rule already ran).
|
||||
if (entitlements.packs && Object.prototype.hasOwnProperty.call(entitlements.packs, packId)) {
|
||||
return !!entitlements.packs[packId];
|
||||
}
|
||||
// No granular packs map (a coarse/legacy entitlement): free packs are always allowed; else
|
||||
// fall back to the coarse premium flag. Production entitlements always carry the packs map,
|
||||
// so this branch never weakens real gating.
|
||||
const def = DEFAULT_CATALOG[packId];
|
||||
if (def && isFreePack(def)) return true;
|
||||
return !!entitlements.premium;
|
||||
}
|
||||
|
||||
@@ -117,9 +162,13 @@ function normalizeAppearance(input = {}, entitlements = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CATALOG,
|
||||
PACKS,
|
||||
PREMIUM_LEVELS,
|
||||
TIER_OF,
|
||||
tierForLevel,
|
||||
snapshotForUser,
|
||||
catalogSummary,
|
||||
canUsePack,
|
||||
canUseAppearance,
|
||||
normalizeAppearance,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
const ChatProvider = require('./ChatProvider');
|
||||
const { escapeChatText } = require('../../utils/escapeChat');
|
||||
|
||||
/**
|
||||
* NostrChatProvider — global cross-server chat over nostr, layered on the existing ChatProvider seam.
|
||||
*
|
||||
* It DECORATES a local provider (SocketChatProvider): every message is still delivered in-game +
|
||||
* persisted exactly as before, and on top of that a global message is fanned out to nostr relays
|
||||
* (relay.smirk.cash) so other game servers/modes see it. Messages arriving from the relay are
|
||||
* delivered locally-only (never re-published), so there are no echo loops.
|
||||
*
|
||||
* publish(local msg) → local.publish (in-game + history) + sign & relay.publish (global fan-out)
|
||||
* relay event (remote)→ local.publish only (in-game + history), text/username escaped
|
||||
*
|
||||
* Topology: client → server → relay. The server stays the moderation authority (bans, rate-limit,
|
||||
* history in Postgres via the local provider); the relay is transport + fan-out. Signing with a
|
||||
* registered Smirk npub (the bridge key, or later each player's own) is what the relay's
|
||||
* inbox-outbox policy requires to publish — so global chat is a concrete reason to sign in with Smirk.
|
||||
*
|
||||
* Behavior-preserving: with no transport/signer (nostr disabled) this is exactly the local provider.
|
||||
*/
|
||||
class NostrChatProvider extends ChatProvider {
|
||||
constructor({
|
||||
local,
|
||||
transport = null,
|
||||
signer = null,
|
||||
scope = 'global',
|
||||
channelTag = 'wowngeon-global',
|
||||
kind = 1,
|
||||
serverOrigin = null,
|
||||
debugManager = null,
|
||||
maxRemotePerMin = 120,
|
||||
now = () => Date.now()
|
||||
} = {}) {
|
||||
super();
|
||||
if (!local) throw new Error('NostrChatProvider requires a local provider');
|
||||
this.local = local;
|
||||
this.transport = transport;
|
||||
this.signer = signer;
|
||||
this.scope = scope;
|
||||
this.channelTag = channelTag;
|
||||
this.kind = kind;
|
||||
this.serverOrigin = serverOrigin;
|
||||
this.debugManager = debugManager;
|
||||
this.maxRemotePerMin = maxRemotePerMin;
|
||||
this.now = now;
|
||||
|
||||
this._seen = new Set(); // event ids we've delivered/published, for echo dedupe
|
||||
this._seenOrder = []; // bounded FIFO of the same ids
|
||||
this._remoteTimes = []; // recent remote-delivery timestamps (rate limit)
|
||||
}
|
||||
|
||||
_globalEnabled() {
|
||||
return this.scope === 'global' && !!this.transport;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.local.initialize();
|
||||
if (!this._globalEnabled()) return;
|
||||
const since = Math.floor(this.now() / 1000);
|
||||
// Only future messages — no history flood on connect (local history covers the past).
|
||||
this.transport.subscribe([{ kinds: [this.kind], '#t': [this.channelTag], since }], (ev) => this._onRemote(ev));
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[nostr] chat subscribed: tag=${this.channelTag} kind=${this.kind} publish=${this.signer ? 'on' : 'read-only'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(msg = {}) {
|
||||
// 1) Always deliver + persist locally, exactly as before.
|
||||
await this.local.publish(msg);
|
||||
|
||||
// 2) Fan out global messages to nostr (skip room-scoped and skip messages we received
|
||||
// from the relay, which arrive here only via local delivery — never through publish).
|
||||
if (msg.remote || msg.scope !== 'global' || !this._globalEnabled() || !this.signer) return;
|
||||
try {
|
||||
const template = {
|
||||
kind: this.kind,
|
||||
created_at: Math.floor((msg.ts || this.now()) / 1000),
|
||||
tags: [['t', this.channelTag], ['n', String(msg.username || '').slice(0, 32)]],
|
||||
content: String(msg.text == null ? '' : msg.text)
|
||||
};
|
||||
if (this.serverOrigin) template.tags.push(['origin', String(this.serverOrigin)]);
|
||||
const signed = this.signer.sign(template);
|
||||
if (signed && signed.id) this._markSeen(signed.id); // our own echo will be ignored
|
||||
await this.transport.publish(signed);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] fan-out failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// A message arriving from the relay: deliver locally only (no re-publish → no loop).
|
||||
_onRemote(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
if (typeof event.content !== 'string' || !Array.isArray(event.tags)) return;
|
||||
// Ours (echo) or already seen → drop.
|
||||
if (this.signer && event.pubkey === this.signer.pubkey) return;
|
||||
if (event.id && !this._markSeen(event.id)) return;
|
||||
// Rate-limit remote delivery so a hostile relay can't flood in-game clients.
|
||||
if (!this._allowRemote()) return;
|
||||
|
||||
const nTag = event.tags.find(t => Array.isArray(t) && t[0] === 'n');
|
||||
const rawName = (nTag && nTag[1]) || (typeof event.pubkey === 'string' ? event.pubkey.slice(0, 8) : 'nostr');
|
||||
const username = escapeChatText(rawName, 32) || 'nostr';
|
||||
const text = escapeChatText(event.content, 200);
|
||||
const ts = Number.isFinite(event.created_at) ? event.created_at * 1000 : this.now();
|
||||
|
||||
// remote:true prevents re-fan-out; local.publish delivers to in-game clients + persists.
|
||||
this.local.publish({ scope: 'global', username, text, ts, userId: null, remote: true })
|
||||
.catch(err => console.error('[nostr] local deliver of remote failed:', err.message));
|
||||
}
|
||||
|
||||
_allowRemote() {
|
||||
const cutoff = this.now() - 60000;
|
||||
this._remoteTimes = this._remoteTimes.filter(t => t >= cutoff);
|
||||
if (this._remoteTimes.length >= this.maxRemotePerMin) return false;
|
||||
this._remoteTimes.push(this.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the id is new (and records it); false if already seen. Bounded FIFO.
|
||||
_markSeen(id) {
|
||||
if (this._seen.has(id)) return false;
|
||||
this._seen.add(id);
|
||||
this._seenOrder.push(id);
|
||||
if (this._seenOrder.length > 1000) {
|
||||
const old = this._seenOrder.shift();
|
||||
this._seen.delete(old);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getHistory(opts) {
|
||||
return this.local.getHistory(opts);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try { if (this.transport) this.transport.close(); } catch (_) {}
|
||||
await this.local.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NostrChatProvider;
|
||||
@@ -0,0 +1,46 @@
|
||||
const NostrChatProvider = require('./NostrChatProvider');
|
||||
const NostrTransport = require('./nostr/NostrTransport');
|
||||
const { createBridgeSigner } = require('./nostr/bridgeSigner');
|
||||
|
||||
function parseBool(v) {
|
||||
if (v == null) return false;
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(v).trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat provider for the given local (in-game) provider. Single opt-in seam for Pillar 5.
|
||||
*
|
||||
* Behavior-preserving: with NOSTR_CHAT_ENABLED unset (today) the plain local provider is returned
|
||||
* unchanged. Enable to layer global cross-server chat over nostr:
|
||||
* NOSTR_CHAT_ENABLED=true
|
||||
* NOSTR_CHAT_SCOPE=global|local (local == plain local chat; global fans out over relays)
|
||||
* NOSTR_RELAYS=wss://relay.smirk.cash[,...]
|
||||
* NOSTR_CHAT_TAG=wowngeon-global (the shared channel topic)
|
||||
* NOSTR_CHAT_KIND=1
|
||||
* NOSTR_BRIDGE_SK=<nsec|hex> (server bridge key; must be a registered Smirk npub to publish.
|
||||
* Omit → receive-only global chat.)
|
||||
*/
|
||||
function buildChatProvider({ local, env = process.env, debugManager = null } = {}) {
|
||||
if (!local) throw new Error('buildChatProvider requires a local provider');
|
||||
if (!parseBool(env.NOSTR_CHAT_ENABLED)) return local;
|
||||
|
||||
const scope = (env.NOSTR_CHAT_SCOPE || 'global').toLowerCase();
|
||||
if (scope !== 'global') return local; // local-only is exactly the plain provider
|
||||
|
||||
const relays = (env.NOSTR_RELAYS || 'wss://relay.smirk.cash').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const transport = new NostrTransport({ relays, debugManager });
|
||||
const signer = createBridgeSigner(env.NOSTR_BRIDGE_SK);
|
||||
|
||||
return new NostrChatProvider({
|
||||
local,
|
||||
transport,
|
||||
signer,
|
||||
scope: 'global',
|
||||
channelTag: env.NOSTR_CHAT_TAG || 'wowngeon-global',
|
||||
kind: parseInt(env.NOSTR_CHAT_KIND || '1', 10) || 1,
|
||||
serverOrigin: env.NOSTR_SERVER_ORIGIN || env.SERVER_ID || null,
|
||||
debugManager
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { buildChatProvider, NostrChatProvider, NostrTransport };
|
||||
@@ -0,0 +1,65 @@
|
||||
const { loadNostrTools } = require('../../../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* Thin transport over nostr-tools SimplePool: connect to relays, publish signed events, subscribe
|
||||
* to filters. Isolated behind this class so NostrChatProvider can be unit-tested with a fake
|
||||
* transport (no network). Degrades to a warn-and-noop if nostr-tools/ws can't load, so enabling
|
||||
* chat can never crash the server.
|
||||
*
|
||||
* Reading (REQ/subscribe) is open on relay.smirk.cash; only publishing is policy-gated.
|
||||
*/
|
||||
class NostrTransport {
|
||||
constructor({ relays = ['wss://relay.smirk.cash'], debugManager = null } = {}) {
|
||||
this.relays = Array.isArray(relays) ? relays.filter(Boolean) : [];
|
||||
this.debugManager = debugManager;
|
||||
this.pool = null;
|
||||
this._sub = null;
|
||||
this._ok = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.pool || !this.relays.length) return this._ok;
|
||||
try {
|
||||
const tools = loadNostrTools();
|
||||
if (typeof tools.useWebSocketImplementation === 'function') {
|
||||
try { tools.useWebSocketImplementation(require('ws')); } catch (_) { /* browser/global WS */ }
|
||||
}
|
||||
this.pool = new tools.SimplePool();
|
||||
this._ok = true;
|
||||
} catch (err) {
|
||||
console.error('[nostr] transport disabled (nostr-tools/ws unavailable):', err.message);
|
||||
this._ok = false;
|
||||
}
|
||||
return this._ok;
|
||||
}
|
||||
|
||||
async publish(signedEvent) {
|
||||
if (!this.connect()) return;
|
||||
try {
|
||||
await Promise.any(this.pool.publish(this.relays, signedEvent));
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] publish failed:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(filters, onEvent) {
|
||||
if (!this.connect()) return;
|
||||
try {
|
||||
this._sub = this.pool.subscribeMany(this.relays, filters, {
|
||||
onevent: (ev) => { try { onEvent(ev); } catch (e) { console.error('[nostr] onEvent error:', e.message); } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[nostr] subscribe failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
try { if (this._sub && typeof this._sub.close === 'function') this._sub.close(); } catch (_) {}
|
||||
try { if (this.pool && typeof this.pool.close === 'function') this.pool.close(this.relays); } catch (_) {}
|
||||
this._sub = null;
|
||||
this.pool = null;
|
||||
this._ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NostrTransport;
|
||||
@@ -0,0 +1,61 @@
|
||||
const { loadNostrTools } = require('../../../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* A signer produces signed nostr events for outbound chat. Contract:
|
||||
* { pubkey: <64-hex x-only>, sign(template) -> signedEvent } (template: {kind, created_at, tags, content})
|
||||
*
|
||||
* createBridgeSigner builds a SERVER bridge signer from a secret key (hex or nsec). This is the
|
||||
* drop-in model: the game server signs cross-server chat with one key. For global chat to be
|
||||
* ACCEPTED by relay.smirk.cash (default `inbox-outbox` policy → only registered npubs may publish),
|
||||
* this bridge pubkey must be registered as a Smirk npub (an ops step, like the BTCPay API key).
|
||||
* A future upgrade signs per-message with each player's OWN Smirk npub (window.smirk.signNostrEvent)
|
||||
* so chat identity == wallet identity cryptographically — the provider stays signer-agnostic, so
|
||||
* that path only swaps the signer.
|
||||
*
|
||||
* Returns null when no key is configured → outbound nostr disabled (the provider still receives).
|
||||
*/
|
||||
function createBridgeSigner(secret) {
|
||||
if (!secret) return null;
|
||||
let tools, sk, pubkey;
|
||||
try {
|
||||
tools = loadNostrTools();
|
||||
sk = normalizeSecret(secret, tools);
|
||||
pubkey = tools.getPublicKey(sk);
|
||||
} catch (err) {
|
||||
console.error('[nostr] bridge signer disabled — bad NOSTR_BRIDGE_SK:', err.message);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pubkey,
|
||||
sign(template) {
|
||||
// finalizeEvent fills pubkey/id/sig and stamps created_at if absent.
|
||||
return tools.finalizeEvent(
|
||||
{
|
||||
kind: template.kind,
|
||||
created_at: template.created_at || Math.floor(Date.now() / 1000),
|
||||
tags: template.tags || [],
|
||||
content: template.content || ''
|
||||
},
|
||||
sk
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Accept an nsec bech32 or a 64-hex string; return the Uint8Array secret nostr-tools expects.
|
||||
function normalizeSecret(secret, tools) {
|
||||
const s = String(secret).trim();
|
||||
if (s.startsWith('nsec1')) {
|
||||
const dec = tools.nip19.decode(s);
|
||||
if (dec.type !== 'nsec') throw new Error('not an nsec');
|
||||
return dec.data; // Uint8Array
|
||||
}
|
||||
if (/^[0-9a-fA-F]{64}$/.test(s)) {
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = parseInt(s.substr(i * 2, 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('expected nsec or 64-hex secret key');
|
||||
}
|
||||
|
||||
module.exports = { createBridgeSigner };
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const ChatHistoryManager = require('./chatHistoryManager');
|
||||
const SocketChatProvider = require('./chat/SocketChatProvider');
|
||||
const { buildChatProvider } = require('./chat');
|
||||
const { clientIp, stableId } = require('./rateLimitContext');
|
||||
|
||||
class ChatHandler {
|
||||
@@ -25,16 +26,18 @@ class ChatHandler {
|
||||
maxHistoryMessages: 50
|
||||
});
|
||||
|
||||
// Chat delivery + history go through a ChatProvider seam so the backend is swappable
|
||||
// (e.g. a Nostr channel later). The default provider shares the ChatHistoryManager
|
||||
// instance above, so ban checks / stats that read this.chatHistory keep working and
|
||||
// lifecycle (initialize/shutdown) stays owned here.
|
||||
this.chatProvider = new SocketChatProvider({
|
||||
// Chat delivery + history go through a ChatProvider seam so the backend is swappable.
|
||||
// The local provider shares the ChatHistoryManager instance above, so ban checks / stats
|
||||
// that read this.chatHistory keep working and lifecycle stays owned here. buildChatProvider
|
||||
// optionally wraps it with a NostrChatProvider for global cross-server chat (Pillar 5) when
|
||||
// NOSTR_CHAT_ENABLED is set; otherwise it returns this local provider unchanged.
|
||||
const localChatProvider = new SocketChatProvider({
|
||||
io: this.io,
|
||||
broadcastManager: this.broadcastManager,
|
||||
debugManager: this.debugManager,
|
||||
historyManager: this.chatHistory
|
||||
});
|
||||
this.chatProvider = buildChatProvider({ local: localChatProvider, debugManager: this.debugManager });
|
||||
|
||||
// Memory leak prevention - cleanup old timestamps
|
||||
this._chatLastSent = new Map();
|
||||
@@ -48,6 +51,11 @@ class ChatHandler {
|
||||
*/
|
||||
async initialize() {
|
||||
await this.chatHistory.initialize();
|
||||
// Lets a NostrChatProvider connect + subscribe to the relay. No-op for the plain local
|
||||
// provider (history is already initialized above and injected, not owned by the provider).
|
||||
if (this.chatProvider && typeof this.chatProvider.initialize === 'function') {
|
||||
await this.chatProvider.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const Appearance = require('../multiplayer/appearance');
|
||||
const Entitlements = require('../multiplayer/entitlements');
|
||||
const CatalogService = require('../services/catalogService');
|
||||
|
||||
function parseAppearance(value) {
|
||||
if (!value) return Appearance.normalizeAppearance('default');
|
||||
@@ -14,11 +15,26 @@ function parseAppearance(value) {
|
||||
}
|
||||
|
||||
class IdentityService {
|
||||
constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null } = {}) {
|
||||
constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null, catalogService = null } = {}) {
|
||||
this.db = db || gameModeManager?.db || null;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.sessionManager = sessionManager;
|
||||
this.debugManager = debugManager;
|
||||
// Operator-owned cosmetic catalog (DB-backed, cached). Falls back to the built-in default
|
||||
// when the table is absent, so this never breaks a fresh/partly-migrated DB.
|
||||
this.catalogService = catalogService || (this.db ? new CatalogService({ db: this.db }) : null);
|
||||
}
|
||||
|
||||
async _catalog() {
|
||||
if (!this.catalogService) return undefined; // snapshotForUser falls back to DEFAULT_CATALOG
|
||||
try {
|
||||
return await this.catalogService.getCatalog();
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('[IdentityService] catalog load failed; using default:', err.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async userForSocket(socket) {
|
||||
@@ -56,9 +72,10 @@ class IdentityService {
|
||||
}
|
||||
|
||||
async entitlementsForUser(user) {
|
||||
if (!user) return Entitlements.snapshotForUser({});
|
||||
const catalog = await this._catalog();
|
||||
if (!user) return Entitlements.snapshotForUser({}, [], catalog);
|
||||
const grants = await this.packGrantsForUser(user.id);
|
||||
return Entitlements.snapshotForUser(user, grants);
|
||||
return Entitlements.snapshotForUser(user, grants, catalog);
|
||||
}
|
||||
|
||||
async entitlementsForSocket(socket) {
|
||||
|
||||
@@ -293,6 +293,14 @@ class PaymentHandlers {
|
||||
}
|
||||
|
||||
_monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game') {
|
||||
// Select the provider that owns this chain: native wallet-RPC by default, or a BTCPay/
|
||||
// checkout gateway when this.cryptoType is routed to one. Native watches by subaddress;
|
||||
// a gateway watches by invoice id. The confirmation handler is identical either way.
|
||||
const registry = this.gameModeManager && this.gameModeManager.paymentProviders;
|
||||
const provider = registry ? registry.getProvider(cryptoType) : null;
|
||||
const isGateway = !!(provider && provider.id !== 'native-monero');
|
||||
const watchRef = isGateway ? (paymentRequest.invoiceId || paymentRequest.address) : paymentRequest.address;
|
||||
|
||||
// Record mapping so we can stop later (replace existing entry)
|
||||
this.socketPaymentMap.set(socket.id, {
|
||||
address: paymentRequest.address,
|
||||
@@ -301,10 +309,39 @@ class PaymentHandlers {
|
||||
cryptoType,
|
||||
paymentType,
|
||||
package: paymentRequest.package,
|
||||
provider: provider || null,
|
||||
watchRef,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => {
|
||||
// Expire request after 30 minutes if not confirmed
|
||||
const expiryTimeout = setTimeout(() => {
|
||||
const mapping = this.socketPaymentMap.get(socket.id);
|
||||
if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
// Store & unref so tests / process can exit (the 30-min timer must never keep the
|
||||
// event loop alive on its own — in production the server keeps the process running).
|
||||
if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref();
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
|
||||
// Start watching. onUpdate delivers a raw wallet-style status; for native that IS the
|
||||
// walletService status (unchanged legacy path), for a gateway it's mapped from the
|
||||
// Greenfield invoice. Fall back to direct wallet monitoring when no provider is registered.
|
||||
const onStatus = (status) => this._handlePaymentStatus(socket, paymentRequest, currentUser, status);
|
||||
if (provider && typeof provider.startWatch === 'function') {
|
||||
provider.startWatch(watchRef, onStatus, 2000);
|
||||
} else {
|
||||
this.walletService.startPaymentMonitoring(paymentRequest.address, onStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle one payment status update (raw wallet-style shape: in_mempool/confirmed/complete/
|
||||
// amount/required/confirmations). Extracted verbatim from the monitor callback so native and
|
||||
// gateway providers share the exact same confirmation logic.
|
||||
async _handlePaymentStatus(socket, paymentRequest, currentUser, status) {
|
||||
if (status.in_mempool && !status.confirmed) {
|
||||
if (this.mempoolNotified.has(paymentRequest.address)) return;
|
||||
this.mempoolNotified.set(paymentRequest.address, Date.now());
|
||||
@@ -459,6 +496,25 @@ class PaymentHandlers {
|
||||
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations });
|
||||
|
||||
// Unify to credits: a direct/single_game entry counts as buying + spending 1
|
||||
// credit, so it advances total_credits_purchased and unlocks the same tier/
|
||||
// threshold cosmetics as a credit purchase. (Runs once — guarded by wasUpdated.)
|
||||
if (this.gameModeManager && typeof this.gameModeManager.recordDirectEntryPurchase === 'function') {
|
||||
try {
|
||||
const rec = await this.gameModeManager.recordDirectEntryPurchase(socket.id);
|
||||
if (rec) {
|
||||
socket.emit('credits_update', {
|
||||
balance: rec.balance,
|
||||
totalCreditsPurchased: rec.totalCreditsPurchased,
|
||||
...(rec.entitlements || {})
|
||||
});
|
||||
if (rec.entitlements) socket.emit('identity_update', { entitlements: rec.entitlements });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PaymentHandlers] direct-entry credit record failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT: If payment confirmed before mempool detection (fast blocks),
|
||||
// the player may not be in the queue yet. Add them now with confirmed=true.
|
||||
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
|
||||
@@ -501,20 +557,6 @@ class PaymentHandlers {
|
||||
// Clean up monitoring for this socket (even if duplicate)
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Expire request after 30 minutes if not confirmed
|
||||
const expiryTimeout = setTimeout(() => {
|
||||
const mapping = this.socketPaymentMap.get(socket.id);
|
||||
if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
// Store & unref so tests / process can exit (the 30-min timer must never keep the
|
||||
// event loop alive on its own — in production the server keeps the process running).
|
||||
if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref();
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -532,7 +574,11 @@ class PaymentHandlers {
|
||||
stopMonitoringForSocket(socketId) {
|
||||
const mapping = this.socketPaymentMap.get(socketId);
|
||||
if (mapping) {
|
||||
this.walletService.stopPaymentMonitoring(mapping.address);
|
||||
if (mapping.provider && typeof mapping.provider.stopWatch === 'function') {
|
||||
mapping.provider.stopWatch(mapping.watchRef || mapping.address); // native delegates to stopPaymentMonitoring
|
||||
} else {
|
||||
this.walletService.stopPaymentMonitoring(mapping.address);
|
||||
}
|
||||
this.socketPaymentMap.delete(socketId);
|
||||
this.mempoolNotified.delete(mapping.address);
|
||||
if (mapping.paymentId != null) {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
const { PaymentProvider } = require('./paymentProvider');
|
||||
const ChainProfile = require('../../chain/chainProfile');
|
||||
const Atomic = require('../../money/atomic');
|
||||
|
||||
// Convert a decimal crypto string (e.g. "1.5") to an integer atomic amount for `decimals` places.
|
||||
// Truncates any digits beyond `decimals` (the gateway already quantizes to chain precision).
|
||||
function decimalToAtomic(dec, decimals) {
|
||||
const s = String(dec == null ? '' : dec).trim();
|
||||
if (!s || s === '-') return 0;
|
||||
const neg = s[0] === '-';
|
||||
const [ip, fpRaw = ''] = s.replace('-', '').split('.');
|
||||
const fp = (fpRaw + '0'.repeat(decimals)).slice(0, decimals);
|
||||
const atomic = BigInt(ip || '0') * (10n ** BigInt(decimals)) + BigInt(fp || '0');
|
||||
return Number(neg ? -atomic : atomic);
|
||||
}
|
||||
|
||||
/**
|
||||
* BTCPay Greenfield provider — the first payment plugin. One client class serves every endpoint
|
||||
* that speaks the BTCPay Greenfield API. On the operator's LAN that is THREE endpoints (see the
|
||||
* btcpay-infra-topology memo): real BTCPay Server for BTC/LTC, plus the `xmrcheckout` and
|
||||
* `wowcheckout` shims which expose Greenfield-compatible `/api/v1/stores/{id}/invoices` routes for
|
||||
* XMR and WOW. So the same class is registered up to three times with different `id`/baseUrl/store/
|
||||
* key/chains (id defaults to 'btcpay'). Grin is not deployed yet. The invoice is denominated by
|
||||
* `currency` — pass the chain ticker (e.g. 'XMR') for a native-crypto amount. Auth is
|
||||
* `Authorization: token <apiKey>` on all three.
|
||||
*
|
||||
* fetchImpl is injectable for tests; defaults to the global fetch (Node >= 18).
|
||||
*/
|
||||
class BTCPayProvider extends PaymentProvider {
|
||||
constructor({ id = 'btcpay', baseUrl, storeId, apiKey, chains = ['BTC', 'LTC', 'XMR', 'WOW', 'GRIN'], fetchImpl = null } = {}) {
|
||||
super(id);
|
||||
this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
||||
this.storeId = storeId;
|
||||
this.apiKey = apiKey;
|
||||
this.chains = new Set(chains.map(c => String(c).toUpperCase()));
|
||||
this.fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
||||
}
|
||||
|
||||
supportsChain(chain) {
|
||||
return this.chains.has(String(chain || '').toUpperCase());
|
||||
}
|
||||
|
||||
_headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': `token ${this.apiKey}` };
|
||||
}
|
||||
|
||||
async _req(method, path, body) {
|
||||
if (!this.fetch) throw new Error('btcpay: no fetch available');
|
||||
if (!this.baseUrl || !this.storeId || !this.apiKey) throw new Error('btcpay: not configured (BTCPAY_URL/STORE_ID/API_KEY)');
|
||||
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: this._headers(),
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch (_) { /* non-json */ }
|
||||
if (!res.ok) {
|
||||
const msg = (json && (json.message || (json[0] && json[0].message))) || `btcpay ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// Normalize the BTCPay invoice status to our contract.
|
||||
static _mapStatus(s) {
|
||||
switch (String(s || '')) {
|
||||
case 'Settled':
|
||||
case 'Complete': return { status: 'paid', complete: true };
|
||||
case 'Processing': return { status: 'processing', complete: false };
|
||||
case 'Expired': return { status: 'expired', complete: false };
|
||||
case 'Invalid': return { status: 'invalid', complete: false };
|
||||
case 'New':
|
||||
default: return { status: 'pending', complete: false };
|
||||
}
|
||||
}
|
||||
|
||||
async createInvoice({ chain, amountAtomic, description, orderId } = {}) {
|
||||
const ch = String(chain || '').toUpperCase();
|
||||
const amount = Atomic.format(amountAtomic, ChainProfile.decimalsFor(ch)); // decimal string in the chain's units
|
||||
const invoice = await this._req('POST', `/api/v1/stores/${this.storeId}/invoices`, {
|
||||
amount,
|
||||
currency: ch, // price the invoice in the crypto itself
|
||||
metadata: { orderId: orderId || null, itemDesc: description || 'Game entry' }
|
||||
});
|
||||
// The address for the chosen chain comes from the invoice's payment methods.
|
||||
let address = null, uri = null;
|
||||
try {
|
||||
const methods = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${invoice.id}/payment-methods`);
|
||||
const list = Array.isArray(methods) ? methods : [];
|
||||
const match = list.find(m => String(m.paymentMethod || m.cryptoCode || '').toUpperCase().startsWith(ch)) || list[0];
|
||||
if (match) { address = match.destination || match.address || null; uri = match.paymentLink || null; }
|
||||
} catch (_) { /* address can be fetched later; invoice still valid */ }
|
||||
return {
|
||||
invoiceId: invoice.id,
|
||||
address,
|
||||
uri: uri || invoice.checkoutLink || null,
|
||||
amountAtomic: String(amountAtomic),
|
||||
expiresAt: invoice.expirationTime || null,
|
||||
raw: invoice
|
||||
};
|
||||
}
|
||||
|
||||
async getInvoiceStatus(invoiceRef) {
|
||||
const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef;
|
||||
const inv = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}`);
|
||||
const mapped = BTCPayProvider._mapStatus(inv.status);
|
||||
return {
|
||||
status: mapped.status,
|
||||
complete: mapped.complete,
|
||||
paidAtomic: null, // BTCPay reports fiat/crypto paid via payment-methods; not needed for a paid/settled gate
|
||||
confirmations: null,
|
||||
raw: inv
|
||||
};
|
||||
}
|
||||
|
||||
// Read the invoice as a RAW wallet-style status (the shape the confirmation callback consumes),
|
||||
// so a gateway payment flows through the exact same paymentHandlers path as a native one.
|
||||
// Settled -> confirmed+complete; Processing -> in_mempool. Paid/required amounts (atomic) come
|
||||
// from the invoice payment-methods (totalPaid / amount), converted via the chain's decimals.
|
||||
async getWalletStatus(invoiceRef) {
|
||||
const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef;
|
||||
const inv = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}`);
|
||||
const mapped = BTCPayProvider._mapStatus(inv.status);
|
||||
let amount = 0, required = 0;
|
||||
if (mapped.status === 'processing' || mapped.status === 'paid') {
|
||||
try {
|
||||
const methods = await this._req('GET', `/api/v1/stores/${this.storeId}/invoices/${id}/payment-methods`);
|
||||
const list = Array.isArray(methods) ? methods : [];
|
||||
const m = list[0] || {};
|
||||
const dec = ChainProfile.decimalsFor(m.cryptoCode || m.paymentMethod || '');
|
||||
amount = decimalToAtomic(m.totalPaid != null ? m.totalPaid : m.paymentMethodPaid, dec);
|
||||
required = decimalToAtomic(m.amount, dec);
|
||||
} catch (_) { /* amount stays 0; a Settled invoice still confirms via `complete` */ }
|
||||
}
|
||||
return {
|
||||
in_mempool: mapped.status === 'processing',
|
||||
confirmed: mapped.status === 'paid',
|
||||
complete: mapped.complete,
|
||||
amount,
|
||||
required,
|
||||
confirmations: null,
|
||||
_terminal: mapped.status === 'expired' || mapped.status === 'invalid'
|
||||
};
|
||||
}
|
||||
|
||||
// Poll-based watch (BTCPay also supports webhooks; a webhook bridge can call onUpdate directly).
|
||||
// onUpdate receives the raw wallet-style status from getWalletStatus.
|
||||
startWatch(invoiceRef, onUpdate, intervalMs = 4000) {
|
||||
const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef;
|
||||
if (this._watchers && this._watchers.has(id)) return;
|
||||
this._watchers = this._watchers || new Map();
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const st = await this.getWalletStatus(id);
|
||||
onUpdate(st);
|
||||
if (st.complete || st._terminal) this.stopWatch(id);
|
||||
} catch (_) { /* keep polling */ }
|
||||
}, intervalMs);
|
||||
if (timer.unref) timer.unref();
|
||||
this._watchers.set(id, timer);
|
||||
}
|
||||
stopWatch(invoiceRef) {
|
||||
const id = typeof invoiceRef === 'object' ? invoiceRef.invoiceId : invoiceRef;
|
||||
if (this._watchers && this._watchers.has(id)) { clearInterval(this._watchers.get(id)); this._watchers.delete(id); }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BTCPayProvider;
|
||||
@@ -0,0 +1,99 @@
|
||||
const { PaymentProvider, PaymentProviderRegistry } = require('./paymentProvider');
|
||||
const BTCPayProvider = require('./btcpayProvider');
|
||||
const NativeMoneroProvider = require('./nativeMoneroProvider');
|
||||
|
||||
/**
|
||||
* Parse an operator routing override. Accepts JSON ({"BTC":"btcpay","XMR":"native-monero"})
|
||||
* or a compact "CHAIN:provider,CHAIN:provider" string. Returns {} on empty/garbage.
|
||||
*/
|
||||
function parseRouting(raw) {
|
||||
if (!raw) return {};
|
||||
const s = String(raw).trim();
|
||||
if (!s) return {};
|
||||
if (s[0] === '{') {
|
||||
try {
|
||||
const obj = JSON.parse(s);
|
||||
const out = {};
|
||||
for (const k of Object.keys(obj)) out[String(k).toUpperCase()] = String(obj[k]);
|
||||
return out;
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
const out = {};
|
||||
for (const pair of s.split(',')) {
|
||||
const [chain, prov] = pair.split(':').map(x => (x || '').trim());
|
||||
if (chain && prov) out[chain.toUpperCase()] = prov;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Chains parsed from a "btc,ltc" style env list, uppercased.
|
||||
function parseChains(raw, fallback) {
|
||||
if (!raw) return fallback;
|
||||
const list = String(raw).split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
|
||||
return list.length ? list : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the payment-provider registry from environment/config. Single wiring seam for Pillar 3,
|
||||
* shaped to the operator's real infra (see btcpay-infra-topology memo): three Greenfield endpoints
|
||||
* — real BTCPay (BTC/LTC), xmrcheckout (XMR), wowcheckout (WOW) — plus the native wallet-RPC.
|
||||
*
|
||||
* Behavior-preserving: with NO gateway env set (prod today) only the native Monero/Wownero provider
|
||||
* is registered and every chain routes to it — identical to the current flow. Each endpoint
|
||||
* activates independently when its *_URL/_STORE_ID/_API_KEY trio is present:
|
||||
* BTCPAY_URL/BTCPAY_STORE_ID/BTCPAY_API_KEY -> id 'btcpay' (BTCPAY_CHAINS, default BTC,LTC)
|
||||
* XMRCHECKOUT_URL/XMRCHECKOUT_STORE_ID/..._API_KEY -> id 'xmrcheckout' (XMR)
|
||||
* WOWCHECKOUT_URL/WOWCHECKOUT_STORE_ID/..._API_KEY -> id 'wowcheckout' (WOW)
|
||||
* Default routing sends each chain to its gateway when present, else native for XMR/WOW.
|
||||
* PAYMENT_ROUTING (JSON or "CHAIN:provider,...") overrides any chain->provider mapping.
|
||||
*/
|
||||
function buildProviderRegistry({ env = process.env, walletService = null } = {}) {
|
||||
const registry = new PaymentProviderRegistry();
|
||||
const routing = {};
|
||||
let hasNative = false;
|
||||
|
||||
if (walletService) {
|
||||
registry.register(new NativeMoneroProvider({ walletService }));
|
||||
hasNative = true;
|
||||
routing.XMR = 'native-monero';
|
||||
routing.WOW = 'native-monero';
|
||||
}
|
||||
|
||||
// Real BTCPay (BTC/LTC by default; operator may widen via BTCPAY_CHAINS).
|
||||
if (env.BTCPAY_URL && env.BTCPAY_STORE_ID && env.BTCPAY_API_KEY) {
|
||||
const chains = parseChains(env.BTCPAY_CHAINS, ['BTC', 'LTC']);
|
||||
registry.register(new BTCPayProvider({ id: 'btcpay', baseUrl: env.BTCPAY_URL, storeId: env.BTCPAY_STORE_ID, apiKey: env.BTCPAY_API_KEY, chains }));
|
||||
for (const c of chains) routing[c] = 'btcpay';
|
||||
}
|
||||
|
||||
// xmrcheckout / wowcheckout — Greenfield-compatible shims, same provider class, different id.
|
||||
const shims = [
|
||||
{ prefix: 'XMRCHECKOUT', id: 'xmrcheckout', chain: 'XMR' },
|
||||
{ prefix: 'WOWCHECKOUT', id: 'wowcheckout', chain: 'WOW' }
|
||||
];
|
||||
for (const s of shims) {
|
||||
const url = env[`${s.prefix}_URL`], storeId = env[`${s.prefix}_STORE_ID`], apiKey = env[`${s.prefix}_API_KEY`];
|
||||
if (url && storeId && apiKey) {
|
||||
registry.register(new BTCPayProvider({ id: s.id, baseUrl: url, storeId, apiKey, chains: [s.chain] }));
|
||||
routing[s.chain] = s.id; // a dedicated checkout gateway wins over native for its chain
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(routing, parseRouting(env.PAYMENT_ROUTING)); // explicit operator override wins over all
|
||||
registry.setRouting(routing);
|
||||
|
||||
// Default provider (for chains with no explicit route): native if present, else the first registered gateway.
|
||||
const firstId = registry.providers.keys().next().value || null;
|
||||
registry.setDefault(hasNative ? 'native-monero' : firstId);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PaymentProvider,
|
||||
PaymentProviderRegistry,
|
||||
BTCPayProvider,
|
||||
NativeMoneroProvider,
|
||||
buildProviderRegistry,
|
||||
parseRouting
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
const { PaymentProvider } = require('./paymentProvider');
|
||||
|
||||
/**
|
||||
* Native Monero/Wownero provider — wraps the existing walletRPCService (subaddress invoicing +
|
||||
* transfer_split payouts) so "our own XMR/WOW stuff" is just another plugin behind the same
|
||||
* contract. Behavior-preserving: it delegates to the exact calls the game already makes today.
|
||||
* Supports XMR/WOW only; other chains route to BTCPay.
|
||||
*/
|
||||
class NativeMoneroProvider extends PaymentProvider {
|
||||
constructor({ walletService, chains = ['XMR', 'WOW'] } = {}) {
|
||||
super('native-monero');
|
||||
this.wallet = walletService;
|
||||
this.chains = new Set(chains.map(c => String(c).toUpperCase()));
|
||||
}
|
||||
|
||||
supportsChain(chain) { return this.chains.has(String(chain || '').toUpperCase()); }
|
||||
|
||||
_addr(ref) { return typeof ref === 'object' ? (ref.address || ref.invoiceId) : ref; }
|
||||
|
||||
_normalize(st) {
|
||||
const complete = !!(st && st.confirmed && st.complete);
|
||||
return {
|
||||
status: complete ? 'paid' : (st && st.detected ? 'processing' : 'pending'),
|
||||
complete,
|
||||
paidAtomic: st && st.amount != null ? String(st.amount) : null,
|
||||
confirmations: st && st.confirmations != null ? st.confirmations : null,
|
||||
raw: st
|
||||
};
|
||||
}
|
||||
|
||||
async createInvoice({ amountAtomic, description, userId, orderId } = {}) {
|
||||
const req = await this.wallet.createPaymentRequest(amountAtomic, description || 'Game entry', userId, orderId);
|
||||
const address = req && (req.address || req.subaddress) || null;
|
||||
return {
|
||||
invoiceId: address, // native invoices are keyed by their subaddress
|
||||
address,
|
||||
addressIndex: (req && req.addressIndex != null) ? req.addressIndex : null, // Monero-specific; enables monitoring restore after restart
|
||||
uri: (req && req.uri) || null,
|
||||
amountAtomic: String(amountAtomic),
|
||||
expiresAt: (req && req.expiresAt) || null,
|
||||
raw: req
|
||||
};
|
||||
}
|
||||
|
||||
async getInvoiceStatus(invoiceRef) {
|
||||
return this._normalize(await this.wallet.checkPaymentStatus(this._addr(invoiceRef)));
|
||||
}
|
||||
|
||||
// The monitoring contract passes the RAW wallet status through untouched — the shape
|
||||
// checkPaymentStatus produces ({ in_mempool, confirmed, complete, amount, required,
|
||||
// confirmations }) — so the confirmation callback in paymentHandlers is byte-for-byte
|
||||
// the legacy path for native. (getInvoiceStatus above still returns the normalized shape.)
|
||||
startWatch(invoiceRef, onUpdate, intervalMs = 2000) {
|
||||
const address = this._addr(invoiceRef);
|
||||
if (typeof this.wallet.startPaymentMonitoring === 'function') {
|
||||
this.wallet.startPaymentMonitoring(address, (st) => onUpdate(st), intervalMs);
|
||||
}
|
||||
}
|
||||
stopWatch(invoiceRef) {
|
||||
if (typeof this.wallet.stopPaymentMonitoring === 'function') this.wallet.stopPaymentMonitoring(this._addr(invoiceRef));
|
||||
}
|
||||
|
||||
async sendPayout({ address, amountAtomic } = {}) {
|
||||
const result = await this.wallet.processBatchPayout([{ address, amount: amountAtomic }]);
|
||||
const txids = (result && (result.tx_hash_list || (result.txHash ? [result.txHash] : []))) || [];
|
||||
return { txids, raw: result };
|
||||
}
|
||||
|
||||
async validateAddress(chain, address) {
|
||||
try {
|
||||
if (typeof this.wallet.rpcCall === 'function') {
|
||||
const r = await this.wallet.rpcCall('validate_address', { address });
|
||||
return { valid: !!(r && r.valid) };
|
||||
}
|
||||
} catch (_) { /* fall through */ }
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NativeMoneroProvider;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Modular payment-provider system (Pillar 3, reframed).
|
||||
*
|
||||
* Instead of the game implementing per-chain wallet/daemon code, it talks to pluggable PAYMENT
|
||||
* PROVIDERS. BTCPay Server is the first plugin (it already handles BTC/LTC natively + XMR/WOW via
|
||||
* checkout plugins + Grin via our own plugin — all five Smirk chains). The existing native
|
||||
* Monero/Wownero wallet-RPC integration is a second plugin, so an operator can route each chain to
|
||||
* whichever provider they run (e.g. BTCPay for BTC/LTC/GRIN, native-RPC for XMR/WOW).
|
||||
*
|
||||
* A provider implements this contract (all amounts are atomic-unit strings/BigInt for the chain):
|
||||
* async createInvoice({ chain, amountAtomic, description, userId, orderId }) ->
|
||||
* { invoiceId, address, uri, amountAtomic, expiresAt, raw }
|
||||
* async getInvoiceStatus(invoiceRef) ->
|
||||
* { status: 'pending'|'processing'|'paid'|'expired'|'invalid', paidAtomic, confirmations, complete, raw }
|
||||
* startWatch(invoiceRef, onUpdate, intervalMs) -> void (poll or webhook-bridge)
|
||||
* stopWatch(invoiceRef) -> void
|
||||
* async sendPayout({ chain, address, amountAtomic }) -> { txid | txids, raw } (optional)
|
||||
* async validateAddress(chain, address) -> { valid, reason } (optional)
|
||||
* supportsChain(chain) -> boolean
|
||||
*/
|
||||
|
||||
class PaymentProvider {
|
||||
/** @param {string} id stable provider id, e.g. 'btcpay' | 'native-monero' */
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
}
|
||||
supportsChain(_chain) { return true; }
|
||||
async createInvoice(_req) { throw new Error(`${this.id}: createInvoice not implemented`); }
|
||||
async getInvoiceStatus(_ref) { throw new Error(`${this.id}: getInvoiceStatus not implemented`); }
|
||||
startWatch(_ref, _onUpdate, _intervalMs) { /* optional */ }
|
||||
stopWatch(_ref) { /* optional */ }
|
||||
async sendPayout(_req) { throw new Error(`${this.id}: sendPayout not implemented`); }
|
||||
async validateAddress(_chain, _address) { return { valid: true }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes each chain to a registered provider. Operator-configurable: setRouting({ BTC:'btcpay',
|
||||
* XMR:'native-monero', ... }) with a default. Unknown chains fall back to the default provider.
|
||||
*/
|
||||
class PaymentProviderRegistry {
|
||||
constructor({ defaultProviderId = null } = {}) {
|
||||
this.providers = new Map();
|
||||
this.routing = {};
|
||||
this.defaultProviderId = defaultProviderId;
|
||||
}
|
||||
register(provider) {
|
||||
if (!provider || !provider.id) throw new Error('provider must have an id');
|
||||
this.providers.set(provider.id, provider);
|
||||
if (!this.defaultProviderId) this.defaultProviderId = provider.id;
|
||||
return this;
|
||||
}
|
||||
setRouting(map = {}) {
|
||||
const out = {};
|
||||
for (const k of Object.keys(map)) out[String(k).toUpperCase()] = map[k];
|
||||
this.routing = out;
|
||||
return this;
|
||||
}
|
||||
setDefault(providerId) { this.defaultProviderId = providerId; return this; }
|
||||
|
||||
providerIdForChain(chain) {
|
||||
const c = String(chain || '').toUpperCase();
|
||||
return this.routing[c] || this.defaultProviderId;
|
||||
}
|
||||
getProvider(chain) {
|
||||
const id = this.providerIdForChain(chain);
|
||||
const p = id ? this.providers.get(id) : null;
|
||||
if (p && (typeof p.supportsChain !== 'function' || p.supportsChain(chain))) return p;
|
||||
// Fall back to any registered provider that supports the chain.
|
||||
for (const prov of this.providers.values()) {
|
||||
if (typeof prov.supportsChain === 'function' && prov.supportsChain(chain)) return prov;
|
||||
}
|
||||
return p || null;
|
||||
}
|
||||
get(id) { return this.providers.get(id) || null; }
|
||||
}
|
||||
|
||||
module.exports = { PaymentProvider, PaymentProviderRegistry };
|
||||
@@ -0,0 +1,77 @@
|
||||
const { DEFAULT_CATALOG } = require('../multiplayer/entitlements');
|
||||
|
||||
/**
|
||||
* Loads the operator-owned cosmetic catalog (migration 024 `cosmetic_catalog`) — the single
|
||||
* server-authoritative source for pack definitions + unlock rules, replacing the split-brain
|
||||
* hardcoded PACKS in entitlements.js (server) and assetPacks.js (client, via the served snapshot).
|
||||
*
|
||||
* Cached with a short TTL. Falls back to the built-in DEFAULT_CATALOG when the table is absent
|
||||
* (fresh/partly-migrated DBs, tests) so nothing regresses without it.
|
||||
*/
|
||||
class CatalogService {
|
||||
constructor({ db = null, ttlMs = 60000 } = {}) {
|
||||
this.db = db;
|
||||
this.ttlMs = ttlMs;
|
||||
this._cache = null;
|
||||
this._cacheAt = 0;
|
||||
}
|
||||
|
||||
async getCatalog() {
|
||||
const now = Date.now();
|
||||
if (this._cache && (now - this._cacheAt) < this.ttlMs) return this._cache;
|
||||
const loaded = await this._load();
|
||||
this._cache = loaded;
|
||||
this._cacheAt = now;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this._cache = null;
|
||||
this._cacheAt = 0;
|
||||
}
|
||||
|
||||
async _load() {
|
||||
if (!this.db || typeof this.db.query !== 'function') return DEFAULT_CATALOG;
|
||||
try {
|
||||
const res = await this.db.query(`
|
||||
SELECT pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, metadata
|
||||
FROM cosmetic_catalog
|
||||
WHERE active = TRUE
|
||||
ORDER BY sort_order, pack_id
|
||||
`);
|
||||
const rows = (res && res.rows) || [];
|
||||
if (rows.length === 0) return DEFAULT_CATALOG;
|
||||
const out = {};
|
||||
for (const r of rows) {
|
||||
// Defend against non-catalog rows (e.g. a mock/query returning grant rows, which
|
||||
// have pack_id but no label). Real catalog rows have both NOT-NULL fields.
|
||||
if (!r || typeof r.pack_id !== 'string' || !r.pack_id || typeof r.label !== 'string' || !r.label) continue;
|
||||
const tier = Number(r.tier || 0);
|
||||
const minCredits = r.unlock_min_credits == null ? null : Number(r.unlock_min_credits);
|
||||
const grantOnly = !!r.grant_only;
|
||||
const premium = !(tier === 0 && minCredits == null && !grantOnly);
|
||||
out[r.pack_id] = {
|
||||
id: r.pack_id,
|
||||
label: r.label,
|
||||
kind: r.kind || 'render-pack',
|
||||
projection: r.projection || null,
|
||||
tier,
|
||||
unlockMinCredits: minCredits,
|
||||
grantOnly,
|
||||
premium,
|
||||
metadata: r.metadata || {}
|
||||
};
|
||||
}
|
||||
if (Object.keys(out).length === 0) return DEFAULT_CATALOG; // no valid catalog rows
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Table missing (fresh/partly-migrated DB) — degrade to the built-in default.
|
||||
if (err && (err.code === '42P01' || /cosmetic_catalog/i.test(err.message || ''))) {
|
||||
return DEFAULT_CATALOG;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CatalogService;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* HTML-entity escape + length cap for chat text. Mirrors the inline escaping ChatHandler applies
|
||||
* to local messages, so untrusted remote (nostr) messages are sanitized identically before they
|
||||
* are delivered to browsers. Escaping is the caller's job per the ChatProvider contract — remote
|
||||
* events have no trusted caller, so the NostrChatProvider applies this.
|
||||
*/
|
||||
const DEFAULT_MAX = 200;
|
||||
|
||||
function escapeChatText(input, maxLen = DEFAULT_MAX) {
|
||||
return String(input == null ? '' : input)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
module.exports = { escapeChatText, DEFAULT_MAX };
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Load nostr-tools in a way that works in BOTH runtimes (see the same shim in utils/nip98.js):
|
||||
* - Production (plain Node >=22): a normal require() succeeds — Node natively require()s
|
||||
* nostr-tools' ESM-only transitive deps (@noble/*).
|
||||
* - A CJS-only test runtime (Jest without --experimental-vm-modules) cannot parse those ESM
|
||||
* deps, so we fall back to nostr-tools' pre-bundled self-contained IIFE build.
|
||||
* Same public API either way. Shared so chat + auth load nostr identically.
|
||||
*/
|
||||
function loadNostrTools() {
|
||||
try {
|
||||
return require('nostr-tools');
|
||||
} catch (_e) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cjsIndex = require.resolve('nostr-tools');
|
||||
const bundlePath = path.join(path.dirname(cjsIndex), '..', 'nostr.bundle.js');
|
||||
const src = fs.readFileSync(bundlePath, 'utf8');
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(`${src}\nreturn NostrTools;`)();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { loadNostrTools };
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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['roguelike-interior']).toBe(true); // free
|
||||
expect(s.packs['generated-skins']).toBe(false); // threshold 1, 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('backward-compat: buying any credit unlocks the seed packs (all threshold 1)', () => {
|
||||
const s = snapshotForUser({ credits: 5, total_credits_purchased: 1 });
|
||||
expect(s.packs['generated-skins']).toBe(true);
|
||||
expect(s.packs['iso-dungeon']).toBe(true);
|
||||
expect(s.packs['kenney-3d-characters']).toBe(true);
|
||||
expect(s.premium).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, 'roguelike-interior')).toBe(true); // free
|
||||
expect(Entitlements.canUsePack(locked, 'iso-dungeon')).toBe(false); // locked
|
||||
const unlocked = snapshotForUser({ total_credits_purchased: 1 });
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
// The seed premium packs (threshold 1) are now unlocked, exactly like a credit purchase.
|
||||
expect(rec.entitlements.packs['iso-dungeon']).toBe(true);
|
||||
expect(rec.entitlements.packs['generated-skins']).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ describe('cosmetic entitlements', () => {
|
||||
const entitlements = Entitlements.snapshotForUser({ credits: 7, total_credits_purchased: 10 });
|
||||
|
||||
expect(entitlements.premium).toBe(true);
|
||||
expect(entitlements.level).toBe('credits');
|
||||
expect(entitlements.level).toBe('free'); // spend unlocks packs by threshold, but isn't a premium tier
|
||||
expect(entitlements.packs['generated-skins']).toBe(true);
|
||||
expect(entitlements.packs['iso-dungeon']).toBe(true);
|
||||
expect(entitlements.packs['kenney-3d-characters']).toBe(true);
|
||||
|
||||
@@ -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: '<b>hi</b>', 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');
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,19 @@ describe('PaymentHandlers basic', () => {
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock1' });
|
||||
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' });
|
||||
expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW');
|
||||
expect(gateway.startWatch).toHaveBeenCalled();
|
||||
expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const { buildProviderRegistry, parseRouting } = require('../src/payments/providers');
|
||||
|
||||
const fakeWallet = { createPaymentRequest: async () => ({}), checkPaymentStatus: async () => ({}), processBatchPayout: async () => ({}) };
|
||||
const CHECKOUT = { // full LAN infra: real BTCPay (BTC/LTC) + xmrcheckout + wowcheckout
|
||||
BTCPAY_URL: 'http://10.42.1.33', BTCPAY_STORE_ID: 'b', BTCPAY_API_KEY: 'k',
|
||||
XMRCHECKOUT_URL: 'http://10.42.1.33:8080', XMRCHECKOUT_STORE_ID: 'x', XMRCHECKOUT_API_KEY: 'k',
|
||||
WOWCHECKOUT_URL: 'http://10.42.1.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://10.42.1.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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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('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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Pillar 4 — Ruleset engine + PvP modes on the match engine.
|
||||
*/
|
||||
const { defineRuleset, WIN, getRuleset, listRulesets, rulesetFromMatchOpts } = require('../src/game/rulesets');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
|
||||
const SEED = 'ruleset-test-seed';
|
||||
|
||||
describe('defineRuleset normalization', () => {
|
||||
test('empty spec yields safe classic defaults', () => {
|
||||
const r = defineRuleset({});
|
||||
expect(r.winCondition.type).toBe(WIN.FIRST_TO_EXIT);
|
||||
expect(r.entities.monster).toBe(true);
|
||||
expect(r.entities.pvpCombat).toBe(false);
|
||||
expect(r.timing.tickMs).toBe(250);
|
||||
expect(r.economy.model).toBe('free');
|
||||
expect(Object.isFrozen(r)).toBe(true);
|
||||
});
|
||||
|
||||
test('clamps out-of-range values and unknown win type', () => {
|
||||
const r = defineRuleset({ players: { min: 99, max: 2 }, timing: { tickMs: 1 }, winCondition: { type: 'nonsense' } });
|
||||
expect(r.players.max).toBe(2);
|
||||
expect(r.players.min).toBeLessThanOrEqual(r.players.max);
|
||||
expect(r.timing.tickMs).toBe(50); // clamped up to the floor
|
||||
expect(r.winCondition.type).toBe(WIN.FIRST_TO_EXIT); // invalid -> default
|
||||
});
|
||||
|
||||
test('monster:false forces monsterCount 0', () => {
|
||||
expect(defineRuleset({ entities: { monster: false } }).entities.monsterCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry', () => {
|
||||
test('built-ins include the new PvP + score modes', () => {
|
||||
const ids = listRulesets().map(r => r.id);
|
||||
expect(ids).toEqual(expect.arrayContaining(['solo-classic', 'race', 'last-alive', 'score-attack']));
|
||||
expect(getRuleset('last-alive').entities.pvpCombat).toBe(true);
|
||||
expect(getRuleset('score-attack').winCondition.type).toBe(WIN.HIGH_SCORE);
|
||||
});
|
||||
|
||||
test('unknown id falls back to race', () => {
|
||||
expect(getRuleset('does-not-exist').id).toBe('race');
|
||||
});
|
||||
|
||||
test('rulesetFromMatchOpts preserves race semantics (first-to-exit)', () => {
|
||||
const r = rulesetFromMatchOpts({ economy: 'crypto_race', variant: 'pvp', difficultyPreset: 'race', maxPlayers: 6 });
|
||||
expect(r.winCondition.type).toBe(WIN.FIRST_TO_EXIT); // variant never silently changes the win rule
|
||||
expect(r.economy.model).toBe('crypto_race');
|
||||
expect(r.players.max).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
function findAdjacentWalkable(room) {
|
||||
for (let y = 0; y < room.rows; y++) {
|
||||
for (let x = 0; x < room.cols - 1; x++) {
|
||||
if (room.isWalkable(x, y) && room.isWalkable(x + 1, y)) return { x, y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('MatchRoom drives new rulesets', () => {
|
||||
test('last-alive PvP: stepping onto a rival kills them and ends the match with the survivor as winner', () => {
|
||||
const room = new MatchRoom({
|
||||
ruleset: { id: 'test-pvp', winCondition: { type: WIN.LAST_ALIVE }, entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 } },
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: SEED
|
||||
});
|
||||
expect(room.monster).toBeNull(); // monster:false honored
|
||||
room.start();
|
||||
|
||||
const cell = findAdjacentWalkable(room);
|
||||
room.occupants.get('a').moveTo(cell.x, cell.y);
|
||||
room.occupants.get('b').moveTo(cell.x + 1, cell.y);
|
||||
|
||||
room.queueMove('a', 1, 0); // a strikes east into b
|
||||
const res = room.resolveTick();
|
||||
|
||||
expect(res.events.some(e => e.type === 'player_death' && e.id === 'b' && e.killedBy === 'a')).toBe(true);
|
||||
expect(room.playerStates.get('b').alive).toBe(false);
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('last_alive');
|
||||
|
||||
room.finalize();
|
||||
expect(room.winnerId).toBe('a');
|
||||
expect(room.playerStates.get('a').placement).toBe(1);
|
||||
expect(room.playerStates.get('b').placement).toBe(2);
|
||||
});
|
||||
|
||||
test('high-score: reaching the exit does NOT instantly win — the match keeps going', () => {
|
||||
const room = new MatchRoom({
|
||||
ruleset: { id: 'test-score', winCondition: { type: WIN.HIGH_SCORE }, entities: { monster: false }, players: { min: 1, max: 4 } },
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: SEED
|
||||
});
|
||||
room.start();
|
||||
const exit = room.dungeon.exit;
|
||||
room.occupants.get('a').moveTo(exit[0], exit[1]);
|
||||
const res = room.resolveTick();
|
||||
|
||||
expect(room.playerStates.get('a').escaped).toBe(true);
|
||||
expect(room.status).toBe('active'); // b is still in play — no instant win
|
||||
expect(room.winnerId).toBeNull();
|
||||
expect(res.finished).toBe(false);
|
||||
});
|
||||
|
||||
test('race (default) still ends the moment someone escapes', () => {
|
||||
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
|
||||
room.start();
|
||||
const exit = room.dungeon.exit;
|
||||
room.occupants.get('a').moveTo(exit[0], exit[1]);
|
||||
room.resolveTick();
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('escaped');
|
||||
room.finalize();
|
||||
expect(room.winnerId).toBe('a');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user