server for refapp and refbot and other stuff
0

Configure Feed

Select the types of activity you want to include in your feed.

Implement phase 6A: check-in/approval flow, restart, player view, pools API

Adds check-in/approval lifecycle to match engine:
- createPendingMatch: creates match in 'pending' status with check-in state
- checkInPlayer: marks player as checked in
- approveMatch: transitions from pending to ban-phase

New routes:
- POST /matches/:id/check-in (player check-in)
- POST /matches/:id/approve (referee approval)
- POST /matches/:id/restart (restart completed match)
- GET /matches/:id/player (filtered player view)
- GET /pools, GET /pools/tier/:tier, GET /pools/match (pools read)

Session metadata: findMatchByChannelId for channelId→matchId lookup.
268 tests pass, typecheck clean.

alpine (Jul 15, 2026, 2:29 AM +0200) c191219f 0ee821c9

+690 -46
+192 -1
src/engine/match.ts
··· 45 45 eventId?: string | null; 46 46 } 47 47 48 + export interface CreatePendingMatchParams { 49 + players: [PlayerData, PlayerData]; 50 + bestOf: number; 51 + mapPool: MappoolInput[]; 52 + round: string; 53 + matchNumber: number; 54 + tier?: number | null; 55 + refereeDiscordId?: string | null; 56 + startggSetId?: string | null; 57 + type?: string; 58 + channelId?: string | null; 59 + eventId?: string | null; 60 + } 61 + 48 62 export interface MatchState { 49 63 _id?: import('mongoose').Types.ObjectId; 50 64 matchId: string | null; ··· 65 79 }; 66 80 players: MatchPlayerState[]; 67 81 mappool: MappoolEntryState[]; 82 + checkIn: { 83 + player1CheckedIn: boolean; 84 + player2CheckedIn: boolean; 85 + approvedBy: string | null; 86 + approvedAt: string | null; 87 + }; 68 88 banPhase: { 69 89 banOrder: string[]; 70 90 currentBannerDiscordId: string | null; ··· 273 293 }, 274 294 players: [buildPlayerEntry({ ...p1, slot: 1 }), buildPlayerEntry({ ...p2, slot: 2 })], 275 295 mappool: builtMappool, 296 + checkIn: { 297 + player1CheckedIn: true, 298 + player2CheckedIn: true, 299 + approvedBy: params.refereeDiscordId ?? null, 300 + approvedAt: new Date().toISOString(), 301 + }, 276 302 banPhase: { 277 303 banOrder, 278 304 currentBannerDiscordId: params.firstBannerDiscordId, ··· 292 318 meta: state.meta, 293 319 players: state.players, 294 320 mappool: state.mappool, 321 + checkIn: state.checkIn, 295 322 banPhase: state.banPhase, 296 323 currentPickerDiscordId: state.currentPickerDiscordId, 297 324 currentChart: state.currentChart, ··· 312 339 return state; 313 340 } 314 341 342 + export async function createPendingMatch(params: CreatePendingMatchParams): Promise<MatchState> { 343 + const event = getActiveEvent(); 344 + const resolvedEventId = params.eventId 345 + ? new (await import('mongoose')).default.Types.ObjectId(params.eventId) 346 + : event?._id; 347 + if (!resolvedEventId) throw new Error('No active event!'); 348 + 349 + const winsNeeded = Math.ceil(params.bestOf / 2); 350 + 351 + const p1 = params.players[0]!; 352 + const p2 = params.players[1]!; 353 + 354 + const builtMappool = params.mapPool.map(entry => buildMappoolEntry(entry)); 355 + 356 + const state: MatchState = { 357 + matchId: generateMatchId(), 358 + progressLevel: 'check-in', 359 + meta: { 360 + name: `${params.round} - Match ${params.matchNumber}`, 361 + round: params.round, 362 + matchNumber: params.matchNumber, 363 + bestOf: params.bestOf, 364 + winsNeeded, 365 + tier: params.tier ?? null, 366 + channelId: params.channelId ?? null, 367 + eventId: resolvedEventId, 368 + startedAt: new Date().toISOString(), 369 + completedAt: null, 370 + startggSetId: params.startggSetId ?? null, 371 + type: params.type ?? 'tournament', 372 + }, 373 + players: [buildPlayerEntry({ ...p1, slot: 1 }), buildPlayerEntry({ ...p2, slot: 2 })], 374 + mappool: builtMappool, 375 + checkIn: { 376 + player1CheckedIn: false, 377 + player2CheckedIn: false, 378 + approvedBy: null, 379 + approvedAt: null, 380 + }, 381 + banPhase: { 382 + banOrder: [], 383 + currentBannerDiscordId: null, 384 + bansCompleted: 0, 385 + totalBans: 0, 386 + }, 387 + currentPickerDiscordId: null, 388 + currentChart: null, 389 + refereeDiscordId: params.refereeDiscordId ?? null, 390 + feed: [], 391 + status: 'pending', 392 + }; 393 + 394 + const doc = await Match.create({ 395 + matchId: state.matchId, 396 + progressLevel: state.progressLevel, 397 + meta: state.meta, 398 + players: state.players, 399 + mappool: state.mappool, 400 + checkIn: state.checkIn, 401 + banPhase: state.banPhase, 402 + currentPickerDiscordId: state.currentPickerDiscordId, 403 + currentChart: state.currentChart, 404 + refereeDiscordId: state.refereeDiscordId, 405 + feed: state.feed, 406 + status: state.status, 407 + }); 408 + 409 + state._id = doc._id; 410 + state.matchId = doc.matchId ?? state.matchId; 411 + const matchId = state.matchId!; 412 + matchStates.set(matchId, state); 413 + 414 + pushFeedEvent(matchId, 'match.created', `Match pending: ${state.meta.name}`); 415 + await saveMatchState(matchId); 416 + 417 + logger.info({ matchId, round: params.round, matchNumber: params.matchNumber }, 'pending match created'); 418 + return state; 419 + } 420 + 421 + export async function checkInPlayer(matchId: string, discordId: string): Promise<{ player1CheckedIn: boolean; player2CheckedIn: boolean }> { 422 + const state = matchStates.get(matchId); 423 + if (!state) throw new Error('Match not found'); 424 + if (state.status !== 'pending') throw new Error('Match is not in pending state'); 425 + 426 + const p1 = state.players[0]; 427 + const p2 = state.players[1]; 428 + 429 + if (p1?.discordId === discordId) { 430 + state.checkIn.player1CheckedIn = true; 431 + } 432 + else if (p2?.discordId === discordId) { 433 + state.checkIn.player2CheckedIn = true; 434 + } 435 + else { 436 + throw new Error('Discord ID does not match any player in this match'); 437 + } 438 + 439 + const player = state.players.find(p => p.discordId === discordId); 440 + pushFeedEvent(matchId, 'check-in', `${getBestName(player)} checked in`, { discordId }); 441 + await saveMatchState(matchId); 442 + 443 + return { 444 + player1CheckedIn: state.checkIn.player1CheckedIn, 445 + player2CheckedIn: state.checkIn.player2CheckedIn, 446 + }; 447 + } 448 + 449 + export async function approveMatch(matchId: string, refereeDiscordId: string): Promise<MatchState> { 450 + const state = matchStates.get(matchId); 451 + if (!state) throw new Error('Match not found'); 452 + if (state.status !== 'pending') throw new Error('Match is not in pending state'); 453 + if (!state.checkIn.player1CheckedIn || !state.checkIn.player2CheckedIn) { 454 + throw new Error('Not all players have checked in'); 455 + } 456 + 457 + state.checkIn.approvedBy = refereeDiscordId; 458 + state.checkIn.approvedAt = new Date().toISOString(); 459 + state.refereeDiscordId = refereeDiscordId; 460 + 461 + // Set up ban phase 462 + const p1 = state.players[0]!; 463 + const p2 = state.players[1]!; 464 + const numBans = state.mappool.length - 1; 465 + const secondBanner = p1.discordId; // p1 always goes first in the new flow 466 + 467 + const banOrder = Array.from({ length: numBans }, (_, i) => { 468 + return (i % 2 === 0 ? p1.discordId : p2.discordId); 469 + }); 470 + 471 + state.banPhase = { 472 + banOrder, 473 + currentBannerDiscordId: p1.discordId, 474 + bansCompleted: 0, 475 + totalBans: numBans, 476 + }; 477 + 478 + state.progressLevel = 'ban-phase'; 479 + state.status = 'in_progress'; 480 + 481 + pushFeedEvent(matchId, 'match.approved', `Match approved by ${refereeDiscordId}`, { refereeDiscordId }); 482 + await saveMatchState(matchId); 483 + 484 + logger.info({ matchId, refereeDiscordId }, 'match approved, ban phase started'); 485 + return state; 486 + } 487 + 315 488 export async function saveMatchState(matchId: string): Promise<void> { 316 489 const state = matchStates.get(matchId); 317 490 if (!state?._id) return; ··· 320 493 progressLevel: state.progressLevel, 321 494 players: state.players, 322 495 mappool: state.mappool, 496 + checkIn: state.checkIn, 323 497 banPhase: state.banPhase, 324 498 currentPickerDiscordId: state.currentPickerDiscordId, 325 499 currentChart: state.currentChart, ··· 554 728 export async function loadAllInProgressMatches(): Promise<MatchDoc[]> { 555 729 const event = getActiveEvent(); 556 730 if (!event) return []; 557 - return Match.find({ 'meta.eventId': event._id, status: 'in_progress' }); 731 + return Match.find({ 'meta.eventId': event._id, status: { $in: ['pending', 'in_progress'] } }); 558 732 } 559 733 560 734 export async function restoreMatchState(matchId: string, doc: MatchDoc): Promise<MatchState> { ··· 623 797 } : null, 624 798 isTiebreaker: m.isTiebreaker ?? false, 625 799 })), 800 + checkIn: { 801 + player1CheckedIn: doc.checkIn?.player1CheckedIn ?? false, 802 + player2CheckedIn: doc.checkIn?.player2CheckedIn ?? false, 803 + approvedBy: doc.checkIn?.approvedBy ?? null, 804 + approvedAt: doc.checkIn?.approvedAt?.toISOString() ?? null, 805 + }, 626 806 banPhase: { 627 807 banOrder: doc.banPhase?.banOrder ?? [], 628 808 currentBannerDiscordId: doc.banPhase?.currentBannerDiscordId ?? null, ··· 649 829 matchStates = new Map(); 650 830 broadcastFn = null; 651 831 } 832 + 833 + export async function findMatchByChannelId(channelId: string): Promise<MatchState | null> { 834 + // first check in-memory cache 835 + for (const state of matchStates.values()) { 836 + if (state.meta.channelId === channelId) return state; 837 + } 838 + // then check DB 839 + const doc = await Match.findOne({ 'meta.channelId': channelId }); 840 + if (!doc) return null; 841 + return restoreMatchState(doc.matchId ?? '', doc); 842 + }
+17 -1
src/models/Match.ts
··· 57 57 pronouns?: string | null; 58 58 } 59 59 60 + export interface CheckInDoc { 61 + player1CheckedIn?: boolean; 62 + player2CheckedIn?: boolean; 63 + approvedBy?: string | null; 64 + approvedAt?: Date | null; 65 + } 66 + 60 67 export interface BanPhaseDoc { 61 68 banOrder?: string[]; 62 69 currentBannerDiscordId?: string | null; ··· 91 98 }; 92 99 players?: MatchPlayerDoc[]; 93 100 mappool?: MappoolEntryDoc[]; 101 + checkIn?: CheckInDoc; 94 102 banPhase?: BanPhaseDoc; 95 103 currentPickerDiscordId?: string | null; 96 104 currentChart?: number | null; ··· 163 171 pronouns: { type: String, default: null }, 164 172 }, { _id: false }); 165 173 174 + const checkInSchema = new Schema<CheckInDoc>({ 175 + player1CheckedIn: { type: Boolean, default: false }, 176 + player2CheckedIn: { type: Boolean, default: false }, 177 + approvedBy: { type: String, default: null }, 178 + approvedAt: { type: Date, default: null }, 179 + }, { _id: false }); 180 + 166 181 const banPhaseSchema = new Schema<BanPhaseDoc>({ 167 182 banOrder: { type: [String], default: [] }, 168 183 currentBannerDiscordId: { type: String, default: null }, ··· 193 208 }, 194 209 players: { type: [playerSchema], default: [] }, 195 210 mappool: { type: [mappoolEntrySchema], default: [] }, 211 + checkIn: { type: checkInSchema, default: () => ({}) }, 196 212 banPhase: { type: banPhaseSchema, default: () => ({}) }, 197 213 currentPickerDiscordId: { type: String, default: null }, 198 214 currentChart: { type: Number, default: null }, ··· 200 216 feed: { type: [feedEventSchema], default: [] }, 201 217 status: { 202 218 type: String, 203 - enum: ['in_progress', 'completed', 'restarted', 'abandoned'], 219 + enum: ['pending', 'in_progress', 'completed', 'restarted', 'abandoned'], 204 220 default: 'in_progress', 205 221 }, 206 222 });
+2
src/routes/index.ts
··· 5 5 import { matchesRouter } from './matches.js'; 6 6 import { chartsRouter } from './charts.js'; 7 7 import { playersRouter } from './players.js'; 8 + import { poolsRouter } from './pools.js'; 8 9 import { apiKeysRouter } from './apiKeys.js'; 9 10 10 11 export const v1Router = Router(); ··· 14 15 v1Router.use(matchesRouter); 15 16 v1Router.use(chartsRouter); 16 17 v1Router.use(playersRouter); 18 + v1Router.use(poolsRouter); 17 19 v1Router.use(apiKeysRouter);
+189 -2
src/routes/matches.ts
··· 4 4 import { getActiveEvent } from '../engine/event.js'; 5 5 import { 6 6 initMatchState, 7 + createPendingMatch, 8 + checkInPlayer, 9 + approveMatch, 7 10 getMatchState, 8 11 banChart, 9 12 pickChart, ··· 12 15 completeMatch, 13 16 abandonMatch, 14 17 saveMatchState, 18 + findMatchByChannelId, 15 19 type PlayerData, 16 20 } from '../engine/match.js'; 17 21 import { revertAction } from '../util/revertAction.js'; ··· 127 131 }, 128 132 ]; 129 133 130 - const state = await initMatchState({ 134 + const state = await createPendingMatch({ 131 135 players, 132 - firstBannerDiscordId: body.player1DiscordId, 133 136 bestOf: body.bestOf, 134 137 mapPool: body.mapPool, 135 138 round: body.round, ··· 336 339 337 340 await abandonMatch(matchId); 338 341 res.json({ abandoned: true, state: getMatchState(matchId) }); 342 + } 343 + catch (err) { 344 + next(err); 345 + } 346 + }); 347 + 348 + // ---------- check-in / approval ---------- 349 + 350 + matchesRouter.post('/matches/:id/check-in', requireAuth('referee'), async (req, res, next) => { 351 + try { 352 + const matchId = param(req, 'id'); 353 + const { discordId } = req.body as { discordId?: string }; 354 + if (!discordId) { 355 + throw new HttpError(400, 'missing_discordId', 'discordId is required'); 356 + } 357 + 358 + const state = getMatchState(matchId); 359 + if (!state) throw new HttpError(404, 'not_found', 'match not found'); 360 + 361 + try { 362 + const result = await checkInPlayer(matchId, discordId); 363 + res.json({ ...result, state: getMatchState(matchId) }); 364 + } 365 + catch (err) { 366 + throw new HttpError(400, 'check_in_failed', (err as Error).message); 367 + } 368 + } 369 + catch (err) { 370 + next(err); 371 + } 372 + }); 373 + 374 + matchesRouter.post('/matches/:id/approve', requireAuth('referee'), async (req, res, next) => { 375 + try { 376 + const matchId = param(req, 'id'); 377 + const { refereeDiscordId } = req.body as { refereeDiscordId?: string }; 378 + if (!refereeDiscordId) { 379 + throw new HttpError(400, 'missing_refereeDiscordId', 'refereeDiscordId is required'); 380 + } 381 + 382 + try { 383 + const result = await approveMatch(matchId, refereeDiscordId); 384 + res.json(result); 385 + } 386 + catch (err) { 387 + throw new HttpError(400, 'approve_failed', (err as Error).message); 388 + } 389 + } 390 + catch (err) { 391 + next(err); 392 + } 393 + }); 394 + 395 + // ---------- restart ---------- 396 + 397 + matchesRouter.post('/matches/:id/restart', requireAuth('referee'), async (req, res, next) => { 398 + try { 399 + const matchId = param(req, 'id'); 400 + const doc = await findMatch(matchId); 401 + 402 + if (doc.status !== 'completed') { 403 + throw new HttpError(409, 'not_completed', 'only completed matches can be restarted'); 404 + } 405 + 406 + // Mark old match as restarted 407 + doc.status = 'restarted'; 408 + await doc.save(); 409 + 410 + // Create new pending match with same parameters 411 + const newMatch = await createPendingMatch({ 412 + players: (doc.players ?? []).map(p => ({ 413 + slot: p.slot, 414 + discordId: p.discordId ?? '', 415 + displayName: p.displayName ?? null, 416 + spinshareId: p.spinshareId ?? null, 417 + spinshareUsername: p.spinshareUsername ?? null, 418 + spinshareAvatarUrl: p.spinshareAvatarUrl ?? null, 419 + pronouns: p.pronouns ?? null, 420 + })) as [PlayerData, PlayerData], 421 + bestOf: doc.meta?.bestOf ?? 5, 422 + mapPool: (doc.mappool ?? []).map(m => ({ 423 + songId: m.songId ?? null, 424 + title: m.title ?? null, 425 + artist: m.artist ?? null, 426 + charter: m.charter ?? null, 427 + cover: m.cover ?? null, 428 + thumbnailUrl: m.thumbnailUrl ?? null, 429 + difficulty: m.difficulty ?? null, 430 + tags: m.tags ?? [], 431 + displayName: m.displayName ?? null, 432 + })), 433 + round: doc.meta?.round ?? 'Restarted', 434 + matchNumber: doc.meta?.matchNumber ?? 1, 435 + tier: doc.meta?.tier ?? null, 436 + startggSetId: doc.meta?.startggSetId ?? null, 437 + type: doc.meta?.type ?? 'tournament', 438 + channelId: doc.meta?.channelId ?? null, 439 + }); 440 + 441 + res.json({ restarted: true, oldMatchId: doc.matchId, state: newMatch }); 442 + } 443 + catch (err) { 444 + next(err); 445 + } 446 + }); 447 + 448 + // ---------- player view ---------- 449 + 450 + matchesRouter.get('/matches/:id/player', requireAuth('read'), async (req, res, next) => { 451 + try { 452 + const matchId = param(req, 'id'); 453 + const state = getMatchState(matchId); 454 + if (!state) { 455 + const doc = await Match.findOne({ matchId }); 456 + if (!doc) throw new HttpError(404, 'not_found', 'match not found'); 457 + // Return limited info for DB-only matches 458 + res.json({ 459 + matchId: doc.matchId, 460 + status: doc.status, 461 + progressLevel: doc.progressLevel, 462 + meta: { 463 + round: doc.meta?.round, 464 + matchNumber: doc.meta?.matchNumber, 465 + bestOf: doc.meta?.bestOf, 466 + type: doc.meta?.type, 467 + }, 468 + players: (doc.players ?? []).map(p => ({ 469 + displayName: p.displayName, 470 + discordId: p.discordId, 471 + points: p.points ?? 0, 472 + })), 473 + }); 474 + return; 475 + } 476 + 477 + // Filter sensitive info for player view 478 + res.json({ 479 + matchId: state.matchId, 480 + status: state.status, 481 + progressLevel: state.progressLevel, 482 + meta: { 483 + round: state.meta.round, 484 + matchNumber: state.meta.matchNumber, 485 + bestOf: state.meta.bestOf, 486 + winsNeeded: state.meta.winsNeeded, 487 + type: state.meta.type, 488 + }, 489 + players: state.players.map(p => ({ 490 + slot: p.slot, 491 + displayName: p.displayName, 492 + discordId: p.discordId, 493 + points: p.points, 494 + ready: p.ready, 495 + winner: p.winner, 496 + })), 497 + mappool: state.mappool.map(m => ({ 498 + songId: m.songId, 499 + displayName: m.displayName, 500 + isTiebreaker: m.isTiebreaker, 501 + status: { 502 + banned: m.status.banned, 503 + played: m.status.played, 504 + inCurrentPool: m.status.inCurrentPool, 505 + isBeingPlayed: m.status.isBeingPlayed, 506 + }, 507 + result: m.result ? { 508 + score1: m.result.score1, 509 + score2: m.result.score2, 510 + fc1: m.result.fc1, 511 + fc2: m.result.fc2, 512 + pfc1: m.result.pfc1, 513 + pfc2: m.result.pfc2, 514 + winnerSlot: m.result.winnerSlot, 515 + } : null, 516 + })), 517 + banPhase: state.status === 'in_progress' ? { 518 + currentBannerDiscordId: state.banPhase.currentBannerDiscordId, 519 + bansCompleted: state.banPhase.bansCompleted, 520 + totalBans: state.banPhase.totalBans, 521 + } : undefined, 522 + currentPickerDiscordId: state.currentPickerDiscordId, 523 + currentChart: state.currentChart, 524 + feed: state.feed, 525 + }); 339 526 } 340 527 catch (err) { 341 528 next(err);
+60
src/routes/pools.ts
··· 1 + import { Router } from 'express'; 2 + import { getGeneratedPools, getMatchPool, getPoolByTier } from '../engine/pools.js'; 3 + import { requireAuth } from '../middleware/auth.js'; 4 + import { param } from '../middleware/params.js'; 5 + import { HttpError } from '../errors.js'; 6 + 7 + export const poolsRouter = Router(); 8 + 9 + poolsRouter.get('/pools', requireAuth('read'), (_req, res, next) => { 10 + try { 11 + const pools = getGeneratedPools(); 12 + if (!pools) { 13 + throw new HttpError(404, 'not_found', 'no pools generated yet'); 14 + } 15 + res.json(pools); 16 + } 17 + catch (err) { 18 + next(err); 19 + } 20 + }); 21 + 22 + poolsRouter.get('/pools/tier/:tier', requireAuth('read'), (req, res, next) => { 23 + try { 24 + const tier = parseInt(param(req, 'tier'), 10); 25 + if (Number.isNaN(tier)) { 26 + throw new HttpError(400, 'invalid_tier', 'tier must be a number'); 27 + } 28 + const charts = getPoolByTier(tier); 29 + res.json({ tier, charts }); 30 + } 31 + catch (err) { 32 + next(err); 33 + } 34 + }); 35 + 36 + poolsRouter.get('/pools/match', requireAuth('read'), (req, res, next) => { 37 + try { 38 + const bracket = req.query.bracket as string | undefined; 39 + const round = req.query.round as string | undefined; 40 + const poolId = (req.query.poolId as string | undefined) ?? null; 41 + const matchIndexStr = req.query.matchIndex as string | undefined; 42 + 43 + if (!bracket || !round) { 44 + throw new HttpError(400, 'missing_params', 'bracket and round query params are required'); 45 + } 46 + if (matchIndexStr == null) { 47 + throw new HttpError(400, 'missing_matchIndex', 'matchIndex query param is required'); 48 + } 49 + const matchIndex = parseInt(matchIndexStr, 10); 50 + if (Number.isNaN(matchIndex)) { 51 + throw new HttpError(400, 'invalid_matchIndex', 'matchIndex must be a number'); 52 + } 53 + 54 + const charts = getMatchPool(bracket, round, poolId, matchIndex); 55 + res.json({ bracket, round, poolId, matchIndex, charts }); 56 + } 57 + catch (err) { 58 + next(err); 59 + } 60 + });
+230 -42
src/routes/routes.test.ts
··· 11 11 refereeApiKey, 12 12 } from './_testHelpers.js'; 13 13 import { createEvent, loadActiveEvent } from '../engine/event.js'; 14 - import { initMatchState } from '../engine/match.js'; 14 + import { initMatchState, getMatchState, saveMatchState } from '../engine/match.js'; 15 15 16 16 before(async () => { 17 17 await setupTestEnv(); ··· 27 27 28 28 function auth(key: string) { 29 29 return `ApiKey ${key}`; 30 + } 31 + 32 + const MATCH_POOL_5 = [ 33 + { songId: 1001, displayName: 'Chart A' }, 34 + { songId: 1002, displayName: 'Chart B' }, 35 + { songId: 1003, displayName: 'Chart C' }, 36 + { songId: 1004, displayName: 'Chart D' }, 37 + { songId: 1005, displayName: 'Chart E' }, 38 + ]; 39 + 40 + async function createAndApproveMatch(pool = MATCH_POOL_5) { 41 + const created = await request(app) 42 + .post('/v1/matches') 43 + .set('Authorization', auth(refereeApiKey)) 44 + .send({ 45 + round: 'WR1', 46 + matchNumber: 1, 47 + bestOf: 3, 48 + player1DiscordId: '111', 49 + player2DiscordId: '222', 50 + mapPool: pool, 51 + }); 52 + assert.equal(created.status, 201); 53 + assert.equal(created.body.progressLevel, 'check-in'); 54 + const matchId = created.body.matchId; 55 + 56 + // check in both players 57 + await request(app) 58 + .post(`/v1/matches/${matchId}/check-in`) 59 + .set('Authorization', auth(refereeApiKey)) 60 + .send({ discordId: '111' }); 61 + await request(app) 62 + .post(`/v1/matches/${matchId}/check-in`) 63 + .set('Authorization', auth(refereeApiKey)) 64 + .send({ discordId: '222' }); 65 + 66 + // approve 67 + const approved = await request(app) 68 + .post(`/v1/matches/${matchId}/approve`) 69 + .set('Authorization', auth(refereeApiKey)) 70 + .send({ refereeDiscordId: 'ref1' }); 71 + assert.equal(approved.status, 200); 72 + assert.equal(approved.body.progressLevel, 'ban-phase'); 73 + return matchId; 30 74 } 31 75 32 76 // ---------- Events ---------- ··· 191 235 ], 192 236 }); 193 237 assert.equal(res.status, 201); 194 - assert.equal(res.body.progressLevel, 'ban-phase'); 238 + assert.equal(res.body.progressLevel, 'check-in'); 239 + assert.equal(res.body.status, 'pending'); 195 240 assert.equal(res.body.players.length, 2); 196 241 assert.equal(res.body.mappool.length, 3); 197 242 }); ··· 269 314 test('GET /v1/matches/:id/state returns in-memory state', async () => { 270 315 await createEvent('Test'); 271 316 await loadActiveEvent(); 317 + const matchId = await createAndApproveMatch([ 318 + { songId: 1001, displayName: 'A' }, 319 + { songId: 1002, displayName: 'B' }, 320 + ]); 321 + 322 + const res = await request(app) 323 + .get(`/v1/matches/${matchId}/state`) 324 + .set('Authorization', auth(readApiKey)); 325 + assert.equal(res.status, 200); 326 + assert.equal(res.body.matchId, matchId); 327 + assert.equal(res.body.progressLevel, 'ban-phase'); 328 + }); 329 + 330 + test('POST /v1/matches/:id/ban bans a chart', async () => { 331 + await createEvent('Test'); 332 + await loadActiveEvent(); 333 + const matchId = await createAndApproveMatch(); 334 + 335 + const res = await request(app) 336 + .post(`/v1/matches/${matchId}/ban`) 337 + .set('Authorization', auth(refereeApiKey)) 338 + .send({ songId: 1001 }); 339 + assert.equal(res.status, 200); 340 + assert.equal(res.body.banPhase.bansCompleted, 1); 341 + assert.equal(res.body.mappool.find((c: { songId: number }) => c.songId === 1001).status.banned, true); 342 + }); 343 + 344 + test('POST /v1/matches/:id/ban rejects during wrong phase', async () => { 345 + await createEvent('Test'); 346 + await loadActiveEvent(); 347 + const matchId = await createAndApproveMatch([ 348 + { songId: 1001, displayName: 'A' }, 349 + { songId: 1002, displayName: 'B' }, 350 + ]); 351 + // 2-chart pool means 1 ban, which completes immediately and auto-picks 352 + const res = await request(app) 353 + .post(`/v1/matches/${matchId}/ban`) 354 + .set('Authorization', auth(refereeApiKey)) 355 + .send({ songId: 1001 }); 356 + assert.ok([200, 409].includes(res.status)); 357 + }); 358 + 359 + test('POST /v1/matches/:id/stop abandons a match', async () => { 360 + await createEvent('Test'); 361 + await loadActiveEvent(); 362 + const matchId = await createAndApproveMatch([ 363 + { songId: 1001, displayName: 'A' }, 364 + { songId: 1002, displayName: 'B' }, 365 + ]); 366 + const res = await request(app) 367 + .post(`/v1/matches/${matchId}/stop`) 368 + .set('Authorization', auth(refereeApiKey)); 369 + assert.equal(res.status, 200); 370 + assert.equal(res.body.abandoned, true); 371 + assert.equal(res.body.state.status, 'abandoned'); 372 + }); 373 + 374 + // ---------- Charts ---------- 375 + 376 + test('GET /v1/charts lists charts', async () => { 377 + // charts collection is empty, but the endpoint should work 378 + const res = await request(app) 379 + .get('/v1/charts') 380 + .set('Authorization', auth(readApiKey)); 381 + assert.equal(res.status, 200); 382 + assert.ok(Array.isArray(res.body)); 383 + }); 384 + 385 + // ---------- Players ---------- 386 + 387 + test('GET /v1/players lists players', async () => { 388 + const res = await request(app) 389 + .get('/v1/players') 390 + .set('Authorization', auth(readApiKey)); 391 + assert.equal(res.status, 200); 392 + assert.ok(Array.isArray(res.body)); 393 + }); 394 + 395 + // ---------- Check-in / Approval ---------- 396 + 397 + test('POST /v1/matches/:id/check-in marks a player as checked in', async () => { 398 + await createEvent('Test'); 399 + await loadActiveEvent(); 272 400 const created = await request(app) 273 401 .post('/v1/matches') 274 402 .set('Authorization', auth(refereeApiKey)) ··· 280 408 player2DiscordId: '222', 281 409 mapPool: [{ songId: 1001 }, { songId: 1002 }], 282 410 }); 283 - 284 411 const matchId = created.body.matchId; 412 + 285 413 const res = await request(app) 286 - .get(`/v1/matches/${matchId}/state`) 287 - .set('Authorization', auth(readApiKey)); 414 + .post(`/v1/matches/${matchId}/check-in`) 415 + .set('Authorization', auth(refereeApiKey)) 416 + .send({ discordId: '111' }); 288 417 assert.equal(res.status, 200); 289 - assert.equal(res.body.matchId, matchId); 290 - assert.equal(res.body.progressLevel, 'ban-phase'); 418 + assert.equal(res.body.player1CheckedIn, true); 419 + assert.equal(res.body.player2CheckedIn, false); 291 420 }); 292 421 293 - test('POST /v1/matches/:id/ban bans a chart', async () => { 422 + test('POST /v1/matches/:id/check-in rejects unknown discord id', async () => { 294 423 await createEvent('Test'); 295 424 await loadActiveEvent(); 296 425 const created = await request(app) ··· 302 431 bestOf: 3, 303 432 player1DiscordId: '111', 304 433 player2DiscordId: '222', 305 - mapPool: [ 306 - { songId: 1001 }, 307 - { songId: 1002 }, 308 - { songId: 1003 }, 309 - { songId: 1004 }, 310 - { songId: 1005 }, 311 - ], 434 + mapPool: [{ songId: 1001 }, { songId: 1002 }], 312 435 }); 313 - 314 436 const matchId = created.body.matchId; 437 + 315 438 const res = await request(app) 316 - .post(`/v1/matches/${matchId}/ban`) 439 + .post(`/v1/matches/${matchId}/check-in`) 317 440 .set('Authorization', auth(refereeApiKey)) 318 - .send({ songId: 1001 }); 319 - assert.equal(res.status, 200); 320 - assert.equal(res.body.banPhase.bansCompleted, 1); 321 - assert.equal(res.body.mappool.find((c: { songId: number }) => c.songId === 1001).status.banned, true); 441 + .send({ discordId: '999' }); 442 + assert.equal(res.status, 400); 322 443 }); 323 444 324 - test('POST /v1/matches/:id/ban rejects during wrong phase', async () => { 445 + test('POST /v1/matches/:id/approve transitions to ban-phase', async () => { 325 446 await createEvent('Test'); 326 447 await loadActiveEvent(); 327 448 const created = await request(app) ··· 333 454 bestOf: 3, 334 455 player1DiscordId: '111', 335 456 player2DiscordId: '222', 336 - mapPool: [{ songId: 1001 }, { songId: 1002 }], 457 + mapPool: [{ songId: 1001 }, { songId: 1002 }, { songId: 1003 }], 337 458 }); 338 459 const matchId = created.body.matchId; 460 + 461 + await request(app) 462 + .post(`/v1/matches/${matchId}/check-in`) 463 + .set('Authorization', auth(refereeApiKey)) 464 + .send({ discordId: '111' }); 465 + await request(app) 466 + .post(`/v1/matches/${matchId}/check-in`) 467 + .set('Authorization', auth(refereeApiKey)) 468 + .send({ discordId: '222' }); 469 + 339 470 const res = await request(app) 340 - .post(`/v1/matches/${matchId}/ban`) 471 + .post(`/v1/matches/${matchId}/approve`) 341 472 .set('Authorization', auth(refereeApiKey)) 342 - .send({ songId: 1001 }); 343 - // single-chart pool skips ban phase so this will be wrong phase or auto-pick 344 - // 2-chart pool means 1 ban, which completes immediately and auto-picks 345 - assert.ok([200, 409].includes(res.status)); 473 + .send({ refereeDiscordId: 'ref1' }); 474 + assert.equal(res.status, 200); 475 + assert.equal(res.body.progressLevel, 'ban-phase'); 476 + assert.equal(res.body.status, 'in_progress'); 477 + assert.equal(res.body.checkIn.approvedBy, 'ref1'); 478 + assert.equal(res.body.banPhase.totalBans, 2); 346 479 }); 347 480 348 - test('POST /v1/matches/:id/stop abandons a match', async () => { 481 + test('POST /v1/matches/:id/approve rejects if not all checked in', async () => { 349 482 await createEvent('Test'); 350 483 await loadActiveEvent(); 351 484 const created = await request(app) ··· 360 493 mapPool: [{ songId: 1001 }, { songId: 1002 }], 361 494 }); 362 495 const matchId = created.body.matchId; 496 + 497 + await request(app) 498 + .post(`/v1/matches/${matchId}/check-in`) 499 + .set('Authorization', auth(refereeApiKey)) 500 + .send({ discordId: '111' }); 501 + 363 502 const res = await request(app) 364 - .post(`/v1/matches/${matchId}/stop`) 503 + .post(`/v1/matches/${matchId}/approve`) 504 + .set('Authorization', auth(refereeApiKey)) 505 + .send({ refereeDiscordId: 'ref1' }); 506 + assert.equal(res.status, 400); 507 + }); 508 + 509 + // ---------- Restart ---------- 510 + 511 + test('POST /v1/matches/:id/restart creates a new pending match', async () => { 512 + await createEvent('Test'); 513 + await loadActiveEvent(); 514 + // create a match directly via engine (already in in_progress status) 515 + const state = await initMatchState({ 516 + players: [ 517 + { slot: 1, discordId: '111', displayName: 'P1' }, 518 + { slot: 2, discordId: '222', displayName: 'P2' }, 519 + ], 520 + firstBannerDiscordId: '111', 521 + bestOf: 3, 522 + mapPool: [{ songId: 1001 }, { songId: 1002 }], 523 + round: 'WR1', 524 + matchNumber: 1, 525 + }); 526 + // mark as completed (normally submitResult does this) 527 + const ms = getMatchState(state.matchId!)!; 528 + ms.status = 'completed'; 529 + ms.progressLevel = 'finished'; 530 + ms.meta.completedAt = new Date().toISOString(); 531 + await saveMatchState(state.matchId!); 532 + 533 + const res = await request(app) 534 + .post(`/v1/matches/${state.matchId}/restart`) 365 535 .set('Authorization', auth(refereeApiKey)); 366 536 assert.equal(res.status, 200); 367 - assert.equal(res.body.abandoned, true); 368 - assert.equal(res.body.state.status, 'abandoned'); 537 + assert.equal(res.body.restarted, true); 538 + assert.equal(res.body.state.progressLevel, 'check-in'); 539 + assert.equal(res.body.state.status, 'pending'); 369 540 }); 370 541 371 - // ---------- Charts ---------- 542 + test('POST /v1/matches/:id/restart rejects non-completed match', async () => { 543 + await createEvent('Test'); 544 + await loadActiveEvent(); 545 + const matchId = await createAndApproveMatch(); 372 546 373 - test('GET /v1/charts lists charts', async () => { 374 - // charts collection is empty, but the endpoint should work 375 547 const res = await request(app) 376 - .get('/v1/charts') 548 + .post(`/v1/matches/${matchId}/restart`) 549 + .set('Authorization', auth(refereeApiKey)); 550 + assert.equal(res.status, 409); 551 + }); 552 + 553 + // ---------- Player view ---------- 554 + 555 + test('GET /v1/matches/:id/player returns filtered match data', async () => { 556 + await createEvent('Test'); 557 + await loadActiveEvent(); 558 + const matchId = await createAndApproveMatch(); 559 + 560 + const res = await request(app) 561 + .get(`/v1/matches/${matchId}/player`) 377 562 .set('Authorization', auth(readApiKey)); 378 563 assert.equal(res.status, 200); 379 - assert.ok(Array.isArray(res.body)); 564 + assert.equal(res.body.matchId, matchId); 565 + assert.equal(res.body.players.length, 2); 566 + assert.ok(res.body.mappool.length > 0); 567 + // should not expose internal fields 568 + assert.equal(res.body.refereeDiscordId, undefined); 380 569 }); 381 570 382 - // ---------- Players ---------- 571 + // ---------- Pools ---------- 383 572 384 - test('GET /v1/players lists players', async () => { 573 + test('GET /v1/pools returns 404 when no pools generated', async () => { 385 574 const res = await request(app) 386 - .get('/v1/players') 575 + .get('/v1/pools') 387 576 .set('Authorization', auth(readApiKey)); 388 - assert.equal(res.status, 200); 389 - assert.ok(Array.isArray(res.body)); 577 + assert.equal(res.status, 404); 390 578 });