## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
61 lines
1.6 KiB
TypeScript
61 lines
1.6 KiB
TypeScript
interface StandingsEntry {
|
|
teamId: string;
|
|
teamName: string;
|
|
totalPoints: number;
|
|
rank: number | null;
|
|
}
|
|
|
|
interface SendStandingsUpdateOptions {
|
|
webhookUrl: string;
|
|
seasonName: string;
|
|
standings: StandingsEntry[];
|
|
previousStandings: Map<string, number> | Record<string, number>;
|
|
eventName?: string;
|
|
}
|
|
|
|
export async function sendStandingsUpdateNotification({
|
|
webhookUrl,
|
|
seasonName,
|
|
standings,
|
|
previousStandings,
|
|
eventName,
|
|
}: SendStandingsUpdateOptions): Promise<void> {
|
|
const title = eventName
|
|
? `📊 ${seasonName} — Standings after ${eventName}`
|
|
: `📊 ${seasonName} — Standings Update`;
|
|
|
|
const getPrev = (teamId: string) =>
|
|
previousStandings instanceof Map
|
|
? previousStandings.get(teamId)
|
|
: previousStandings[teamId];
|
|
|
|
const lines = standings
|
|
.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999))
|
|
.map((s) => {
|
|
const prev = getPrev(s.teamId);
|
|
const diff =
|
|
prev !== undefined ? s.totalPoints - prev : null;
|
|
const diffStr =
|
|
diff !== null && diff !== 0
|
|
? diff > 0
|
|
? ` (+${diff.toFixed(1)})`
|
|
: ` (${diff.toFixed(1)})`
|
|
: "";
|
|
const rankStr = s.rank != null ? `${s.rank}.` : "-";
|
|
return `${rankStr} **${s.teamName}** — ${s.totalPoints.toFixed(1)} pts${diffStr}`;
|
|
});
|
|
|
|
const content = [`**${title}**`, "", ...lines].join("\n");
|
|
|
|
const response = await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Discord webhook returned ${response.status}: ${await response.text()}`
|
|
);
|
|
}
|
|
}
|