2025-10-28 23:50:50 -07:00
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
import { eq, and, inArray } from "drizzle-orm";
|
2026-03-21 09:44:05 -07:00
|
|
|
|
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
|
2025-11-08 22:35:07 -08:00
|
|
|
|
import { getSeasonResults } from "./participant-season-result";
|
2025-11-21 22:05:50 -08:00
|
|
|
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
2026-03-17 11:16:36 -07:00
|
|
|
|
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
2026-03-17 12:34:10 -07:00
|
|
|
|
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
2026-04-15 09:40:53 -07:00
|
|
|
|
import { doesLoserAdvance } from "~/models/playoff-match";
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
|
import { getUserDisplayName } from "~/models/user";
|
2026-03-18 22:15:28 -07:00
|
|
|
|
import { createDailySnapshot } from "~/models/standings";
|
2026-03-21 13:41:39 -07:00
|
|
|
|
import { logger } from "~/lib/logger";
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Core scoring calculation engine
|
|
|
|
|
|
* This file contains the logic for calculating fantasy points based on participant results
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export type ScoringPattern =
|
2026-03-07 21:59:29 -08:00
|
|
|
|
| "playoff_bracket"
|
2025-10-28 23:50:50 -07:00
|
|
|
|
| "season_standings"
|
|
|
|
|
|
| "qualifying_points";
|
|
|
|
|
|
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
// ── Round scoring configuration ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface RoundScoringConfig {
|
|
|
|
|
|
/** Final position assigned to the loser of this round. */
|
|
|
|
|
|
loserPosition: number;
|
|
|
|
|
|
/** true = loser is still competing (AFL double-chance); false = loser is eliminated. */
|
|
|
|
|
|
loserIsPartial: boolean;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Guaranteed minimum position for the winner.
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
* null signals a finalization round: both winner and loser receive their exact positions.
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
*/
|
|
|
|
|
|
winnerFloor: number | null;
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* When winnerFloor is null, the winner is finalized at this position.
|
|
|
|
|
|
* Defaults to 1 (champion) when omitted — set to 3 for a 3rd place game.
|
|
|
|
|
|
*/
|
|
|
|
|
|
winnerPosition?: number;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Standard round name → scoring config mapping.
|
|
|
|
|
|
* Covers all common bracket formats (NCAA, NBA, NFL, Darts, Snooker, AFL).
|
|
|
|
|
|
*/
|
|
|
|
|
|
const ROUND_CONFIG: Record<string, RoundScoringConfig> = {
|
|
|
|
|
|
// ── Finals equivalents ─────────────────────────────────────────────────────
|
|
|
|
|
|
Finals: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
|
|
|
|
|
Championship: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
|
|
|
|
|
"Super Bowl": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
|
|
|
|
|
"NBA Finals": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
|
|
|
|
|
"Grand Final": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
|
|
|
|
|
// ── Semi-final equivalents ─────────────────────────────────────────────────
|
|
|
|
|
|
Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
|
|
|
|
|
"Final Four": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
|
|
|
|
|
"Conference Finals": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
|
|
|
|
|
"Conference Championship": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
|
|
|
|
|
"Preliminary Finals": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
|
|
|
|
|
// ── Quarter-final equivalents ──────────────────────────────────────────────
|
|
|
|
|
|
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
|
|
|
|
|
"Elite Eight": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
|
|
|
|
|
Divisional: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
|
|
|
|
|
"Conference Semifinals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
|
|
|
|
|
// ── AFL-specific rounds ────────────────────────────────────────────────────
|
|
|
|
|
|
// Qualifying Finals losers get a second chance via Semi-Finals (double-chance).
|
|
|
|
|
|
"Qualifying Finals": { loserPosition: 5, loserIsPartial: true, winnerFloor: 3 },
|
|
|
|
|
|
// Elimination Finals losers are permanently out (7th–8th).
|
|
|
|
|
|
"Elimination Finals": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Per-template round config overrides.
|
|
|
|
|
|
* Some round names mean different things in different bracket formats.
|
|
|
|
|
|
*
|
|
|
|
|
|
* IMPORTANT: Template overrides only apply when bracketTemplateId matches exactly.
|
|
|
|
|
|
* For example, "Semi-Finals" in afl_10 eliminates the loser (5th–6th), while a
|
|
|
|
|
|
* generic "Semi-Finals" round (without afl_10 template) is not in ROUND_CONFIG at
|
|
|
|
|
|
* all — use "Semifinals" (no hyphen) for standard brackets.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>> = {
|
|
|
|
|
|
afl_10: {
|
|
|
|
|
|
"Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
|
|
|
|
|
},
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
fifa_48: {
|
|
|
|
|
|
// QF winner is guaranteed 4th at worst (lose SF → lose 3PG).
|
|
|
|
|
|
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
|
|
|
|
|
|
// SF loser still has the 3rd place game — provisional 4th until it's played.
|
|
|
|
|
|
Semifinals: { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
|
|
|
|
|
|
// 3rd place game finalizes both positions distinctly.
|
|
|
|
|
|
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
|
|
|
|
|
},
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-17 12:34:10 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Returns true if a non-scoring round's winners are entering the first scoring round
|
|
|
|
|
|
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
|
|
|
|
|
*
|
|
|
|
|
|
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
|
|
|
|
|
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
|
|
|
|
|
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function doesNonScoringRoundFeedIntoScoringRound(
|
|
|
|
|
|
round: string,
|
|
|
|
|
|
bracketTemplateId: string | null | undefined
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
|
|
|
|
|
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
|
|
|
|
|
if (!template) return true; // Unknown template: preserve old behavior
|
|
|
|
|
|
const currentRound = template.rounds.find((r) => r.name === round);
|
|
|
|
|
|
if (!currentRound) return true; // Unknown round: preserve old behavior
|
|
|
|
|
|
const nextRoundName = currentRound.feedsInto;
|
|
|
|
|
|
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
|
|
|
|
|
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
|
|
|
|
|
return nextRound?.isScoring === true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Look up the scoring config for a given round name, applying any template-specific
|
|
|
|
|
|
* overrides before falling back to the standard ROUND_CONFIG.
|
|
|
|
|
|
* Returns null for unrecognized rounds.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getRoundConfig(
|
|
|
|
|
|
round: string,
|
|
|
|
|
|
bracketTemplateId?: string | null
|
|
|
|
|
|
): RoundScoringConfig | null {
|
|
|
|
|
|
if (bracketTemplateId && TEMPLATE_ROUND_CONFIG[bracketTemplateId]?.[round]) {
|
|
|
|
|
|
return TEMPLATE_ROUND_CONFIG[bracketTemplateId][round];
|
|
|
|
|
|
}
|
|
|
|
|
|
return ROUND_CONFIG[round] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
/**
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Process a playoff event completion and assign final placements.
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
*
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Loser placement rules per round type:
|
|
|
|
|
|
* Non-scoring rounds: losers get 0 pts (pre-bracket elimination)
|
|
|
|
|
|
* Quarterfinals / equivalent: losers share T5–T8
|
|
|
|
|
|
* Semifinals / equivalent: losers share T3–T4
|
|
|
|
|
|
* Finals / equivalent: loser gets 2nd, winner gets 1st
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
*
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Progressive floor scoring: winners immediately earn a provisional placement
|
|
|
|
|
|
* reflecting their worst-case finish from this point forward.
|
2025-10-28 23:50:50 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function processPlayoffEvent(
|
|
|
|
|
|
eventId: string,
|
2026-03-17 12:34:10 -07:00
|
|
|
|
providedDb?: ReturnType<typeof database>,
|
|
|
|
|
|
options?: { skipRecalculate?: boolean }
|
2025-10-28 23:50:50 -07:00
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
// Get the event
|
|
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
|
|
|
|
where: eq(schema.scoringEvents.id, eventId),
|
|
|
|
|
|
with: {
|
|
|
|
|
|
sportsSeason: {
|
|
|
|
|
|
with: {
|
|
|
|
|
|
seasonSports: {
|
|
|
|
|
|
with: {
|
|
|
|
|
|
season: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
if (!event) {
|
|
|
|
|
|
throw new Error(`Event ${eventId} not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!event.playoffRound) {
|
|
|
|
|
|
throw new Error(`Event ${eventId} is not a playoff event`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
// Get all matches for this event in the specified round
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
const matches = await db.query.playoffMatches.findMany({
|
2025-11-04 22:09:44 -08:00
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
|
eq(schema.playoffMatches.round, event.playoffRound)
|
|
|
|
|
|
),
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Process based on round
|
|
|
|
|
|
const round = event.playoffRound;
|
|
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
// Check if this is a scoring round
|
|
|
|
|
|
// First try to get isScoring from the match (template-based)
|
|
|
|
|
|
const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets
|
|
|
|
|
|
|
2025-11-21 22:05:50 -08:00
|
|
|
|
// PHASE 5.3: Handle teams that didn't make the playoffs
|
|
|
|
|
|
// Get all participants in the sports season
|
|
|
|
|
|
const allParticipants = await db.query.participants.findMany({
|
|
|
|
|
|
where: eq(schema.participants.sportsSeasonId, event.sportsSeasonId),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Get all participant IDs that are in ANY playoff match (winners or losers)
|
|
|
|
|
|
const allMatches = await db.query.playoffMatches.findMany({
|
|
|
|
|
|
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const participantsInBracket = new Set<string>();
|
|
|
|
|
|
for (const match of allMatches) {
|
|
|
|
|
|
if (match.winnerId) participantsInBracket.add(match.winnerId);
|
|
|
|
|
|
if (match.loserId) participantsInBracket.add(match.loserId);
|
|
|
|
|
|
if (match.participant1Id) participantsInBracket.add(match.participant1Id);
|
|
|
|
|
|
if (match.participant2Id) participantsInBracket.add(match.participant2Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Mark participants NOT in the bracket as eliminated (didn't make playoffs)
|
|
|
|
|
|
for (const participant of allParticipants) {
|
|
|
|
|
|
if (!participantsInBracket.has(participant.id)) {
|
|
|
|
|
|
// Check if they already have a result (don't overwrite)
|
|
|
|
|
|
const existingResult = await db.query.participantResults.findFirst({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.participantResults.participantId, participant.id),
|
|
|
|
|
|
eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!existingResult) {
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
participant.id,
|
|
|
|
|
|
event.sportsSeasonId,
|
|
|
|
|
|
0, // 0 = didn't make playoffs, eliminated
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
// Get season scoring rules for the first affected season
|
|
|
|
|
|
// (all seasons should have their own rules, but we use the event's sports season to find one)
|
|
|
|
|
|
const seasonSport = event.sportsSeason.seasonSports[0];
|
|
|
|
|
|
if (!seasonSport) {
|
|
|
|
|
|
throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const scoringRules = await getScoringRules(seasonSport.seasonId, db);
|
|
|
|
|
|
if (!scoringRules) {
|
|
|
|
|
|
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
if (!isScoring) {
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
2026-03-17 12:34:10 -07:00
|
|
|
|
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
|
|
|
|
|
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
|
|
|
|
|
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
|
|
|
|
|
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
|
|
|
|
|
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
2025-11-04 22:09:44 -08:00
|
|
|
|
for (const match of matches) {
|
2026-04-15 09:40:53 -07:00
|
|
|
|
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
|
|
|
|
|
if (match.loserId && !loserAdvances) {
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
2025-11-04 22:09:44 -08:00
|
|
|
|
}
|
2026-03-17 12:34:10 -07:00
|
|
|
|
if (match.winnerId && awardFloor) {
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
2026-03-10 10:27:58 -07:00
|
|
|
|
}
|
2025-11-04 22:09:44 -08:00
|
|
|
|
}
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
const config = getRoundConfig(round, event.bracketTemplateId);
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
if (config === null) {
|
|
|
|
|
|
// Unrecognized scoring round: losers earn 0 pts (outside top-8).
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.warn(
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
`[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const match of matches) {
|
|
|
|
|
|
if (match.loserId) {
|
|
|
|
|
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
|
|
|
|
|
}
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
}
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
} else if (config.winnerFloor === null) {
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
// Finalization round: both participants receive their exact positions.
|
|
|
|
|
|
// winnerPosition defaults to 1 (champion); set to 3 for a 3rd place game.
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
const finalMatch = matches[0];
|
|
|
|
|
|
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
|
|
|
|
|
|
throw new Error("Finals match is not complete");
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
}
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, config.winnerPosition ?? 1, db);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// All other scoring rounds: assign loser's final (or provisional) position.
|
|
|
|
|
|
for (const match of matches) {
|
|
|
|
|
|
if (match.loserId) {
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
match.loserId,
|
|
|
|
|
|
event.sportsSeasonId,
|
|
|
|
|
|
config.loserPosition,
|
|
|
|
|
|
db,
|
|
|
|
|
|
config.loserIsPartial
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-10 10:27:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
|
|
|
|
|
// winner is already finalized as 1st above. For non-scoring rounds it also
|
|
|
|
|
|
// returns null (winners were given floor 5 inline above).
|
2026-03-10 10:27:58 -07:00
|
|
|
|
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
|
|
|
|
|
round,
|
|
|
|
|
|
event.bracketTemplateId,
|
|
|
|
|
|
isScoring
|
|
|
|
|
|
);
|
|
|
|
|
|
if (guaranteedMinimum !== null) {
|
|
|
|
|
|
for (const match of matches) {
|
|
|
|
|
|
if (match.winnerId) {
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
match.winnerId,
|
|
|
|
|
|
event.sportsSeasonId,
|
|
|
|
|
|
guaranteedMinimum,
|
|
|
|
|
|
db,
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
true // isPartialScore: still competing, floor will rise as they advance
|
2026-03-10 10:27:58 -07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 12:34:10 -07:00
|
|
|
|
// Recalculate standings for all affected leagues (skipped when caller handles it)
|
|
|
|
|
|
if (!options?.skipRecalculate) {
|
|
|
|
|
|
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId });
|
|
|
|
|
|
}
|
2025-11-21 22:05:50 -08:00
|
|
|
|
|
2026-03-10 10:27:58 -07:00
|
|
|
|
// Auto-trigger probability recalculation after result
|
2025-11-21 22:05:50 -08:00
|
|
|
|
try {
|
|
|
|
|
|
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-11-21 22:05:50 -08:00
|
|
|
|
`[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}`
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error(
|
2025-11-21 22:05:50 -08:00
|
|
|
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`,
|
|
|
|
|
|
error
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Process a single completed match and immediately award points.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Called when a match winner is set, before the full round is complete.
|
|
|
|
|
|
* Loser placement is final (isPartialScore=false); winner placement is
|
|
|
|
|
|
* provisional (isPartialScore=true) reflecting their guaranteed minimum finish.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param skipSideEffects - When true, skips standings recalc and probability update.
|
|
|
|
|
|
* Use this when processing multiple matches in a batch (call side effects once after
|
|
|
|
|
|
* the loop instead of once per match).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function processMatchResult(
|
|
|
|
|
|
params: {
|
|
|
|
|
|
round: string;
|
|
|
|
|
|
winnerId: string;
|
|
|
|
|
|
loserId: string;
|
|
|
|
|
|
/** isScoring ?? true: default to true for legacy rows that pre-date the isScoring column. */
|
|
|
|
|
|
isScoring: boolean;
|
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
|
bracketTemplateId?: string | null;
|
2026-03-17 11:16:36 -07:00
|
|
|
|
eventId?: string;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
eventName?: string;
|
2026-03-17 12:34:10 -07:00
|
|
|
|
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
|
|
|
|
|
matchId?: string;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
skipSideEffects?: boolean;
|
2026-04-14 22:16:57 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* When true, the loser of this non-scoring round advances to another match
|
|
|
|
|
|
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
|
|
|
|
|
* marked as eliminated yet. Has no effect when isScoring=true.
|
|
|
|
|
|
*/
|
|
|
|
|
|
loserAdvances?: boolean;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
},
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
2026-04-14 22:16:57 -07:00
|
|
|
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
|
|
|
|
|
|
if (!isScoring) {
|
2026-04-14 22:16:57 -07:00
|
|
|
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
|
|
|
|
|
// unless loserAdvances=true (e.g. NBA Play-In 7v8 loser goes to Round 2).
|
2026-03-17 12:34:10 -07:00
|
|
|
|
// Winner only banks provisional T5–T8 floor if they're entering the first
|
|
|
|
|
|
// scoring round (guaranteed top-8). For multi-round pre-bracket sequences
|
|
|
|
|
|
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners
|
|
|
|
|
|
// should receive floor points.
|
2026-04-14 22:16:57 -07:00
|
|
|
|
if (!loserAdvances) {
|
|
|
|
|
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
|
|
|
|
|
}
|
2026-03-17 12:34:10 -07:00
|
|
|
|
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
|
|
|
|
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
|
|
|
|
|
}
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
const config = getRoundConfig(round, bracketTemplateId);
|
|
|
|
|
|
|
|
|
|
|
|
if (config === null) {
|
|
|
|
|
|
// Unrecognized scoring round: loser earns 0 pts, winner gets fallback T5–T8 floor.
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.warn(
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
`[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
|
|
|
|
|
|
);
|
|
|
|
|
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
|
|
|
|
|
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
|
|
|
|
|
} else if (config.winnerFloor === null) {
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
// Finalization round: winner and loser both get their exact positions.
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerPosition ?? 1, db);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
// All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
|
|
|
|
|
|
await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
|
|
|
|
|
|
await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!skipSideEffects) {
|
2026-03-17 12:34:10 -07:00
|
|
|
|
const sideEffectOptions = (eventName || eventId)
|
|
|
|
|
|
? { eventName, eventId, matchIds: matchId ? [matchId] : undefined }
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
try {
|
|
|
|
|
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error(
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
|
|
|
|
|
error
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Helper to upsert participant result
|
2026-03-10 10:27:58 -07:00
|
|
|
|
*
|
|
|
|
|
|
* isPartialScore=true means the participant is still alive and this placement
|
|
|
|
|
|
* is their guaranteed minimum floor — it will be replaced as they advance or
|
|
|
|
|
|
* when they are finally eliminated.
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
*/
|
|
|
|
|
|
async function upsertParticipantResult(
|
|
|
|
|
|
participantId: string,
|
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
|
finalPosition: number,
|
2026-03-10 10:27:58 -07:00
|
|
|
|
db: ReturnType<typeof database>,
|
|
|
|
|
|
isPartialScore = false
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const existing = await db.query.participantResults.findFirst({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.participantResults.participantId, participantId),
|
|
|
|
|
|
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
2026-03-10 10:27:58 -07:00
|
|
|
|
// Never un-finalize: if the row is already finalized (isPartialScore=false),
|
|
|
|
|
|
// a call with isPartialScore=true must not overwrite it (e.g. a re-run of
|
|
|
|
|
|
// processPlayoffEvent after a manual finalization).
|
|
|
|
|
|
if (!existing.isPartialScore && isPartialScore) return;
|
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
await db
|
|
|
|
|
|
.update(schema.participantResults)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
finalPosition,
|
2026-03-10 10:27:58 -07:00
|
|
|
|
isPartialScore,
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.participantResults.id, existing.id));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await db.insert(schema.participantResults).values({
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
finalPosition,
|
2026-03-10 10:27:58 -07:00
|
|
|
|
isPartialScore,
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Returns the guaranteed minimum final position for winners of a given round.
|
2025-11-11 10:08:25 -08:00
|
|
|
|
*
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* This is the worst-case placement a participant can achieve if they lose
|
|
|
|
|
|
* every remaining match from this point forward. Returns null when:
|
|
|
|
|
|
* - The round is non-scoring (winners haven't reached a points-paying zone)
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
* - The round is the Finals (winner already finalized as 1st place inline)
|
2026-03-10 10:27:58 -07:00
|
|
|
|
*/
|
2026-04-14 22:16:57 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Returns true if a loser should appear in a Discord standings notification.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A loser is shown when:
|
|
|
|
|
|
* - Their team's score changed this round (e.g. they received a floor or position), OR
|
|
|
|
|
|
* - They were definitively eliminated (non-partial result exists), which covers
|
|
|
|
|
|
* 0-pt eliminations that produce no score delta.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Losers who advance to another match (loserAdvances=true) have neither a score
|
|
|
|
|
|
* change nor a finalized result, so they are correctly suppressed.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function isLoserNotifiable(
|
|
|
|
|
|
loserId: string | null | undefined,
|
|
|
|
|
|
loserTeamId: string | undefined,
|
|
|
|
|
|
changedTeamIds: Set<string>,
|
|
|
|
|
|
finalizedLoserIds: Set<string>
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
if (!loserId) return false;
|
|
|
|
|
|
const scoreChanged = loserTeamId ? changedTeamIds.has(loserTeamId) : false;
|
|
|
|
|
|
return scoreChanged || finalizedLoserIds.has(loserId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 10:27:58 -07:00
|
|
|
|
export function getGuaranteedMinimumPosition(
|
|
|
|
|
|
round: string,
|
|
|
|
|
|
bracketTemplateId: string | null | undefined,
|
|
|
|
|
|
isScoring: boolean
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
if (!isScoring) return null;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
const config = getRoundConfig(round, bracketTemplateId);
|
|
|
|
|
|
if (config === null) return 5; // Unknown scoring round: fallback to T5–T8 floor
|
|
|
|
|
|
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
2026-03-10 10:27:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Process a qualifying event completion and update QP totals.
|
|
|
|
|
|
* Ties in QP are handled by sharing placements (averaged points).
|
2025-10-28 23:50:50 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function processQualifyingEvent(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
|
// Import qualifying points functions
|
|
|
|
|
|
const { getEventResults } = await import("./event-result");
|
|
|
|
|
|
const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points");
|
|
|
|
|
|
|
|
|
|
|
|
// Get the event
|
|
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
|
|
|
|
where: eq(schema.scoringEvents.id, eventId),
|
|
|
|
|
|
with: {
|
|
|
|
|
|
sportsSeason: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!event) {
|
|
|
|
|
|
throw new Error(`Event ${eventId} not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!event.isQualifyingEvent) {
|
|
|
|
|
|
throw new Error(`Event ${eventId} is not a qualifying event`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get all event results for this qualifying event
|
|
|
|
|
|
const results = await getEventResults(eventId, db);
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this was already processed (for majorsCompleted counter)
|
|
|
|
|
|
const wasAlreadyProcessed = results.some(r => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0);
|
|
|
|
|
|
|
|
|
|
|
|
// Group results by placement to handle ties
|
|
|
|
|
|
const placementGroups = new Map<number, typeof results>();
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
|
if (result.placement) {
|
|
|
|
|
|
const group = placementGroups.get(result.placement) || [];
|
|
|
|
|
|
group.push(result);
|
|
|
|
|
|
placementGroups.set(result.placement, group);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Process each placement group and update event_results with QP awarded
|
|
|
|
|
|
for (const [placement, group] of placementGroups) {
|
|
|
|
|
|
const tieCount = group.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate total QP for the positions this group occupies
|
|
|
|
|
|
// A tie for Nth place occupies positions N through N + tieCount - 1
|
|
|
|
|
|
let totalQP = 0;
|
|
|
|
|
|
for (let pos = placement; pos < placement + tieCount; pos++) {
|
|
|
|
|
|
const qpForPos = await getQPForPlacement(event.sportsSeasonId, pos, db);
|
|
|
|
|
|
totalQP += qpForPos;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Divide equally among tied participants
|
|
|
|
|
|
const qpPerParticipant = totalQP / tieCount;
|
|
|
|
|
|
|
|
|
|
|
|
// Update the event result with the QP awarded
|
|
|
|
|
|
for (const result of group) {
|
|
|
|
|
|
await db
|
|
|
|
|
|
.update(schema.eventResults)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
qualifyingPointsAwarded: qpPerParticipant.toString(),
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.eventResults.id, result.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Recalculate totals for all participants from scratch
|
|
|
|
|
|
// This is the source of truth - sums all their event_results
|
|
|
|
|
|
const participantIds = new Set(results.map(r => r.participantId));
|
|
|
|
|
|
for (const participantId of participantIds) {
|
|
|
|
|
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Increment majorsCompleted counter (only if this is the first time processing)
|
|
|
|
|
|
if (!wasAlreadyProcessed) {
|
|
|
|
|
|
const sportsSeason = event.sportsSeason;
|
|
|
|
|
|
await db
|
|
|
|
|
|
.update(schema.sportsSeasons)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
|
|
|
|
|
}
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-11-11 10:08:25 -08:00
|
|
|
|
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
|
|
|
|
|
);
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Finalize qualifying points and convert to fantasy placements.
|
|
|
|
|
|
* Called manually by admin when all events are complete.
|
|
|
|
|
|
* Tied QP totals result in shared placements (averaged points).
|
2025-10-28 23:50:50 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function finalizeQualifyingPoints(
|
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
|
// Import qualifying points functions
|
|
|
|
|
|
const { getQPStandings, updateFinalRankings } = await import("./qualifying-points");
|
|
|
|
|
|
|
|
|
|
|
|
// Get all participants ranked by total QP (highest first)
|
|
|
|
|
|
const standings = await getQPStandings(sportsSeasonId, db);
|
|
|
|
|
|
|
|
|
|
|
|
if (standings.length === 0) {
|
|
|
|
|
|
throw new Error(`No qualifying points standings found for sports season ${sportsSeasonId}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update final rankings in participant_qualifying_totals
|
|
|
|
|
|
await updateFinalRankings(sportsSeasonId, db);
|
|
|
|
|
|
|
|
|
|
|
|
// Group participants by QP total to handle ties (Q5, Q19)
|
|
|
|
|
|
const groupedByQP = new Map<string, typeof standings>();
|
|
|
|
|
|
for (const standing of standings) {
|
|
|
|
|
|
const qp = standing.totalQualifyingPoints;
|
|
|
|
|
|
if (!groupedByQP.has(qp)) {
|
|
|
|
|
|
groupedByQP.set(qp, []);
|
|
|
|
|
|
}
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
groupedByQP.get(qp)?.push(standing);
|
2025-11-11 10:08:25 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort groups by QP (descending)
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const sortedGroups = Array.from(groupedByQP.entries()).toSorted((a, b) => {
|
2025-11-11 10:08:25 -08:00
|
|
|
|
return parseFloat(b[0]) - parseFloat(a[0]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Assign fantasy placements (1-8) based on QP ranking
|
|
|
|
|
|
let currentPlacement = 1;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [_, group] of sortedGroups) {
|
|
|
|
|
|
// Stop if we've gone beyond the top 8 fantasy placements
|
|
|
|
|
|
if (currentPlacement > 8) break;
|
|
|
|
|
|
|
|
|
|
|
|
const participantsInGroup = group.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate how many placements this group shares
|
|
|
|
|
|
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
|
|
|
|
|
|
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Assign placements to participants in this group
|
|
|
|
|
|
for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) {
|
|
|
|
|
|
const standing = group[i];
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
standing.participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
currentPlacement,
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If there are more participants than scoring positions, assign 0 to the rest
|
|
|
|
|
|
for (let i = actualParticipantsScoring; i < participantsInGroup; i++) {
|
|
|
|
|
|
const standing = group[i];
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
standing.participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
0, // Beyond top 8
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Move to next placement group
|
|
|
|
|
|
currentPlacement += participantsInGroup;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Assign 0 points to any remaining participants beyond top 8
|
|
|
|
|
|
if (currentPlacement <= standings.length) {
|
|
|
|
|
|
for (let i = currentPlacement - 1; i < standings.length; i++) {
|
|
|
|
|
|
const standing = standings[i];
|
|
|
|
|
|
const hasResult = await db.query.participantResults.findFirst({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.participantResults.participantId, standing.participantId),
|
|
|
|
|
|
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasResult) {
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
standing.participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
0,
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Mark sports season as finalized
|
|
|
|
|
|
await db
|
|
|
|
|
|
.update(schema.sportsSeasons)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
qualifyingPointsFinalized: true,
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger recalculation for all affected leagues
|
2026-03-17 11:16:36 -07:00
|
|
|
|
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
2026-03-10 10:27:58 -07:00
|
|
|
|
// Auto-trigger probability recalculation after result
|
2025-11-21 22:05:50 -08:00
|
|
|
|
try {
|
|
|
|
|
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-11-21 22:05:50 -08:00
|
|
|
|
`[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}`
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error(
|
2025-11-21 22:05:50 -08:00
|
|
|
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
|
|
|
|
|
error
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-11-11 10:08:25 -08:00
|
|
|
|
`[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed`
|
2025-10-28 23:50:50 -07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-16 12:22:08 -07:00
|
|
|
|
* Process season standings (auto racing: F1, IndyCar, etc.) and assign final placements.
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Reads from participant_season_results (championship points) and converts
|
|
|
|
|
|
* final standings positions to fantasy placements. Ties share placements.
|
2025-10-28 23:50:50 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function processSeasonStandings(
|
|
|
|
|
|
sportsSeasonId: string,
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
2025-11-08 22:35:07 -08:00
|
|
|
|
// Get all season results for this sports season, sorted by standings
|
|
|
|
|
|
const seasonResults = await getSeasonResults(sportsSeasonId, db);
|
|
|
|
|
|
|
|
|
|
|
|
// Group participants by position to handle ties
|
|
|
|
|
|
const positionGroups: Record<number, string[]> = {};
|
|
|
|
|
|
|
|
|
|
|
|
for (const result of seasonResults) {
|
|
|
|
|
|
const position = result.currentPosition;
|
|
|
|
|
|
if (position === null) continue; // Skip participants without a position
|
|
|
|
|
|
|
|
|
|
|
|
if (!positionGroups[position]) {
|
|
|
|
|
|
positionGroups[position] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
positionGroups[position].push(result.participantId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get sorted positions
|
|
|
|
|
|
const positions = Object.keys(positionGroups)
|
|
|
|
|
|
.map(Number)
|
2026-03-21 09:44:05 -07:00
|
|
|
|
.toSorted((a, b) => a - b);
|
2025-11-08 22:35:07 -08:00
|
|
|
|
|
|
|
|
|
|
// Assign fantasy placements (1-8) based on standings
|
|
|
|
|
|
let currentPlacement = 1;
|
|
|
|
|
|
|
|
|
|
|
|
for (const position of positions) {
|
|
|
|
|
|
const participantIds = positionGroups[position];
|
|
|
|
|
|
|
|
|
|
|
|
// Stop if we've gone beyond the top 8 fantasy placements
|
|
|
|
|
|
if (currentPlacement > 8) break;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate how many placements this group shares
|
|
|
|
|
|
const participantsInGroup = participantIds.length;
|
|
|
|
|
|
|
|
|
|
|
|
// If this group would exceed position 8, only some participants get points
|
|
|
|
|
|
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
|
|
|
|
|
|
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Determine if we should assign placements to this group
|
|
|
|
|
|
if (currentPlacement <= 8) {
|
|
|
|
|
|
// All participants tied at this position get the first placement in the range
|
|
|
|
|
|
// (e.g., if 4 people tie for 5th place, they all get placement 5)
|
|
|
|
|
|
// The scoring system will handle averaging the points for positions 5, 6, 7, 8
|
|
|
|
|
|
for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
|
|
|
|
|
|
const participantId = participantIds[i];
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
currentPlacement,
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If there are more participants than scoring positions, assign 0 to the rest
|
|
|
|
|
|
for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
|
|
|
|
|
|
const participantId = participantIds[i];
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
0, // Beyond top 8
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Position is beyond top 8, assign 0 points
|
|
|
|
|
|
for (const participantId of participantIds) {
|
|
|
|
|
|
await upsertParticipantResult(
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
|
0,
|
|
|
|
|
|
db
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Move to next placement group
|
|
|
|
|
|
currentPlacement += participantsInGroup;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger recalculation for all affected leagues
|
2026-03-17 11:16:36 -07:00
|
|
|
|
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
2026-03-10 10:27:58 -07:00
|
|
|
|
// Auto-trigger probability recalculation after result
|
2025-11-21 22:05:50 -08:00
|
|
|
|
try {
|
|
|
|
|
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-11-21 22:05:50 -08:00
|
|
|
|
`[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}`
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error(
|
2025-11-21 22:05:50 -08:00
|
|
|
|
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
|
|
|
|
|
error
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-11-08 22:35:07 -08:00
|
|
|
|
`[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
|
2025-10-28 23:50:50 -07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Calculate total fantasy points for a team in a specific season
|
|
|
|
|
|
* Sums points from all drafted participants based on their final placements
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function calculateTeamScore(
|
|
|
|
|
|
teamId: string,
|
|
|
|
|
|
seasonId: string,
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<{
|
|
|
|
|
|
totalPoints: number;
|
|
|
|
|
|
placementCounts: Record<number, number>;
|
|
|
|
|
|
participantsCompleted: number;
|
|
|
|
|
|
participantsTotal: number;
|
|
|
|
|
|
}> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
|
|
// Get season scoring rules
|
|
|
|
|
|
const scoringRules = await getScoringRules(seasonId, db);
|
|
|
|
|
|
if (!scoringRules) {
|
|
|
|
|
|
throw new Error(`Season ${seasonId} not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get all draft picks for this team
|
|
|
|
|
|
const picks = await db.query.draftPicks.findMany({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.draftPicks.teamId, teamId),
|
|
|
|
|
|
eq(schema.draftPicks.seasonId, seasonId)
|
|
|
|
|
|
),
|
|
|
|
|
|
with: {
|
|
|
|
|
|
participant: {
|
|
|
|
|
|
with: {
|
|
|
|
|
|
results: true,
|
2026-03-10 10:27:58 -07:00
|
|
|
|
sportsSeason: true,
|
2025-10-28 23:50:50 -07:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let totalPoints = 0;
|
|
|
|
|
|
const placementCounts: Record<number, number> = {
|
|
|
|
|
|
1: 0,
|
|
|
|
|
|
2: 0,
|
|
|
|
|
|
3: 0,
|
|
|
|
|
|
4: 0,
|
|
|
|
|
|
5: 0,
|
|
|
|
|
|
6: 0,
|
|
|
|
|
|
7: 0,
|
|
|
|
|
|
8: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
let participantsCompleted = 0;
|
|
|
|
|
|
|
2026-03-17 12:34:10 -07:00
|
|
|
|
// Cache bracket template IDs per sports season (one extra query per unique bracket season).
|
|
|
|
|
|
// Needed to determine the correct scoring tier structure (e.g. AFL splits 5–8 into two pairs).
|
|
|
|
|
|
const bracketTemplateCache = new Map<string, string | null>();
|
|
|
|
|
|
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
|
|
|
|
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
2026-03-17 12:34:10 -07:00
|
|
|
|
}
|
|
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
|
|
|
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
|
columns: { bracketTemplateId: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const templateId = event?.bracketTemplateId ?? null;
|
|
|
|
|
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
|
|
|
|
|
return templateId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
for (const pick of picks) {
|
2026-03-10 10:27:58 -07:00
|
|
|
|
// One result per participant per sports season (enforced by upsertParticipantResult).
|
|
|
|
|
|
// If duplicates exist due to data corruption, the first row wins — acceptable trade-off.
|
|
|
|
|
|
const result = pick.participant.results[0];
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
2026-03-21 09:44:05 -07:00
|
|
|
|
if (result && result.finalPosition !== null && result.finalPosition > 0) {
|
2026-03-10 10:27:58 -07:00
|
|
|
|
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
2026-03-17 12:34:10 -07:00
|
|
|
|
let points: number;
|
|
|
|
|
|
if (isBracket) {
|
|
|
|
|
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
|
|
|
|
|
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
|
|
|
|
|
}
|
2025-10-28 23:50:50 -07:00
|
|
|
|
totalPoints += points;
|
|
|
|
|
|
|
2026-03-29 16:59:13 -07:00
|
|
|
|
// All participants with a valid position count toward the placement tiebreaker,
|
|
|
|
|
|
// including those still in progress (isPartialScore). Their current position
|
|
|
|
|
|
// is the best available signal, and if it changes recalculateStandings will run again.
|
|
|
|
|
|
if (result.finalPosition <= 8) {
|
|
|
|
|
|
placementCounts[result.finalPosition]++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
|
if (!result.isPartialScore) {
|
2026-03-29 16:59:13 -07:00
|
|
|
|
// Only fully finalized participants count toward "completed" progress tracking
|
2026-03-17 14:34:09 -07:00
|
|
|
|
participantsCompleted++;
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
2026-03-17 15:02:19 -07:00
|
|
|
|
} else if (result && !result.isPartialScore) {
|
|
|
|
|
|
// Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done
|
|
|
|
|
|
participantsCompleted++;
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
totalPoints,
|
|
|
|
|
|
placementCounts,
|
|
|
|
|
|
participantsCompleted,
|
|
|
|
|
|
participantsTotal: picks.length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
|
/**
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Calculate projected total points for a team.
|
|
|
|
|
|
* Combines actual points from finished participants with EV projections for the rest.
|
2026-02-14 22:30:12 -08:00
|
|
|
|
*/
|
|
|
|
|
|
export async function calculateTeamProjectedScore(
|
|
|
|
|
|
teamId: string,
|
|
|
|
|
|
seasonId: string,
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<{
|
|
|
|
|
|
actualPoints: number;
|
|
|
|
|
|
projectedPoints: number;
|
|
|
|
|
|
participantsFinished: number;
|
|
|
|
|
|
participantsRemaining: number;
|
|
|
|
|
|
}> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
|
|
// Get season scoring rules
|
|
|
|
|
|
const scoringRules = await getScoringRules(seasonId, db);
|
|
|
|
|
|
if (!scoringRules) {
|
|
|
|
|
|
throw new Error(`Season ${seasonId} not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get all draft picks for this team with their results and EVs
|
|
|
|
|
|
const picks = await db.query.draftPicks.findMany({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.draftPicks.teamId, teamId),
|
|
|
|
|
|
eq(schema.draftPicks.seasonId, seasonId)
|
|
|
|
|
|
),
|
|
|
|
|
|
with: {
|
|
|
|
|
|
participant: {
|
|
|
|
|
|
with: {
|
|
|
|
|
|
results: true,
|
|
|
|
|
|
sportsSeason: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let actualPoints = 0;
|
|
|
|
|
|
let participantsFinished = 0;
|
2026-03-17 14:00:32 -07:00
|
|
|
|
const unfinishedParticipants: Array<{ participantId: string; sportsSeasonId: string; floorPoints: number }> = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Cache bracket template IDs per sports season
|
|
|
|
|
|
const bracketTemplateCache = new Map<string, string | null>();
|
|
|
|
|
|
async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
|
|
|
|
|
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
2026-03-17 14:00:32 -07:00
|
|
|
|
}
|
|
|
|
|
|
const event = await db.query.scoringEvents.findFirst({
|
|
|
|
|
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
|
columns: { bracketTemplateId: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const templateId = event?.bracketTemplateId ?? null;
|
|
|
|
|
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
|
|
|
|
|
return templateId;
|
|
|
|
|
|
}
|
2026-02-14 22:30:12 -08:00
|
|
|
|
|
|
|
|
|
|
// Separate finished vs unfinished participants
|
|
|
|
|
|
for (const pick of picks) {
|
|
|
|
|
|
const result = pick.participant.results[0];
|
2026-03-17 14:00:32 -07:00
|
|
|
|
const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
|
2026-02-14 22:30:12 -08:00
|
|
|
|
|
2026-03-17 14:00:32 -07:00
|
|
|
|
if (result && result.finalPosition !== null && !result.isPartialScore) {
|
|
|
|
|
|
// Participant is fully finalized — use bracket-averaged points
|
|
|
|
|
|
let points: number;
|
|
|
|
|
|
if (isBracket) {
|
|
|
|
|
|
const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
|
|
|
|
|
|
points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
points = calculateFantasyPoints(result.finalPosition, scoringRules);
|
|
|
|
|
|
}
|
2026-02-14 22:30:12 -08:00
|
|
|
|
actualPoints += points;
|
|
|
|
|
|
participantsFinished++;
|
2026-03-17 14:00:32 -07:00
|
|
|
|
} else if (result && result.finalPosition !== null && result.isPartialScore) {
|
|
|
|
|
|
// Still alive with a provisional floor — count floor as actual, EV for projection.
|
|
|
|
|
|
// Note: NOT incremented in participantsFinished; these participants are still competing.
|
|
|
|
|
|
const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
|
|
|
|
|
|
const floorPoints = isBracket
|
|
|
|
|
|
? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
|
|
|
|
|
|
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
|
|
|
|
|
actualPoints += floorPoints;
|
|
|
|
|
|
// EV already accounts for their full projected value, so subtract floor to avoid
|
|
|
|
|
|
// double-counting when we do actualPoints + evSum below
|
|
|
|
|
|
unfinishedParticipants.push({
|
|
|
|
|
|
participantId: pick.participant.id,
|
|
|
|
|
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
|
|
|
|
|
floorPoints,
|
|
|
|
|
|
});
|
2026-02-14 22:30:12 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
// Participant is unfinished - will need EV
|
|
|
|
|
|
unfinishedParticipants.push({
|
|
|
|
|
|
participantId: pick.participant.id,
|
|
|
|
|
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
2026-03-17 14:00:32 -07:00
|
|
|
|
floorPoints: 0,
|
2026-02-14 22:30:12 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get EVs for unfinished participants
|
|
|
|
|
|
let evSum = 0;
|
|
|
|
|
|
if (unfinishedParticipants.length > 0) {
|
|
|
|
|
|
// Import the participant EV model
|
|
|
|
|
|
const { getParticipantEV } = await import("./participant-expected-value");
|
|
|
|
|
|
const { calculateEV } = await import("~/services/ev-calculator");
|
|
|
|
|
|
|
2026-03-17 14:00:32 -07:00
|
|
|
|
for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
|
2026-02-14 22:30:12 -08:00
|
|
|
|
const ev = await getParticipantEV(participantId, sportsSeasonId);
|
|
|
|
|
|
|
|
|
|
|
|
if (ev) {
|
|
|
|
|
|
// EV is already calculated with default scoring (100/70/50/40/25/25/15/15)
|
|
|
|
|
|
// We need to recalculate with THIS league's scoring rules
|
|
|
|
|
|
const probabilities = {
|
|
|
|
|
|
probFirst: parseFloat(ev.probFirst),
|
|
|
|
|
|
probSecond: parseFloat(ev.probSecond),
|
|
|
|
|
|
probThird: parseFloat(ev.probThird),
|
|
|
|
|
|
probFourth: parseFloat(ev.probFourth),
|
|
|
|
|
|
probFifth: parseFloat(ev.probFifth),
|
|
|
|
|
|
probSixth: parseFloat(ev.probSixth),
|
|
|
|
|
|
probSeventh: parseFloat(ev.probSeventh),
|
|
|
|
|
|
probEighth: parseFloat(ev.probEighth),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const leagueSpecificEV = calculateEV(probabilities, scoringRules);
|
2026-03-17 14:00:32 -07:00
|
|
|
|
// For partial-score participants, floor is already in actualPoints — add only the
|
|
|
|
|
|
// incremental EV above the floor to avoid double-counting in projectedPoints.
|
|
|
|
|
|
// For pending participants (floorPoints=0) this is just the full EV.
|
|
|
|
|
|
// Math.max guards against the rare case where EV drops below the floor.
|
|
|
|
|
|
evSum += Math.max(0, leagueSpecificEV - floorPoints);
|
2026-02-14 22:30:12 -08:00
|
|
|
|
}
|
2026-03-17 14:00:32 -07:00
|
|
|
|
// If no EV data exists, assume 0 additional projected points
|
2026-02-14 22:30:12 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const projectedPoints = actualPoints + evSum;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
actualPoints: Math.round(actualPoints * 100) / 100,
|
|
|
|
|
|
projectedPoints: Math.round(projectedPoints * 100) / 100,
|
|
|
|
|
|
participantsFinished,
|
|
|
|
|
|
participantsRemaining: unfinishedParticipants.length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Compare two teams for ranking purposes using tiebreaker logic
|
|
|
|
|
|
* Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied
|
|
|
|
|
|
*
|
|
|
|
|
|
* Tiebreaker order:
|
|
|
|
|
|
* 1. Total points (higher is better)
|
|
|
|
|
|
* 2. Number of 1st place finishes (higher is better)
|
|
|
|
|
|
* 3. Number of 2nd place finishes (higher is better)
|
|
|
|
|
|
* 4. Continue through all placements (3rd, 4th, ..., 8th)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function compareTeamsForRanking(
|
|
|
|
|
|
teamA: {
|
|
|
|
|
|
totalPoints: number;
|
|
|
|
|
|
placementCounts: Record<number, number>;
|
|
|
|
|
|
},
|
|
|
|
|
|
teamB: {
|
|
|
|
|
|
totalPoints: number;
|
|
|
|
|
|
placementCounts: Record<number, number>;
|
|
|
|
|
|
}
|
|
|
|
|
|
): number {
|
2026-03-27 20:45:15 -07:00
|
|
|
|
// First compare by total points (descending), rounded to hundredths to avoid
|
|
|
|
|
|
// floating point noise causing spurious non-ties in the display.
|
|
|
|
|
|
const roundedA = Math.round(teamA.totalPoints * 100) / 100;
|
|
|
|
|
|
const roundedB = Math.round(teamB.totalPoints * 100) / 100;
|
|
|
|
|
|
if (roundedA !== roundedB) {
|
|
|
|
|
|
return roundedB - roundedA;
|
2025-11-13 13:24:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If points are tied, apply tiebreaker cascade
|
|
|
|
|
|
for (let placement = 1; placement <= 8; placement++) {
|
|
|
|
|
|
const countA = teamA.placementCounts[placement] || 0;
|
|
|
|
|
|
const countB = teamB.placementCounts[placement] || 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (countA !== countB) {
|
|
|
|
|
|
return countB - countA; // More of this placement is better
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Completely tied
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Assign ranks to teams based on their scores and tiebreakers
|
|
|
|
|
|
* Teams with identical scores and placements will share the same rank
|
|
|
|
|
|
*/
|
|
|
|
|
|
function assignRanks(
|
|
|
|
|
|
teams: Array<{
|
|
|
|
|
|
teamId: string;
|
|
|
|
|
|
totalPoints: number;
|
|
|
|
|
|
placementCounts: Record<number, number>;
|
|
|
|
|
|
}>
|
|
|
|
|
|
): Map<string, number> {
|
|
|
|
|
|
// Sort teams by ranking criteria
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const sorted = [...teams].toSorted(compareTeamsForRanking);
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
|
|
|
|
|
const ranks = new Map<string, number>();
|
|
|
|
|
|
let currentRank = 1;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
|
|
|
|
const team = sorted[i];
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this team is tied with the previous team
|
|
|
|
|
|
if (i > 0) {
|
|
|
|
|
|
const prevTeam = sorted[i - 1];
|
|
|
|
|
|
const comparison = compareTeamsForRanking(team, prevTeam);
|
|
|
|
|
|
|
|
|
|
|
|
if (comparison !== 0) {
|
|
|
|
|
|
// Not tied, advance rank
|
|
|
|
|
|
currentRank = i + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
// If comparison === 0, keep the same rank (tie)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ranks.set(team.teamId, currentRank);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ranks;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
/**
|
2026-03-10 10:27:58 -07:00
|
|
|
|
* Recalculate standings for all teams in a season.
|
|
|
|
|
|
* Called after any scoring event completes or participant results change.
|
|
|
|
|
|
* Implements tiebreaker logic: total points, then placement counts (1st, 2nd, …).
|
2025-10-28 23:50:50 -07:00
|
|
|
|
*/
|
|
|
|
|
|
export async function recalculateStandings(
|
|
|
|
|
|
seasonId: string,
|
|
|
|
|
|
providedDb?: ReturnType<typeof database>
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
|
|
const teams = await db.query.teams.findMany({
|
|
|
|
|
|
where: eq(schema.teams.seasonId, seasonId),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
|
// Calculate scores for all teams
|
|
|
|
|
|
const teamScores = await Promise.all(
|
|
|
|
|
|
teams.map(async (team) => {
|
|
|
|
|
|
const score = await calculateTeamScore(team.id, seasonId, db);
|
2026-02-14 22:30:12 -08:00
|
|
|
|
const projected = await calculateTeamProjectedScore(team.id, seasonId, db);
|
2025-11-13 13:24:03 -08:00
|
|
|
|
return {
|
|
|
|
|
|
teamId: team.id,
|
|
|
|
|
|
totalPoints: score.totalPoints,
|
|
|
|
|
|
placementCounts: score.placementCounts,
|
|
|
|
|
|
participantsCompleted: score.participantsCompleted,
|
|
|
|
|
|
participantsTotal: score.participantsTotal,
|
2026-02-14 22:30:12 -08:00
|
|
|
|
actualPoints: projected.actualPoints,
|
|
|
|
|
|
projectedPoints: projected.projectedPoints,
|
|
|
|
|
|
participantsFinished: projected.participantsFinished,
|
2025-11-13 13:24:03 -08:00
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Assign ranks based on tiebreaker logic
|
|
|
|
|
|
const ranks = assignRanks(teamScores);
|
|
|
|
|
|
|
|
|
|
|
|
// Update team standings with scores and ranks
|
|
|
|
|
|
for (const teamScore of teamScores) {
|
|
|
|
|
|
const newRank = ranks.get(teamScore.teamId) || 1;
|
2025-10-28 23:50:50 -07:00
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
|
// Get existing standing to preserve previousRank
|
2025-10-28 23:50:50 -07:00
|
|
|
|
const existing = await db.query.teamStandings.findFirst({
|
|
|
|
|
|
where: and(
|
2025-11-13 13:24:03 -08:00
|
|
|
|
eq(schema.teamStandings.teamId, teamScore.teamId),
|
2025-10-28 23:50:50 -07:00
|
|
|
|
eq(schema.teamStandings.seasonId, seasonId)
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-13 13:24:03 -08:00
|
|
|
|
const standingData = {
|
|
|
|
|
|
totalPoints: teamScore.totalPoints.toString(),
|
|
|
|
|
|
currentRank: newRank,
|
|
|
|
|
|
previousRank: existing ? existing.currentRank : null,
|
|
|
|
|
|
firstPlaceCount: teamScore.placementCounts[1],
|
|
|
|
|
|
secondPlaceCount: teamScore.placementCounts[2],
|
|
|
|
|
|
thirdPlaceCount: teamScore.placementCounts[3],
|
|
|
|
|
|
fourthPlaceCount: teamScore.placementCounts[4],
|
|
|
|
|
|
fifthPlaceCount: teamScore.placementCounts[5],
|
|
|
|
|
|
sixthPlaceCount: teamScore.placementCounts[6],
|
|
|
|
|
|
seventhPlaceCount: teamScore.placementCounts[7],
|
|
|
|
|
|
eighthPlaceCount: teamScore.placementCounts[8],
|
|
|
|
|
|
participantsRemaining:
|
|
|
|
|
|
teamScore.participantsTotal - teamScore.participantsCompleted,
|
2026-02-14 22:30:12 -08:00
|
|
|
|
actualPoints: teamScore.actualPoints?.toString() ?? null,
|
|
|
|
|
|
projectedPoints: teamScore.projectedPoints?.toString() ?? null,
|
|
|
|
|
|
participantsFinished: teamScore.participantsFinished ?? null,
|
2025-11-13 13:24:03 -08:00
|
|
|
|
calculatedAt: new Date(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
if (existing) {
|
|
|
|
|
|
await db
|
|
|
|
|
|
.update(schema.teamStandings)
|
2025-11-13 13:24:03 -08:00
|
|
|
|
.set(standingData)
|
2025-10-28 23:50:50 -07:00
|
|
|
|
.where(eq(schema.teamStandings.id, existing.id));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await db.insert(schema.teamStandings).values({
|
2025-11-13 13:24:03 -08:00
|
|
|
|
teamId: teamScore.teamId,
|
2025-10-28 23:50:50 -07:00
|
|
|
|
seasonId,
|
2025-11-13 13:24:03 -08:00
|
|
|
|
...standingData,
|
2025-10-28 23:50:50 -07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`);
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Recalculate standings for all leagues that include a specific sports season
|
|
|
|
|
|
* Called after a sports season event completes
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function recalculateAffectedLeagues(
|
|
|
|
|
|
sportsSeasonId: string,
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
providedDb?: ReturnType<typeof database>,
|
2026-04-15 09:40:53 -07:00
|
|
|
|
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
2025-10-28 23:50:50 -07:00
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = providedDb || database();
|
|
|
|
|
|
|
|
|
|
|
|
// Get all fantasy seasons that include this sports season
|
|
|
|
|
|
const seasonSports = await db.query.seasonSports.findMany({
|
|
|
|
|
|
where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const seasonIds = seasonSports.map((ss) => ss.seasonId);
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
// Look up sport name for the notification header
|
|
|
|
|
|
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
|
|
|
|
|
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
|
|
|
|
|
with: { sport: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const sportName = sportsSeason?.sport?.name;
|
|
|
|
|
|
|
2026-03-17 12:34:10 -07:00
|
|
|
|
// Look up matches for the Discord notification.
|
|
|
|
|
|
// When matchIds is provided, only those specific matches are shown (e.g. current batch).
|
|
|
|
|
|
// Falling back to all completed matches for the event would include prior rounds.
|
2026-03-17 11:16:36 -07:00
|
|
|
|
let allCompletedMatches: Array<{
|
|
|
|
|
|
winnerId: string | null;
|
|
|
|
|
|
loserId: string | null;
|
|
|
|
|
|
winnerName: string | null;
|
|
|
|
|
|
loserName: string | null;
|
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (options?.eventId) {
|
2026-03-17 12:34:10 -07:00
|
|
|
|
const whereClause = options.matchIds?.length
|
|
|
|
|
|
? inArray(schema.playoffMatches.id, options.matchIds)
|
|
|
|
|
|
: and(
|
|
|
|
|
|
eq(schema.playoffMatches.scoringEventId, options.eventId),
|
|
|
|
|
|
eq(schema.playoffMatches.isComplete, true)
|
|
|
|
|
|
);
|
2026-03-17 11:16:36 -07:00
|
|
|
|
const matches = await db.query.playoffMatches.findMany({
|
2026-03-17 12:34:10 -07:00
|
|
|
|
where: whereClause,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
with: {
|
|
|
|
|
|
winner: true,
|
|
|
|
|
|
loser: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
allCompletedMatches = matches
|
|
|
|
|
|
.filter((m) => m.winnerId && m.loserId)
|
|
|
|
|
|
.map((m) => ({
|
|
|
|
|
|
winnerId: m.winnerId,
|
|
|
|
|
|
loserId: m.loserId,
|
|
|
|
|
|
winnerName: m.winner?.name ?? null,
|
|
|
|
|
|
loserName: m.loser?.name ?? null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:16:57 -07:00
|
|
|
|
// Build the set of loser participant IDs that have a finalized (non-partial) result.
|
|
|
|
|
|
// Used in Discord notifications to surface 0-pt eliminations that produce no score delta.
|
|
|
|
|
|
const loserParticipantIds = allCompletedMatches
|
|
|
|
|
|
.map((m) => m.loserId)
|
|
|
|
|
|
.filter((id): id is string => id !== null);
|
|
|
|
|
|
const finalizedLoserIds = new Set<string>();
|
|
|
|
|
|
if (loserParticipantIds.length > 0) {
|
|
|
|
|
|
const loserResults = await db.query.participantResults.findMany({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
inArray(schema.participantResults.participantId, loserParticipantIds),
|
|
|
|
|
|
eq(schema.participantResults.sportsSeasonId, sportsSeasonId),
|
|
|
|
|
|
eq(schema.participantResults.isPartialScore, false)
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
// Recalculate each affected season
|
|
|
|
|
|
for (const seasonId of seasonIds) {
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
// Capture standings before recalculation for delta calculation
|
|
|
|
|
|
const beforeStandings = await db.query.teamStandings.findMany({
|
|
|
|
|
|
where: eq(schema.teamStandings.seasonId, seasonId),
|
|
|
|
|
|
with: { team: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
const previousPointsById = new Map(
|
|
|
|
|
|
beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
|
|
|
|
|
|
);
|
2026-03-19 15:52:57 -07:00
|
|
|
|
const previousRanksById = new Map(
|
|
|
|
|
|
beforeStandings.map((s) => [s.teamId, s.currentRank])
|
|
|
|
|
|
);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
|
2025-10-28 23:50:50 -07:00
|
|
|
|
await recalculateStandings(seasonId, db);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
|
2026-03-18 22:15:28 -07:00
|
|
|
|
try {
|
|
|
|
|
|
await createDailySnapshot(seasonId, db);
|
|
|
|
|
|
} catch (err) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error(`[ScoringCalculator] Snapshot failed for season ${seasonId}:`, err);
|
2026-03-18 22:15:28 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
// Check if any team's score changed — if so, send Discord notification
|
|
|
|
|
|
const afterStandings = await db.query.teamStandings.findMany({
|
|
|
|
|
|
where: eq(schema.teamStandings.seasonId, seasonId),
|
|
|
|
|
|
with: { team: true },
|
|
|
|
|
|
orderBy: (ts, { asc }) => [asc(ts.currentRank)],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
const changedTeamIds = new Set(
|
|
|
|
|
|
afterStandings
|
|
|
|
|
|
.filter((s) => {
|
|
|
|
|
|
const prev = previousPointsById.get(s.teamId);
|
|
|
|
|
|
return prev === undefined || prev !== parseFloat(s.totalPoints);
|
|
|
|
|
|
})
|
|
|
|
|
|
.map((s) => s.teamId)
|
|
|
|
|
|
);
|
|
|
|
|
|
const hasChanges = changedTeamIds.size > 0;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
|
|
|
|
|
|
// Look up the league's Discord webhook URL via the season
|
|
|
|
|
|
const season = await db.query.seasons.findFirst({
|
|
|
|
|
|
where: eq(schema.seasons.id, seasonId),
|
|
|
|
|
|
with: { league: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const webhookUrl = season?.league?.discordWebhookUrl;
|
2026-04-15 09:40:53 -07:00
|
|
|
|
if (!webhookUrl || options?.skipDiscord) continue;
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
|
// Build clerkId → username map for all team owners in this season
|
|
|
|
|
|
const ownerClerkIds = afterStandings
|
|
|
|
|
|
.map((s) => s.team.ownerId)
|
|
|
|
|
|
.filter((id): id is string => id !== null && id !== undefined);
|
|
|
|
|
|
const teamOwnerUsers = ownerClerkIds.length
|
|
|
|
|
|
? await db.query.users.findMany({
|
|
|
|
|
|
where: inArray(schema.users.clerkId, ownerClerkIds),
|
|
|
|
|
|
})
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const usernameByClerkId = new Map(
|
|
|
|
|
|
teamOwnerUsers
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
|
.map((u) => [u.clerkId, getUserDisplayName(u)])
|
2026-03-21 09:44:05 -07:00
|
|
|
|
.filter((entry): entry is [string, string] => entry[1] !== null)
|
2026-03-17 14:34:09 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Build teamId → ownerId map from standings data
|
|
|
|
|
|
const ownerIdByTeamId = new Map(
|
|
|
|
|
|
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-14 22:16:57 -07:00
|
|
|
|
// Build scored matches for the notification.
|
|
|
|
|
|
// Winners only appear if their team's score changed (they earned points this round).
|
|
|
|
|
|
// Losers appear if their team's score changed OR they were definitively eliminated
|
|
|
|
|
|
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
|
|
|
|
|
|
// produce no score delta. Losers who advance to another match (loserAdvances=true,
|
|
|
|
|
|
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
|
|
|
|
|
|
// correctly suppressed.
|
2026-03-17 11:16:36 -07:00
|
|
|
|
let scoredMatches: ScoredMatch[] | undefined;
|
|
|
|
|
|
if (allCompletedMatches.length > 0) {
|
|
|
|
|
|
const draftPicks = await db.query.draftPicks.findMany({
|
|
|
|
|
|
where: eq(schema.draftPicks.seasonId, seasonId),
|
|
|
|
|
|
});
|
|
|
|
|
|
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
|
|
|
|
|
|
|
|
|
|
|
|
const relevant = allCompletedMatches.filter(
|
|
|
|
|
|
(m) =>
|
|
|
|
|
|
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
|
|
|
|
|
(m.loserId && draftedIds.has(m.loserId))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (relevant.length > 0) {
|
2026-03-17 14:34:09 -07:00
|
|
|
|
const teamIdByParticipantId = new Map(
|
|
|
|
|
|
draftPicks.map((p) => [p.participantId, p.teamId])
|
|
|
|
|
|
);
|
2026-03-19 15:52:57 -07:00
|
|
|
|
|
2026-04-14 22:16:57 -07:00
|
|
|
|
const lookupUsername = (participantId: string | null | undefined) => {
|
2026-03-17 14:34:09 -07:00
|
|
|
|
if (!participantId) return undefined;
|
|
|
|
|
|
const teamId = teamIdByParticipantId.get(participantId);
|
|
|
|
|
|
if (!teamId) return undefined;
|
|
|
|
|
|
const ownerId = ownerIdByTeamId.get(teamId);
|
|
|
|
|
|
if (!ownerId) return undefined;
|
|
|
|
|
|
return usernameByClerkId.get(ownerId);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
scoredMatches = relevant
|
|
|
|
|
|
.filter((m) => m.winnerName && m.loserName)
|
2026-04-14 22:16:57 -07:00
|
|
|
|
.map((m) => {
|
|
|
|
|
|
const winnerTeamId = m.winnerId ? teamIdByParticipantId.get(m.winnerId) : undefined;
|
|
|
|
|
|
const loserTeamId = m.loserId ? teamIdByParticipantId.get(m.loserId) : undefined;
|
|
|
|
|
|
const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false;
|
|
|
|
|
|
return {
|
|
|
|
|
|
winnerName: m.winnerName ?? "",
|
|
|
|
|
|
loserName: m.loserName ?? "",
|
|
|
|
|
|
winnerUsername: winnerScoreChanged ? lookupUsername(m.winnerId) : undefined,
|
|
|
|
|
|
loserUsername: isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds)
|
|
|
|
|
|
? lookupUsername(m.loserId)
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-03-17 11:16:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 15:52:57 -07:00
|
|
|
|
// Skip notification if scores didn't change AND no match has a displayable username.
|
|
|
|
|
|
const hasScoredMatchesToShow = scoredMatches?.some(
|
|
|
|
|
|
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
2026-03-17 12:34:10 -07:00
|
|
|
|
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
const standings = afterStandings.map((s) => ({
|
|
|
|
|
|
teamId: s.teamId,
|
|
|
|
|
|
teamName: s.team.name,
|
2026-03-17 14:34:09 -07:00
|
|
|
|
username: usernameByClerkId.get(s.team.ownerId ?? ""),
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
totalPoints: parseFloat(s.totalPoints),
|
|
|
|
|
|
rank: s.currentRank,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl,
|
|
|
|
|
|
seasonName: `${season.league.name} ${season.year}`,
|
|
|
|
|
|
standings,
|
|
|
|
|
|
previousStandings: previousPointsById,
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousRanks: previousRanksById,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
sportName,
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
eventName: options?.eventName,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
scoredMatches,
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// Log but don't fail the scoring pipeline if Discord is unreachable
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error(`[ScoringCalculator] Discord notification failed for season ${seasonId}:`, err);
|
Partial bracket scoring, code review fixes, and double-chance logic (#156)
## 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>
2026-03-17 10:50:30 -07:00
|
|
|
|
}
|
2025-10-28 23:50:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.log(
|
2025-10-28 23:50:50 -07:00
|
|
|
|
`[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|