feat: implement keepalive mechanism for IRC connection and enhance match actions with role-based permissions
This commit is contained in:
@@ -9,6 +9,7 @@ function sleep(ms: number) {
|
|||||||
let client: any = null;
|
let client: any = null;
|
||||||
let connecting: Promise<void> | null = null;
|
let connecting: Promise<void> | null = null;
|
||||||
let reconnecting = false;
|
let reconnecting = false;
|
||||||
|
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async function getClient() {
|
async function getClient() {
|
||||||
if (client?.isConnected?.()) return client;
|
if (client?.isConnected?.()) return client;
|
||||||
@@ -29,18 +30,13 @@ async function getClient() {
|
|||||||
|
|
||||||
client = new BanchoClient({
|
client = new BanchoClient({
|
||||||
username: env.OSU_IRC_USERNAME,
|
username: env.OSU_IRC_USERNAME,
|
||||||
password: env.OSU_IRC_PASSWORD,
|
password: env.OSU_IRC_PASSWORD
|
||||||
// bancho.js has built-in reconnect but we need to handle channel rejoin
|
|
||||||
apiKey: undefined
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Lifecycle logging ──
|
// ── Lifecycle logging ──
|
||||||
client.on('connected', () => {
|
|
||||||
console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('disconnected', () => {
|
client.on('disconnected', () => {
|
||||||
console.warn('[Bancho] Disconnected from IRC');
|
console.warn('[Bancho] Disconnected from IRC');
|
||||||
|
stopKeepalive();
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('error', (err: any) => {
|
client.on('error', (err: any) => {
|
||||||
@@ -48,16 +44,18 @@ async function getClient() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Reconnect: re-join all active lobby channels ──
|
// ── Reconnect: re-join all active lobby channels ──
|
||||||
// bancho.js fires 'connected' again after an auto-reconnect
|
|
||||||
let initialConnect = true;
|
let initialConnect = true;
|
||||||
client.on('connected', async () => {
|
client.on('connected', async () => {
|
||||||
if (initialConnect) {
|
if (initialConnect) {
|
||||||
initialConnect = false;
|
initialConnect = false;
|
||||||
return; // skip the first connect, lobbies haven't been created yet
|
console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME);
|
||||||
|
startKeepalive();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnecting = true;
|
reconnecting = true;
|
||||||
console.log('[Bancho] Reconnected — re-joining', lobbies.size, 'active lobby channels');
|
console.log('[Bancho] Reconnected — re-joining', lobbies.size, 'active lobby channels');
|
||||||
|
startKeepalive();
|
||||||
|
|
||||||
for (const [matchId, lobby] of lobbies.entries()) {
|
for (const [matchId, lobby] of lobbies.entries()) {
|
||||||
try {
|
try {
|
||||||
@@ -65,8 +63,6 @@ async function getClient() {
|
|||||||
console.log(`[Bancho] Re-joined channel for match ${matchId}`);
|
console.log(`[Bancho] Re-joined channel for match ${matchId}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Bancho] Failed to re-join channel for match ${matchId}:`, err.message);
|
console.error(`[Bancho] Failed to re-join channel for match ${matchId}:`, err.message);
|
||||||
// Channel may have been closed by Bancho while we were offline
|
|
||||||
// Mark it as dead so orchestrator knows
|
|
||||||
lobby._dead = true;
|
lobby._dead = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +77,36 @@ async function getClient() {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Keepalive: prevent IRC timeout by pinging BanchoBot periodically ────
|
||||||
|
|
||||||
|
function startKeepalive() {
|
||||||
|
stopKeepalive();
|
||||||
|
keepaliveTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
if (client?.isConnected?.()) {
|
||||||
|
// Send a silent PING via raw IRC to keep the connection alive.
|
||||||
|
// bancho.js exposes the underlying IRC socket for raw writes,
|
||||||
|
// but the simplest approach is just sending a stats request to BanchoBot.
|
||||||
|
// This generates minimal traffic but keeps TCP alive.
|
||||||
|
const banchoBot = client.getUser('BanchoBot');
|
||||||
|
if (banchoBot) {
|
||||||
|
// WHOIS is lightweight and doesn't produce visible output in channels
|
||||||
|
client.send?.(`PING :keepalive-${Date.now()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore keepalive errors — if connection is dead, the reconnect handler will fire
|
||||||
|
}
|
||||||
|
}, 30_000); // every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopKeepalive() {
|
||||||
|
if (keepaliveTimer) {
|
||||||
|
clearInterval(keepaliveTimer);
|
||||||
|
keepaliveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the IRC client is currently connected.
|
* Check if the IRC client is currently connected.
|
||||||
*/
|
*/
|
||||||
@@ -89,7 +115,7 @@ export function isBanchoConnected(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're in the middle of a reconnect (channels may be temporarily unavailable).
|
* Check if we're in the middle of a reconnect.
|
||||||
*/
|
*/
|
||||||
export function isBanchoReconnecting(): boolean {
|
export function isBanchoReconnecting(): boolean {
|
||||||
return reconnecting;
|
return reconnecting;
|
||||||
@@ -113,7 +139,7 @@ export class TournamentLobby {
|
|||||||
/** Set to true if the channel couldn't be re-joined after reconnect */
|
/** Set to true if the channel couldn't be re-joined after reconnect */
|
||||||
public _dead = false;
|
public _dead = false;
|
||||||
|
|
||||||
// The message handler function (stored so we can re-attach after reconnect)
|
// Stored so we can re-attach after reconnect
|
||||||
private messageHandler: ((msg: any) => void) | null = null;
|
private messageHandler: ((msg: any) => void) | null = null;
|
||||||
|
|
||||||
// Score collection
|
// Score collection
|
||||||
@@ -170,18 +196,14 @@ export class TournamentLobby {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-join the channel after a reconnect.
|
* Re-join the channel after a reconnect.
|
||||||
* Called internally by the reconnect handler.
|
|
||||||
*/
|
*/
|
||||||
async _rejoinChannel(c: any) {
|
async _rejoinChannel(c: any) {
|
||||||
if (!this.channelName) return;
|
if (!this.channelName) return;
|
||||||
|
|
||||||
// Remove old listener if any
|
|
||||||
if (this.channel && this.messageHandler) {
|
if (this.channel && this.messageHandler) {
|
||||||
try {
|
try {
|
||||||
this.channel.removeListener('message', this.messageHandler);
|
this.channel.removeListener('message', this.messageHandler);
|
||||||
} catch {
|
} catch { /* old channel may be dead */ }
|
||||||
/* old channel may be completely dead */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channel = c.getChannel(this.channelName);
|
this.channel = c.getChannel(this.channelName);
|
||||||
@@ -193,13 +215,10 @@ export class TournamentLobby {
|
|||||||
private attachMessageHandler() {
|
private attachMessageHandler() {
|
||||||
if (!this.channel) return;
|
if (!this.channel) return;
|
||||||
|
|
||||||
// Remove previous handler if exists (prevent duplicates)
|
|
||||||
if (this.messageHandler) {
|
if (this.messageHandler) {
|
||||||
try {
|
try {
|
||||||
this.channel.removeListener('message', this.messageHandler);
|
this.channel.removeListener('message', this.messageHandler);
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageHandler = (msg: any) => {
|
this.messageHandler = (msg: any) => {
|
||||||
@@ -219,7 +238,6 @@ export class TournamentLobby {
|
|||||||
// ── Message Parsing ─────────────────────────────────────────────────
|
// ── Message Parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private parseBanchoMessage(text: string) {
|
private parseBanchoMessage(text: string) {
|
||||||
// Player score: "<user> finished playing (Score: 1,234,567, ... PASSED)"
|
|
||||||
const scoreMatch = text.match(
|
const scoreMatch = text.match(
|
||||||
/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i
|
/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i
|
||||||
);
|
);
|
||||||
@@ -233,7 +251,6 @@ export class TournamentLobby {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game finished
|
|
||||||
if (text.includes('The match has finished!')) {
|
if (text.includes('The match has finished!')) {
|
||||||
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
||||||
if (this.matchFinishedResolve) {
|
if (this.matchFinishedResolve) {
|
||||||
@@ -244,7 +261,6 @@ export class TournamentLobby {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roll result: "Stan rolls 42 point(s)"
|
|
||||||
const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/);
|
const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/);
|
||||||
if (rollMatch) {
|
if (rollMatch) {
|
||||||
const username = rollMatch[1].trim();
|
const username = rollMatch[1].trim();
|
||||||
@@ -256,7 +272,6 @@ export class TournamentLobby {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All players ready
|
|
||||||
if (text.includes('All players are ready')) {
|
if (text.includes('All players are ready')) {
|
||||||
console.log('[Bancho] All players ready');
|
console.log('[Bancho] All players ready');
|
||||||
if (this.allReadyResolve) {
|
if (this.allReadyResolve) {
|
||||||
@@ -266,7 +281,6 @@ export class TournamentLobby {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Room closed (by Bancho or timeout)
|
|
||||||
if (text.includes('Closed the match')) {
|
if (text.includes('Closed the match')) {
|
||||||
console.log(`[Bancho] Room ${this.channelName} was closed`);
|
console.log(`[Bancho] Room ${this.channelName} was closed`);
|
||||||
this._dead = true;
|
this._dead = true;
|
||||||
@@ -275,7 +289,6 @@ export class TournamentLobby {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parsePlayerMessage(sender: string, text: string) {
|
private parsePlayerMessage(sender: string, text: string) {
|
||||||
// !pick NM1, !pick HD2, !pick TB, etc.
|
|
||||||
const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i);
|
const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i);
|
||||||
if (pickMatch) {
|
if (pickMatch) {
|
||||||
const category = pickMatch[1].toUpperCase();
|
const category = pickMatch[1].toUpperCase();
|
||||||
@@ -303,7 +316,6 @@ export class TournamentLobby {
|
|||||||
async send(cmd: string) {
|
async send(cmd: string) {
|
||||||
this.ensureAlive();
|
this.ensureAlive();
|
||||||
|
|
||||||
// If we're in the middle of reconnecting, wait a bit
|
|
||||||
if (reconnecting) {
|
if (reconnecting) {
|
||||||
console.log(`[Bancho] Waiting for reconnect before sending to ${this.channelName}...`);
|
console.log(`[Bancho] Waiting for reconnect before sending to ${this.channelName}...`);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -362,19 +374,12 @@ export class TournamentLobby {
|
|||||||
await this.send(message);
|
await this.send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this lobby is still usable.
|
|
||||||
*/
|
|
||||||
get isAlive(): boolean {
|
get isAlive(): boolean {
|
||||||
return !this._dead && this.channel != null;
|
return !this._dead && this.channel != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Async Waiters ───────────────────────────────────────────────────
|
// ── 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> {
|
waitForReady(timeoutMs = 180_000): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
@@ -388,9 +393,6 @@ export class TournamentLobby {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for "The match has finished!" and return all player scores.
|
|
||||||
*/
|
|
||||||
waitForScores(timeoutMs = 600_000): Promise<PlayerScore[]> {
|
waitForScores(timeoutMs = 600_000): Promise<PlayerScore[]> {
|
||||||
this.collectedScores = [];
|
this.collectedScores = [];
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -406,7 +408,7 @@ export class TournamentLobby {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active lobby registry (in-memory, per server instance) ──────────────
|
// ── Active lobby registry ───────────────────────────────────────────────
|
||||||
|
|
||||||
const lobbies = new Map<string, TournamentLobby>();
|
const lobbies = new Map<string, TournamentLobby>();
|
||||||
|
|
||||||
@@ -428,10 +430,6 @@ export function removeLobby(matchId: string) {
|
|||||||
lobbies.delete(matchId);
|
lobbies.delete(matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of active (non-dead) lobbies.
|
|
||||||
* Useful for checking against the 4-lobby limit for non-bot accounts.
|
|
||||||
*/
|
|
||||||
export function getActiveLobbyCount(): number {
|
export function getActiveLobbyCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [id, lobby] of lobbies.entries()) {
|
for (const [id, lobby] of lobbies.entries()) {
|
||||||
@@ -444,9 +442,6 @@ export function getActiveLobbyCount(): number {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Close all active lobbies. Useful for graceful shutdown.
|
|
||||||
*/
|
|
||||||
export async function closeAllLobbies() {
|
export async function closeAllLobbies() {
|
||||||
console.log(`[Bancho] Closing all ${lobbies.size} active lobbies...`);
|
console.log(`[Bancho] Closing all ${lobbies.size} active lobbies...`);
|
||||||
for (const [matchId, lobby] of lobbies.entries()) {
|
for (const [matchId, lobby] of lobbies.entries()) {
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import {
|
|||||||
setLobby,
|
setLobby,
|
||||||
getLobby,
|
getLobby,
|
||||||
removeLobby,
|
removeLobby,
|
||||||
getActiveLobbyCount,
|
getActiveLobbyCount
|
||||||
isBanchoConnected,
|
|
||||||
isBanchoReconnecting
|
|
||||||
} from '../bancho/client';
|
} from '../bancho/client';
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
@@ -84,6 +82,8 @@ export async function initMatchLobby(matchId: string) {
|
|||||||
// Wire up IRC chat handlers for rolls and picks
|
// Wire up IRC chat handlers for rolls and picks
|
||||||
setupChatHandlers(matchId, lobby);
|
setupChatHandlers(matchId, lobby);
|
||||||
|
|
||||||
|
// ── Send welcome AFTER invites with a delay so players have time to join ──
|
||||||
|
await sleep(5000);
|
||||||
await lobby.chat(
|
await lobby.chat(
|
||||||
`Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.`
|
`Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.`
|
||||||
);
|
);
|
||||||
@@ -107,19 +107,16 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find participant linked to this username
|
|
||||||
let targetParticipant: { id: string; rollValue: number | null } | null = null;
|
let targetParticipant: { id: string; rollValue: number | null } | null = null;
|
||||||
|
|
||||||
for (const p of m.participants) {
|
for (const p of m.participants) {
|
||||||
for (const pl of p.players) {
|
for (const pl of p.players) {
|
||||||
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||||
if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) {
|
if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) {
|
||||||
// If this participant already rolled, try finding another one (same-team testing)
|
|
||||||
if (p.rollValue === null) {
|
if (p.rollValue === null) {
|
||||||
targetParticipant = p;
|
targetParticipant = p;
|
||||||
break;
|
break;
|
||||||
} else if (!targetParticipant) {
|
} else if (!targetParticipant) {
|
||||||
// Keep looking for an unrolled one
|
|
||||||
const unrolled = m.participants.find((pp) => pp.rollValue === null);
|
const unrolled = m.participants.find((pp) => pp.rollValue === null);
|
||||||
if (unrolled) targetParticipant = unrolled;
|
if (unrolled) targetParticipant = unrolled;
|
||||||
}
|
}
|
||||||
@@ -136,7 +133,6 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
|||||||
await submitRoll(matchId, targetParticipant.id, value);
|
await submitRoll(matchId, targetParticipant.id, value);
|
||||||
console.log(`[Orchestrator] IRC roll: ${username} = ${value}`);
|
console.log(`[Orchestrator] IRC roll: ${username} = ${value}`);
|
||||||
|
|
||||||
// Check if all rolled → announce pick order
|
|
||||||
const updated = await getMatchFull(matchId);
|
const updated = await getMatchFull(matchId);
|
||||||
if (updated.state === MATCH_STATES.PICKING) {
|
if (updated.state === MATCH_STATES.PICKING) {
|
||||||
const sorted = [...updated.participants].sort(
|
const sorted = [...updated.participants].sort(
|
||||||
@@ -160,7 +156,6 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse slot label: "NM1" → category=NM, orderInCategory=1
|
|
||||||
const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/);
|
const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/);
|
||||||
if (!labelMatch) {
|
if (!labelMatch) {
|
||||||
await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`);
|
await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`);
|
||||||
@@ -169,14 +164,12 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
|||||||
const category = labelMatch[1];
|
const category = labelMatch[1];
|
||||||
const order = parseInt(labelMatch[2]);
|
const order = parseInt(labelMatch[2]);
|
||||||
|
|
||||||
// Find the expected picker
|
|
||||||
const sorted = [...m.participants].sort(
|
const sorted = [...m.participants].sort(
|
||||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
);
|
);
|
||||||
const expectedIdx = m.games.length % sorted.length;
|
const expectedIdx = m.games.length % sorted.length;
|
||||||
const expectedPicker = sorted[expectedIdx];
|
const expectedPicker = sorted[expectedIdx];
|
||||||
|
|
||||||
// Verify it's this player's turn to pick
|
|
||||||
let isPickerTurn = false;
|
let isPickerTurn = false;
|
||||||
if (expectedPicker) {
|
if (expectedPicker) {
|
||||||
for (const pl of expectedPicker.players) {
|
for (const pl of expectedPicker.players) {
|
||||||
@@ -198,7 +191,6 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the mappool slot
|
|
||||||
const slot = m.mappool?.slots?.find(
|
const slot = m.mappool?.slots?.find(
|
||||||
(s) => s.category === category && s.orderInCategory === order
|
(s) => s.category === category && s.orderInCategory === order
|
||||||
);
|
);
|
||||||
@@ -207,18 +199,26 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already played
|
|
||||||
const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id);
|
const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id);
|
||||||
if (alreadyPlayed) {
|
if (alreadyPlayed) {
|
||||||
await lobby.chat(`${username}: ${slotLabel} has already been played.`);
|
await lobby.chat(`${username}: ${slotLabel} has already been played.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the pick
|
// ── Tiebreaker restriction ──
|
||||||
|
if (slot.category === 'TB') {
|
||||||
|
const config = m.config as MatchConfig;
|
||||||
|
const winsNeeded = Math.ceil(config.bestOf / 2);
|
||||||
|
const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1);
|
||||||
|
if (!allAtMatchPoint) {
|
||||||
|
await lobby.chat(`${username}: Tiebreaker can only be picked at match point!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const game = await pickMap(matchId, expectedPicker!.id, slot.id);
|
const game = await pickMap(matchId, expectedPicker!.id, slot.id);
|
||||||
console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`);
|
console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`);
|
||||||
|
|
||||||
// Set map in lobby and wait for ready
|
|
||||||
playPickedMap(matchId, game.id).catch((err) =>
|
playPickedMap(matchId, game.id).catch((err) =>
|
||||||
console.error('[Orchestrator] IRC play failed:', err.message)
|
console.error('[Orchestrator] IRC play failed:', err.message)
|
||||||
);
|
);
|
||||||
@@ -254,11 +254,9 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
|
|||||||
|
|
||||||
const slot = game.slot;
|
const slot = game.slot;
|
||||||
|
|
||||||
// Set beatmap
|
|
||||||
await lobby.setMap(slot.beatmapId);
|
await lobby.setMap(slot.beatmapId);
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
// Set mods (always NF + slot mods)
|
|
||||||
await lobby.setMods(slot.mods);
|
await lobby.setMods(slot.mods);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -266,23 +264,18 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
|
|||||||
`Playing ${slot.category}${slot.orderInCategory}. Ready up! (Game starts when all players are ready)`
|
`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 {
|
try {
|
||||||
await lobby.waitForReady();
|
await lobby.waitForReady();
|
||||||
await lobby.chat('All ready — starting in 5s!');
|
await lobby.chat('All ready — starting in 5s!');
|
||||||
await lobby.startGame(5);
|
await lobby.startGame(5);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// If ready times out, inform and don't start
|
|
||||||
console.warn('[Orchestrator] Ready timeout:', err.message);
|
console.warn('[Orchestrator] Ready timeout:', err.message);
|
||||||
try {
|
try {
|
||||||
await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!');
|
await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!');
|
||||||
} catch {
|
} catch { /* lobby may be dead */ }
|
||||||
// Lobby may be dead after timeout
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-blocking: wait for scores then process
|
|
||||||
collectScores(matchId, matchGameId, lobby).catch((err) =>
|
collectScores(matchId, matchGameId, lobby).catch((err) =>
|
||||||
console.error('[Orchestrator] Score collection failed:', err)
|
console.error('[Orchestrator] Score collection failed:', err)
|
||||||
);
|
);
|
||||||
@@ -290,16 +283,10 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Force start the current game without waiting for ready.
|
* Force start the current game without waiting for ready.
|
||||||
* Called from the web UI "Force Start" button.
|
|
||||||
*/
|
*/
|
||||||
export async function forceStartGame(matchId: string) {
|
export async function forceStartGame(matchId: string) {
|
||||||
const lobby = getLobby(matchId);
|
const lobby = getLobby(matchId);
|
||||||
if (!lobby) return;
|
if (!lobby || !lobby.isAlive) return;
|
||||||
|
|
||||||
if (!lobby.isAlive) {
|
|
||||||
console.warn('[Orchestrator] Cannot force start — lobby is dead');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await lobby.chat('Force starting in 10s!');
|
await lobby.chat('Force starting in 10s!');
|
||||||
await lobby.startGame(10);
|
await lobby.startGame(10);
|
||||||
@@ -312,7 +299,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
const ircScores = await lobby.waitForScores();
|
const ircScores = await lobby.waitForScores();
|
||||||
console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores);
|
console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores);
|
||||||
|
|
||||||
// Map IRC usernames → matchParticipantPlayer IDs
|
|
||||||
const m = await getMatchFull(matchId);
|
const m = await getMatchFull(matchId);
|
||||||
const scores: { playerId: string; score: number; passed: boolean }[] = [];
|
const scores: { playerId: string; score: number; passed: boolean }[] = [];
|
||||||
|
|
||||||
@@ -320,7 +306,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
for (const participant of m.participants) {
|
for (const participant of m.participants) {
|
||||||
for (const player of participant.players) {
|
for (const player of participant.players) {
|
||||||
const u = await db.query.user.findFirst({ where: eq(user.id, player.userId) });
|
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 ircName = irc.username.toLowerCase().replace(/_/g, ' ');
|
||||||
const dbName = u?.name?.toLowerCase();
|
const dbName = u?.name?.toLowerCase();
|
||||||
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
||||||
@@ -337,7 +322,6 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
|
|
||||||
await submitGameScores(matchGameId, scores);
|
await submitGameScores(matchGameId, scores);
|
||||||
|
|
||||||
// Check if match finished or needs next pick
|
|
||||||
const updated = await getMatchFull(matchId);
|
const updated = await getMatchFull(matchId);
|
||||||
if (updated.state === MATCH_STATES.FINISHED) {
|
if (updated.state === MATCH_STATES.FINISHED) {
|
||||||
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
|
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
|
||||||
@@ -345,12 +329,9 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match!`);
|
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match!`);
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
await lobby.close();
|
await lobby.close();
|
||||||
} catch {
|
} catch { /* lobby may already be closed */ }
|
||||||
/* lobby may already be closed */
|
|
||||||
}
|
|
||||||
removeLobby(matchId);
|
removeLobby(matchId);
|
||||||
} else {
|
} else {
|
||||||
// Announce next picker
|
|
||||||
const sorted = [...updated.participants].sort(
|
const sorted = [...updated.participants].sort(
|
||||||
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
);
|
);
|
||||||
@@ -360,9 +341,7 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna
|
|||||||
await lobby.chat(
|
await lobby.chat(
|
||||||
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick HD1) or pick in web UI.`
|
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick HD1) or pick in web UI.`
|
||||||
);
|
);
|
||||||
} catch {
|
} catch { /* lobby may be dead */ }
|
||||||
/* lobby may be dead */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orc
|
|||||||
import { getLobby } from '$lib/server/bancho/client';
|
import { getLobby } from '$lib/server/bancho/client';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import { hasRole } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { MatchConfig } from '$lib/server/match/types';
|
||||||
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
@@ -41,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { match: m, beatmapCache, userId: locals.user.id };
|
const isStaff = hasRole(locals.user.role, 'referee');
|
||||||
|
|
||||||
|
return { match: m, beatmapCache, userId: locals.user.id, isStaff };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -51,20 +55,14 @@ export const actions: Actions = {
|
|||||||
const lobby = getLobby(params.id);
|
const lobby = getLobby(params.id);
|
||||||
if (!lobby) return { error: 'No active IRC lobby for this match' };
|
if (!lobby) return { error: 'No active IRC lobby for this match' };
|
||||||
|
|
||||||
const m = await getMatchFull(params.id);
|
// Only invite the currently logged-in user
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, locals.user.id) });
|
||||||
const invited: string[] = [];
|
if (u?.name) {
|
||||||
for (const p of m.participants) {
|
await lobby.invite(u.name);
|
||||||
for (const pl of p.players) {
|
return { reinvited: [u.name] };
|
||||||
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
|
||||||
if (u?.name) {
|
|
||||||
await lobby.invite(u.name);
|
|
||||||
invited.push(u.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { reinvited: invited };
|
return { error: 'Could not find your username' };
|
||||||
},
|
},
|
||||||
|
|
||||||
roll: async ({ params, locals }) => {
|
roll: async ({ params, locals }) => {
|
||||||
@@ -132,6 +130,28 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
if (!expectedPicker) return { error: 'Cannot determine picker' };
|
if (!expectedPicker) return { error: 'Cannot determine picker' };
|
||||||
|
|
||||||
|
// ── Verify the logged-in user is on the expected picker's team ──
|
||||||
|
const isOnPickerTeam = expectedPicker.players.some(
|
||||||
|
(pl) => pl.userId === locals.user!.id
|
||||||
|
);
|
||||||
|
// Allow staff to pick on behalf of anyone
|
||||||
|
const isStaff = hasRole(locals.user!.role, 'referee');
|
||||||
|
if (!isOnPickerTeam && !isStaff) {
|
||||||
|
return { error: "It's not your turn to pick" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tiebreaker restriction ──
|
||||||
|
// TB maps can only be picked when both teams are at match point (e.g. 2-2 in BO5)
|
||||||
|
const config = m.config as MatchConfig;
|
||||||
|
const winsNeeded = Math.ceil(config.bestOf / 2);
|
||||||
|
const slot = m.mappool?.slots?.find((s) => s.id === slotId);
|
||||||
|
if (slot?.category === 'TB') {
|
||||||
|
const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1);
|
||||||
|
if (!allAtMatchPoint) {
|
||||||
|
return { error: 'Tiebreaker can only be picked when both teams are at match point' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const game = await pickMap(params.id, expectedPicker.id, slotId);
|
const game = await pickMap(params.id, expectedPicker.id, slotId);
|
||||||
|
|
||||||
@@ -148,12 +168,20 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
forceStart: async ({ params, locals }) => {
|
forceStart: async ({ params, locals }) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
if (!locals.user) redirect(302, '/');
|
||||||
|
// Only staff can force start
|
||||||
|
if (!hasRole(locals.user.role, 'referee')) {
|
||||||
|
return { error: 'Only referees and admins can force start' };
|
||||||
|
}
|
||||||
await forceStartGame(params.id);
|
await forceStartGame(params.id);
|
||||||
return { started: true };
|
return { started: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel: async ({ params, locals }) => {
|
cancel: async ({ params, locals }) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
if (!locals.user) redirect(302, '/');
|
||||||
|
// Only staff can cancel matches
|
||||||
|
if (!hasRole(locals.user.role, 'referee')) {
|
||||||
|
return { error: 'Only referees and admins can cancel matches' };
|
||||||
|
}
|
||||||
|
|
||||||
await cancelMatch(params.id);
|
await cancelMatch(params.id);
|
||||||
await closeLobby(params.id);
|
await closeLobby(params.id);
|
||||||
|
|||||||
@@ -20,6 +20,16 @@
|
|||||||
const expectedPickerIdx = $derived(m.games.length % sortedByPick.length);
|
const expectedPickerIdx = $derived(m.games.length % sortedByPick.length);
|
||||||
const expectedPicker = $derived(sortedByPick[expectedPickerIdx]);
|
const expectedPicker = $derived(sortedByPick[expectedPickerIdx]);
|
||||||
|
|
||||||
|
// Is the logged-in user on the expected picker's team?
|
||||||
|
const isMyTurnToPick = $derived(
|
||||||
|
expectedPicker?.players?.some((pl: any) => pl.userId === data.userId) ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
// TB restriction: only pickable at match point (e.g. 2-2 in BO5)
|
||||||
|
const isTiebreakerAllowed = $derived(
|
||||||
|
m.participants.every((p: any) => p.score === winsNeeded - 1)
|
||||||
|
);
|
||||||
|
|
||||||
const canRoll = $derived(
|
const canRoll = $derived(
|
||||||
m.state === 'ROLLING' && m.participants.some((p: any) => p.rollValue === null)
|
m.state === 'ROLLING' && m.participants.some((p: any) => p.rollValue === null)
|
||||||
);
|
);
|
||||||
@@ -94,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
|
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
|
||||||
|
|
||||||
{#if !['FINISHED', 'CANCELLED'].includes(m.state)}
|
{#if !['FINISHED', 'CANCELLED'].includes(m.state) && data.isStaff}
|
||||||
<form method="post" action="?/cancel" use:enhance>
|
<form method="post" action="?/cancel" use:enhance>
|
||||||
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
|
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -136,13 +146,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Re-invite players -->
|
<!-- Re-invite (self only) — visible during LOBBY, ROLLING, PICKING, PLAYING -->
|
||||||
{#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)}
|
{#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)}
|
||||||
<div class="mt-3 flex justify-center">
|
<div class="mt-3 flex justify-center">
|
||||||
<form method="post" action="?/reinvite" use:enhance={() => {
|
<form method="post" action="?/reinvite" use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
if (result.type === 'success' && (result.data as any)?.reinvited) {
|
if (result.type === 'success' && (result.data as any)?.reinvited) {
|
||||||
// Brief visual feedback — just reload
|
|
||||||
await update();
|
await update();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -151,7 +160,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
|
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
|
||||||
>
|
>
|
||||||
📨 Re-invite players to lobby
|
📨 Re-invite me to lobby
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,6 +211,9 @@
|
|||||||
<h2 class="text-lg font-700">🎯 Pick a Map</h2>
|
<h2 class="text-lg font-700">🎯 Pick a Map</h2>
|
||||||
<p class="mt-1 text-sm">
|
<p class="mt-1 text-sm">
|
||||||
<span class="font-600 text-accent">{expectedPicker?.team.name}</span>'s turn to pick
|
<span class="font-600 text-accent">{expectedPicker?.team.name}</span>'s turn to pick
|
||||||
|
{#if !isMyTurnToPick && !data.isStaff}
|
||||||
|
<span class="text-text-secondary">(waiting...)</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!pick NM1</code> in osu! chat
|
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!pick NM1</code> in osu! chat
|
||||||
@@ -222,6 +234,9 @@
|
|||||||
{#each slots as slot, i}
|
{#each slots as slot, i}
|
||||||
{@const bm = data.beatmapCache[slot.beatmapId]}
|
{@const bm = data.beatmapCache[slot.beatmapId]}
|
||||||
{@const isPlayed = playedSlotIds.has(slot.id)}
|
{@const isPlayed = playedSlotIds.has(slot.id)}
|
||||||
|
{@const isTB = slot.category === 'TB'}
|
||||||
|
{@const tbLocked = isTB && !isTiebreakerAllowed}
|
||||||
|
{@const cantPick = !isMyTurnToPick && !data.isStaff}
|
||||||
|
|
||||||
<form method="post" action="?/pick" use:enhance={() => {
|
<form method="post" action="?/pick" use:enhance={() => {
|
||||||
picking = true;
|
picking = true;
|
||||||
@@ -238,10 +253,12 @@
|
|||||||
<input type="hidden" name="slotId" value={slot.id} />
|
<input type="hidden" name="slotId" value={slot.id} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPlayed || picking}
|
disabled={isPlayed || picking || tbLocked || cantPick}
|
||||||
class="flex w-full items-center gap-3 rounded-lg border p-2 text-left transition-all {isPlayed
|
class="flex w-full items-center gap-3 rounded-lg border p-2 text-left transition-all {isPlayed || tbLocked
|
||||||
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
|
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
|
||||||
: 'border-border bg-surface-700 hover:border-accent hover:bg-surface-600 cursor-pointer'}"
|
: cantPick
|
||||||
|
? 'border-border bg-surface-700 opacity-60 cursor-not-allowed'
|
||||||
|
: 'border-border bg-surface-700 hover:border-accent hover:bg-surface-600 cursor-pointer'}"
|
||||||
>
|
>
|
||||||
{#if bm?.listCoverUrl}
|
{#if bm?.listCoverUrl}
|
||||||
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 rounded object-cover" />
|
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 rounded object-cover" />
|
||||||
@@ -257,7 +274,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
|
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
|
||||||
{#if isPlayed}<span class="text-xs text-text-secondary">✓</span>{/if}
|
{#if isPlayed}
|
||||||
|
<span class="text-xs text-text-secondary">✓</span>
|
||||||
|
{:else if tbLocked}
|
||||||
|
<span class="text-[10px] text-pink-400/60">🔒</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -289,19 +310,21 @@
|
|||||||
Waiting for players to ready up & play in osu!...
|
Waiting for players to ready up & play in osu!...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Force Start button -->
|
<!-- Force Start button — staff only -->
|
||||||
<form method="post" action="?/forceStart" use:enhance={() => {
|
{#if data.isStaff}
|
||||||
forceStarting = true;
|
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||||
return async ({ update }) => { forceStarting = false; await update(); };
|
forceStarting = true;
|
||||||
}}>
|
return async ({ update }) => { forceStarting = false; await update(); };
|
||||||
<button
|
}}>
|
||||||
type="submit"
|
<button
|
||||||
disabled={forceStarting}
|
type="submit"
|
||||||
class="mt-4 rounded-md border border-yellow-500/30 px-4 py-2 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
disabled={forceStarting}
|
||||||
>
|
class="mt-4 rounded-md border border-yellow-500/30 px-4 py-2 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||||
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
|
>
|
||||||
</button>
|
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/routes/api/health/+server.ts
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|||||||
Reference in New Issue
Block a user