mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
188 lines
8.5 KiB
SQL
188 lines
8.5 KiB
SQL
-- Migration 022: Match (race) mode foundation
|
|
--
|
|
-- Adds normalized tables for multiplayer races. Keeps existing games/payments/payouts
|
|
-- tables untouched except for one nullable foreign-key column on payouts.
|
|
--
|
|
-- Design notes:
|
|
-- - One fact, one table: matches, entrants, events, queue, and ticket ledger are separate.
|
|
-- - All money movement uses DatabaseManager.withTransaction() at the application layer.
|
|
-- - Verifiable commitment: seed_hash stored at creation and seed revealed on finish. Production
|
|
-- paid matches additionally persist chain-block derivation metadata inside dungeon JSON.
|
|
-- - Tickets (race_entries) are non-refundable crypto-race entry tokens; they avoid the
|
|
-- need for on-chain refunds when a player leaves the queue before a match starts.
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 1. Matches: one row per race
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS matches (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
|
CHECK (status IN ('pending', 'starting', 'active', 'finished', 'cancelled')),
|
|
economy VARCHAR(20) NOT NULL
|
|
CHECK (economy IN ('free', 'credits_prestige', 'crypto_race')),
|
|
variant VARCHAR(20) NOT NULL DEFAULT 'race'
|
|
CHECK (variant IN ('race', 'pvp')),
|
|
difficulty_preset VARCHAR(20) NOT NULL,
|
|
max_players INT NOT NULL DEFAULT 4
|
|
CHECK (max_players >= 2 AND max_players <= 32),
|
|
seed_hash VARCHAR(64) NOT NULL,
|
|
seed VARCHAR(255), -- revealed on finish for verification
|
|
dungeon JSONB NOT NULL, -- deterministic dungeon snapshot
|
|
start_block_height BIGINT, -- block that triggered the race start
|
|
end_block_height BIGINT, -- block that ended the race (if applicable)
|
|
started_at TIMESTAMPTZ,
|
|
ended_at TIMESTAMPTZ,
|
|
-- Economy fields (all atomic currency units, BIGINT)
|
|
entry_fee_atomic BIGINT DEFAULT 0
|
|
CHECK (entry_fee_atomic >= 0),
|
|
pot_atomic BIGINT DEFAULT 0
|
|
CHECK (pot_atomic >= 0),
|
|
house_fee_atomic BIGINT DEFAULT 0
|
|
CHECK (house_fee_atomic >= 0),
|
|
house_fee_percent DECIMAL(5, 2) DEFAULT 0
|
|
CHECK (house_fee_percent >= 0 AND house_fee_percent <= 100),
|
|
winner_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
|
CREATE INDEX IF NOT EXISTS idx_matches_economy_finished ON matches(economy, ended_at)
|
|
WHERE status = 'finished';
|
|
CREATE INDEX IF NOT EXISTS idx_matches_start_block ON matches(start_block_height)
|
|
WHERE start_block_height IS NOT NULL;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 2. Match entrants: one row per player per match
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS match_entrants (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
socket_id VARCHAR(255) NOT NULL,
|
|
placement INT,
|
|
escaped BOOLEAN DEFAULT FALSE,
|
|
has_treasure BOOLEAN DEFAULT FALSE,
|
|
killed_by VARCHAR(50), -- 'monster', 'player:<id>', 'timeout', 'afk'
|
|
score INT DEFAULT 0
|
|
CHECK (score >= 0),
|
|
payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL,
|
|
entry_consumed BOOLEAN DEFAULT FALSE, -- true once match actually starts
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_match_entrants_user_match
|
|
ON match_entrants(match_id, user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_match_entrants_match
|
|
ON match_entrants(match_id);
|
|
CREATE INDEX IF NOT EXISTS idx_match_entrants_placement
|
|
ON match_entrants(match_id, placement, score DESC);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 3. Match events: replay / spectator / audit feed
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS match_events (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
|
tick INT NOT NULL,
|
|
type VARCHAR(50) NOT NULL,
|
|
payload JSONB NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_match_events_match_tick
|
|
ON match_events(match_id, tick);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 4. Match queue entries: persisted queue state for restart safety
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS match_queue_entries (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
economy VARCHAR(20) NOT NULL
|
|
CHECK (economy IN ('free', 'credits_prestige', 'crypto_race')),
|
|
socket_id VARCHAR(255) NOT NULL,
|
|
session_token VARCHAR(255) NOT NULL,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'queued'
|
|
CHECK (status IN ('queued', 'matched', 'cancelled')),
|
|
match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
matched_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_match_queue_user_economy_queued
|
|
ON match_queue_entries(user_id, economy)
|
|
WHERE status = 'queued';
|
|
CREATE INDEX IF NOT EXISTS idx_match_queue_economy_created
|
|
ON match_queue_entries(economy, created_at)
|
|
WHERE status = 'queued';
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 5. Race entry ticket balance and ledger
|
|
-- -----------------------------------------------------------------------------
|
|
ALTER TABLE users
|
|
ADD COLUMN IF NOT EXISTS race_entries INT NOT NULL DEFAULT 0
|
|
CHECK (race_entries >= 0);
|
|
|
|
CREATE TABLE IF NOT EXISTS race_entry_transactions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
delta INT NOT NULL,
|
|
balance_after INT NOT NULL,
|
|
reason VARCHAR(50) NOT NULL
|
|
CHECK (reason IN ('purchase', 'refund', 'queue_join', 'queue_leave', 'match_start', 'match_cancel', 'admin_grant')),
|
|
match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
|
|
payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL,
|
|
metadata JSONB DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_race_entry_tx_user
|
|
ON race_entry_transactions(user_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_race_entry_tx_match
|
|
ON race_entry_transactions(match_id)
|
|
WHERE match_id IS NOT NULL;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 6. Link payouts to matches (nullable, additive only)
|
|
-- -----------------------------------------------------------------------------
|
|
ALTER TABLE payouts
|
|
ADD COLUMN IF NOT EXISTS match_id UUID REFERENCES matches(id) ON DELETE SET NULL;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_payouts_match
|
|
ON payouts(match_id)
|
|
WHERE match_id IS NOT NULL;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 7. Prestige leaderboard view (credit-prestige races)
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE OR REPLACE VIEW prestige_leaderboard AS
|
|
SELECT
|
|
u.id AS user_id,
|
|
u.display_name,
|
|
MAX(me.score) AS best_score,
|
|
COUNT(*) FILTER (WHERE me.escaped) AS wins,
|
|
COUNT(*) AS races,
|
|
MAX(m.ended_at) AS last_race_at
|
|
FROM match_entrants me
|
|
JOIN matches m ON me.match_id = m.id
|
|
JOIN users u ON me.user_id = u.id
|
|
WHERE m.economy = 'credits_prestige'
|
|
AND m.status = 'finished'
|
|
AND me.score > 0
|
|
GROUP BY u.id, u.display_name;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 8. Safety constraints
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
-- Only one active (pending/completed) payout per match, preventing double payouts.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_match
|
|
ON payouts(match_id)
|
|
WHERE match_id IS NOT NULL AND status IN ('pending', 'completed');
|
|
|
|
-- Only one finished winner record per match (business rule, not a constraint, because
|
|
-- ties are resolved deterministically by the engine; this index supports the lookup).
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_matches_finished_winner
|
|
ON matches(id, winner_user_id)
|
|
WHERE status = 'finished' AND winner_user_id IS NOT NULL;
|