server for refapp and refbot and other stuff
0

Configure Feed

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

Add roll-off flow (SSSO 2.1) replacing check-in for friendlies

- MatchState.roll + 'rolling' phase default for pending matches
- engine roll()/chooseFirstBan() with tie re-roll and winner-gated ban order
- serializePlayerView gains roll + server-computed turn (roll/roll-tie/choose-ban)
- new routes: /matches/:id/player/roll, /choose-first-ban + share equivalents
- HttpError status codes for wrong-phase/forbidden roll actions
- tests: check-in->rolling assertions, friendly roll-off coverage

alpine (Jul 19, 2026, 5:38 AM +0200) 105ecb37 de6b06c3

+266 -11
+96 -7
src/engine/match.ts
··· 98 98 approvedBy: string | null; 99 99 approvedAt: string | null; 100 100 }; 101 + // Roll-off phase that replaces check-in (SSSO 2.1): both players roll a d100, 102 + // the highest roller chooses to ban first or second. 103 + roll: { 104 + player1Roll: number | null; 105 + player2Roll: number | null; 106 + winnerSlot: 1 | 2 | null; 107 + }; 101 108 banPhase: { 102 109 banOrder: string[]; 103 110 currentBannerDiscordId: string | null; ··· 341 348 bansCompleted: 0, 342 349 totalBans: numBans, 343 350 }, 351 + roll: { 352 + player1Roll: null, 353 + player2Roll: null, 354 + winnerSlot: null, 355 + }, 344 356 currentPickerDiscordId: null, 345 357 currentChart: null, 346 358 refereeDiscordId: params.refereeDiscordId ?? null, ··· 356 368 players: state.players, 357 369 mappool: state.mappool, 358 370 checkIn: state.checkIn, 371 + roll: state.roll, 359 372 banPhase: state.banPhase, 360 373 currentPickerDiscordId: state.currentPickerDiscordId, 361 374 currentChart: state.currentChart, ··· 393 406 394 407 const state: MatchState = { 395 408 matchId: generateMatchId(), 396 - progressLevel: 'check-in', 409 + progressLevel: 'rolling', 397 410 meta: { 398 411 name: `${params.round} - Match ${params.matchNumber}`, 399 412 round: params.round, ··· 416 429 approvedBy: null, 417 430 approvedAt: null, 418 431 }, 432 + roll: { 433 + player1Roll: null, 434 + player2Roll: null, 435 + winnerSlot: null, 436 + }, 419 437 banPhase: { 420 438 banOrder: [], 421 439 currentBannerDiscordId: null, ··· 437 455 players: state.players, 438 456 mappool: state.mappool, 439 457 checkIn: state.checkIn, 458 + roll: state.roll, 440 459 banPhase: state.banPhase, 441 460 currentPickerDiscordId: state.currentPickerDiscordId, 442 461 currentChart: state.currentChart, ··· 532 551 state.checkIn.approvedAt = new Date().toISOString(); 533 552 state.refereeDiscordId = refereeDiscordId; 534 553 535 - // Set up ban phase 554 + startBanPhase(state, state.players[0]!.discordId); 555 + 556 + pushFeedEvent(matchId, 'match.approved', `Match approved by ${refereeDiscordId}`, { refereeDiscordId }); 557 + await saveMatchState(matchId); 558 + 559 + logger.info({ matchId, refereeDiscordId }, 'match approved, ban phase started'); 560 + return state; 561 + } 562 + 563 + // Begin the striking (ban) phase. `firstBannerDiscordId` is the player who 564 + // bans first (the roll-off winner's choice of first/second ban). 565 + export function startBanPhase(state: MatchState, firstBannerDiscordId: string): void { 536 566 const p1 = state.players[0]!; 537 567 const p2 = state.players[1]!; 538 568 const numBans = state.mappool.length - 1; 539 569 570 + const secondBanner = firstBannerDiscordId === p1.discordId ? p2 : p1; 540 571 const banOrder = Array.from({ length: numBans }, (_, i) => { 541 - return (i % 2 === 0 ? p1.discordId : p2.discordId); 572 + return (i % 2 === 0 ? firstBannerDiscordId : secondBanner.discordId); 542 573 }); 543 574 544 575 state.banPhase = { 545 576 banOrder, 546 - currentBannerDiscordId: p1.discordId, 577 + currentBannerDiscordId: firstBannerDiscordId, 547 578 bansCompleted: 0, 548 579 totalBans: numBans, 549 580 }; 550 581 551 582 state.progressLevel = 'ban-phase'; 552 583 state.status = 'in_progress'; 584 + } 585 + 586 + // Roll a d100 for the given player. When both players have rolled, the higher 587 + // roll wins (ties clear both rolls so they re-roll). Returns the current roll 588 + // snapshot and whether the roll-off is resolved. 589 + export async function roll(matchId: string, discordId: string): Promise<{ player1Roll: number | null; player2Roll: number | null; winnerSlot: 1 | 2 | null; resolved: boolean; tie: boolean }> { 590 + const state = matchStates.get(matchId); 591 + if (!state) throw new Error('Match not found'); 592 + if (state.status !== 'pending') throw new Error('Match is not in pending state'); 593 + if (state.progressLevel !== 'rolling') throw new HttpError(409, 'wrong_phase', 'Roll-off is not active'); 594 + 595 + const slot = state.players.findIndex(p => p.discordId === discordId); 596 + if (slot < 0) throw new HttpError(403, 'not_participant', 'Discord ID does not match any player in this match'); 553 597 554 - pushFeedEvent(matchId, 'match.approved', `Match approved by ${refereeDiscordId}`, { refereeDiscordId }); 598 + if (slot === 0) state.roll.player1Roll = Math.floor(Math.random() * 100) + 1; 599 + else state.roll.player2Roll = Math.floor(Math.random() * 100) + 1; 600 + 601 + const r1 = state.roll.player1Roll; 602 + const r2 = state.roll.player2Roll; 603 + 604 + if (r1 != null && r2 != null) { 605 + if (r1 === r2) { 606 + // Tie: clear both so both players re-roll. 607 + state.roll.player1Roll = null; 608 + state.roll.player2Roll = null; 609 + state.roll.winnerSlot = null; 610 + pushFeedEvent(matchId, 'roll', 'Roll-off tied, re-rolling', {}); 611 + await saveMatchState(matchId); 612 + return { player1Roll: null, player2Roll: null, winnerSlot: null, resolved: false, tie: true }; 613 + } 614 + state.roll.winnerSlot = r1 > r2 ? 1 : 2; 615 + pushFeedEvent(matchId, 'roll', `${state.players[state.roll.winnerSlot - 1]!.displayName} won the roll-off (${Math.max(r1, r2)} vs ${Math.min(r1, r2)})`, { winnerSlot: state.roll.winnerSlot }); 616 + await saveMatchState(matchId); 617 + return { player1Roll: r1, player2Roll: r2, winnerSlot: state.roll.winnerSlot, resolved: true, tie: false }; 618 + } 619 + 620 + pushFeedEvent(matchId, 'roll', `${state.players[slot]!.displayName} rolled`, { discordId }); 555 621 await saveMatchState(matchId); 622 + return { player1Roll: r1, player2Roll: r2, winnerSlot: null, resolved: false, tie: false }; 623 + } 556 624 557 - logger.info({ matchId, refereeDiscordId }, 'match approved, ban phase started'); 625 + // The roll-off winner chooses to ban first (true) or second (false). This sets 626 + // the first banner and begins the striking phase. 627 + export async function chooseFirstBan(matchId: string, discordId: string, banFirst: boolean): Promise<MatchState> { 628 + const state = matchStates.get(matchId); 629 + if (!state) throw new Error('Match not found'); 630 + if (state.status !== 'pending') throw new Error('Match is not in pending state'); 631 + if (!state.roll.winnerSlot) throw new HttpError(409, 'wrong_phase', 'Roll-off has not resolved yet'); 632 + if (state.players[state.roll.winnerSlot - 1]!.discordId !== discordId) { 633 + throw new HttpError(403, 'forbidden', 'Only the roll-off winner can choose the ban order'); 634 + } 635 + 636 + const winner = state.players[state.roll.winnerSlot - 1]!; 637 + const firstBannerDiscordId = banFirst ? winner.discordId : state.players[state.roll.winnerSlot === 1 ? 1 : 0]!.discordId; 638 + startBanPhase(state, firstBannerDiscordId); 639 + pushFeedEvent(matchId, 'ban-order', `${winner.displayName} bans ${banFirst ? 'first' : 'second'}`, { firstBannerDiscordId }); 640 + await saveMatchState(matchId); 558 641 return state; 559 642 } 560 643 ··· 580 663 players: state.players, 581 664 mappool: state.mappool, 582 665 checkIn: state.checkIn, 666 + roll: state.roll, 583 667 banPhase: state.banPhase, 584 668 currentPickerDiscordId: state.currentPickerDiscordId, 585 669 currentChart: state.currentChart, ··· 971 1055 player1CheckedIn: doc.checkIn?.player1CheckedIn ?? false, 972 1056 player2CheckedIn: doc.checkIn?.player2CheckedIn ?? false, 973 1057 approvedBy: doc.checkIn?.approvedBy ?? null, 974 - approvedAt: doc.checkIn?.approvedAt?.toISOString() ?? null, 1058 + approvedAt: doc.checkIn?.approvedAt?.toISOString?.() ?? null, 1059 + }, 1060 + roll: { 1061 + player1Roll: doc.roll?.player1Roll ?? null, 1062 + player2Roll: doc.roll?.player2Roll ?? null, 1063 + winnerSlot: doc.roll?.winnerSlot ?? null, 975 1064 }, 976 1065 banPhase: { 977 1066 banOrder: doc.banPhase?.banOrder ?? [],
+15 -1
src/models/Match.ts
··· 82 82 totalBans?: number; 83 83 } 84 84 85 + export interface RollDoc { 86 + player1Roll?: number | null; 87 + player2Roll?: number | null; 88 + winnerSlot?: 1 | 2 | null; 89 + } 90 + 85 91 export interface FeedEventDoc { 86 92 type: string; 87 93 timestamp?: Date; ··· 110 116 players?: MatchPlayerDoc[]; 111 117 mappool?: MappoolEntryDoc[]; 112 118 checkIn?: CheckInDoc; 119 + roll?: RollDoc; 113 120 banPhase?: BanPhaseDoc; 114 121 currentPickerDiscordId?: string | null; 115 122 currentChart?: number | null; ··· 208 215 totalBans: { type: Number, default: 0 }, 209 216 }, { _id: false }); 210 217 218 + const rollSchema = new Schema<RollDoc>({ 219 + player1Roll: { type: Number, default: null }, 220 + player2Roll: { type: Number, default: null }, 221 + winnerSlot: { type: Number, default: null }, 222 + }, { _id: false }); 223 + 211 224 const matchSchema = new Schema<MatchDoc>({ 212 225 matchId: { type: String, index: true, default: null }, 213 226 progressLevel: { 214 227 type: String, 215 - enum: ['check-in', 'ban-phase', 'ready-check', 'playing', 'picking-post-result', 'finished'], 228 + enum: ['check-in', 'rolling', 'ban-phase', 'ready-check', 'playing', 'picking-post-result', 'finished'], 216 229 default: 'check-in', 217 230 }, 218 231 meta: { ··· 232 245 players: { type: [playerSchema], default: [] }, 233 246 mappool: { type: [mappoolEntrySchema], default: [] }, 234 247 checkIn: { type: checkInSchema, default: () => ({}) }, 248 + roll: { type: rollSchema, default: () => ({}) }, 235 249 banPhase: { type: banPhaseSchema, default: () => ({}) }, 236 250 currentPickerDiscordId: { type: String, default: null }, 237 251 currentChart: { type: Number, default: null },
+56 -1
src/routes/friendly.test.ts
··· 98 98 assert.equal(res.status, 201); 99 99 assert.equal(res.body.meta.type, 'friendly'); 100 100 assert.equal(res.body.meta.eventId, null); 101 - assert.equal(res.body.progressLevel, 'check-in'); 101 + assert.equal(res.body.progressLevel, 'rolling'); 102 102 }); 103 103 104 104 test('POST /v1/matches with type tournament and no active event fails', async () => { ··· 577 577 .post(`/v1/matches/${created.body.matchId}/claim`) 578 578 .set('Authorization', bearer('someone')); 579 579 assert.equal(claim.status, 403); 580 + 581 + // Both players roll, re-rolling on a tie, until the roll-off resolves. 582 + async function rollToResolution(token: string): Promise<number> { 583 + let winnerSlot: number | null = null; 584 + for (let i = 0; i < 20 && winnerSlot == null; i++) { 585 + await request(app).post(`/v1/matches/share/${token}/roll?slot=1`); 586 + const rb = await request(app).post(`/v1/matches/share/${token}/roll?slot=2`); 587 + winnerSlot = rb.body.roll?.winnerSlot ?? null; 588 + } 589 + return winnerSlot!; 590 + } 591 + 592 + test('friendly roll-off replaces check-in and sets first banner', async () => { 593 + const { shareToken } = await createFriendlyWithPlaceholderP2(); 594 + const matchId = (await request(app).get(`/v1/matches/share/${shareToken}?slot=1`)).body.matchId; 595 + // Creator is slot 1; claim the open seat for the opponent (slot 2). 596 + await request(app).post(`/v1/matches/${matchId}/claim`).set('Authorization', bearer('opponent')); 597 + assert.equal((await request(app).get(`/v1/matches/share/${shareToken}?slot=1`)).body.progressLevel, 'rolling'); 598 + 599 + const winnerSlot = await rollToResolution(shareToken); 600 + assert.ok(winnerSlot === 1 || winnerSlot === 2, 'roll-off must resolve to a winner'); 601 + 602 + // Winner chooses to ban first. 603 + const chosen = await request(app) 604 + .post(`/v1/matches/share/${shareToken}/choose-first-ban?slot=${winnerSlot}`) 605 + .send({ banFirst: true }); 606 + assert.equal(chosen.status, 200, JSON.stringify(chosen.body)); 607 + assert.equal(chosen.body.progressLevel, 'ban-phase'); 608 + const winnerId = chosen.body.players[winnerSlot - 1].discordId; 609 + assert.equal(chosen.body.banPhase.currentBannerDiscordId, winnerId, 'winner who chose first must ban first'); 610 + 611 + // Winner choosing to ban second puts the other player first. 612 + const { shareToken: t2 } = await createFriendlyWithPlaceholderP2(); 613 + const id2 = (await request(app).get(`/v1/matches/share/${t2}?slot=1`)).body.matchId; 614 + await request(app).post(`/v1/matches/${id2}/claim`).set('Authorization', bearer('opponent')); 615 + const w2 = await rollToResolution(t2); 616 + const chosen2 = await request(app) 617 + .post(`/v1/matches/share/${t2}/choose-first-ban?slot=${w2}`) 618 + .send({ banFirst: false }); 619 + assert.equal(chosen2.status, 200); 620 + const loserId = chosen2.body.players[w2 === 1 ? 1 : 0].discordId; 621 + assert.equal(chosen2.body.banPhase.currentBannerDiscordId, loserId, 'winner who chose second yields first ban to opponent'); 622 + }); 623 + 624 + test('friendly roll-off: non-winner cannot choose ban order', async () => { 625 + const { shareToken } = await createFriendlyWithPlaceholderP2(); 626 + const matchId = (await request(app).get(`/v1/matches/share/${shareToken}?slot=1`)).body.matchId; 627 + await request(app).post(`/v1/matches/${matchId}/claim`).set('Authorization', bearer('opponent')); 628 + const winnerSlot = await rollToResolution(shareToken); 629 + const loserSlot = winnerSlot === 1 ? 2 : 1; 630 + const bad = await request(app) 631 + .post(`/v1/matches/share/${shareToken}/choose-first-ban?slot=${loserSlot}`) 632 + .send({ banFirst: true }); 633 + assert.equal(bad.status, 403, 'only the roll-off winner may choose the ban order'); 634 + }); 580 635 });
+97
src/routes/matches.ts
··· 9 9 claimSeat, 10 10 approveMatch, 11 11 maybeAutoApprove, 12 + roll, 13 + chooseFirstBan, 14 + startBanPhase, 12 15 getMatchState, 13 16 banChart, 14 17 pickChart, ··· 90 93 player2CheckedIn: state.checkIn?.player2CheckedIn ?? false, 91 94 approved: !!state.checkIn?.approvedBy, 92 95 }, 96 + roll: { 97 + player1Roll: state.roll?.player1Roll ?? null, 98 + player2Roll: state.roll?.player2Roll ?? null, 99 + winnerSlot: state.roll?.winnerSlot ?? null, 100 + }, 93 101 mappool: state.mappool.map(m => ({ 94 102 songId: m.songId, 95 103 displayName: m.displayName, ··· 133 141 currentPickerDiscordId: state.currentPickerDiscordId, 134 142 currentChart: state.currentChart, 135 143 feed: state.feed, 144 + turn: computeTurn(state), 136 145 }; 146 + } 147 + 148 + // Server-owned "whose turn / what action" hint so the client renders without 149 + // re-deriving match state. kind drives the UI; actorSlot is the player who acts. 150 + function computeTurn(state: MatchState): { kind: string; actorSlot: number | null } { 151 + if (state.progressLevel === 'rolling') { 152 + if (!state.roll.winnerSlot) { 153 + const rolled = state.roll.player1Roll != null && state.roll.player2Roll != null; 154 + if (rolled) return { kind: 'roll-tie', actorSlot: null }; 155 + if (state.roll.player1Roll == null) return { kind: 'roll', actorSlot: 1 }; 156 + return { kind: 'roll', actorSlot: 2 }; 157 + } 158 + return { kind: 'choose-ban', actorSlot: state.roll.winnerSlot }; 159 + } 160 + if (state.progressLevel === 'ban-phase') { 161 + return { kind: 'ban', actorSlot: state.banPhase.currentBannerDiscordId 162 + ? (state.players.findIndex(p => p.discordId === state.banPhase.currentBannerDiscordId) + 1) || null 163 + : null }; 164 + } 165 + if (state.progressLevel === 'picking-post-result') { 166 + return { kind: 'pick', actorSlot: state.currentPickerDiscordId 167 + ? (state.players.findIndex(p => p.discordId === state.currentPickerDiscordId) + 1) || null 168 + : null }; 169 + } 170 + return { kind: state.progressLevel, actorSlot: null }; 137 171 } 138 172 139 173 matchesRouter.get('/matches/share/:token', async (req, res, next) => { ··· 159 193 await maybeAutoApprove(state.matchId!); 160 194 audit('check-in', req, state.matchId!, { discordId, via: 'share' }); 161 195 res.json({ ...serializePlayerView(getMatchState(state.matchId!)!) }); 196 + } 197 + catch (err) { 198 + next(err); 199 + } 200 + }); 201 + 202 + // Share-link roll-off (replaces check-in for friendlies). 203 + matchesRouter.post('/matches/share/:token/roll', async (req, res, next) => { 204 + try { 205 + const token = param(req, 'token'); 206 + const slot = parseSlot(req.query.slot ?? req.body?.slot); 207 + const discordId = shareDiscordId(token, slot); 208 + const state = assertShareMatch(token, slot); 209 + if (state.status !== 'pending') throw new HttpError(409, 'wrong_status', 'match is not awaiting roll-off'); 210 + const result = await roll(state.matchId!, discordId); 211 + audit('roll', req, state.matchId!, { discordId, via: 'share' }); 212 + res.json(serializePlayerView(getMatchState(state.matchId!)!)); 213 + } 214 + catch (err) { 215 + next(err); 216 + } 217 + }); 218 + 219 + // Share-link: the roll-off winner chooses to ban first or second. 220 + matchesRouter.post('/matches/share/:token/choose-first-ban', async (req, res, next) => { 221 + try { 222 + const token = param(req, 'token'); 223 + const slot = parseSlot(req.query.slot ?? req.body?.slot); 224 + const discordId = shareDiscordId(token, slot); 225 + const state = assertShareMatch(token, slot); 226 + const banFirst = !!req.body?.banFirst; 227 + const updated = await chooseFirstBan(state.matchId!, discordId, banFirst); 228 + audit('ban-order', req, state.matchId!, { discordId, banFirst, via: 'share' }); 229 + res.json(serializePlayerView(updated)); 162 230 } 163 231 catch (err) { 164 232 next(err); ··· 721 789 const allReady = await setPlayerReady(matchId, req.actor!.discordId!); 722 790 audit('ready', req, matchId, { discordId: req.actor!.discordId!, allReady }); 723 791 res.json({ ready: true, allReady, state: getMatchState(matchId) }); 792 + } 793 + catch (err) { 794 + next(err); 795 + } 796 + }); 797 + 798 + // Roll-off: replaces check-in. Each player rolls a d100; the higher roller 799 + // then chooses to ban first or second via /player/choose-first-ban. 800 + matchesRouter.post('/matches/:id/player/roll', requireAuth('read'), async (req, res, next) => { 801 + try { 802 + const matchId = param(req, 'id'); 803 + const state = assertParticipant(matchId, req.actor); 804 + const result = await roll(matchId, req.actor!.discordId!); 805 + audit('roll', req, matchId, { discordId: req.actor!.discordId!, via: 'player' }); 806 + res.json(serializePlayerView(getMatchState(matchId)!)); 807 + } 808 + catch (err) { 809 + next(err); 810 + } 811 + }); 812 + 813 + matchesRouter.post('/matches/:id/player/choose-first-ban', requireAuth('read'), async (req, res, next) => { 814 + try { 815 + const matchId = param(req, 'id'); 816 + const state = assertParticipant(matchId, req.actor); 817 + const banFirst = !!req.body?.banFirst; 818 + const updated = await chooseFirstBan(matchId, req.actor!.discordId!, banFirst); 819 + audit('ban-order', req, matchId, { discordId: req.actor!.discordId!, banFirst, via: 'player' }); 820 + res.json(serializePlayerView(updated)); 724 821 } 725 822 catch (err) { 726 823 next(err);
+2 -2
src/routes/routes.test.ts
··· 59 59 mapPool: pool, 60 60 }); 61 61 assert.equal(created.status, 201); 62 - assert.equal(created.body.progressLevel, 'check-in'); 62 + assert.equal(created.body.progressLevel, 'rolling'); 63 63 const matchId = created.body.matchId; 64 64 65 65 // check in both players ··· 249 249 ], 250 250 }); 251 251 assert.equal(res.status, 201); 252 - assert.equal(res.body.progressLevel, 'check-in'); 252 + assert.equal(res.body.progressLevel, 'rolling'); 253 253 assert.equal(res.body.status, 'pending'); 254 254 assert.equal(res.body.players.length, 2); 255 255 assert.equal(res.body.mappool.length, 5);