osu match playing
This commit is contained in:
281
src/lib/server/bancho/client.ts
Normal file
281
src/lib/server/bancho/client.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ── Singleton bancho.js connection ──────────────────────────────────────
|
||||
|
||||
let client: any = null;
|
||||
let connecting: Promise<void> | null = null;
|
||||
|
||||
async function getClient() {
|
||||
if (client?.isConnected?.()) return client;
|
||||
if (connecting) {
|
||||
await connecting;
|
||||
return client!;
|
||||
}
|
||||
|
||||
if (!env.OSU_IRC_USERNAME || !env.OSU_IRC_PASSWORD) {
|
||||
throw new Error('OSU_IRC_USERNAME and OSU_IRC_PASSWORD must be set in .env');
|
||||
}
|
||||
|
||||
const banchoModule = await import('bancho.js');
|
||||
const BanchoClient =
|
||||
banchoModule.BanchoClient ??
|
||||
banchoModule.default?.BanchoClient ??
|
||||
banchoModule.default;
|
||||
client = new BanchoClient({
|
||||
username: env.OSU_IRC_USERNAME,
|
||||
password: env.OSU_IRC_PASSWORD
|
||||
});
|
||||
|
||||
connecting = client.connect().then(() => {
|
||||
console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME);
|
||||
connecting = null;
|
||||
});
|
||||
|
||||
await connecting;
|
||||
return client;
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlayerScore {
|
||||
username: string;
|
||||
score: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
// ── Tournament Lobby ────────────────────────────────────────────────────
|
||||
|
||||
export class TournamentLobby {
|
||||
private channel: any = null;
|
||||
private channelName = '';
|
||||
public osuMatchId = 0;
|
||||
|
||||
// Score collection
|
||||
private collectedScores: PlayerScore[] = [];
|
||||
private matchFinishedResolve: ((scores: PlayerScore[]) => void) | null = null;
|
||||
|
||||
// Ready tracking
|
||||
private allReadyResolve: (() => void) | null = null;
|
||||
|
||||
// Event callbacks (set by orchestrator)
|
||||
public onRollResult: ((username: string, value: number) => void) | null = null;
|
||||
public onPickCommand: ((username: string, slotLabel: string) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Create a new multiplayer lobby via !mp make.
|
||||
*/
|
||||
async create(name: string): Promise<{ matchId: number; channel: string }> {
|
||||
const c = await getClient();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Lobby creation timed out')),
|
||||
15000
|
||||
);
|
||||
|
||||
const banchoBot = c.getUser('BanchoBot');
|
||||
|
||||
const onPM = (msg: any) => {
|
||||
const text: string = msg.message ?? String(msg);
|
||||
const m = text.match(/https:\/\/osu\.ppy\.sh\/mp\/(\d+)/);
|
||||
if (m) {
|
||||
clearTimeout(timeout);
|
||||
banchoBot.removeListener('message', onPM);
|
||||
|
||||
this.osuMatchId = parseInt(m[1]);
|
||||
this.channelName = `#mp_${this.osuMatchId}`;
|
||||
|
||||
this.joinChannel(c).then(() =>
|
||||
resolve({ matchId: this.osuMatchId, channel: this.channelName })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
banchoBot.on('message', onPM);
|
||||
banchoBot.sendMessage(`!mp make ${name}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async joinChannel(c: any) {
|
||||
this.channel = c.getChannel(this.channelName);
|
||||
await this.channel.join();
|
||||
|
||||
this.channel.on('message', (msg: any) => {
|
||||
const text: string = msg.message ?? String(msg);
|
||||
const sender: string = msg.user?.ircUsername ?? '';
|
||||
|
||||
if (sender === 'BanchoBot') {
|
||||
this.parseBanchoMessage(text);
|
||||
} else if (sender) {
|
||||
this.parsePlayerMessage(sender, text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Message Parsing ─────────────────────────────────────────────────
|
||||
|
||||
private parseBanchoMessage(text: string) {
|
||||
// Player score: "<user> finished playing (Score: 1,234,567, ... PASSED)"
|
||||
const scoreMatch = text.match(
|
||||
/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i
|
||||
);
|
||||
if (scoreMatch) {
|
||||
this.collectedScores.push({
|
||||
username: scoreMatch[1].trim(),
|
||||
score: parseInt(scoreMatch[2].replace(/,/g, '')),
|
||||
passed: scoreMatch[3] === 'PASSED'
|
||||
});
|
||||
console.log(`[Bancho] Score: ${scoreMatch[1].trim()} = ${scoreMatch[2]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Game finished
|
||||
if (text.includes('The match has finished!')) {
|
||||
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
||||
if (this.matchFinishedResolve) {
|
||||
this.matchFinishedResolve([...this.collectedScores]);
|
||||
this.matchFinishedResolve = null;
|
||||
}
|
||||
this.collectedScores = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Roll result: "Stan rolls 42 point(s)"
|
||||
const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/);
|
||||
if (rollMatch) {
|
||||
const username = rollMatch[1].trim();
|
||||
const value = parseInt(rollMatch[2]);
|
||||
console.log(`[Bancho] Roll: ${username} = ${value}`);
|
||||
if (this.onRollResult) {
|
||||
this.onRollResult(username, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All players ready
|
||||
if (text.includes('All players are ready')) {
|
||||
console.log('[Bancho] All players ready');
|
||||
if (this.allReadyResolve) {
|
||||
this.allReadyResolve();
|
||||
this.allReadyResolve = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private parsePlayerMessage(sender: string, text: string) {
|
||||
// !pick NM1, !pick HD2, !pick TB, etc.
|
||||
const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i);
|
||||
if (pickMatch) {
|
||||
const category = pickMatch[1].toUpperCase();
|
||||
const num = pickMatch[2] ? parseInt(pickMatch[2]) : 1;
|
||||
const label = `${category}${num}`;
|
||||
console.log(`[Bancho] Pick command from ${sender}: ${label}`);
|
||||
if (this.onPickCommand) {
|
||||
this.onPickCommand(sender, label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IRC Commands ────────────────────────────────────────────────────
|
||||
|
||||
async send(cmd: string) {
|
||||
if (!this.channel) throw new Error('Lobby not created');
|
||||
console.log(`[Bancho] ${this.channelName} > ${cmd}`);
|
||||
await this.channel.sendMessage(cmd);
|
||||
}
|
||||
|
||||
async invite(username: string) {
|
||||
await this.send(`!mp invite ${username.replace(/ /g, '_')}`);
|
||||
}
|
||||
|
||||
async setProperties(teamMode: number, scoreMode: number, size: number) {
|
||||
await this.send(`!mp set ${teamMode} ${scoreMode} ${size}`);
|
||||
}
|
||||
|
||||
async setMap(beatmapId: string | number, playmode = 0) {
|
||||
await this.send(`!mp map ${beatmapId} ${playmode}`);
|
||||
}
|
||||
|
||||
async setMods(mods: string[]) {
|
||||
const filtered = mods.filter((m) => !['NM', 'TB', 'None', 'NF'].includes(m));
|
||||
if (mods.includes('FM') || mods.includes('Freemod')) {
|
||||
await this.send('!mp mods Freemod NF');
|
||||
} else if (filtered.length > 0) {
|
||||
await this.send(`!mp mods NF ${filtered.join(' ')}`);
|
||||
} else {
|
||||
await this.send('!mp mods NF');
|
||||
}
|
||||
}
|
||||
|
||||
async startGame(delaySec = 5) {
|
||||
await this.send(`!mp start ${delaySec}`);
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
await this.send('!mp close');
|
||||
} catch {
|
||||
/* lobby may already be closed */
|
||||
}
|
||||
}
|
||||
|
||||
async chat(message: string) {
|
||||
await this.send(message);
|
||||
}
|
||||
|
||||
// ── Async Waiters ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for all players to click "Ready" in osu! client.
|
||||
* BanchoBot sends "All players are ready" when they do.
|
||||
*/
|
||||
waitForReady(timeoutMs = 180_000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => {
|
||||
this.allReadyResolve = null;
|
||||
reject(new Error('Timed out waiting for players to ready'));
|
||||
}, timeoutMs);
|
||||
this.allReadyResolve = () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for "The match has finished!" and return all player scores.
|
||||
*/
|
||||
waitForScores(timeoutMs = 600_000): Promise<PlayerScore[]> {
|
||||
this.collectedScores = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => {
|
||||
this.matchFinishedResolve = null;
|
||||
reject(new Error('Timed out waiting for match scores'));
|
||||
}, timeoutMs);
|
||||
this.matchFinishedResolve = (scores) => {
|
||||
clearTimeout(t);
|
||||
resolve(scores);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active lobby registry (in-memory, per server instance) ──────────────
|
||||
|
||||
const lobbies = new Map<string, TournamentLobby>();
|
||||
|
||||
export function getLobby(matchId: string) {
|
||||
return lobbies.get(matchId);
|
||||
}
|
||||
export function setLobby(matchId: string, lobby: TournamentLobby) {
|
||||
lobbies.set(matchId, lobby);
|
||||
}
|
||||
export function removeLobby(matchId: string) {
|
||||
lobbies.delete(matchId);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
uniqueIndex,
|
||||
real
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export * from './auth.schema';
|
||||
|
||||
@@ -51,6 +52,7 @@ export const mappoolSlot = pgTable('mappool_slot', {
|
||||
category: text('category').notNull(),
|
||||
orderInCategory: integer('order_in_category').notNull(),
|
||||
beatmapId: text('beatmap_id').notNull(),
|
||||
starRating: real('star_rating'),
|
||||
mods: text('mods').array().default([]).notNull()
|
||||
});
|
||||
|
||||
@@ -91,7 +93,7 @@ export const matchParticipant = pgTable(
|
||||
pickOrder: integer('pick_order')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('idx_mp_match_team').on(t.matchId, t.teamId),
|
||||
index('idx_mp_match_team').on(t.matchId, t.teamId),
|
||||
uniqueIndex('idx_mp_match_slot').on(t.matchId, t.slot),
|
||||
index('idx_mp_match').on(t.matchId)
|
||||
]
|
||||
@@ -158,7 +160,33 @@ export const matchGameScore = pgTable(
|
||||
(t) => [index('idx_mgs_game').on(t.matchGameId)]
|
||||
);
|
||||
|
||||
import { relations } from 'drizzle-orm';
|
||||
// ── Match Queue ─────────────────────────────────────────────────────────
|
||||
export const matchQueue = pgTable(
|
||||
'match_queue',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
teamId: uuid('team_id')
|
||||
.notNull()
|
||||
.references(() => team.id),
|
||||
elo: integer('elo').default(1000).notNull(),
|
||||
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('idx_queue_user').on(t.userId),
|
||||
index('idx_queue_elo').on(t.elo)
|
||||
]
|
||||
);
|
||||
|
||||
// ── Player ELO ──────────────────────────────────────────────────────────
|
||||
export const playerRating = pgTable('player_rating', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull().unique(),
|
||||
elo: integer('elo').default(1000).notNull(),
|
||||
wins: integer('wins').default(0).notNull(),
|
||||
losses: integer('losses').default(0).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// ── Relations ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,3 +238,7 @@ export const matchGameScoreRelations = relations(matchGameScore, ({ one }) => ({
|
||||
references: [matchParticipantPlayer.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
|
||||
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
|
||||
}));
|
||||
|
||||
523
src/lib/server/match/engine.ts
Normal file
523
src/lib/server/match/engine.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
match,
|
||||
matchParticipant,
|
||||
matchParticipantPlayer,
|
||||
matchGame,
|
||||
matchGameScore,
|
||||
matchQueue,
|
||||
playerRating,
|
||||
teamMember,
|
||||
} from '$lib/server/db/schema';
|
||||
import { eq, asc, sql } from 'drizzle-orm';
|
||||
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
|
||||
|
||||
// ── Queue ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function joinQueue(userId: string, teamId: string) {
|
||||
let rating = await db.query.playerRating.findFirst({
|
||||
where: eq(playerRating.userId, userId)
|
||||
});
|
||||
|
||||
if (!rating) {
|
||||
[rating] = await db
|
||||
.insert(playerRating)
|
||||
.values({ userId, elo: 1000 })
|
||||
.returning();
|
||||
}
|
||||
|
||||
const existing = await db.query.matchQueue.findFirst({
|
||||
where: eq(matchQueue.userId, userId)
|
||||
});
|
||||
|
||||
if (existing) throw new Error('Already in queue');
|
||||
|
||||
await db.insert(matchQueue).values({
|
||||
userId,
|
||||
teamId,
|
||||
elo: rating.elo
|
||||
});
|
||||
|
||||
return tryMatchFromQueue();
|
||||
}
|
||||
|
||||
export async function leaveQueue(userId: string) {
|
||||
await db.delete(matchQueue).where(eq(matchQueue.userId, userId));
|
||||
}
|
||||
|
||||
export async function getQueueStatus(userId: string) {
|
||||
const entry = await db.query.matchQueue.findFirst({
|
||||
where: eq(matchQueue.userId, userId)
|
||||
});
|
||||
|
||||
const count = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(matchQueue);
|
||||
|
||||
return {
|
||||
inQueue: !!entry,
|
||||
queueSize: Number(count[0].count),
|
||||
joinedAt: entry?.joinedAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function tryMatchFromQueue() {
|
||||
const queue = await db.query.matchQueue.findMany({
|
||||
orderBy: asc(matchQueue.elo)
|
||||
});
|
||||
|
||||
if (queue.length < 2) return null;
|
||||
|
||||
// Find closest ELO pair
|
||||
let bestPair: [typeof queue[0], typeof queue[0]] | null = null;
|
||||
let smallestGap = Infinity;
|
||||
|
||||
for (let i = 0; i < queue.length - 1; i++) {
|
||||
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
|
||||
if (gap < smallestGap) {
|
||||
smallestGap = gap;
|
||||
bestPair = [queue[i], queue[i + 1]];
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestPair) return null;
|
||||
|
||||
// Pick mappool based on average player rating
|
||||
const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2;
|
||||
const selectedPool = await selectMappoolForRating(avgElo);
|
||||
|
||||
if (!selectedPool) throw new Error('No mappools available');
|
||||
|
||||
// Remove both from queue
|
||||
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId));
|
||||
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId));
|
||||
|
||||
const created = await createMatch({
|
||||
name: 'Ranked Match',
|
||||
config: { bestOf: 7, teamSize: 1, scoringType: 'score' },
|
||||
mappoolId: selectedPool.id,
|
||||
teams: [bestPair[0].teamId, bestPair[1].teamId],
|
||||
createdBy: 'system'
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function selectMappoolForRating(avgElo: number) {
|
||||
// Get all mappools with their slots
|
||||
const pools = await db.query.mappool.findMany({
|
||||
with: { slots: true }
|
||||
});
|
||||
|
||||
if (pools.length === 0) return null;
|
||||
|
||||
// Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7)
|
||||
// Simple linear: ELO 800 → 3★, ELO 1200 → 7★
|
||||
const targetStars = 3 + ((avgElo - 800) / 400) * 4;
|
||||
const clampedTarget = Math.max(2, Math.min(8, targetStars));
|
||||
|
||||
let bestPool = pools[0];
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const pool of pools) {
|
||||
if (pool.slots.length === 0) continue;
|
||||
|
||||
const avgStars =
|
||||
pool.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / pool.slots.length;
|
||||
const diff = Math.abs(avgStars - clampedTarget);
|
||||
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestPool = pool;
|
||||
}
|
||||
}
|
||||
|
||||
return bestPool;
|
||||
}
|
||||
|
||||
// ── Create Match ────────────────────────────────────────────────────────
|
||||
|
||||
export async function createMatch(opts: {
|
||||
name: string;
|
||||
config: MatchConfig;
|
||||
mappoolId: string;
|
||||
teams: string[];
|
||||
createdBy: string;
|
||||
}) {
|
||||
const { name, config, mappoolId, teams, createdBy } = opts;
|
||||
|
||||
const [created] = await db
|
||||
.insert(match)
|
||||
.values({
|
||||
name,
|
||||
config,
|
||||
mappoolId,
|
||||
createdBy,
|
||||
state: MATCH_STATES.CREATED
|
||||
})
|
||||
.returning();
|
||||
|
||||
for (let i = 0; i < teams.length; i++) {
|
||||
const [participant] = await db
|
||||
.insert(matchParticipant)
|
||||
.values({
|
||||
matchId: created.id,
|
||||
teamId: teams[i],
|
||||
slot: i + 1
|
||||
})
|
||||
.returning();
|
||||
|
||||
const members = await db.query.teamMember.findMany({
|
||||
where: eq(teamMember.teamId, teams[i])
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
await db.insert(matchParticipantPlayer).values({
|
||||
participantId: participant.id,
|
||||
userId: member.userId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return getMatchFull(created.id);
|
||||
}
|
||||
|
||||
// ── State Transitions ───────────────────────────────────────────────────
|
||||
|
||||
export async function moveToLobby(matchId: string, osuLobbyId?: number) {
|
||||
const m = await getMatchOrThrow(matchId);
|
||||
assertState(m.state, MATCH_STATES.CREATED);
|
||||
|
||||
await db
|
||||
.update(match)
|
||||
.set({
|
||||
state: MATCH_STATES.LOBBY,
|
||||
osuLobbyId: osuLobbyId ?? null,
|
||||
startedAt: new Date()
|
||||
})
|
||||
.where(eq(match.id, matchId));
|
||||
|
||||
return getMatchFull(matchId);
|
||||
}
|
||||
|
||||
export async function moveToRolling(matchId: string) {
|
||||
const m = await getMatchOrThrow(matchId);
|
||||
assertState(m.state, MATCH_STATES.LOBBY);
|
||||
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: MATCH_STATES.ROLLING })
|
||||
.where(eq(match.id, matchId));
|
||||
|
||||
return getMatchFull(matchId);
|
||||
}
|
||||
|
||||
// ── Roll ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function submitRoll(matchId: string, participantId: string, value: number) {
|
||||
const m = await getMatchOrThrow(matchId);
|
||||
assertState(m.state, MATCH_STATES.ROLLING);
|
||||
|
||||
await db
|
||||
.update(matchParticipant)
|
||||
.set({ rollValue: value })
|
||||
.where(eq(matchParticipant.id, participantId));
|
||||
|
||||
const participants = await db.query.matchParticipant.findMany({
|
||||
where: eq(matchParticipant.matchId, matchId)
|
||||
});
|
||||
|
||||
const allRolled = participants.every(
|
||||
(p) => (p.id === participantId ? true : p.rollValue !== null)
|
||||
);
|
||||
|
||||
if (allRolled) {
|
||||
const sorted = [...participants]
|
||||
.map((p) => ({
|
||||
...p,
|
||||
rollValue: p.id === participantId ? value : p.rollValue!
|
||||
}))
|
||||
.sort((a, b) => b.rollValue - a.rollValue);
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
await db
|
||||
.update(matchParticipant)
|
||||
.set({ pickOrder: i + 1 })
|
||||
.where(eq(matchParticipant.id, sorted[i].id));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: MATCH_STATES.PICKING })
|
||||
.where(eq(match.id, matchId));
|
||||
}
|
||||
|
||||
return getMatchFull(matchId);
|
||||
}
|
||||
|
||||
// ── Pick Map ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function pickMap(matchId: string, participantId: string, mappoolSlotId: string) {
|
||||
const m = await getMatchOrThrow(matchId);
|
||||
assertState(m.state, MATCH_STATES.PICKING);
|
||||
|
||||
const participants = await db.query.matchParticipant.findMany({
|
||||
where: eq(matchParticipant.matchId, matchId)
|
||||
});
|
||||
|
||||
const totalGames = await db.query.matchGame.findMany({
|
||||
where: eq(matchGame.matchId, matchId)
|
||||
});
|
||||
|
||||
const expectedPicker = getExpectedPicker(participants, totalGames.length);
|
||||
if (expectedPicker.id !== participantId) {
|
||||
throw new Error('Not your turn to pick');
|
||||
}
|
||||
|
||||
const alreadyPlayed = totalGames.some((g) => g.mappoolSlotId === mappoolSlotId);
|
||||
if (alreadyPlayed) {
|
||||
throw new Error('This map has already been played');
|
||||
}
|
||||
|
||||
const [game] = await db
|
||||
.insert(matchGame)
|
||||
.values({
|
||||
matchId,
|
||||
gameNumber: totalGames.length + 1,
|
||||
mappoolSlotId,
|
||||
pickedByParticipantId: participantId,
|
||||
state: GAME_STATES.PLAYING,
|
||||
startedAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: MATCH_STATES.PLAYING })
|
||||
.where(eq(match.id, matchId));
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
// ── Submit Scores ───────────────────────────────────────────────────────
|
||||
|
||||
export async function submitGameScores(
|
||||
matchGameId: string,
|
||||
scores: {
|
||||
playerId: string;
|
||||
score: number;
|
||||
accuracy?: number;
|
||||
maxCombo?: number;
|
||||
count300?: number;
|
||||
count100?: number;
|
||||
count50?: number;
|
||||
countMiss?: number;
|
||||
mods?: string[];
|
||||
passed?: boolean;
|
||||
}[]
|
||||
) {
|
||||
const game = await db.query.matchGame.findFirst({
|
||||
where: eq(matchGame.id, matchGameId)
|
||||
});
|
||||
|
||||
if (!game) throw new Error('Game not found');
|
||||
if (game.state !== GAME_STATES.PLAYING) throw new Error(`Game is ${game.state}, not PLAYING`);
|
||||
|
||||
for (const s of scores) {
|
||||
await db.insert(matchGameScore).values({
|
||||
matchGameId,
|
||||
playerId: s.playerId,
|
||||
score: s.score,
|
||||
accuracy: s.accuracy ?? 0,
|
||||
maxCombo: s.maxCombo ?? 0,
|
||||
count300: s.count300 ?? 0,
|
||||
count100: s.count100 ?? 0,
|
||||
count50: s.count50 ?? 0,
|
||||
countMiss: s.countMiss ?? 0,
|
||||
mods: s.mods ?? [],
|
||||
passed: s.passed ?? true
|
||||
});
|
||||
}
|
||||
|
||||
const allScores = await db.query.matchGameScore.findMany({
|
||||
where: eq(matchGameScore.matchGameId, matchGameId),
|
||||
with: { player: true }
|
||||
});
|
||||
|
||||
const participantScores = new Map<string, number>();
|
||||
for (const s of allScores) {
|
||||
const pid = s.player.participantId;
|
||||
participantScores.set(pid, (participantScores.get(pid) ?? 0) + s.score);
|
||||
}
|
||||
|
||||
let winnerId: string | null = null;
|
||||
let highScore = -1;
|
||||
for (const [pid, total] of participantScores) {
|
||||
if (total > highScore) {
|
||||
highScore = total;
|
||||
winnerId = pid;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(matchGame)
|
||||
.set({
|
||||
state: GAME_STATES.FINISHED,
|
||||
winnerParticipantId: winnerId,
|
||||
finishedAt: new Date()
|
||||
})
|
||||
.where(eq(matchGame.id, matchGameId));
|
||||
|
||||
if (winnerId) {
|
||||
const participant = await db.query.matchParticipant.findFirst({
|
||||
where: eq(matchParticipant.id, winnerId)
|
||||
});
|
||||
if (participant) {
|
||||
await db
|
||||
.update(matchParticipant)
|
||||
.set({ score: participant.score + 1 })
|
||||
.where(eq(matchParticipant.id, winnerId));
|
||||
}
|
||||
}
|
||||
|
||||
const m = await getMatchOrThrow(game.matchId);
|
||||
const config = m.config as MatchConfig;
|
||||
const winsNeeded = Math.ceil(config.bestOf / 2);
|
||||
|
||||
const participants = await db.query.matchParticipant.findMany({
|
||||
where: eq(matchParticipant.matchId, game.matchId)
|
||||
});
|
||||
|
||||
const matchWinner = participants.find((p) => p.score >= winsNeeded);
|
||||
|
||||
if (matchWinner) {
|
||||
await db
|
||||
.update(match)
|
||||
.set({
|
||||
state: MATCH_STATES.FINISHED,
|
||||
winnerId: matchWinner.teamId,
|
||||
finishedAt: new Date()
|
||||
})
|
||||
.where(eq(match.id, game.matchId));
|
||||
|
||||
await updateElo(participants, matchWinner.id);
|
||||
} else {
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: MATCH_STATES.PICKING })
|
||||
.where(eq(match.id, game.matchId));
|
||||
}
|
||||
|
||||
return getMatchFull(game.matchId);
|
||||
}
|
||||
|
||||
// ── ELO ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateElo(
|
||||
participants: { id: string; teamId: string }[],
|
||||
winnerId: string
|
||||
) {
|
||||
for (const p of participants) {
|
||||
const players = await db.query.matchParticipantPlayer.findMany({
|
||||
where: eq(matchParticipantPlayer.participantId, p.id)
|
||||
});
|
||||
|
||||
const isWinner = p.id === winnerId;
|
||||
|
||||
for (const player of players) {
|
||||
let rating = await db.query.playerRating.findFirst({
|
||||
where: eq(playerRating.userId, player.userId)
|
||||
});
|
||||
|
||||
if (!rating) {
|
||||
[rating] = await db
|
||||
.insert(playerRating)
|
||||
.values({ userId: player.userId, elo: 1000 })
|
||||
.returning();
|
||||
}
|
||||
|
||||
const K = 32;
|
||||
const expected = 0.5;
|
||||
const actual = isWinner ? 1 : 0;
|
||||
const newElo = Math.round(rating.elo + K * (actual - expected));
|
||||
|
||||
await db
|
||||
.update(playerRating)
|
||||
.set({
|
||||
elo: newElo,
|
||||
wins: isWinner ? rating.wins + 1 : rating.wins,
|
||||
losses: isWinner ? rating.losses : rating.losses + 1,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(playerRating.userId, player.userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancel Match ────────────────────────────────────────────────────────
|
||||
|
||||
export async function cancelMatch(matchId: string) {
|
||||
await db
|
||||
.update(match)
|
||||
.set({
|
||||
state: MATCH_STATES.CANCELLED,
|
||||
finishedAt: new Date()
|
||||
})
|
||||
.where(eq(match.id, matchId));
|
||||
|
||||
return getMatchFull(matchId);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getExpectedPicker(
|
||||
participants: { id: string; pickOrder: number | null; score: number }[],
|
||||
gameCount: number
|
||||
) {
|
||||
const sorted = [...participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
|
||||
const pickerIndex = gameCount % sorted.length;
|
||||
return sorted[pickerIndex];
|
||||
}
|
||||
|
||||
export async function getMatchFull(matchId: string) {
|
||||
const m = await db.query.match.findFirst({
|
||||
where: eq(match.id, matchId),
|
||||
with: {
|
||||
mappool: {
|
||||
with: { slots: true }
|
||||
},
|
||||
participants: {
|
||||
with: {
|
||||
team: true,
|
||||
players: true
|
||||
}
|
||||
},
|
||||
games: {
|
||||
with: {
|
||||
slot: true,
|
||||
scores: {
|
||||
with: { player: true }
|
||||
}
|
||||
},
|
||||
orderBy: (g, { asc }) => [asc(g.gameNumber)]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!m) throw new Error('Match not found');
|
||||
return m;
|
||||
}
|
||||
|
||||
async function getMatchOrThrow(matchId: string) {
|
||||
const m = await db.query.match.findFirst({
|
||||
where: eq(match.id, matchId)
|
||||
});
|
||||
if (!m) throw new Error('Match not found');
|
||||
return m;
|
||||
}
|
||||
|
||||
function assertState(current: string, expected: string) {
|
||||
if (current !== expected) {
|
||||
throw new Error(`Match is ${current}, expected ${expected}`);
|
||||
}
|
||||
}
|
||||
331
src/lib/server/match/orchestrator.ts
Normal file
331
src/lib/server/match/orchestrator.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { match, matchGame, mappoolSlot, user } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { MATCH_STATES, type MatchConfig } from './types';
|
||||
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
|
||||
import { TournamentLobby, setLobby, getLobby, removeLobby } from '../bancho/client';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IRC lobby → configure → invite players → wire up chat handlers → move to ROLLING.
|
||||
*/
|
||||
export async function initMatchLobby(matchId: string) {
|
||||
const m = await getMatchFull(matchId);
|
||||
const config = m.config as MatchConfig;
|
||||
const lobby = new TournamentLobby();
|
||||
|
||||
const lobbyName = `VASH: (${m.participants[0]?.team.name}) vs (${m.participants[1]?.team.name})`;
|
||||
console.log(`[Orchestrator] Creating lobby: ${lobbyName}`);
|
||||
|
||||
const { matchId: osuId, channel } = await lobby.create(lobbyName);
|
||||
console.log(`[Orchestrator] Created ${channel} (osu mp/${osuId})`);
|
||||
|
||||
setLobby(matchId, lobby);
|
||||
|
||||
// CREATED → LOBBY
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: MATCH_STATES.LOBBY, osuLobbyId: osuId, startedAt: new Date() })
|
||||
.where(eq(match.id, matchId));
|
||||
|
||||
// Configure: HeadToHead, correct score mode, correct size
|
||||
const scoreMode =
|
||||
config.scoringType === 'score_v2' ? 3
|
||||
: config.scoringType === 'accuracy' ? 1
|
||||
: config.scoringType === 'combo' ? 2
|
||||
: 0;
|
||||
await lobby.setProperties(0, scoreMode, config.teamSize * 2);
|
||||
|
||||
// Remove password so players can join via mp link (and rejoin after disconnect)
|
||||
await lobby.send('!mp password');
|
||||
await sleep(500);
|
||||
|
||||
// Invite all players from both teams (with dedup + delays)
|
||||
const invited = new Set<string>();
|
||||
for (const p of m.participants) {
|
||||
for (const pl of p.players) {
|
||||
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||
if (u?.name && !invited.has(u.name.toLowerCase())) {
|
||||
await lobby.invite(u.name);
|
||||
invited.add(u.name.toLowerCase());
|
||||
console.log(`[Orchestrator] Invited ${u.name}`);
|
||||
await sleep(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up IRC chat handlers for rolls and picks
|
||||
setupChatHandlers(matchId, lobby);
|
||||
|
||||
await lobby.chat(
|
||||
`Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.`
|
||||
);
|
||||
|
||||
// LOBBY → ROLLING
|
||||
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
|
||||
|
||||
return { osuMatchId: osuId, channel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up IRC event callbacks so players can !roll and !pick from osu! chat.
|
||||
*/
|
||||
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
||||
// ── Handle !roll from IRC ───────────────────────────────────────────
|
||||
lobby.onRollResult = async (username: string, value: number) => {
|
||||
try {
|
||||
const m = await getMatchFull(matchId);
|
||||
if (m.state !== MATCH_STATES.ROLLING) {
|
||||
await lobby.chat(`Rolls are not active right now.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find participant linked to this username
|
||||
let targetParticipant: { id: string; rollValue: number | null } | null = null;
|
||||
|
||||
for (const p of m.participants) {
|
||||
for (const pl of p.players) {
|
||||
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||
if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) {
|
||||
// If this participant already rolled, try finding another one (same-team testing)
|
||||
if (p.rollValue === null) {
|
||||
targetParticipant = p;
|
||||
break;
|
||||
} else if (!targetParticipant) {
|
||||
// Keep looking for an unrolled one
|
||||
const unrolled = m.participants.find((pp) => pp.rollValue === null);
|
||||
if (unrolled) targetParticipant = unrolled;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetParticipant?.rollValue === null) break;
|
||||
}
|
||||
|
||||
if (!targetParticipant || targetParticipant.rollValue !== null) {
|
||||
await lobby.chat(`${username}: already rolled or not in this match.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await submitRoll(matchId, targetParticipant.id, value);
|
||||
console.log(`[Orchestrator] IRC roll: ${username} = ${value}`);
|
||||
|
||||
// Check if all rolled → announce pick order
|
||||
const updated = await getMatchFull(matchId);
|
||||
if (updated.state === MATCH_STATES.PICKING) {
|
||||
const sorted = [...updated.participants].sort(
|
||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||
);
|
||||
await lobby.chat(
|
||||
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Orchestrator] IRC roll error:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Handle !pick from IRC ───────────────────────────────────────────
|
||||
lobby.onPickCommand = async (username: string, slotLabel: string) => {
|
||||
try {
|
||||
const m = await getMatchFull(matchId);
|
||||
if (m.state !== MATCH_STATES.PICKING) {
|
||||
await lobby.chat(`Picks are not active right now.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse slot label: "NM1" → category=NM, orderInCategory=1
|
||||
const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/);
|
||||
if (!labelMatch) {
|
||||
await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`);
|
||||
return;
|
||||
}
|
||||
const category = labelMatch[1];
|
||||
const order = parseInt(labelMatch[2]);
|
||||
|
||||
// Find the expected picker
|
||||
const sorted = [...m.participants].sort(
|
||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||
);
|
||||
const expectedIdx = m.games.length % sorted.length;
|
||||
const expectedPicker = sorted[expectedIdx];
|
||||
|
||||
// Verify it's this player's turn to pick
|
||||
let isPickerTurn = false;
|
||||
if (expectedPicker) {
|
||||
for (const pl of expectedPicker.players) {
|
||||
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||
if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) {
|
||||
isPickerTurn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For same-team testing, allow any participant
|
||||
if (!isPickerTurn && m.participants[0]?.teamId === m.participants[1]?.teamId) {
|
||||
isPickerTurn = true;
|
||||
}
|
||||
|
||||
if (!isPickerTurn) {
|
||||
await lobby.chat(`${username}: It's not your turn to pick.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the mappool slot
|
||||
const slot = m.mappool?.slots?.find(
|
||||
(s) => s.category === category && s.orderInCategory === order
|
||||
);
|
||||
if (!slot) {
|
||||
await lobby.chat(`${username}: Slot ${slotLabel} not found in this mappool.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already played
|
||||
const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id);
|
||||
if (alreadyPlayed) {
|
||||
await lobby.chat(`${username}: ${slotLabel} has already been played.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the pick
|
||||
const game = await pickMap(matchId, expectedPicker!.id, slot.id);
|
||||
console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`);
|
||||
|
||||
// Set map in lobby and wait for ready
|
||||
playPickedMap(matchId, game.id).catch((err) =>
|
||||
console.error('[Orchestrator] IRC play failed:', err.message)
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[Orchestrator] IRC pick error:', err.message);
|
||||
await lobby.chat(`Error: ${err.message}`).catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after a map is picked (from web UI or IRC).
|
||||
* Sets map + mods in IRC, waits for ready, starts game, collects scores.
|
||||
*/
|
||||
export async function playPickedMap(matchId: string, matchGameId: string) {
|
||||
const lobby = getLobby(matchId);
|
||||
if (!lobby) {
|
||||
console.warn('[Orchestrator] No IRC lobby for', matchId, '— skipping IRC');
|
||||
return;
|
||||
}
|
||||
|
||||
const game = await db.query.matchGame.findFirst({
|
||||
where: eq(matchGame.id, matchGameId),
|
||||
with: { slot: true }
|
||||
});
|
||||
if (!game) throw new Error('Game not found');
|
||||
|
||||
const slot = game.slot;
|
||||
|
||||
// Set beatmap
|
||||
await lobby.setMap(slot.beatmapId);
|
||||
await sleep(1000);
|
||||
|
||||
// Set mods (always NF + slot mods)
|
||||
await lobby.setMods(slot.mods);
|
||||
await sleep(500);
|
||||
|
||||
await lobby.chat(
|
||||
`Playing ${slot.category}${slot.orderInCategory}. Ready up! (Game starts when all players are ready)`
|
||||
);
|
||||
|
||||
// Wait for all players to ready up in osu!, then start
|
||||
try {
|
||||
await lobby.waitForReady();
|
||||
await lobby.chat('All ready — starting in 5s!');
|
||||
await lobby.startGame(5);
|
||||
} catch (err: any) {
|
||||
// If ready times out, inform and don't start
|
||||
console.warn('[Orchestrator] Ready timeout:', err.message);
|
||||
await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-blocking: wait for scores then process
|
||||
collectScores(matchId, matchGameId, lobby).catch((err) =>
|
||||
console.error('[Orchestrator] Score collection failed:', err)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force start the current game without waiting for ready.
|
||||
* Called from the web UI "Force Start" button.
|
||||
*/
|
||||
export async function forceStartGame(matchId: string) {
|
||||
const lobby = getLobby(matchId);
|
||||
if (!lobby) return;
|
||||
|
||||
await lobby.chat('Force starting in 10s!');
|
||||
await lobby.startGame(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background task: waits for IRC scores, writes them to DB, advances match state.
|
||||
*/
|
||||
async function collectScores(matchId: string, matchGameId: string, lobby: TournamentLobby) {
|
||||
const ircScores = await lobby.waitForScores();
|
||||
console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores);
|
||||
|
||||
// Map IRC usernames → matchParticipantPlayer IDs
|
||||
const m = await getMatchFull(matchId);
|
||||
const scores: { playerId: string; score: number; passed: boolean }[] = [];
|
||||
|
||||
for (const irc of ircScores) {
|
||||
for (const participant of m.participants) {
|
||||
for (const player of participant.players) {
|
||||
const u = await db.query.user.findFirst({ where: eq(user.id, player.userId) });
|
||||
// IRC uses underscores for spaces in usernames
|
||||
const ircName = irc.username.toLowerCase().replace(/_/g, ' ');
|
||||
const dbName = u?.name?.toLowerCase();
|
||||
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
||||
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scores.length === 0) {
|
||||
console.warn('[Orchestrator] No scores matched any IRC usernames');
|
||||
return;
|
||||
}
|
||||
|
||||
await submitGameScores(matchGameId, scores);
|
||||
|
||||
// Check if match finished or needs next pick
|
||||
const updated = await getMatchFull(matchId);
|
||||
if (updated.state === MATCH_STATES.FINISHED) {
|
||||
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
|
||||
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match!`);
|
||||
await sleep(5000);
|
||||
await lobby.close();
|
||||
removeLobby(matchId);
|
||||
} else {
|
||||
// Announce next picker
|
||||
const sorted = [...updated.participants].sort(
|
||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||
);
|
||||
const nextIdx = updated.games.length % sorted.length;
|
||||
const nextPicker = sorted[nextIdx];
|
||||
await lobby.chat(
|
||||
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick HD1) or pick in web UI.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the IRC lobby for a match.
|
||||
*/
|
||||
export async function closeLobby(matchId: string) {
|
||||
const lobby = getLobby(matchId);
|
||||
if (lobby) {
|
||||
await lobby.close();
|
||||
removeLobby(matchId);
|
||||
}
|
||||
}
|
||||
24
src/lib/server/match/types.ts
Normal file
24
src/lib/server/match/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface MatchConfig {
|
||||
bestOf: number;
|
||||
teamSize: number;
|
||||
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
|
||||
}
|
||||
|
||||
export const MATCH_STATES = {
|
||||
CREATED: 'CREATED',
|
||||
LOBBY: 'LOBBY',
|
||||
ROLLING: 'ROLLING',
|
||||
PICKING: 'PICKING',
|
||||
PLAYING: 'PLAYING',
|
||||
FINISHED: 'FINISHED',
|
||||
CANCELLED: 'CANCELLED'
|
||||
} as const;
|
||||
|
||||
export const GAME_STATES = {
|
||||
PENDING: 'PENDING',
|
||||
PLAYING: 'PLAYING',
|
||||
FINISHED: 'FINISHED'
|
||||
} as const;
|
||||
|
||||
export type MatchState = (typeof MATCH_STATES)[keyof typeof MATCH_STATES];
|
||||
export type GameState = (typeof GAME_STATES)[keyof typeof GAME_STATES];
|
||||
Reference in New Issue
Block a user