diff --git a/.gitignore b/.gitignore index 45369ce..762441f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,8 +54,10 @@ coverage/ # Screenshots Screenshot*.png -# Loading scripts +# Loading scripts (one-off tournament data dumps, kept local only) packages/bot/scripts/load*.ts +# ...except the committed loader-harness template +!packages/bot/scripts/load-example-spec.ts # Database backups *.backup.sql diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..0758713 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,51 @@ +# Scorebot + +Live score tracking for KC Ultimate (Tech) games, fed by WhatsApp chat messages. This glossary is the canonical language for game and stats concepts across bot, shared, and web packages. + +## Language + +### Game structure + +**Game**: +One tracked match between our team and an opponent, made up of an ordered list of events. + +**Event**: +A single recorded happening in a game: a goal, halftime, timeout, note, game start, or game end. + +**Point**: +One unit of play from a pull to a goal. Every goal event ends exactly one point. +_Avoid_: possession, rally + +**Point Ledger**: +The derived, per-point account of a game: for each point, who scored, whether it was a hold or a break, which of our lines played it, and whether we forced a turn. All break/hold and line questions are answered from the ledger. + +**Starting on Offense**: +Whether our team received the first pull. Determines possession at the start of each half; the receiving team flips at halftime. + +### Point outcomes + +**Hold**: +A point won by the team that started it on offense. + +**Break**: +A point won by the team that started it on defense. + +**Dirty Hold**: +A hold where our O-line first lost possession and had to force a turn to win the point back. Counts as a hold, signals sloppy offense. + +**Failed Conversion**: +A D-line point where we forced at least one turn but still conceded the goal. + +**Forced Turn**: +A change of possession our defense caused (block or steal), as opposed to an opponent's unforced error. Only our own forced turns are logged. + +**Inferred Result**: +A hold/break call made without knowing starting possession, guessed from consecutive scoring. Display-only: shown on the timeline, excluded from efficiency stats. + +### Lines + +**O-line**: +The lineup fielded when we start a point on offense. + +**D-line**: +The lineup fielded when we start a point on defense. diff --git a/docs/adr/0001-do-first-dual-write.md b/docs/adr/0001-do-first-dual-write.md new file mode 100644 index 0000000..95ae64e --- /dev/null +++ b/docs/adr/0001-do-first-dual-write.md @@ -0,0 +1,12 @@ +# The Durable Object is authoritative; every write goes DO-first, then D1 + +Every game mutation is a dual-write: the GameState Durable Object holds live state, D1 holds the durable mirror and query index. We decided the DO is authoritative for a live game and all writes flow DO → D1, with D1 failures surfaced as errors rather than swallowed. The GameStore module is the only place this ordering lives; callers never coordinate the two stores themselves. + +## Considered Options + +- **D1-first with the DO as cache** — one always-authoritative store, but forfeits the DO's single-threaded per-game ordering and invariant enforcement, and would require invalidation plumbing. +- **Keep the historical split** — metadata updates used to write D1-first with a fire-and-forget DO update, while event writes went DO-first. Rejected: a failed fire-and-forget silently diverged the stores until eviction, and the ambiguity already produced a data-loss bug (`INSERT OR REPLACE` cascade-deleted a game's events; see the comment in `packages/bot/src/db/database.ts`). + +## Consequences + +On cold start (DO evicted) the store rehydrates the DO from D1, so D1 is briefly the seed — but never the write target of record. Any future write path added outside GameStore should be treated as a bug. diff --git a/docs/adr/0002-do-rpc-transport.md b/docs/adr/0002-do-rpc-transport.md new file mode 100644 index 0000000..c1e29c5 --- /dev/null +++ b/docs/adr/0002-do-rpc-transport.md @@ -0,0 +1,7 @@ +# GameState is called via Durable Object RPC, not fetch routing + +GameState originally exposed a hand-written `fetch()` switch over ~10 magic paths because pre-2024 Workers runtimes required an HTTP boundary for DOs. We decided to bump `compatibility_date` (from 2024-01-01 to a current date, ≥ 2024-04-03) and expose typed RPC methods instead, deleting the path switch and all request/response JSON marshalling in callers and tests. + +## Consequences + +The compatibility-date bump flips other runtime defaults accumulated since 2024-01-01; the deploy needs a smoke test of game creation, event add/undo, and the stats endpoints, with rollback being a redeploy of the previous compat date. Tests call DO methods directly instead of constructing 104 `new Request(...)` objects. diff --git a/docs/architecture-review-2026-07-06.html b/docs/architecture-review-2026-07-06.html new file mode 100644 index 0000000..67e8b47 --- /dev/null +++ b/docs/architecture-review-2026-07-06.html @@ -0,0 +1,398 @@ + + + + + Architecture review — discore + + + + + +
+ + +
+

Architecture review — discore

+

2026-07-06 · packages/shared · packages/bot · packages/web · no CONTEXT.md or ADRs found — domain terms below (Game, Point, break, hold, O-line) are drawn from the code itself

+
+ module + deep module + seam + leakage / duplication +
+
+ + +
+ + +
+
+

1 · One possession engine: deepen break/hold into a Point Ledger

+ Strong + in-process +
+ +
+ shared/src/utils.ts:93–174 · web/src/components/breakDetection.ts:10–39 · web/src/components/efficiencyStats.ts:42–67 · web/src/stats.ts:518–540 · web/src/gameUtils.ts · bot/src/services/StatsCalculator.ts:496–535 +
+ +

Problem. “Was this point a break or a hold?” is answered by three unrelated algorithms: calculateLineStats runs possession forward, isBreakScore scans backward from each goal, and gameUtils finds halftime a third way by timestamp. The same game page uses two of them and they can disagree. The opponent-by-symmetry arithmetic (themHolds = dLinePoints − dLineBreaks…) is inlined three times. No locality: a possession rule change lands in four files, two mental models.

+ + +
+
+
Before — three algorithms, one concept
+
+
+flowchart TB
+  G[Game + events]
+  G --> A["calculateLineStats
forward possession flag"] + G --> B["isBreakScore
backward goal scan"] + G --> H["findHalftimePointIndex
timestamp heuristic"] + A --> E[efficiencyStats] + B --> T[timeline / progression] + H --> T + E -. "symmetry math ×3" .-> S[stats.ts summary] + A --> SC[bot StatsCalculator] + classDef leak stroke:#dc2626,stroke-width:2px,color:#7f1d1d; + class A,B,H leak +
+
+
+
+
After — one deep Point Ledger module
+ +
+
Game + events
+
+
+
interface · buildPointLedger(game) → Point[]
+
+
forward possession model (the tested one)
+
halftime possession flip
+
break / hold / failed-conversion per point
+
O-line / D-line attribution
+
opponent stats by symmetry (toSummaryStats)
+
+
+
+
+ timeline + progression + efficiency + StatsCalculator +
+
+
+
+ +

Solution. Deepen calculateLineStats into a Point Ledger module in shared: one pass over events producing annotated points (scorer, break/hold, line, halftime crossing). Delete isBreakScore and the timestamp halftime heuristic; both web renderers and the bot's StatsCalculator read the ledger. Deletion test on isBreakScore: its complexity reappears — inside the ledger, once.

+ +
    +
  • locality: possession rules in one module
  • +
  • leverage: one interface, 5+ call sites
  • +
  • timeline and stats can no longer disagree
  • +
  • existing utils.test.ts suite carries over
  • +
  • untested divergent algorithm gets deleted
  • +
  • pure function — the interface is the test surface
  • +
+
+ + +
+
+

2 · A GameStore behind one seam: concentrate the DO ↔ D1 dual-write

+ Strong + local-substitutable +
+ +
+ bot/src/api/router.ts:179–250, 338–389 · bot/src/durable-objects/GameState.ts:31–104, 337–369 · bot/src/db/database.ts:14–96 +
+ +

Problem. Every write to a game is a dual-write — Durable Object memory plus D1 — but the pairing rules live in the Router, not behind a seam. mutateGameViaDO writes DO-first-then-D1; updateGame writes D1-first and fire-and-forgets the DO. The five-field patch logic is implemented twice (GameState and Router). Which store is authoritative depends on whether the DO is warm. This seam has already bitten: a comment in database.ts documents that INSERT OR REPLACE cascade-deleted a game's events. None of it is tested — no router.test.ts, no database.test.ts.

+ +
+
+
Before — Router orchestrates two stores, two orderings
+
+
+sequenceDiagram
+  participant R as Router
+  participant DO as GameState DO
+  participant D1 as DatabaseService
+  Note over R: addEvent / undo / delete
+  R->>DO: POST /events (magic path)
+  DO-->>R: {game}
+  R->>D1: saveStrategy? events : metadata
+  Note over R: updateGame — opposite order
+  R->>D1: saveGameMetadata FIRST
+  R--)DO: PUT /update (fire & forget)
+  Note over DO,D1: divergence on failure
+                
+
+
+
+
After — one GameStore module owns the pairing
+
+
+ HTTP Router (thin adapter) +
+
+
+
interface · GameStore
+
get · addEvent · update · undo · deleteEvent · setLineups
+
+
write ordering (DO → D1, always)
+
save-strategy selection
+
rehydration on DO eviction
+
single updateFields implementation
+
+
+
+ + DO adapter + D1 adapter + in-memory fakes (tests) +
+
+
+
+ +

Solution. Extract a GameStore module whose implementation owns both writes, the ordering, and rehydration. The Router shrinks to an HTTP adapter: parse request → call GameStore → serialize. GameStore accepts its DO-namespace and D1 dependencies rather than creating them — two adapters justify the seam: real bindings in prod, in-memory fakes in tests. The dual-write invariants (the exact logic behind the past CASCADE data-loss bug) finally get a test surface.

+ +
    +
  • locality: sync rules in one implementation
  • +
  • dual-write ordering becomes an invariant, not a convention
  • +
  • tests hit one interface, no miniflare
  • +
  • updateFields duplication deleted
  • +
  • interface shrinks: 6 verbs replace 10 magic paths
  • +
+
+ + +
+
+

3 · One loader harness for 22 tournament scripts

+ Strong + ports & adapters +
+ +
+ bot/scripts/load-*.ts, reload-*.ts, fix-*.ts — 22 files, ~5,300 lines +
+ +

Problem. Each tournament was onboarded by copying the previous script. 22 files each declare API_URL — and they disagree (9 workers.dev, 5 kcuda.org, 4 localhost, 4 mixed). 13 re-declare the Event shape instead of importing AddEventRequest from shared. 13 carry a hand-rolled edt() time helper with the game's date and a DST offset baked into the body. Deletion test: delete any one script and the logic doesn't vanish — it's already reappeared 13 times.

+ +
+
+
Before — 22 shallow modules, interface ≈ implementation
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ copied logic (API_URL, edt(), addEvent, Event type) + actual game data +
+
+
+
After — one deep loader, scripts become data
+
+
+ yula-day1.ts + states.ts + amherst.ts + + ← games + events only +
+
+
+
interface · loadTournament(spec)
+
+
one API_URL, env-driven
+
timezone-correct time helper (date is data)
+
shared AddEventRequest type from @scorebot/shared
+
createGame / addEvent / endGame HTTP adapter
+
idempotent re-run (reload = same interface)
+
+
+
+
+
+ +

Solution. One scripts/lib/loader.ts module: loadTournament(spec) takes a declarative tournament spec (date, games, events); its implementation owns the HTTP adapter, the timezone math, and idempotent re-runs. Existing scripts shrink to data. The "fix" scripts (fix-missing-dplays et al.) become ordinary re-loads through the same interface.

+ +
    +
  • leverage: one implementation, 22 call sites
  • +
  • next tournament = a data file, zero logic
  • +
  • one API_URL instead of 22 disagreeing ones
  • +
  • DST bugs concentrate in one time helper
  • +
  • ~2,000 lines of copied logic deleted
  • +
+
+ + +
+
+

4 · Finish the gameClient seam: one path from pages to the API

+ Worth exploring + ports & adapters +
+ +
+ web/src/api/gameClient.ts:7–24 · web/src/main.ts:8,15,151 · web/src/stats.ts:15,56–70,157–179 · web/src/games.ts:11,32–49 +
+ +

Problem. The seam exists — gameClient.ts — but only main.ts crosses it. stats.ts and games.ts route around it with hand-rolled fetches; API_BASE_URL is declared three times; “list games” has two different URLs (/games vs /games?limit=100); each page owns its own setInterval poller and try/catch/showError block. The client currently fails the deletion test — one caller — because the other pages leak past it.

+ +
+
+
Before — seam bypassed by 2 of 3 pages
+
+
+flowchart LR
+  M[main.ts] --> C[gameClient]
+  C --> API[(Worker API)]
+  S[stats.ts] -- "own fetch ×2
own API_BASE_URL" --> API + GA[games.ts] -- "own fetch + poller
own API_BASE_URL" --> API + linkStyle 2,3 stroke:#dc2626,stroke-width:2px; +
+
+
+
+
After — every page crosses the same seam
+
+
+flowchart LR
+  M[main.ts] --> C
+  S[stats.ts] --> C
+  GA[games.ts] --> C
+  subgraph C[gameClient — deep]
+    direction TB
+    I["fetchGames · fetchGame · fetchStats
poll(fn, interval) · one base URL · one error path"] + end + C --> API[(Worker API)] + style C fill:#0f172a,color:#fff,stroke:#0f172a + style I fill:#1e293b,color:#cbd5e1,stroke:#334155 +
+
+
+
+ +

Solution. Deepen gameClient into the only module that knows the base URL, endpoints, error handling, and polling cadence; pages call typed functions. With a fake adapter at this seam, page logic becomes testable without a network — two adapters make it a real seam.

+ +
    +
  • locality: endpoint/auth changes in one module
  • +
  • “list games” means one thing again
  • +
  • poller written once, three call sites
  • +
  • fake adapter unlocks page tests
  • +
+
+ + +
+
+

5 · Give renderers an interface: data in, element out

+ Speculative + in-process +
+ +
+ web/src/components/*.ts · web/src/stats.ts:22,293,380 · web/index.html · web/stats.html +
+ +

Problem. Every renderer is a void function that reaches into document.getElementById('…') for elements that must pre-exist in the HTML scaffold. The interface between TS and HTML is a bag of ~30 untyped string IDs checked by nothing, and stats.ts types its inputs as any despite PlayerStats/GameContextStats existing in shared. Result: zero web tests, because nothing can be exercised without booting the exact page DOM.

+ +
+
+
Before — interface as wide as the implementation
+ +
+
+
interface: 30+ element IDs, ambient DOM, any inputs, call-order rules
+
implementation: formatting + table building
+
shallow
+
+
+
+
+
After — short interface, tall implementation
+
+
+
render(stats: PlayerStats[]) → HTMLElement
+
implementation: all formatting, tables, badges; page shell mounts the result
+
deep
+
+
+
+
+ +

Solution. Renderers take typed data and return an element; only the page shell touches document. Restore the shared types at the interface (delete the anys). Speculative because it touches every component for a payoff that mostly lands if web tests are actually wanted — a cheap first step is just typing the seam: replace any with the shared types, no structural change.

+ +
    +
  • the interface becomes the test surface
  • +
  • compiler checks the shared contract again
  • +
  • ID-string coupling to HTML shrinks to the shell
  • +
+
+ + +
+

Smaller findings (no card warranted)

+
    +
  • Three regexes recognize “a defensive play in a note” — MessageParser.ts:197, StatsCalculator.ts:113, shared/utils.ts:177. Folds naturally into the Point Ledger work (candidate 1) or a small shared recognizer.
  • +
  • Dead export: getGameDuration (shared/utils.ts:74) has no caller outside its own tests — a tested pass-through; delete it. getGameByChatId (database.ts:164) is likewise unused.
  • +
  • shared/utils is two things fused: a deep core (types + calculateLineStats) plus single-caller one-liners (formatScore, formatTime) that add interface without leverage.
  • +
  • “Point index” is reconstructed three ways — possession toggle, goal-event index, and lineups[].pointNumber — assumed aligned with no shared helper guaranteeing it. Also folds into candidate 1.
  • +
  • innerHTML vs createElement coexist with no rule (games.ts:172, timeline.ts:103 vs the rest) — pick one in candidate 5.
  • +
+
+
+ + +
+
+
Top recommendation
+

Start with the Point Ledger (candidate 1)

+

It deepens the domain heart of the whole system — break/hold is what the product is — and both adapters already exist (bot StatsCalculator and web renderers), so the seam is real, not hypothetical. It's a pure-function refactor with a test suite already in place to extend, it deletes an untested divergent algorithm that can visibly disagree with the stats on the same page, and it hands candidates 2 and 5 a cleaner shape to build on. The GameStore (candidate 2) is the follow-up: it guards the one seam that has already caused data loss.

+
+
+ + +
+ + diff --git a/packages/bot/build.js b/packages/bot/build.js index 46e5d9e..b0df8a3 100644 --- a/packages/bot/build.js +++ b/packages/bot/build.js @@ -32,7 +32,9 @@ async function build() { format: 'esm', platform: 'browser', target: 'es2022', - external: [], + // `cloudflare:workers` is provided by the Workers runtime — keep it + // external so workerd resolves it instead of esbuild trying to bundle it. + external: ['cloudflare:workers'], conditions: ['worker', 'browser'], mainFields: ['browser', 'module', 'main'], resolveExtensions: ['.ts', '.js'], diff --git a/packages/bot/scripts/add-halftime-events.ts b/packages/bot/scripts/archive/add-halftime-events.ts similarity index 100% rename from packages/bot/scripts/add-halftime-events.ts rename to packages/bot/scripts/archive/add-halftime-events.ts diff --git a/packages/bot/scripts/delete-games.ts b/packages/bot/scripts/archive/delete-games.ts similarity index 100% rename from packages/bot/scripts/delete-games.ts rename to packages/bot/scripts/archive/delete-games.ts diff --git a/packages/bot/scripts/fix-missing-dplays.ts b/packages/bot/scripts/archive/fix-missing-dplays.ts similarity index 100% rename from packages/bot/scripts/fix-missing-dplays.ts rename to packages/bot/scripts/archive/fix-missing-dplays.ts diff --git a/packages/bot/scripts/archive/load-amherst-invite.ts b/packages/bot/scripts/archive/load-amherst-invite.ts new file mode 100644 index 0000000..d530966 --- /dev/null +++ b/packages/bot/scripts/archive/load-amherst-invite.ts @@ -0,0 +1,428 @@ +/** + * Load Tech Support games: + * - vs Hunter (4/27/26 HSL game) — Tech 11-4 W + * - Amherst Invite 2026 (5/2-5/3, UMass Amherst): + * 1. vs Amherst BxJVA (5/2 AM) — Tech 13-5 W + * 2. vs Arlington (5/2 AM) — Tech 11-9 W + * 3. vs St. John's Prep (5/2 AM) — Tech 13-2 W + * 4. vs Northampton (5/2 PM) — Tech 10-9 W (universe) + * 5. vs St. John's Prep — semis (5/3) — Tech 13-2 W + * 6. vs Four Rivers — final (5/3) — Tech 9-10 L (hard cap) + * + * Run: API_URL=https://api.score.kcuda.org npx tsx scripts/load-amherst-invite.ts + */ + +const API_URL = process.env.API_URL || 'https://api.score.kcuda.org'; + +const EventType = { + GAME_START: 'game_start', + GOAL: 'goal', + HALFTIME: 'halftime', + SECOND_HALF_START: 'second_half_start', + GAME_END: 'game_end', + TIMEOUT: 'timeout', + NOTE: 'note', +} as const; +type EventType = typeof EventType[keyof typeof EventType]; + +type EventInput = { + type: EventType; + team?: 'us' | 'them'; + message: string; + playerName?: string; + assistName?: string; + defensivePlay?: 'block' | 'steal'; +}; + +type GameSpec = { + chatId: string; + ourTeamName: string; + opponentName: string; + tournamentName: string; + gameDate: string; + gameOrder: number; + startingOnOffense: boolean; + events: EventInput[]; +}; + +// Helpers to keep event lists readable. +const goalUs = (assist: string, scorer: string, score: string, dPlay?: 'block' | 'steal'): EventInput => ({ + type: EventType.GOAL, + team: 'us', + message: `${assist} to ${scorer} ${score}`, + playerName: scorer, + assistName: assist, + ...(dPlay && { defensivePlay: dPlay }), +}); +const goalThem = (opp: string, score: string): EventInput => ({ + type: EventType.GOAL, + team: 'them', + message: `${opp} scores ${score}`, +}); +const block = (player: string): EventInput => ({ + type: EventType.NOTE, + team: 'us', + message: `${player} block`, + playerName: player, + defensivePlay: 'block', +}); +const steal = (player: string): EventInput => ({ + type: EventType.NOTE, + team: 'us', + message: `${player} steal`, + playerName: player, + defensivePlay: 'steal', +}); +const note = (msg: string): EventInput => ({ type: EventType.NOTE, message: msg }); +const half = (): EventInput => ({ type: EventType.HALFTIME, message: 'Halftime' }); +const timeoutThem = (opp: string): EventInput => ({ type: EventType.TIMEOUT, team: 'them', message: `Timeout ${opp}` }); +const timeoutUs = (): EventInput => ({ type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }); + +const games: GameSpec[] = [ + // ────────────────────────────────────────────────────────────────── + // Game vs Hunter — 4/27/26, HSL + // Broadcast by Irene (subbing in); some early-half attribution inferred. + // ────────────────────────────────────────────────────────────────── + { + chatId: 'apr27-hunter-hsl', + ourTeamName: 'Tech Support', + opponentName: 'Hunter', + tournamentName: 'High School League', + gameDate: '2026-04-27', + gameOrder: 1, + startingOnOffense: true, + events: [ + goalThem('Hunter', '0-1'), // Tech turnover, Hunter scored + goalUs('Asher', 'Theo', '1-1'), + block('Max'), + { type: EventType.GOAL, team: 'us', message: '2-1', defensivePlay: 'block' }, // no passer named + block('Max'), + goalUs('Ellis', 'Foster', '3-1', 'block'), + goalThem('Hunter', '3-2'), + goalUs('Asher', 'Gus', '4-2'), + goalUs('Nico', 'Cyrus', '5-2'), + goalUs('Mason', 'Jake', '6-2'), + goalUs('Foster', 'Cyrus', '7-2'), + half(), + goalUs('Asher', 'Nico', '8-2'), + block('Cyrus'), + goalUs('Mason', 'Jake', '9-2', 'block'), + goalThem('Hunter', '9-3'), + goalUs('Mason', 'Gus', '10-3'), + goalThem('Hunter', '10-4'), + goalUs('Alex', 'Cyrus', '11-4'), + ], + }, + + // ────────────────────────────────────────────────────────────────── + // Game 1 vs Amherst BxJVA — 5/2/26 ~8:46 AM + // ────────────────────────────────────────────────────────────────── + { + chatId: 'amherst-may2-game1-bxjva', + ourTeamName: 'Tech Support', + opponentName: 'Amherst BxJVA', + tournamentName: 'Amherst Invite 2026', + gameDate: '2026-05-02', + gameOrder: 1, + startingOnOffense: true, + events: [ + block('Nate'), + goalUs('Nico', 'Alex', '1-0', 'block'), + goalUs('Mason', 'Foster', '2-0'), + block('Teyo'), + block('Anatole'), + goalUs('Anatole', 'Teyo', '3-0', 'block'), + block('Jake'), + goalUs('Jake', 'Noah', '4-0', 'block'), + timeoutThem('Amherst'), + block('Nico'), + steal('Mason'), + goalUs('Nico', 'Nate', '5-0', 'steal'), + block('Jake'), + goalUs('Alex', 'Foster', '6-0', 'block'), + block('Mason'), + goalUs('Asher', 'Theo', '7-0', 'block'), + half(), + goalUs('Asher', 'Anatole', '8-0'), + goalThem('Amherst', '8-1'), + goalUs('Mason', 'Max', '9-1'), + goalThem('Amherst', '9-2'), + block('Dock'), + goalUs('Mason', 'Jake', '10-2', 'block'), + goalThem('Amherst', '10-3'), + goalUs('Alex', 'Gus', '11-3'), + block('Marley'), + goalUs('Mason', 'Foster', '12-3', 'block'), + goalThem('Amherst', '12-4'), + block('Marley'), + goalThem('Amherst', '12-5'), + goalUs('Jed', 'Anatole', '13-5'), + ], + }, + + // ────────────────────────────────────────────────────────────────── + // Game 2 vs Arlington — 5/2/26 ~10:15 AM + // ────────────────────────────────────────────────────────────────── + { + chatId: 'amherst-may2-game2-arlington', + ourTeamName: 'Tech Support', + opponentName: 'Arlington', + tournamentName: 'Amherst Invite 2026', + gameDate: '2026-05-02', + gameOrder: 2, + startingOnOffense: true, + events: [ + goalThem('Arlington', '0-1'), + steal('Gus'), + goalUs('Marley', 'Gus', '1-1', 'steal'), + goalThem('Arlington', '1-2'), + goalThem('Arlington', '1-3'), + timeoutUs(), + note('Mason tip'), + block('Corbin'), + goalUs('Mason', 'Nate', '2-3', 'block'), + block('Ellis'), + note('Nico foot block'), + goalUs('Mason', 'Nico', '3-3', 'block'), + block('Noah'), + timeoutThem('Arlington'), + goalUs('Nico', 'Alex', '4-3'), + block('Mason'), + goalUs('Mason', 'Jake', '5-3', 'block'), // huck + goalUs('Nico', 'Cyrus', '6-3'), // deep + goalThem('Arlington', '6-4'), + half(), + block('Ellis'), + goalUs('Mason', 'Jake', '7-4', 'block'), + block('Nate'), + goalUs('Jed', 'Alex', '8-4', 'block'), + goalThem('Arlington', '8-5'), + goalUs('Ellis', 'Alex', '9-5'), + goalThem('Arlington', '9-6'), + goalThem('Arlington', '9-7'), + timeoutUs(), + goalThem('Arlington', '9-8'), + goalUs('Ellis', 'Anatole', '10-8'), + goalThem('Arlington', '10-9'), + goalUs('Ellis', 'Nico', '11-9'), + ], + }, + + // ────────────────────────────────────────────────────────────────── + // Game 3 vs St. John's Prep — 5/2/26 ~11:45 AM (pool) + // ────────────────────────────────────────────────────────────────── + { + chatId: 'amherst-may2-game3-sjp', + ourTeamName: 'Tech Support', + opponentName: "St. John's Prep", + tournamentName: 'Amherst Invite 2026', + gameDate: '2026-05-02', + gameOrder: 3, + startingOnOffense: true, + events: [ + goalUs('Ellis', 'Nate', '1-0'), // layout + goalUs('Mason', 'Nico', '2-0'), + timeoutThem("St. John's Prep"), + block('Jake'), + goalUs('Mason', 'Ben', '3-0', 'block'), // huck + goalThem("St. John's Prep", '3-1'), + goalUs('Alex', 'Marley', '4-1'), + block('Jake'), + goalUs('Mason', 'Foster', '5-1', 'block'), + block('Noah'), + goalUs('Nico', 'Noah', '6-1', 'block'), + goalUs('Jake', 'Theo', '7-1'), + half(), + steal('Nico'), + goalUs('Mason', 'Jake', '8-1', 'steal'), + goalUs('Ellis', 'Anatole', '9-1'), + block('Jake'), + block('Nico'), + steal('Jake'), + goalUs('Mason', 'Jake', '10-1', 'steal'), + goalUs('Alex', 'Nate', '11-1'), + goalThem("St. John's Prep", '11-2'), + goalUs('Ellis', 'Max', '12-2'), + goalUs('Nico', 'Foster', '13-2'), + ], + }, + + // ────────────────────────────────────────────────────────────────── + // Game 4 vs Northampton — 5/2/26 ~4:09 PM (pool, universe) + // ────────────────────────────────────────────────────────────────── + { + chatId: 'amherst-may2-game4-northampton', + ourTeamName: 'Tech Support', + opponentName: 'Northampton', + tournamentName: 'Amherst Invite 2026', + gameDate: '2026-05-02', + gameOrder: 4, + startingOnOffense: true, + events: [ + steal('Ellis'), + goalUs('Marley', 'Gus', '1-0', 'steal'), + goalThem('Northampton', '1-1'), + goalUs('Gus', 'Nate', '2-1'), + goalThem('Northampton', '2-2'), + goalUs('Ellis', 'Gus', '3-2'), // deep + goalUs('Ellis', 'Nico', '4-2'), + goalUs('Mason', 'Teyo', '5-2'), + timeoutThem('Northampton'), + goalThem('Northampton', '5-3'), + block('Alex'), + goalUs('Ellis', 'Alex', '6-3', 'block'), + block('Nico'), + block('Ben'), + goalThem('Northampton', '6-4'), + half(), + goalThem('Northampton', '6-5'), + goalUs('Ellis', 'Alex', '7-5'), + goalThem('Northampton', '7-6'), + goalThem('Northampton', '7-7'), + goalUs('Nate', 'Max', '8-7'), + timeoutThem('Northampton'), + block('Jake'), + goalThem('Northampton', '8-8'), + goalUs('Alex', 'Jake', '9-8'), + goalThem('Northampton', '9-9'), + goalUs('Ellis', 'Jake', '10-9'), // ftw, universe + ], + }, + + // ────────────────────────────────────────────────────────────────── + // Game 5 vs St. John's Prep — 5/3/26 ~11:15 AM (semis) + // ────────────────────────────────────────────────────────────────── + { + chatId: 'amherst-may3-semi-sjp', + ourTeamName: 'Tech Support', + opponentName: "St. John's Prep", + tournamentName: 'Amherst Invite 2026', + gameDate: '2026-05-03', + gameOrder: 1, + startingOnOffense: true, + events: [ + goalUs('Ellis', 'Cyrus', '1-0'), + goalUs('Toby', 'Mason', '2-0'), + goalUs('Nico', 'Foster', '3-0'), + block('Gus'), + goalThem("St. John's Prep", '3-1'), + goalUs('Ellis', 'Toby', '4-1'), + goalThem("St. John's Prep", '4-2'), + goalUs('Alex', 'Ellis', '5-2'), + goalUs('Jed', 'Nico', '6-2'), + timeoutUs(), + block('Toby'), + block('Toby'), + goalUs('Ellis', 'Nico', '7-2', 'block'), + half(), + goalUs('Mason', 'Nico', '8-2'), + goalUs('Toby', 'Nico', '9-2'), + goalUs('Cyrus', 'Nate', '10-2'), + goalUs('Cyrus', 'Corbin', '11-2'), + block('Asher'), + block('Teyo'), + goalUs('Toby', 'Foster', '12-2', 'block'), + block('Gus'), + goalUs('Max', 'Gus', '13-2', 'block'), // ftw + ], + }, + + // ────────────────────────────────────────────────────────────────── + // Game 6 vs Four Rivers — 5/3/26 ~2:15 PM (final, hard cap loss) + // ────────────────────────────────────────────────────────────────── + { + chatId: 'amherst-may3-final-fourrivers', + ourTeamName: 'Tech Support', + opponentName: 'Four Rivers', + tournamentName: 'Amherst Invite 2026', + gameDate: '2026-05-03', + gameOrder: 2, + startingOnOffense: false, // Tech started on D + events: [ + goalUs('Ellis', 'Teyo', '1-0'), // diving grab + block('Toby'), + goalThem('Four Rivers', '1-1'), + goalThem('Four Rivers', '1-2'), + goalUs('Mason', 'Nate', '2-2'), // huck + goalThem('Four Rivers', '2-3'), + goalUs('Mason', 'Nate', '3-3'), + goalThem('Four Rivers', '3-4'), + block('Mason'), + goalUs('Jed', 'Nate', '4-4', 'block'), + goalThem('Four Rivers', '4-5'), + goalThem('Four Rivers', '4-6'), + block('Alex'), + block('Corbin'), + goalUs('Alex', 'Nate', '5-6', 'block'), + block('Foster'), + block('Gus'), + goalUs('Mason', 'Jed', '6-6', 'block'), + timeoutUs(), + steal('Gus'), + goalThem('Four Rivers', '6-7'), + half(), + goalThem('Four Rivers', '6-8'), + goalUs('Ellis', 'Corbin', '7-8'), + block('Mason'), + goalThem('Four Rivers', '7-9'), + goalThem('Four Rivers', '7-10'), + goalUs('Nico', 'Max', '8-10'), + block('Teyo'), + goalUs('Ellis', 'Nico', '9-10'), // confirmed by Patrick after the game + ], + }, +]; + +async function createGame(spec: GameSpec): Promise { + console.log(`\n→ ${spec.opponentName} (${spec.gameDate}, order ${spec.gameOrder})`); + const res = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: spec.chatId, + ourTeamName: spec.ourTeamName, + opponentName: spec.opponentName, + tournamentName: spec.tournamentName, + gameDate: spec.gameDate, + gameOrder: spec.gameOrder, + }), + }); + if (!res.ok) throw new Error(`createGame failed: ${res.status} ${await res.text()}`); + const { game } = (await res.json()) as { game: { id: string } }; + return game.id; +} + +async function postEvent(gameId: string, body: object): Promise { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`postEvent failed: ${res.status} ${await res.text()}`); +} + +async function loadGame(spec: GameSpec): Promise { + const gameId = await createGame(spec); + await postEvent(gameId, { + type: EventType.GAME_START, + message: `Game start - Tech starting on ${spec.startingOnOffense ? 'offense' : 'defense'}`, + startingOnOffense: spec.startingOnOffense, + }); + for (const ev of spec.events) await postEvent(gameId, ev); + await postEvent(gameId, { type: EventType.GAME_END, message: 'Game complete' }); + console.log(` ✓ ${spec.events.length} events → ${gameId}`); +} + +async function main() { + console.log(`API: ${API_URL}`); + const filterChat = process.env.ONLY_CHAT_ID; + const toLoad = filterChat ? games.filter((g) => g.chatId === filterChat) : games; + console.log(`Loading ${toLoad.length} games...`); + for (const spec of toLoad) await loadGame(spec); + console.log('\n✅ Done. View at https://score.kcuda.org'); +} + +main().catch((err) => { + console.error('❌', err); + process.exit(1); +}); diff --git a/packages/bot/scripts/archive/load-battle-of-hudson.ts b/packages/bot/scripts/archive/load-battle-of-hudson.ts new file mode 100644 index 0000000..b432fee --- /dev/null +++ b/packages/bot/scripts/archive/load-battle-of-hudson.ts @@ -0,0 +1,219 @@ +/** + * Script to load Battle of the Hudson tournament games (3/8/26) + */ + +const API_URL = 'https://scorebot-api.siener.workers.dev'; + +// Helper to convert "3/8/26, HH:MM:SS AM/PM" to unix ms in Eastern time +function et(time: string): number { + // Parse time like "8:59:04 AM" or "12:51:32 PM" + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const ampm = match[4].toUpperCase(); + if (ampm === 'PM' && hours !== 12) hours += 12; + if (ampm === 'AM' && hours === 12) hours = 0; + // March 8, 2026 is DST spring-forward day, but clocks change at 2 AM + // All game times are after 2 AM, so EDT (UTC-4) applies + const date = new Date(Date.UTC(2026, 2, 8, hours + 4, minutes, seconds)); + return date.getTime(); +} + +interface GameData { + ourTeamName: string; + opponentName: string; + chatId: string; + startingOnOffense?: boolean; + tournamentName?: string; + gameDate?: string; + gameOrder?: number; + events: Array<{ + type: string; + team?: string; + message: string; + defensivePlay?: 'block' | 'steal'; + timestamp?: number; + }>; +} + +const games: GameData[] = [ + // Game 1: Tech Support vs Montclair - Final: 13-4 + { + ourTeamName: 'Tech Support', + opponentName: 'Montclair', + chatId: 'mar8-game1-montclair', + startingOnOffense: false, + tournamentName: 'Battle of the Hudson', + gameDate: '2026-03-08', + gameOrder: 1, + events: [ + { type: 'game_start', message: 'First pull', timestamp: et('8:59:04 AM') }, + { type: 'goal', team: 'them', message: '0-1 Montclair', timestamp: et('9:01:05 AM') }, + { type: 'goal', team: 'us', message: '1-1 Ellis to Jake', timestamp: et('9:05:21 AM') }, + { type: 'goal', team: 'them', message: '1-2', timestamp: et('9:08:55 AM') }, + { type: 'goal', team: 'us', message: '2-2 Ellis to Alex', timestamp: et('9:12:09 AM') }, + { type: 'goal', team: 'us', message: '3-2 Mason greatest to Nico', timestamp: et('9:16:11 AM') }, + { type: 'goal', team: 'them', message: '3-3', timestamp: et('9:20:43 AM') }, + { type: 'goal', team: 'us', message: '4-3 Corbin to Ellis', timestamp: et('9:24:02 AM') }, + { type: 'goal', team: 'us', message: '5-3 Nico to Jake', timestamp: et('9:27:35 AM') }, + { type: 'goal', team: 'us', message: '6-3 Mason to Jake', timestamp: et('9:31:58 AM') }, + { type: 'timeout', team: 'them', message: 'Timeout Montclair', timestamp: et('9:32:59 AM') }, + { type: 'goal', team: 'us', message: '7-3 Ellis to Corbin', defensivePlay: 'block', timestamp: et('9:39:51 AM') }, + { type: 'halftime', message: 'Half', timestamp: et('9:40:00 AM') }, + { type: 'goal', team: 'us', message: '8-3 Ellis blade to Alex', timestamp: et('9:49:08 AM') }, + { type: 'goal', team: 'us', message: '9-3 Mason to Jake', timestamp: et('9:51:40 AM') }, + { type: 'goal', team: 'them', message: '9-4', timestamp: et('9:54:14 AM') }, + { type: 'goal', team: 'us', message: '10-4 Nico deep to Cyrus', timestamp: et('9:57:13 AM') }, + { type: 'goal', team: 'us', message: '11-4 Mason hammer to Asher', timestamp: et('9:59:31 AM') }, + { type: 'goal', team: 'us', message: '12-4 Mason hammer to Asher', timestamp: et('10:01:46 AM') }, + { type: 'goal', team: 'us', message: '13-4 Cyrus to Jake', defensivePlay: 'block', timestamp: et('10:08:24 AM') }, + { type: 'game_end', message: 'Final: 13-4', timestamp: et('10:08:53 AM') }, + ], + }, + + // Game 2: Tech Support vs Columbia High School - Final: 13-12 + { + ourTeamName: 'Tech Support', + opponentName: 'Columbia High School', + chatId: 'mar8-game2-columbia', + startingOnOffense: true, + tournamentName: 'Battle of the Hudson', + gameDate: '2026-03-08', + gameOrder: 2, + events: [ + { type: 'game_start', message: 'Tech starting on O in lights', timestamp: et('10:59:30 AM') }, + { type: 'goal', team: 'us', message: '1-0 Mason to Gus', timestamp: et('11:02:07 AM') }, + { type: 'goal', team: 'them', message: '1-1', timestamp: et('11:04:15 AM') }, + { type: 'goal', team: 'us', message: '2-1 Mason hammer to Corbin', timestamp: et('11:08:02 AM') }, + { type: 'goal', team: 'them', message: '2-2', timestamp: et('11:11:12 AM') }, + { type: 'note', message: 'Jake steal', timestamp: et('11:10:29 AM') }, + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: et('11:11:21 AM') }, + { type: 'goal', team: 'them', message: '2-3', timestamp: et('11:15:29 AM') }, + { type: 'goal', team: 'us', message: '3-3 Mason deep to Jake', timestamp: et('11:18:50 AM') }, + { type: 'note', message: 'Toby block', timestamp: et('11:21:56 AM') }, + { type: 'goal', team: 'them', message: '3-4', timestamp: et('11:23:41 AM') }, + { type: 'goal', team: 'them', message: '3-5', timestamp: et('11:26:32 AM') }, + { type: 'goal', team: 'us', message: '4-5 Mason to Alex', timestamp: et('11:30:46 AM') }, + { type: 'goal', team: 'us', message: '5-5 Mason to Toby', timestamp: et('11:33:47 AM') }, + { type: 'note', message: 'Toby block', timestamp: et('11:35:56 AM') }, + { type: 'goal', team: 'them', message: '5-6', timestamp: et('11:37:22 AM') }, + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: et('11:37:49 AM') }, + { type: 'goal', team: 'us', message: '6-6 Ellis to Mason', timestamp: et('11:43:28 AM') }, + { type: 'goal', team: 'them', message: '6-7', timestamp: et('11:47:09 AM') }, + { type: 'halftime', message: 'Half', timestamp: et('11:48:13 AM') }, + { type: 'goal', team: 'them', message: '6-8', timestamp: et('12:00:09 PM') }, + { type: 'goal', team: 'them', message: '6-9', timestamp: et('12:05:52 PM') }, + { type: 'goal', team: 'us', message: '7-9 Ellis to Jake', timestamp: et('12:09:48 PM') }, + { type: 'goal', team: 'them', message: '7-10', timestamp: et('12:12:04 PM') }, + { type: 'goal', team: 'us', message: '8-10 Alex to Jake', timestamp: et('12:14:54 PM') }, + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: et('12:20:50 PM') }, + { type: 'goal', team: 'us', message: '9-10 Mason deep to Jake', defensivePlay: 'block', timestamp: et('12:25:30 PM') }, + { type: 'goal', team: 'them', message: '9-11', timestamp: et('12:27:45 PM') }, + { type: 'goal', team: 'them', message: '9-12', timestamp: et('12:31:13 PM') }, + { type: 'goal', team: 'us', message: '10-12 Jake to Gus', timestamp: et('12:33:33 PM') }, + { type: 'goal', team: 'us', message: '11-12 Ellis to Alex', defensivePlay: 'steal', timestamp: et('12:35:56 PM') }, + { type: 'goal', team: 'us', message: '12-12 Ellis to Alex', timestamp: et('12:43:06 PM') }, + { type: 'goal', team: 'us', message: '13-12 Ellis to Alex', defensivePlay: 'block', timestamp: et('12:51:16 PM') }, + { type: 'game_end', message: 'Final: 13-12 Universe point', timestamp: et('12:51:32 PM') }, + ], + }, +]; + +async function deleteGame(gameId: string) { + // Delete via D1 directly isn't possible from here, so we use the API + const response = await fetch(`${API_URL}/games/${gameId}`, { method: 'DELETE' }); + if (response.ok) { + console.log(` Deleted game ${gameId}`); + } else { + console.log(` Could not delete ${gameId}: ${response.status}`); + } +} + +async function createGame(game: GameData) { + console.log(`\n📊 Creating game: ${game.ourTeamName} vs ${game.opponentName}`); + + const createResponse = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: game.chatId, + ourTeamName: game.ourTeamName, + opponentName: game.opponentName, + tournamentName: game.tournamentName, + gameDate: game.gameDate, + gameOrder: game.gameOrder, + }), + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create game: ${await createResponse.text()}`); + } + + const { game: createdGame } = await createResponse.json(); + console.log(`✅ Game created: ${createdGame.id}`); + + for (const event of game.events) { + const eventPayload: any = { + type: event.type, + message: event.message, + }; + + if (event.team) eventPayload.team = event.team; + if (event.defensivePlay) eventPayload.defensivePlay = event.defensivePlay; + if (event.timestamp) eventPayload.timestamp = event.timestamp; + + if (event.type === 'game_start' && game.startingOnOffense !== undefined) { + eventPayload.startingOnOffense = game.startingOnOffense; + } + + const eventResponse = await fetch(`${API_URL}/games/${createdGame.id}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(eventPayload), + }); + + if (!eventResponse.ok) { + console.error(`❌ Failed to add event: ${event.message}`); + console.error(await eventResponse.text()); + continue; + } + + const defensiveInfo = event.defensivePlay ? ` [${event.defensivePlay}]` : ''; + console.log(` ✓ ${event.message}${defensiveInfo}`); + } + + console.log(`🎉 Game loaded successfully!`); + return createdGame; +} + +async function main() { + console.log('🚀 Loading Battle of the Hudson games (3/8/26)...\n'); + console.log(`API URL: ${API_URL}\n`); + + // First, find and delete existing Battle of the Hudson games + console.log('🗑️ Checking for existing games to replace...'); + const listResponse = await fetch(`${API_URL}/games`); + const { games: existingGames } = await listResponse.json(); + for (const g of existingGames) { + if (g.gameDate === '2026-03-08') { + await deleteGame(g.id); + } + } + + console.log('\n📥 Loading games with timestamps...\n'); + + for (const game of games) { + try { + await createGame(game); + } catch (error) { + console.error(`❌ Error loading game:`, error); + } + } + + console.log('\n✅ All games loaded!'); + console.log(`\nView at: https://score.kcuda.org`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-fall-tournaments.ts b/packages/bot/scripts/archive/load-fall-tournaments.ts new file mode 100644 index 0000000..2544e75 --- /dev/null +++ b/packages/bot/scripts/archive/load-fall-tournaments.ts @@ -0,0 +1,787 @@ +/** + * Script to load Fall 2025 tournament games: + * - Fall Flock 2025 (Oct 11-12) + * - Halloween Havoc 2025 (Oct 26) + * - Coconut Classic 2025 (Nov 1-2) + * - Hucksgiving 2025 (Nov 22) + * Run with: node --loader ts-node/esm scripts/load-fall-tournaments.ts + */ + +import { EventType } from '@scorebot/shared'; + +interface GameData { + chatId: string; + ourTeamName: string; + opponentName: string; + tournamentName: string; + gameDate: string; // YYYY-MM-DD format + gameOrder: number; // Order within tournament day + startTime: string; // HH:MM format for actual game start time + startingOnOffense: boolean; + events: Array<{ + type: EventType; + team?: 'us' | 'them'; + message: string; + playerName?: string; + assistName?: string; + defensivePlay?: 'block' | 'steal'; + }>; +} + +const API_URL = process.env.API_URL || 'http://localhost:8787'; + +// All tournament games with detailed events +const games: GameData[] = [ + // Fall Flock - October 11, 2025 + { + chatId: 'fall-flock-oct11-game1', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Columbia JV', + tournamentName: 'Fall Flock 2025', + gameDate: '2025-10-11', + gameOrder: 1, + startTime: '09:29', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jed to Cyrus 1-0', playerName: 'Cyrus', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Marley 2-0', playerName: 'Marley', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jed 3-0', playerName: 'Jed', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Ben to Teyo 4-0', playerName: 'Teyo', assistName: 'Ben' }, + { type: EventType.GOAL, team: 'us', message: 'Noah D to Jed 5-0', playerName: 'Jed', assistName: 'Noah D' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 5-1' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Ellis 6-1', playerName: 'Ellis', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'us', message: 'Corbin to Jake 7-1', playerName: 'Jake', assistName: 'Corbin' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 8-1', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal in endzone', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 9-1', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Alex steal', playerName: 'Alex', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Noah SL 10-1', playerName: 'Noah SL', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Corbin block', playerName: 'Corbin', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Corbin 11-1', playerName: 'Corbin', assistName: 'Marley' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Noah D 12-1', playerName: 'Noah D', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nico 13-1 Game', playerName: 'Nico', assistName: 'Ellis' }, + ] + }, + { + chatId: 'fall-flock-oct11-game2', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Beacon', + tournamentName: 'Fall Flock 2025', + gameDate: '2025-10-11', + gameOrder: 2, + startTime: '11:34', + startingOnOffense: false, + events: [ + { type: EventType.NOTE, team: 'us', message: 'Cyrus D', playerName: 'Cyrus', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Jed 1-0', playerName: 'Jed', assistName: 'Marley' }, + { type: EventType.GOAL, team: 'them', message: 'Beacon scores 1-1' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Nico 2-1', playerName: 'Nico', assistName: 'Marley' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 3-1', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 4-1', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 5-1', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Cyrus 6-1', playerName: 'Cyrus', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake endzone steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Teyo 7-1', playerName: 'Teyo', assistName: 'Nico' }, + { type: EventType.HALFTIME, team: 'us', message: 'Halftime' }, + { type: EventType.NOTE, team: 'us', message: 'Alex block', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Max 8-1', playerName: 'Max', assistName: 'Jed' }, + { type: EventType.NOTE, team: 'us', message: 'Marley foot block', playerName: 'Marley', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Marley 9-1', playerName: 'Marley', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 10-1', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Beacon scores 10-2' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Ellis 11-2', playerName: 'Ellis', assistName: 'Jed' }, + { type: EventType.NOTE, team: 'us', message: 'Ben D', playerName: 'Ben', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Noah D 12-2', playerName: 'Noah D', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Noah SL D', playerName: 'Noah SL', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 13-2 Game', playerName: 'Jed', assistName: 'Ellis' }, + ] + }, + { + chatId: 'fall-flock-oct11-game3', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Westfield', + tournamentName: 'Fall Flock 2025', + gameDate: '2025-10-11', + gameOrder: 3, + startTime: '14:04', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Alex 1-0', playerName: 'Alex', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 2-0', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Asher 3-0', playerName: 'Asher', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 4-0', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Westfield' }, + { type: EventType.GOAL, team: 'them', message: 'Westfield scores 4-1' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jed 5-1', playerName: 'Jed', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 6-1', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis steal', playerName: 'Ellis', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Yoyo to Max 7-1', playerName: 'Max', assistName: 'Yoyo' }, + { type: EventType.GOAL, team: 'them', message: 'Westfield scores 7-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Cyrus 8-2', playerName: 'Cyrus', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Noah D 9-2', playerName: 'Noah D', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis endzone D', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Alex 10-2', playerName: 'Alex', assistName: 'Jed' }, + { type: EventType.NOTE, team: 'us', message: 'Nico hand block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Westfield scores 10-3' }, + { type: EventType.NOTE, team: 'us', message: 'Noah SL endzone D', playerName: 'Noah SL', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 11-3', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Marley 12-3', playerName: 'Marley', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 13-3 Game', playerName: 'Jed', assistName: 'Ellis' }, + ] + }, + + // Fall Flock - October 12, 2025 + { + chatId: 'fall-flock-oct12-semifinal', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Montclair', + tournamentName: 'Fall Flock 2025', + gameDate: '2025-10-12', + gameOrder: 1, + startTime: '11:02', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Corbin to Cyrus 1-0', playerName: 'Cyrus', assistName: 'Corbin' }, + { type: EventType.NOTE, team: 'us', message: 'Corbin D', playerName: 'Corbin', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 2-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Marley slap down D', playerName: 'Marley', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 3-0', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 3-1' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 4-1', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 4-2' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Cyrus 5-2', playerName: 'Cyrus', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 5-3' }, + { type: EventType.GOAL, team: 'us', message: 'Corbin to Nico 6-3', playerName: 'Nico', assistName: 'Corbin' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 6-4' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 6-5' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair ties 6-6' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair takes lead 6-7' }, + { type: EventType.NOTE, team: 'us', message: 'Corbin D', playerName: 'Corbin', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 7-7', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 8-7', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Alex D', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 9-7', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Marley D', playerName: 'Marley', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 10-7', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Nico D', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 11-7', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake endzone D', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 11-8' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Jed 12-8 Game', playerName: 'Jed', assistName: 'Marley' }, + ] + }, + { + chatId: 'fall-flock-oct12-final', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Columbia', + tournamentName: 'Fall Flock 2025', + gameDate: '2025-10-12', + gameOrder: 2, + startTime: '13:47', + startingOnOffense: false, + events: [ + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 0-1' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Corbin 1-1', playerName: 'Corbin', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 1-2' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 1-3' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 2-3', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Alex D', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 3-3', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 3-4' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 4-4', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 4-5' }, + { type: EventType.NOTE, team: 'us', message: 'Jake D', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus endzone steal', playerName: 'Cyrus', defensivePlay: 'steal' }, + { type: EventType.NOTE, team: 'us', message: 'Jake endzone steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.NOTE, team: 'us', message: 'Jed D', playerName: 'Jed', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 5-5', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ellis 6-5 Break', playerName: 'Ellis', assistName: 'Alex' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 6-6' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 7-6', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 7-7' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ellis 8-7', playerName: 'Ellis', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 8-8' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 8-9' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Cyrus 9-9', playerName: 'Cyrus', assistName: 'Jake' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 9-10' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 9-11' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 10-11', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 10-12' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia wins 10-13' }, + ] + }, + + // Halloween Havoc - October 26, 2025 + { + chatId: 'halloween-havoc-oct26-game1', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Bergen Ultimate', + tournamentName: 'Halloween Havoc 2025', + gameDate: '2025-10-26', + gameOrder: 1, + startTime: '09:29', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Ellis to Dockery 1-0', playerName: 'Dockery', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Theo 2-0', playerName: 'Theo', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Bergen scores 2-1' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Jed 3-1', playerName: 'Jed', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nico 4-1', playerName: 'Nico', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Mason hammer to Max 5-1', playerName: 'Max', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 6-1', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Cyrus 7-1', playerName: 'Cyrus', assistName: 'Jake' }, + { type: EventType.HALFTIME, team: 'us', message: 'Halftime' }, + { type: EventType.NOTE, team: 'us', message: 'Alex block at goal line', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Mason 8-1', playerName: 'Mason', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Nico steal', playerName: 'Nico', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Asher to Jake 9-1', playerName: 'Jake', assistName: 'Asher' }, + { type: EventType.GOAL, team: 'them', message: 'Bergen scores 9-2' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 10-2', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Nate steal', playerName: 'Nate', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Bergen scores 10-3' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Asher 11-3', playerName: 'Asher', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'Jed diving block', playerName: 'Jed', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Alex 12-3', playerName: 'Alex', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'us', message: 'Asher to Ellis 13-3 Game', playerName: 'Ellis', assistName: 'Asher' }, + ] + }, + { + chatId: 'halloween-havoc-oct26-semifinal', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Montclair', + tournamentName: 'Halloween Havoc 2025', + gameDate: '2025-10-26', + gameOrder: 2, + startTime: '11:34', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Nate to Jake 1-0', playerName: 'Jake', assistName: 'Nate' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Ellis 2-0 Break', playerName: 'Ellis', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus block', playerName: 'Cyrus', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nate 3-0', playerName: 'Nate', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 3-1' }, + { type: EventType.NOTE, team: 'us', message: 'Ben block', playerName: 'Ben', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 4-1', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to diving Cyrus 5-1', playerName: 'Cyrus', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'us', message: 'Nate to Mason 6-1', playerName: 'Mason', assistName: 'Nate' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 6-2' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Thaddeus 7-2', playerName: 'Thaddeus', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 7-3' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 7-4' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 7-5' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nate 8-5', playerName: 'Nate', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 8-6' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 9-6', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 9-7' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Theo 10-7', playerName: 'Theo', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 10-8' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Noah 11-8', playerName: 'Noah', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Mason foot block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Ellis 12-8', playerName: 'Ellis', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Montclair scores 12-9' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nico 13-9 Game', playerName: 'Nico', assistName: 'Ellis' }, + ] + }, + { + chatId: 'halloween-havoc-oct26-final', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Columbia', + tournamentName: 'Halloween Havoc 2025', + gameDate: '2025-10-26', + gameOrder: 3, + startTime: '13:49', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Nico 1-0', playerName: 'Nico', assistName: 'Cyrus' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 2-0', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 2-1' }, + { type: EventType.GOAL, team: 'us', message: 'Nate to Jake 3-1', playerName: 'Jake', assistName: 'Nate' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 3-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 4-2', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 4-3' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 5-3', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 5-4' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Noah 6-4', playerName: 'Noah', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.NOTE, team: 'us', message: 'Mason steal', playerName: 'Mason', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Nate 7-4', playerName: 'Nate', assistName: 'Mason' }, + { type: EventType.HALFTIME, team: 'us', message: 'Halftime' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 7-5' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 7-6' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 8-6', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 8-7' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 9-7', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake sky steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 9-8' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Columbia' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'us', message: 'Theo to Max 10-8', playerName: 'Max', assistName: 'Theo' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia scores 10-9' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 10-10' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Mason 11-10', playerName: 'Mason', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 11-11' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 12-11', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Mason foot block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 13-11 Break for the win', playerName: 'Jed', assistName: 'Ellis' }, + ] + }, + + // Coconut Classic - November 1, 2025 + { + chatId: 'coconut-nov1-game1', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Bethesda Chevy-Chase', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-01', + gameOrder: 1, + startTime: '11:00', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 1-0', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Anatole 2-0', playerName: 'Anatole', assistName: 'Cyrus' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 3-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Alex block', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Alex tipped to Cyrus 4-0', playerName: 'Cyrus', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Dock 5-0', playerName: 'Dock', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ben block', playerName: 'Ben', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ben 6-0', playerName: 'Ben', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Theo steal', playerName: 'Theo', defensivePlay: 'steal' }, + { type: EventType.NOTE, team: 'us', message: 'Nico steal', playerName: 'Nico', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 7-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Cyrus 8-0', playerName: 'Cyrus', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Asher block', playerName: 'Asher', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Jake 9-0', playerName: 'Jake', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis steal', playerName: 'Ellis', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Ellis 10-0', playerName: 'Ellis', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Anatole 11-0', playerName: 'Anatole', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'BCC scores 11-1' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Cyrus 12-1', playerName: 'Cyrus', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Asher block', playerName: 'Asher', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Corbin 13-1', playerName: 'Corbin', assistName: 'Jake' }, + ] + }, + { + chatId: 'coconut-nov1-game2', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Lower Merion', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-01', + gameOrder: 2, + startTime: '13:00', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 0-1' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to diving Cyrus 1-1', playerName: 'Cyrus', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 2-1', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion ties 2-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 3-2', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus block', playerName: 'Cyrus', defensivePlay: 'block' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Lower Merion' }, + { type: EventType.NOTE, team: 'us', message: 'Yoyo block', playerName: 'Yoyo', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 4-2', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 5-2', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 5-3' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Mason 6-3', playerName: 'Mason', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Cyrus diving 7-3', playerName: 'Cyrus', assistName: 'Jake' }, + { type: EventType.HALFTIME, message: 'Halftime 7-3' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 7-4' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Corbin 8-4', playerName: 'Corbin', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 8-5' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 8-6' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Lower Merion' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Corbin 9-6', playerName: 'Corbin', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 9-7' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to diving Jake 10-7', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 10-8' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 10-9' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 11-9', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 11-10' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 12-10', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 12-11' }, + { type: EventType.NOTE, team: 'us', message: 'Corbin steal', playerName: 'Corbin', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Anatole 13-11', playerName: 'Anatole', assistName: 'Ellis' }, + ] + }, + { + chatId: 'coconut-nov1-game3', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Strathaven', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-01', + gameOrder: 3, + startTime: '14:59', + startingOnOffense: false, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 1-0 Break', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 2-0', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Strathaven' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Anatole 3-0', playerName: 'Anatole', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Nico 4-0', playerName: 'Nico', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'NSL block', playerName: 'Noah SL', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 4-1' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 5-1', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 5-2' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Alex 6-2', playerName: 'Alex', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Alex block', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Asher 7-2', playerName: 'Asher', assistName: 'Alex' }, + { type: EventType.HALFTIME, message: 'Halftime 7-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 8-2', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 8-3' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nico 9-3', playerName: 'Nico', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 9-4' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to diving Alex 10-4', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 10-5' }, + { type: EventType.GOAL, team: 'us', message: 'Jake hammer to Dock 11-5', playerName: 'Dock', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 11-6' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Dock 12-6', playerName: 'Dock', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 12-7' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 13-7', playerName: 'Mason', assistName: 'Jake' }, + ] + }, + + // Coconut Classic - November 2, 2025 + { + chatId: 'coconut-nov2-game1', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Jackson-Reed', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-02', + gameOrder: 1, + startTime: '09:30', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 0-1' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus steal', playerName: 'Cyrus', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jed 1-1', playerName: 'Jed', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 1-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 2-2', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 2-3' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 3-3', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 3-4' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Jackson-Reed' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 3-5' }, + { type: EventType.GOAL, team: 'us', message: 'Mason hammer to Jed 4-5', playerName: 'Jed', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Anatole 5-5', playerName: 'Anatole', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block in end zone', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason hammer to Cyrus 6-5', playerName: 'Cyrus', assistName: 'Mason' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Jackson-Reed' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed ties 6-6' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 7-6', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.HALFTIME, message: 'Halftime 7-6' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed ties 7-7' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Ellis 8-7', playerName: 'Ellis', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed ties 8-8' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ellis 9-8', playerName: 'Ellis', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Toby block', playerName: 'Toby', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Anatole 10-8', playerName: 'Anatole', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus steal', playerName: 'Cyrus', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Toby 11-8', playerName: 'Toby', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Anatole 12-8', playerName: 'Anatole', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Jackson-Reed' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 13-8', playerName: 'Mason', assistName: 'Jake' }, + ] + }, + { + chatId: 'coconut-nov2-game2', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Columbia', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-02', + gameOrder: 2, + startTime: '11:30', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 1-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 1-1' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Dock 2-1', playerName: 'Dock', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 2-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 3-2', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 3-3' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 3-4' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Nico 4-4', playerName: 'Nico', assistName: 'Mason' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 4-5' }, + { type: EventType.NOTE, team: 'us', message: 'Toby block', playerName: 'Toby', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 5-5', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 5-6' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Columbia' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 6-6', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 6-7' }, + { type: EventType.HALFTIME, message: 'Halftime 6-7' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 6-8' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 6-9' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 7-9', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Mason 8-9', playerName: 'Mason', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 8-10' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 9-10', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Toby block', playerName: 'Toby', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 9-11' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 9-12' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Columbia' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 10-12', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia wins 10-13' }, + ] + }, + + // Hucksgiving - November 22, 2025 + { + chatId: 'hucksgiving-nov22-game1', + ourTeamName: 'Brooklyn Tech B', + opponentName: 'Brooklyn Magic', + tournamentName: 'Hucksgiving 2025', + gameDate: '2025-11-22', + gameOrder: 1, + startTime: '09:04', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 1-0', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'D Marley', playerName: 'Marley', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Marley 2-0', playerName: 'Marley', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'them', message: 'Brooklyn Magic scores 2-1' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ellis 3-1', playerName: 'Ellis', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Alex 4-1', playerName: 'Alex', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis D', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 5-1', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Nico 6-1', playerName: 'Nico', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Jake 7-1', playerName: 'Jake', assistName: 'Marley' }, + { type: EventType.NOTE, team: 'us', message: 'Alex steal', playerName: 'Alex', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Cyrus 8-1', playerName: 'Cyrus', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Brooklyn Magic scores 8-2' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Marley 9-2', playerName: 'Marley', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Toby to Marley 10-2', playerName: 'Marley', assistName: 'Toby' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Noah 11-2', playerName: 'Noah', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 12-2', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Alex 13-2', playerName: 'Alex', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Theo 14-2', playerName: 'Theo', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Cyrus 15-2', playerName: 'Cyrus', assistName: 'Nico' }, + ] + }, + { + chatId: 'hucksgiving-nov22-game2', + ourTeamName: 'Brooklyn Tech A', + opponentName: 'Bard', + tournamentName: 'Hucksgiving 2025', + gameDate: '2025-11-22', + gameOrder: 2, + startTime: '11:00', + startingOnOffense: false, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 1-0 Break', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 1-1' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Jed 2-1', playerName: 'Jed', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'D Ellis', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Jed 3-1', playerName: 'Jed', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jed 4-1', playerName: 'Jed', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Max 5-1', playerName: 'Max', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Theo 6-1', playerName: 'Theo', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 6-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 7-2', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'D Jake', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Jed 8-2', playerName: 'Jed', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'D Ellis', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'D Alex', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 8-3' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jake 9-3', playerName: 'Jake', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Noah 10-3', playerName: 'Noah', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'D Jed', playerName: 'Jed', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Nico 11-3', playerName: 'Nico', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Nico 12-3', playerName: 'Nico', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 12-4' }, + { type: EventType.GOAL, team: 'us', message: 'Noah to Alex 13-4', playerName: 'Alex', assistName: 'Noah' }, + { type: EventType.NOTE, team: 'us', message: 'End zone D Theo', playerName: 'Theo', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'D Nico', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Noah 14-4', playerName: 'Noah', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'Max D', playerName: 'Max', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jake 15-4', playerName: 'Jake', assistName: 'Alex' }, + ] + }, + { + chatId: 'hucksgiving-nov22-game3', + ourTeamName: 'Brooklyn Tech A', + opponentName: 'Mikey Grauer\'s Ringers', + tournamentName: 'Hucksgiving 2025', + gameDate: '2025-11-22', + gameOrder: 3, + startTime: '13:04', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Nico to Alex 1-0', playerName: 'Alex', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 1-1' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 1-2' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Max 2-2', playerName: 'Max', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'End zone steal Jake', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Jed 3-2', playerName: 'Jed', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 3-3' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Nico 4-3', playerName: 'Nico', assistName: 'Marley' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 4-4' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Jed 5-4', playerName: 'Jed', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 5-5' }, + { type: EventType.NOTE, team: 'us', message: 'End zone block Nico', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Alex 6-5', playerName: 'Alex', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'D Noah', playerName: 'Noah', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 7-5', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent scores 7-6' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 7-7' }, + { type: EventType.NOTE, team: 'us', message: 'End zone D Jake', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Theo 8-7', playerName: 'Theo', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 8-8' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Theo 9-8', playerName: 'Theo', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 9-9' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 9-10' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 10-10', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 10-11' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 11-11', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 11-12' }, + { type: EventType.GOAL, team: 'us', message: 'Tech ties 12-12', playerName: 'Unknown' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jake to Jed 13-12 GAME', playerName: 'Jed', assistName: 'Jake' }, + ] + }, +]; + +async function createGame(gameData: GameData): Promise { + console.log(`\nCreating game: ${gameData.ourTeamName} vs ${gameData.opponentName} (${gameData.tournamentName})`); + + const createResponse = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: gameData.chatId, + ourTeamName: gameData.ourTeamName, + opponentName: gameData.opponentName, + tournamentName: gameData.tournamentName, + gameDate: gameData.gameDate, + gameOrder: gameData.gameOrder, + }), + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create game: ${await createResponse.text()}`); + } + + const { game } = await createResponse.json(); + const gameId = game.id; + console.log(`✓ Created game ${gameId}`); + + // Start the game with starting offense/defense info + console.log(` Starting game (starting on ${gameData.startingOnOffense ? 'O' : 'D'})...`); + const startResponse = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: EventType.GAME_START, + message: `Game start - Tech starting on ${gameData.startingOnOffense ? 'offense' : 'defense'}`, + startingOnOffense: gameData.startingOnOffense, + }), + }); + + if (!startResponse.ok) { + throw new Error(`Failed to start game: ${await startResponse.text()}`); + } + + // Add events + console.log(` Adding ${gameData.events.length} events...`); + for (let i = 0; i < gameData.events.length; i++) { + const event = gameData.events[i]; + + const eventResponse = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + + if (!eventResponse.ok) { + console.error(`Failed to add event ${i + 1}: ${event.message}`); + throw new Error(`Failed to add event: ${await eventResponse.text()}`); + } + + // Show progress every 5 events + if ((i + 1) % 5 === 0 || i === gameData.events.length - 1) { + console.log(` ${i + 1}/${gameData.events.length} events added`); + } + } + + // End the game + console.log(` Ending game...`); + const endResponse = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: EventType.GAME_END, + message: 'Game complete', + }), + }); + + if (!endResponse.ok) { + throw new Error(`Failed to end game: ${await endResponse.text()}`); + } + + console.log(`✅ Game completed: ${gameId}`); + return gameId; +} + +async function loadAllGames() { + console.log('🚀 Starting to load Fall tournament games...\n'); + console.log(`Total games to load: ${games.length}`); + console.log(`API URL: ${API_URL}\n`); + + const gameIds: string[] = []; + + for (const gameData of games) { + try { + const gameId = await createGame(gameData); + gameIds.push(gameId); + } catch (error) { + console.error(`\n❌ Error loading game:`, error); + throw error; + } + } + + console.log('\n✅ All games loaded successfully!'); + console.log('\nGame IDs:'); + gameIds.forEach((id, index) => { + console.log(` ${index + 1}. ${games[index].tournamentName} - ${games[index].gameDate}: ${id}`); + }); + + console.log(`\n🌐 View games at: ${API_URL.replace('8787', '3000')}/games`); +} + +loadAllGames().catch(console.error); diff --git a/packages/bot/scripts/archive/load-hsl-bard.ts b/packages/bot/scripts/archive/load-hsl-bard.ts new file mode 100644 index 0000000..055ca0d --- /dev/null +++ b/packages/bot/scripts/archive/load-hsl-bard.ts @@ -0,0 +1,173 @@ +/** + * Load High School League game: Tech Support vs Bard (3/19/26) + * Final: 11-5 W + */ + +const API_URL = process.env.API_URL || 'https://api.score.kcuda.org'; + +/** Convert "H:MM:SS AM/PM" EDT to Unix ms (EDT = UTC-4, DST active) */ +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + // EDT is UTC-4 + return new Date(Date.UTC(2026, 2, 19, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; + score?: { us: number; them: number }; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + // Create game + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'hsl-bard-2026-03-19', + ourTeamName: 'Tech Support', + opponentName: 'Bard', + tournamentName: 'High School League', + gameDate: '2026-03-19', + gameOrder: 1, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + const gameId = game.id; + console.log(`Created game: ${gameId}`); + + const events: Event[] = [ + // === GAME START === + { type: 'game_start', startingOnOffense: true, timestamp: edt('4:53:25 PM') }, + + // === Point 1: 0-1 Bard (Tech started O, got broken) === + { type: 'note', message: 'Steal Nico', timestamp: edt('4:54:01 PM') }, + { type: 'note', message: 'Block Asher', timestamp: edt('4:54:39 PM') }, + { type: 'goal', team: 'them', message: '0-1', timestamp: edt('4:55:38 PM') }, + + // === Point 2: 1-1 Tech (Ellis steal → Jake to Foster) === + { type: 'note', message: 'Ellis steal', timestamp: edt('4:58:58 PM') }, + { type: 'goal', team: 'us', message: 'Jake to Foster', defensivePlay: 'steal', timestamp: edt('4:59:33 PM') }, + + // === Point 3: 2-1 Tech (Cyrus to Nico) === + { type: 'goal', team: 'us', message: 'Cyrus to Nico', timestamp: edt('5:03:07 PM') }, + + // === Point 4: 3-1 Tech (lots of D, Asher to Alex) === + { type: 'note', message: 'Alex diving block', timestamp: edt('5:04:40 PM') }, + { type: 'note', message: 'Jake steal', timestamp: edt('5:06:10 PM') }, + { type: 'note', message: 'Alex block', timestamp: edt('5:07:28 PM') }, + { type: 'note', message: 'Gus block', timestamp: edt('5:07:29 PM') }, + { type: 'note', message: 'Foster block', timestamp: edt('5:09:28 PM') }, + { type: 'note', message: 'Noah block', timestamp: edt('5:10:20 PM') }, + { type: 'goal', team: 'us', message: 'Asher to Alex', defensivePlay: 'block', timestamp: edt('5:10:47 PM') }, + + // === Timeout Bard === + { type: 'timeout', team: 'them', message: 'Timeout Bard', timestamp: edt('5:11:50 PM') }, + + // === Point 5: 3-2 Bard === + { type: 'note', message: 'Noah block', timestamp: edt('5:13:27 PM') }, + { type: 'goal', team: 'them', message: '3-2', timestamp: edt('5:15:22 PM') }, + + // === Point 6: 4-2 Tech (Asher to Gus) === + { type: 'goal', team: 'us', message: 'Asher to Gus', timestamp: edt('5:18:08 PM') }, + + // === Point 7: 4-3 Bard === + { type: 'goal', team: 'them', message: '4-3', timestamp: edt('5:22:15 PM') }, + + // === Timeout Bard === + { type: 'timeout', team: 'them', message: 'Timeout Bard', timestamp: edt('5:24:26 PM') }, + + // === Point 8: 5-3 Tech (Ellis steal → Ellis to Asher) === + { type: 'note', message: 'Ellis steal', timestamp: edt('5:27:24 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Asher', defensivePlay: 'steal', timestamp: edt('5:27:28 PM') }, + + // === Point 9: 6-3 Tech (Jake steal → Ellis to Noah) === + { type: 'note', message: 'Jake steal', timestamp: edt('5:31:33 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Noah', defensivePlay: 'steal', timestamp: edt('5:31:47 PM') }, + + // === Point 10: 6-4 Bard === + { type: 'note', message: 'Ben block', timestamp: edt('5:34:10 PM') }, + { type: 'goal', team: 'them', message: '6-4', timestamp: edt('5:34:59 PM') }, + + // === Point 11: 7-4 Tech (Jake huck to Gus) === + { type: 'goal', team: 'us', message: 'Jake huck to Gus', timestamp: edt('5:37:36 PM') }, + + // === Point 12: 8-4 Tech (Jake blocks → Timeout Tech → Ellis to Alex) === + { type: 'note', message: 'Jake block', timestamp: edt('5:40:47 PM') }, + { type: 'note', message: 'Jake block', timestamp: edt('5:42:54 PM') }, + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('5:43:09 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Alex', defensivePlay: 'block', timestamp: edt('5:46:30 PM') }, + + // === HALFTIME === + { type: 'halftime', timestamp: edt('5:46:37 PM') }, + + // === SECOND HALF START === + { type: 'second_half_start', timestamp: edt('5:54:00 PM') }, + + // === Point 13: 9-4 Tech (tons of D → Cyrus to Max) === + { type: 'note', message: 'Cyrus steal', timestamp: edt('5:54:39 PM') }, + { type: 'note', message: 'Anatole steal', timestamp: edt('5:56:19 PM') }, + { type: 'note', message: 'Jake steal', timestamp: edt('5:56:33 PM') }, + { type: 'note', message: 'Anatole block', timestamp: edt('5:57:46 PM') }, + { type: 'note', message: 'Nico steal', timestamp: edt('5:58:53 PM') }, + { type: 'goal', team: 'us', message: 'Cyrus to Max', defensivePlay: 'steal', timestamp: edt('5:59:47 PM') }, + + // === Point 14: 9-5 Bard === + { type: 'note', message: 'Asher diving block', timestamp: edt('6:02:19 PM') }, + { type: 'goal', team: 'them', message: '9-5', timestamp: edt('6:06:45 PM') }, + + // === Point 15: 10-5 Tech (Jake steal → Alex to Nico) === + { type: 'note', message: 'Jake steal', timestamp: edt('6:09:35 PM') }, + { type: 'goal', team: 'us', message: 'Alex to Nico skying', defensivePlay: 'steal', timestamp: edt('6:11:08 PM') }, + + // === Point 16: 11-5 Tech (Anatole steal, Teyo block → Asher to Foster) === + { type: 'note', message: 'Anatole steal', timestamp: edt('6:13:57 PM') }, + { type: 'note', message: 'Teyo block', timestamp: edt('6:14:13 PM') }, + { type: 'goal', team: 'us', message: 'Asher to Foster', defensivePlay: 'block', timestamp: edt('6:14:41 PM') }, + + // === GAME END === + { type: 'game_end', timestamp: edt('6:15:16 PM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(gameId, event); + } + + console.log(`\nDone! Game ${gameId} loaded with ${events.length} events.`); + console.log(`Final score: Tech Support 11 - 5 Bard`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/load-hsl-bronx-science.ts b/packages/bot/scripts/archive/load-hsl-bronx-science.ts similarity index 100% rename from packages/bot/scripts/load-hsl-bronx-science.ts rename to packages/bot/scripts/archive/load-hsl-bronx-science.ts diff --git a/packages/bot/scripts/archive/load-hsl-final-bard.ts b/packages/bot/scripts/archive/load-hsl-final-bard.ts new file mode 100644 index 0000000..149d853 --- /dev/null +++ b/packages/bot/scripts/archive/load-hsl-final-bard.ts @@ -0,0 +1,160 @@ +/** + * Load 2026 NYC HSL Championship final (5/30): Tech Support vs Bard — W 13-6. + * Tech repeats as City Champs. + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 4, 30, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'hsl-final-bard-2026-05-30', + ourTeamName: 'Tech Support', + opponentName: 'Bard', + tournamentName: 'NYC HSL Championship', + gameDate: '2026-05-30', + gameOrder: 1, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: false, timestamp: edt('3:34:27 PM') }, + + // 1-0 Tech: Toby block (corrected later from Mason), Mason to Jake — BREAK + { type: 'note', message: 'Toby block', timestamp: edt('3:37:35 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Jake', defensivePlay: 'block', timestamp: edt('3:37:49 PM') }, + + // 2-0 Tech: Mason to Jake + { type: 'goal', team: 'us', message: 'Mason to Jake', timestamp: edt('3:40:29 PM') }, + + // 3-0 Tech: Ellis steal → Mason to Jake — BREAK + { type: 'note', message: 'Ellis steal', timestamp: edt('3:45:04 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Jake', defensivePlay: 'steal', timestamp: edt('3:45:59 PM') }, + + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('3:46:19 PM') }, + + // 4-0 Tech: Nico to Foster + { type: 'goal', team: 'us', message: 'Nico to Foster', timestamp: edt('3:50:46 PM') }, + + // 4-1 Bard + { type: 'goal', team: 'them', message: '4-1', timestamp: edt('3:53:00 PM') }, + + // 5-1 Tech: Ellis block → Corbin block → Alex to Cyrus + { type: 'note', message: 'Ellis block', timestamp: edt('3:55:09 PM') }, + { type: 'note', message: 'Corbin block', timestamp: edt('3:55:18 PM') }, + { type: 'goal', team: 'us', message: 'Alex to Cyrus', defensivePlay: 'block', timestamp: edt('3:56:24 PM') }, + + // 5-2 Bard (Ben steal didn't convert) + { type: 'note', message: 'Ben steal', timestamp: edt('4:01:12 PM') }, + { type: 'goal', team: 'them', message: '5-2', timestamp: edt('4:01:45 PM') }, + + { type: 'timeout', team: 'them', message: 'Timeout Bard', timestamp: edt('4:03:17 PM') }, + + // 6-2 Tech: long point — Anatole/Marley/Nate blocks, Cyrus steal, Ellis block → Alex to Ellis + { type: 'note', message: 'Anatole block', timestamp: edt('4:08:25 PM') }, + { type: 'note', message: 'Marley block', timestamp: edt('4:09:25 PM') }, + { type: 'note', message: 'Nate block', timestamp: edt('4:09:33 PM') }, + { type: 'note', message: 'Nate steal', timestamp: edt('4:10:00 PM') }, + { type: 'timeout', team: 'them', message: 'Timeout Bard', timestamp: edt('4:10:42 PM') }, + { type: 'note', message: 'Cyrus steal', timestamp: edt('4:13:59 PM') }, + { type: 'note', message: 'Ellis block', timestamp: edt('4:17:11 PM') }, + { type: 'goal', team: 'us', message: 'Alex to Ellis', defensivePlay: 'block', timestamp: edt('4:17:15 PM') }, + + // 7-2 Tech: Teyo block → Mason to Jake + { type: 'note', message: 'Teyo block', timestamp: edt('4:19:32 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Jake', defensivePlay: 'block', timestamp: edt('4:20:00 PM') }, + + // 8-2 Tech: Nico to Jake — break for half + { type: 'goal', team: 'us', message: 'Nico to Jake', timestamp: edt('4:22:17 PM') }, + { type: 'halftime', timestamp: edt('4:22:27 PM') }, + { type: 'second_half_start', timestamp: edt('4:30:00 PM') }, + + // 9-2 Tech: Ellis to Nate + { type: 'goal', team: 'us', message: 'Ellis to Nate', timestamp: edt('4:33:10 PM') }, + + // 9-3 Bard (Asher/Nico/Mason blocks but they still scored) + { type: 'note', message: 'Asher block', timestamp: edt('4:36:46 PM') }, + { type: 'note', message: 'Nico block', timestamp: edt('4:37:14 PM') }, + { type: 'note', message: 'Mason block', timestamp: edt('4:37:43 PM') }, + { type: 'goal', team: 'them', message: '9-3', timestamp: edt('4:39:04 PM') }, + + { type: 'goal', team: 'them', message: '9-4', timestamp: edt('4:41:45 PM') }, + { type: 'goal', team: 'them', message: '9-5', timestamp: edt('4:47:33 PM') }, + + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('4:48:56 PM') }, + + // 10-5 Tech: Ellis to Jake + { type: 'goal', team: 'us', message: 'Ellis to Jake', timestamp: edt('4:52:17 PM') }, + + { type: 'goal', team: 'them', message: '10-6', timestamp: edt('4:55:03 PM') }, + + // 11-6 Tech: Jake block → Ellis block → Ellis to Alex + { type: 'note', message: 'Jake block', timestamp: edt('4:59:22 PM') }, + { type: 'note', message: 'Ellis block', timestamp: edt('5:01:06 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Alex', defensivePlay: 'block', timestamp: edt('5:03:02 PM') }, + + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('5:05:22 PM') }, + + // 12-6 Tech: Jake to Teyo + { type: 'goal', team: 'us', message: 'Jake to Teyo', timestamp: edt('5:08:40 PM') }, + + // 13-6 Tech: Jake to diving Mason — REPEAT CITY CHAMPS + { type: 'goal', team: 'us', message: 'Jake to diving Mason — REPEAT CITY CHAMPS', timestamp: edt('5:14:31 PM') }, + { type: 'game_end', message: 'Tech win 13-6 — NYC HSL Champions!', timestamp: edt('5:14:38 PM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\n🏆 Tech Support 13-6 Bard — NYC HSL Champions (repeat)!`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-hsl-semi-hunter.ts b/packages/bot/scripts/archive/load-hsl-semi-hunter.ts new file mode 100644 index 0000000..33a8089 --- /dev/null +++ b/packages/bot/scripts/archive/load-hsl-semi-hunter.ts @@ -0,0 +1,151 @@ +/** + * Load 2026 NYC HSL Semifinal (5/14): Tech Support vs Hunter — W 15-6. + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 4, 14, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'hsl-semi-hunter-2026-05-14', + ourTeamName: 'Tech Support', + opponentName: 'Hunter', + tournamentName: 'High School League', + gameDate: '2026-05-14', + gameOrder: 1, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('5:53:09 PM') }, + + // 1-0 Tech: Marley to Corbin + { type: 'goal', team: 'us', message: 'Marley to Corbin', timestamp: edt('5:54:12 PM') }, + + // 1-1 Hunter + { type: 'goal', team: 'them', message: '1-1', timestamp: edt('5:56:18 PM') }, + + // 2-1 Tech: Marley block → Alex to Nate + { type: 'note', message: 'Marley block', timestamp: edt('5:59:14 PM') }, + { type: 'goal', team: 'us', message: 'Alex to Nate', defensivePlay: 'block', timestamp: edt('5:59:43 PM') }, + + // 3-1 Tech: Theo steal → Mason huck to Nico + { type: 'note', message: 'Theo steal', timestamp: edt('6:02:27 PM') }, + { type: 'goal', team: 'us', message: 'Mason huck to Nico', defensivePlay: 'steal', timestamp: edt('6:02:33 PM') }, + + // 4-1 Tech: Mason steal → Asher to Mason + { type: 'note', message: 'Mason steal', timestamp: edt('6:04:26 PM') }, + { type: 'goal', team: 'us', message: 'Asher to Mason', defensivePlay: 'steal', timestamp: edt('6:04:50 PM') }, + + // 5-1 Tech: Asher to Foster + { type: 'goal', team: 'us', message: 'Asher to Foster', timestamp: edt('6:11:15 PM') }, + + // 6-1 Tech: Ellis to Max on the deflection + { type: 'goal', team: 'us', message: 'Ellis to Max on the deflection', timestamp: edt('6:14:17 PM') }, + + // 6-2 Hunter + { type: 'goal', team: 'them', message: '6-2', timestamp: edt('6:17:23 PM') }, + + // 7-2 Tech: Cyrus to Nate + { type: 'goal', team: 'us', message: 'Cyrus to Nate', timestamp: edt('6:20:34 PM') }, + + // 8-2 Tech: Nico to Theo + { type: 'goal', team: 'us', message: 'Nico to Theo', timestamp: edt('6:24:01 PM') }, + + { type: 'halftime', timestamp: edt('6:24:14 PM') }, + { type: 'second_half_start', timestamp: edt('6:30:00 PM') }, + + // 8-3 Hunter + { type: 'goal', team: 'them', message: '8-3', timestamp: edt('6:31:20 PM') }, + + // 9-3 Tech: Alex to Nate + { type: 'goal', team: 'us', message: 'Alex to Nate', timestamp: edt('6:34:48 PM') }, + + // 10-3 Tech: Ellis steal → Mason to Teyo + { type: 'note', message: 'Ellis steal', timestamp: edt('6:37:18 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Teyo', defensivePlay: 'steal', timestamp: edt('6:37:51 PM') }, + + // 11-3 Tech: Foster to Ben + { type: 'goal', team: 'us', message: 'Foster to Ben', timestamp: edt('6:41:00 PM') }, + + // 11-4 Hunter (Alex block didn't convert) + { type: 'note', message: 'Alex block', timestamp: edt('6:43:06 PM') }, + { type: 'goal', team: 'them', message: '11-4', timestamp: edt('6:43:19 PM') }, + + // 11-5 Hunter (Marley block didn't convert) + { type: 'note', message: 'Marley block', timestamp: edt('6:45:21 PM') }, + { type: 'goal', team: 'them', message: '11-5', timestamp: edt('6:47:49 PM') }, + + // 12-5 Tech: Alex to Nate + { type: 'goal', team: 'us', message: 'Alex to Nate', timestamp: edt('6:49:59 PM') }, + + // 13-5 Tech: Nico steal → Mason huck to Toby + { type: 'note', message: 'Nico steal', timestamp: edt('6:52:26 PM') }, + { type: 'goal', team: 'us', message: 'Mason huck to Toby', defensivePlay: 'steal', timestamp: edt('6:52:37 PM') }, + + // 14-5 Tech: Mason deep to Teyo + { type: 'goal', team: 'us', message: 'Mason deep to Teyo', timestamp: edt('6:55:34 PM') }, + + // 14-6 Hunter + { type: 'goal', team: 'them', message: '14-6', timestamp: edt('6:57:53 PM') }, + + // 15-6 Tech: Cyrus to Nate — game + { type: 'goal', team: 'us', message: 'Cyrus to Nate', timestamp: edt('7:00:29 PM') }, + { type: 'game_end', message: 'Tech win 15-6 — on to the City Championship!', timestamp: edt('7:00:36 PM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\nDone! Tech Support 15-6 Hunter — on to the final.`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/load-nov1-games.ts b/packages/bot/scripts/archive/load-nov1-games.ts similarity index 100% rename from packages/bot/scripts/load-nov1-games.ts rename to packages/bot/scripts/archive/load-nov1-games.ts diff --git a/packages/bot/scripts/archive/load-recent-tournaments.ts b/packages/bot/scripts/archive/load-recent-tournaments.ts new file mode 100644 index 0000000..4a94221 --- /dev/null +++ b/packages/bot/scripts/archive/load-recent-tournaments.ts @@ -0,0 +1,479 @@ +/** + * Script to load recent tournament games: + * - Coconut Classic 2025 (Nov 1-2) + * - Hucksgiving 2025 (Nov 22) + * Run with: API_URL=https://api.score.kcuda.org npx tsx scripts/load-recent-tournaments.ts + */ + +import { EventType } from '@scorebot/shared'; + +interface GameData { + chatId: string; + ourTeamName: string; + opponentName: string; + tournamentName: string; + gameDate: string; // YYYY-MM-DD format + gameOrder: number; // Order within tournament day + startTime: string; // HH:MM format for actual game start time + startingOnOffense: boolean; + events: Array<{ + type: EventType; + team?: 'us' | 'them'; + message: string; + playerName?: string; + assistName?: string; + defensivePlay?: 'block' | 'steal'; + }>; +} + +const API_URL = process.env.API_URL || 'http://localhost:8787'; + +// Recent tournament games +const games: GameData[] = [ + // Coconut Classic - November 1, 2025 + { + chatId: 'coconut-nov1-game1', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Bethesda Chevy-Chase', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-01', + gameOrder: 1, + startTime: '11:00', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 1-0', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Anatole 2-0', playerName: 'Anatole', assistName: 'Cyrus' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 3-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Alex block', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Alex tipped to Cyrus 4-0', playerName: 'Cyrus', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Dock 5-0', playerName: 'Dock', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ben block', playerName: 'Ben', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ben 6-0', playerName: 'Ben', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Theo steal', playerName: 'Theo', defensivePlay: 'steal' }, + { type: EventType.NOTE, team: 'us', message: 'Nico steal', playerName: 'Nico', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 7-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Cyrus 8-0', playerName: 'Cyrus', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Asher block', playerName: 'Asher', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Jake 9-0', playerName: 'Jake', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis steal', playerName: 'Ellis', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Ellis 10-0', playerName: 'Ellis', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Anatole 11-0', playerName: 'Anatole', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'BCC scores 11-1' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Cyrus 12-1', playerName: 'Cyrus', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Asher block', playerName: 'Asher', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Corbin 13-1', playerName: 'Corbin', assistName: 'Jake' }, + ] + }, + { + chatId: 'coconut-nov1-game2', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Lower Merion', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-01', + gameOrder: 2, + startTime: '13:00', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 0-1' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to diving Cyrus 1-1', playerName: 'Cyrus', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 2-1', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion ties 2-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 3-2', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus block', playerName: 'Cyrus', defensivePlay: 'block' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Lower Merion' }, + { type: EventType.NOTE, team: 'us', message: 'Yoyo block', playerName: 'Yoyo', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 4-2', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 5-2', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 5-3' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Mason 6-3', playerName: 'Mason', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Dock block', playerName: 'Dock', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Cyrus diving 7-3', playerName: 'Cyrus', assistName: 'Jake' }, + { type: EventType.HALFTIME, message: 'Halftime 7-3' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 7-4' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Corbin 8-4', playerName: 'Corbin', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 8-5' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 8-6' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Lower Merion' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Corbin 9-6', playerName: 'Corbin', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 9-7' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to diving Jake 10-7', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 10-8' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 10-9' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 11-9', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 11-10' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 12-10', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Lower Merion scores 12-11' }, + { type: EventType.NOTE, team: 'us', message: 'Corbin steal', playerName: 'Corbin', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Anatole 13-11', playerName: 'Anatole', assistName: 'Ellis' }, + ] + }, + { + chatId: 'coconut-nov1-game3', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Strathaven', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-01', + gameOrder: 3, + startTime: '14:59', + startingOnOffense: false, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 1-0 Break', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 2-0', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Strathaven' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Anatole 3-0', playerName: 'Anatole', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Nico 4-0', playerName: 'Nico', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'NSL block', playerName: 'Noah SL', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 4-1' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 5-1', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 5-2' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Alex 6-2', playerName: 'Alex', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Alex block', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Asher 7-2', playerName: 'Asher', assistName: 'Alex' }, + { type: EventType.HALFTIME, message: 'Halftime 7-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 8-2', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 8-3' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Nico 9-3', playerName: 'Nico', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 9-4' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to diving Alex 10-4', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 10-5' }, + { type: EventType.GOAL, team: 'us', message: 'Jake hammer to Dock 11-5', playerName: 'Dock', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 11-6' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Dock 12-6', playerName: 'Dock', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Strathaven scores 12-7' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 13-7', playerName: 'Mason', assistName: 'Jake' }, + ] + }, + + // Coconut Classic - November 2, 2025 + { + chatId: 'coconut-nov2-game1', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Jackson-Reed', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-02', + gameOrder: 1, + startTime: '09:30', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 0-1' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus steal', playerName: 'Cyrus', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jed 1-1', playerName: 'Jed', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 1-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 2-2', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 2-3' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 3-3', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 3-4' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Jackson-Reed' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed scores 3-5' }, + { type: EventType.GOAL, team: 'us', message: 'Mason hammer to Jed 4-5', playerName: 'Jed', assistName: 'Mason' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Anatole 5-5', playerName: 'Anatole', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block in end zone', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Mason hammer to Cyrus 6-5', playerName: 'Cyrus', assistName: 'Mason' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Jackson-Reed' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed ties 6-6' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 7-6', playerName: 'Mason', assistName: 'Jake' }, + { type: EventType.HALFTIME, message: 'Halftime 7-6' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed ties 7-7' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Ellis 8-7', playerName: 'Ellis', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'them', message: 'Jackson-Reed ties 8-8' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ellis 9-8', playerName: 'Ellis', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Toby block', playerName: 'Toby', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Anatole 10-8', playerName: 'Anatole', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'Cyrus steal', playerName: 'Cyrus', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Toby 11-8', playerName: 'Toby', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Anatole 12-8', playerName: 'Anatole', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Jackson-Reed' }, + { type: EventType.NOTE, team: 'us', message: 'Mason block', playerName: 'Mason', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Mason 13-8', playerName: 'Mason', assistName: 'Jake' }, + ] + }, + { + chatId: 'coconut-nov2-game2', + ourTeamName: 'Brooklyn Tech', + opponentName: 'Columbia', + tournamentName: 'Coconut Classic 2025', + gameDate: '2025-11-02', + gameOrder: 2, + startTime: '11:30', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 1-0', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 1-1' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Dock 2-1', playerName: 'Dock', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 2-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 3-2', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia ties 3-3' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 3-4' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Nico 4-4', playerName: 'Nico', assistName: 'Mason' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 4-5' }, + { type: EventType.NOTE, team: 'us', message: 'Toby block', playerName: 'Toby', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 5-5', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Nico block', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'Jake block', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 5-6' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Columbia' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 6-6', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia takes lead 6-7' }, + { type: EventType.HALFTIME, message: 'Halftime 6-7' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 6-8' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 6-9' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Alex 7-9', playerName: 'Alex', assistName: 'Ellis' }, + { type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Mason 8-9', playerName: 'Mason', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 8-10' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 9-10', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Toby block', playerName: 'Toby', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 9-11' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis block', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia extends lead 9-12' }, + { type: EventType.TIMEOUT, team: 'them', message: 'Timeout Columbia' }, + { type: EventType.GOAL, team: 'us', message: 'Mason to Jake 10-12', playerName: 'Jake', assistName: 'Mason' }, + { type: EventType.GOAL, team: 'them', message: 'Columbia wins 10-13' }, + ] + }, + + // Hucksgiving - November 22, 2025 + { + chatId: 'hucksgiving-nov22-game1', + ourTeamName: 'Brooklyn Tech B', + opponentName: 'Brooklyn Magic', + tournamentName: 'Hucksgiving 2025', + gameDate: '2025-11-22', + gameOrder: 1, + startTime: '09:04', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 1-0', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'D Marley', playerName: 'Marley', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Marley 2-0', playerName: 'Marley', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'them', message: 'Brooklyn Magic scores 2-1' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Ellis 3-1', playerName: 'Ellis', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Alex 4-1', playerName: 'Alex', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'Ellis D', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 5-1', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Nico 6-1', playerName: 'Nico', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Jake 7-1', playerName: 'Jake', assistName: 'Marley' }, + { type: EventType.NOTE, team: 'us', message: 'Alex steal', playerName: 'Alex', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Cyrus 8-1', playerName: 'Cyrus', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Brooklyn Magic scores 8-2' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Marley 9-2', playerName: 'Marley', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Toby to Marley 10-2', playerName: 'Marley', assistName: 'Toby' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Noah 11-2', playerName: 'Noah', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jake 12-2', playerName: 'Jake', assistName: 'Ellis' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Alex 13-2', playerName: 'Alex', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Theo 14-2', playerName: 'Theo', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Cyrus 15-2', playerName: 'Cyrus', assistName: 'Nico' }, + ] + }, + { + chatId: 'hucksgiving-nov22-game2', + ourTeamName: 'Brooklyn Tech A', + opponentName: 'Bard', + tournamentName: 'Hucksgiving 2025', + gameDate: '2025-11-22', + gameOrder: 2, + startTime: '11:00', + startingOnOffense: false, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Jake to Ellis 1-0 Break', playerName: 'Ellis', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 1-1' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Jed 2-1', playerName: 'Jed', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'D Ellis', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Jed 3-1', playerName: 'Jed', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jed 4-1', playerName: 'Jed', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Max 5-1', playerName: 'Max', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Theo 6-1', playerName: 'Theo', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 6-2' }, + { type: EventType.GOAL, team: 'us', message: 'Ellis to Jed 7-2', playerName: 'Jed', assistName: 'Ellis' }, + { type: EventType.NOTE, team: 'us', message: 'D Jake', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Jed 8-2', playerName: 'Jed', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'D Ellis', playerName: 'Ellis', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'D Alex', playerName: 'Alex', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 8-3' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jake 9-3', playerName: 'Jake', assistName: 'Alex' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Noah 10-3', playerName: 'Noah', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'D Jed', playerName: 'Jed', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Nico 11-3', playerName: 'Nico', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'us', message: 'Jed to Nico 12-3', playerName: 'Nico', assistName: 'Jed' }, + { type: EventType.GOAL, team: 'them', message: 'Bard scores 12-4' }, + { type: EventType.GOAL, team: 'us', message: 'Noah to Alex 13-4', playerName: 'Alex', assistName: 'Noah' }, + { type: EventType.NOTE, team: 'us', message: 'End zone D Theo', playerName: 'Theo', defensivePlay: 'block' }, + { type: EventType.NOTE, team: 'us', message: 'D Nico', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Max to Noah 14-4', playerName: 'Noah', assistName: 'Max' }, + { type: EventType.NOTE, team: 'us', message: 'Max D', playerName: 'Max', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jake 15-4', playerName: 'Jake', assistName: 'Alex' }, + ] + }, + { + chatId: 'hucksgiving-nov22-game3', + ourTeamName: 'Brooklyn Tech A', + opponentName: 'Mikey Grauer\'s Ringers', + tournamentName: 'Hucksgiving 2025', + gameDate: '2025-11-22', + gameOrder: 3, + startTime: '13:04', + startingOnOffense: true, + events: [ + { type: EventType.GOAL, team: 'us', message: 'Nico to Alex 1-0', playerName: 'Alex', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 1-1' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 1-2' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Max 2-2', playerName: 'Max', assistName: 'Nico' }, + { type: EventType.NOTE, team: 'us', message: 'End zone steal Jake', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'us', message: 'Cyrus to Jed 3-2', playerName: 'Jed', assistName: 'Cyrus' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 3-3' }, + { type: EventType.GOAL, team: 'us', message: 'Marley to Nico 4-3', playerName: 'Nico', assistName: 'Marley' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 4-4' }, + { type: EventType.GOAL, team: 'us', message: 'Nico to Jed 5-4', playerName: 'Jed', assistName: 'Nico' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 5-5' }, + { type: EventType.NOTE, team: 'us', message: 'End zone block Nico', playerName: 'Nico', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Alex 6-5', playerName: 'Alex', assistName: 'Jake' }, + { type: EventType.NOTE, team: 'us', message: 'D Noah', playerName: 'Noah', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 7-5', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent scores 7-6' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 7-7' }, + { type: EventType.NOTE, team: 'us', message: 'End zone D Jake', playerName: 'Jake', defensivePlay: 'block' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Theo 8-7', playerName: 'Theo', assistName: 'Alex' }, + { type: EventType.NOTE, team: 'us', message: 'Jake steal', playerName: 'Jake', defensivePlay: 'steal' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 8-8' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Theo 9-8', playerName: 'Theo', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent ties 9-9' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 9-10' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 10-10', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 10-11' }, + { type: EventType.GOAL, team: 'us', message: 'Jake to Nico 11-11', playerName: 'Nico', assistName: 'Jake' }, + { type: EventType.GOAL, team: 'them', message: 'Opponent takes lead 11-12' }, + { type: EventType.GOAL, team: 'us', message: 'Tech ties 12-12', playerName: 'Unknown' }, + { type: EventType.GOAL, team: 'us', message: 'Alex to Jake to Jed 13-12 GAME', playerName: 'Jed', assistName: 'Jake' }, + ] + }, +]; + +async function createGame(gameData: GameData): Promise { + console.log(`\nCreating game: ${gameData.ourTeamName} vs ${gameData.opponentName} (${gameData.tournamentName})`); + + const createResponse = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: gameData.chatId, + ourTeamName: gameData.ourTeamName, + opponentName: gameData.opponentName, + tournamentName: gameData.tournamentName, + gameDate: gameData.gameDate, + gameOrder: gameData.gameOrder, + }), + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create game: ${await createResponse.text()}`); + } + + const { game } = await createResponse.json(); + const gameId = game.id; + console.log(`✓ Created game ${gameId}`); + + // Start the game with starting offense/defense info + console.log(` Starting game (starting on ${gameData.startingOnOffense ? 'O' : 'D'})...`); + const startResponse = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: EventType.GAME_START, + message: `Game start - Tech starting on ${gameData.startingOnOffense ? 'offense' : 'defense'}`, + startingOnOffense: gameData.startingOnOffense, + }), + }); + + if (!startResponse.ok) { + throw new Error(`Failed to start game: ${await startResponse.text()}`); + } + + // Add events + console.log(` Adding ${gameData.events.length} events...`); + for (let i = 0; i < gameData.events.length; i++) { + const event = gameData.events[i]; + + const eventResponse = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + + if (!eventResponse.ok) { + console.error(`Failed to add event ${i + 1}: ${event.message}`); + throw new Error(`Failed to add event: ${await eventResponse.text()}`); + } + + // Show progress every 5 events + if ((i + 1) % 5 === 0 || i === gameData.events.length - 1) { + console.log(` ${i + 1}/${gameData.events.length} events added`); + } + } + + // End the game + console.log(` Ending game...`); + const endResponse = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: EventType.GAME_END, + message: 'Game complete', + }), + }); + + if (!endResponse.ok) { + throw new Error(`Failed to end game: ${await endResponse.text()}`); + } + + console.log(`✅ Game completed: ${gameId}`); + return gameId; +} + +async function loadAllGames() { + console.log('🚀 Starting to load recent tournament games...\n'); + console.log(`Total games to load: ${games.length}`); + console.log(`API URL: ${API_URL}\n`); + + const gameIds: string[] = []; + + for (const gameData of games) { + try { + const gameId = await createGame(gameData); + gameIds.push(gameId); + } catch (error) { + console.error(`\n❌ Error loading game:`, error); + throw error; + } + } + + console.log('\n✅ All games loaded successfully!'); + console.log('\nGame IDs:'); + gameIds.forEach((id, index) => { + console.log(` ${index + 1}. ${games[index].tournamentName} - ${games[index].gameDate}: ${id}`); + }); + + console.log(`\n🌐 View games at: https://score.kcuda.org`); +} + +loadAllGames().catch(console.error); diff --git a/packages/bot/scripts/archive/load-states.ts b/packages/bot/scripts/archive/load-states.ts new file mode 100644 index 0000000..2aa6599 --- /dev/null +++ b/packages/bot/scripts/archive/load-states.ts @@ -0,0 +1,331 @@ +/** + * Load 2026 NY State High School Ultimate Championship games (5/23-5/24). + * Tech Support went 5-0 and won back-to-back state championships. + * + * Game 1: Tech vs Regis — W 9-8 (5/23) + * Game 2: Tech vs Murrow — W 15-5 (5/23) + * Game 3: Tech vs Bard — W 13-8 (5/24) + * Game 4: Tech vs Scarsdale — W 11-9 (5/24) + * Game 5: Tech vs Bethlehem — W 15-5 (5/24 — final) + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(day: number, time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + // May 2026 is EDT (UTC-4). Year=2026, month=4 (May, 0-indexed). + return new Date(Date.UTC(2026, 4, day, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +type Game = { + chatId: string; + opponentName: string; + gameDate: string; + gameOrder: number; + events: Event[]; +}; + +const games: Game[] = [ + // ---------------- Game 1: vs Regis — W 9-8 (5/23) ---------------- + { + chatId: 'states-regis-2026-05-23', + opponentName: 'Regis', + gameDate: '2026-05-23', + gameOrder: 1, + events: [ + { type: 'game_start', startingOnOffense: true, timestamp: edt(23, '11:02:35 AM') }, + { type: 'note', message: 'Cyrus block', timestamp: edt(23, '11:03:41 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', timestamp: edt(23, '11:08:39 AM') }, + { type: 'note', message: 'Foster steal', timestamp: edt(23, '11:11:39 AM') }, + { type: 'goal', team: 'them', message: '1-1', defensivePlay: 'steal', timestamp: edt(23, '11:12:45 AM') }, + { type: 'goal', team: 'them', message: '1-2', timestamp: edt(23, '11:15:28 AM') }, + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt(23, '11:16:20 AM') }, + { type: 'goal', team: 'us', message: 'Cyrus to Gus', timestamp: edt(23, '11:20:09 AM') }, + { type: 'note', message: 'Asher block', timestamp: edt(23, '11:22:08 AM') }, + { type: 'note', message: 'Asher block again', timestamp: edt(23, '11:23:25 AM') }, + { type: 'goal', team: 'them', message: '2-3', timestamp: edt(23, '11:24:42 AM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(23, '11:26:50 AM') }, + { type: 'goal', team: 'them', message: '2-4', defensivePlay: 'steal', timestamp: edt(23, '11:29:56 AM') }, + { type: 'goal', team: 'them', message: '2-5', timestamp: edt(23, '11:32:53 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Asher', timestamp: edt(23, '11:35:13 AM') }, + { type: 'note', message: 'Mason foot block', timestamp: edt(23, '11:37:34 AM') }, + { type: 'goal', team: 'them', message: '3-6', defensivePlay: 'block', timestamp: edt(23, '11:46:55 AM') }, + { type: 'note', message: 'Mason block', timestamp: edt(23, '11:52:52 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', defensivePlay: 'block', timestamp: edt(23, '11:53:44 AM') }, + { type: 'timeout', team: 'them', message: 'Timeout Regis', timestamp: edt(23, '11:53:56 AM') }, + { type: 'note', message: 'Gus steal', timestamp: edt(23, '11:58:29 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Max', defensivePlay: 'steal', timestamp: edt(23, '12:00:34 PM') }, + { type: 'note', message: 'Jake block', timestamp: edt(23, '12:02:37 PM') }, + { type: 'note', message: 'Stall turnover to Tech', timestamp: edt(23, '12:06:16 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Jake', defensivePlay: 'block', timestamp: edt(23, '12:06:35 PM') }, + { type: 'note', message: 'Jake block', timestamp: edt(23, '12:09:30 PM') }, + { type: 'goal', team: 'us', message: 'Mason hammer to Ben', defensivePlay: 'block', timestamp: edt(23, '12:10:23 PM') }, + { type: 'goal', team: 'them', message: '7-7', timestamp: edt(23, '12:13:42 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Cyrus', timestamp: edt(23, '12:16:54 PM') }, + { type: 'halftime', timestamp: edt(23, '12:17:05 PM') }, + { type: 'second_half_start', timestamp: edt(23, '12:25:00 PM') }, + { type: 'note', message: 'Mason block', timestamp: edt(23, '12:26:59 PM') }, + { type: 'goal', team: 'us', message: 'Jed to Teyo', defensivePlay: 'block', timestamp: edt(23, '12:29:40 PM') }, + { type: 'note', message: 'Nate block', timestamp: edt(23, '12:31:51 PM') }, + { type: 'note', message: 'Ellis block', timestamp: edt(23, '12:35:27 PM') }, + { type: 'goal', team: 'them', message: '9-8', timestamp: edt(23, '12:35:55 PM') }, + { type: 'game_end', message: 'Tech win 9-8', timestamp: edt(23, '12:36:08 PM') }, + ], + }, + + // ---------------- Game 2: vs Murrow — W 15-5 (5/23) ---------------- + { + chatId: 'states-murrow-2026-05-23', + opponentName: 'Murrow', + gameDate: '2026-05-23', + gameOrder: 2, + events: [ + { type: 'game_start', startingOnOffense: false, timestamp: edt(23, '1:02:29 PM') }, + { type: 'goal', team: 'them', message: '0-1', timestamp: edt(23, '1:04:20 PM') }, + { type: 'goal', team: 'us', message: 'Marley to Gus', timestamp: edt(23, '1:06:43 PM') }, + { type: 'note', message: 'Ben block to Foster block', timestamp: edt(23, '1:11:21 PM') }, + { type: 'goal', team: 'us', message: 'Asher to Max', defensivePlay: 'block', timestamp: edt(23, '1:13:26 PM') }, + { type: 'note', message: 'Jake block', timestamp: edt(23, '1:16:30 PM') }, + { type: 'goal', team: 'us', message: 'Jake to Cyrus', defensivePlay: 'block', timestamp: edt(23, '1:17:00 PM') }, + { type: 'goal', team: 'them', message: '3-2', timestamp: edt(23, '1:19:55 PM') }, + { type: 'note', message: 'Ellis block', timestamp: edt(23, '1:23:07 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Nate', defensivePlay: 'block', timestamp: edt(23, '1:23:12 PM') }, + { type: 'goal', team: 'them', message: '4-3', timestamp: edt(23, '1:25:26 PM') }, + { type: 'goal', team: 'us', message: 'Gus to Foster (after deep Ellis to Gus)', timestamp: edt(23, '1:27:50 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Asher', timestamp: edt(23, '1:30:20 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Foster', timestamp: edt(23, '1:33:32 PM') }, + { type: 'goal', team: 'them', message: '7-4', timestamp: edt(23, '1:37:12 PM') }, + { type: 'timeout', team: 'them', message: 'Timeout Murrow', timestamp: edt(23, '1:38:13 PM') }, + { type: 'note', message: 'Corbin block', timestamp: edt(23, '1:41:15 PM') }, + { type: 'goal', team: 'us', message: 'Mason deep to Nico', defensivePlay: 'block', timestamp: edt(23, '1:41:47 PM') }, + { type: 'halftime', timestamp: edt(23, '1:41:57 PM') }, + { type: 'second_half_start', timestamp: edt(23, '1:50:00 PM') }, + { type: 'goal', team: 'them', message: '8-5', timestamp: edt(23, '1:52:45 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Asher', timestamp: edt(23, '1:55:52 PM') }, + { type: 'note', message: 'Asher block', timestamp: edt(23, '1:58:32 PM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(23, '1:59:26 PM') }, + { type: 'goal', team: 'us', message: 'Jake to Ben', defensivePlay: 'steal', timestamp: edt(23, '1:59:33 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Cyrus', timestamp: edt(23, '2:03:29 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Nico', timestamp: edt(23, '2:07:24 PM') }, + { type: 'goal', team: 'us', message: 'Marley to Nate', timestamp: edt(23, '2:11:27 PM') }, + { type: 'note', message: 'Jed block', timestamp: edt(23, '2:13:54 PM') }, + { type: 'note', message: 'Mason block', timestamp: edt(23, '2:15:10 PM') }, + { type: 'goal', team: 'us', message: 'Nico to Mason', defensivePlay: 'block', timestamp: edt(23, '2:15:55 PM') }, + { type: 'note', message: 'Foster steal', timestamp: edt(23, '2:18:15 PM') }, + { type: 'goal', team: 'us', message: 'Cyrus to Foster ftw', defensivePlay: 'steal', timestamp: edt(23, '2:19:11 PM') }, + { type: 'game_end', message: 'Tech win 15-5', timestamp: edt(23, '2:23:57 PM') }, + ], + }, + + // ---------------- Game 3: vs Bard — W 13-8 (5/24) ---------------- + { + chatId: 'states-bard-2026-05-24', + opponentName: 'Bard', + gameDate: '2026-05-24', + gameOrder: 3, + events: [ + { type: 'game_start', startingOnOffense: false, timestamp: edt(24, '8:56:24 AM') }, + { type: 'note', message: 'Jake block', timestamp: edt(24, '9:03:11 AM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(24, '9:03:35 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Jake', defensivePlay: 'steal', timestamp: edt(24, '9:03:40 AM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(24, '9:05:52 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Jake w the toe drag', defensivePlay: 'steal', timestamp: edt(24, '9:06:02 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Nate', timestamp: edt(24, '9:09:12 AM') }, + { type: 'note', message: 'Jed block', timestamp: edt(24, '9:11:36 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Asher', defensivePlay: 'block', timestamp: edt(24, '9:11:44 AM') }, + { type: 'timeout', team: 'them', message: 'Timeout Bard', timestamp: edt(24, '9:12:35 AM') }, + { type: 'goal', team: 'them', message: '4-1', timestamp: edt(24, '9:15:59 AM') }, + { type: 'goal', team: 'them', message: '4-2', timestamp: edt(24, '9:20:36 AM') }, + { type: 'goal', team: 'us', message: 'Marley to Ellis', timestamp: edt(24, '9:23:06 AM') }, + { type: 'goal', team: 'them', message: '5-3', timestamp: edt(24, '9:25:44 AM') }, + { type: 'goal', team: 'them', message: '5-4', timestamp: edt(24, '9:28:49 AM') }, + { type: 'goal', team: 'them', message: '5-5', timestamp: edt(24, '9:31:43 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Jake', timestamp: edt(24, '9:34:37 AM') }, + { type: 'note', message: 'Jed steal', timestamp: edt(24, '9:37:29 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Jake', defensivePlay: 'steal', timestamp: edt(24, '9:37:55 AM') }, + { type: 'note', message: 'Ellis block', timestamp: edt(24, '9:40:13 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Ellis', defensivePlay: 'block', timestamp: edt(24, '9:42:18 AM') }, + { type: 'halftime', timestamp: edt(24, '9:42:25 AM') }, + { type: 'second_half_start', timestamp: edt(24, '9:50:00 AM') }, + { type: 'note', message: 'Cyrus steal', timestamp: edt(24, '9:52:05 AM') }, + { type: 'goal', team: 'us', message: 'Marley to Foster', defensivePlay: 'steal', timestamp: edt(24, '9:52:58 AM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(24, '9:58:07 AM') }, + { type: 'note', message: 'Jake block', timestamp: edt(24, '10:01:07 AM') }, + { type: 'note', message: 'Max block', timestamp: edt(24, '10:02:05 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Jake', defensivePlay: 'block', timestamp: edt(24, '10:03:00 AM') }, + { type: 'goal', team: 'them', message: '10-6', timestamp: edt(24, '10:08:17 AM') }, + { type: 'goal', team: 'them', message: '10-7', timestamp: edt(24, '10:10:55 AM') }, + { type: 'timeout', team: 'them', message: 'Timeout', timestamp: edt(24, '10:12:06 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', timestamp: edt(24, '10:15:11 AM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(24, '10:17:30 AM') }, + { type: 'note', message: 'Mason diving block', timestamp: edt(24, '10:18:44 AM') }, + { type: 'goal', team: 'them', message: '11-8', timestamp: edt(24, '10:20:03 AM') }, + { type: 'note', message: 'Corbin block', timestamp: edt(24, '10:23:18 AM') }, + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt(24, '10:24:46 AM') }, + { type: 'goal', team: 'us', message: 'Jake to Nate', defensivePlay: 'block', timestamp: edt(24, '10:29:45 AM') }, + { type: 'note', message: 'Mason block', timestamp: edt(24, '10:33:00 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Nico ftw', defensivePlay: 'block', timestamp: edt(24, '10:33:11 AM') }, + { type: 'game_end', message: 'Tech win 13-8', timestamp: edt(24, '10:33:53 AM') }, + ], + }, + + // ---------------- Game 4: vs Scarsdale — W 11-9 (5/24) ---------------- + { + chatId: 'states-scarsdale-2026-05-24', + opponentName: 'Scarsdale', + gameDate: '2026-05-24', + gameOrder: 4, + events: [ + { type: 'game_start', startingOnOffense: true, timestamp: edt(24, '11:10:29 AM') }, + { type: 'note', message: 'Nico block', timestamp: edt(24, '11:12:40 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Nate', timestamp: edt(24, '11:13:53 AM') }, + { type: 'note', message: 'Mason block', timestamp: edt(24, '11:16:29 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Teyo', defensivePlay: 'block', timestamp: edt(24, '11:18:36 AM') }, + { type: 'goal', team: 'them', message: '2-1', timestamp: edt(24, '11:22:26 AM') }, + { type: 'note', message: 'Ellis steal', timestamp: edt(24, '11:27:20 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Nate', defensivePlay: 'steal', timestamp: edt(24, '11:28:51 AM') }, + { type: 'timeout', team: 'them', message: 'Timeout', timestamp: edt(24, '11:30:23 AM') }, + { type: 'goal', team: 'them', message: '3-2', timestamp: edt(24, '11:32:37 AM') }, + { type: 'note', message: 'Gus block', timestamp: edt(24, '11:39:09 AM') }, + { type: 'note', message: 'Foster block', timestamp: edt(24, '11:40:51 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Mason', defensivePlay: 'block', timestamp: edt(24, '11:41:04 AM') }, + { type: 'goal', team: 'them', message: '4-3', timestamp: edt(24, '11:44:59 AM') }, + { type: 'goal', team: 'us', message: 'Marley to Corbin', timestamp: edt(24, '11:50:16 AM') }, + { type: 'goal', team: 'them', message: '5-4', timestamp: edt(24, '11:53:06 AM') }, + { type: 'goal', team: 'them', message: '5-5', timestamp: edt(24, '11:55:57 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', timestamp: edt(24, '12:00:09 PM') }, + { type: 'note', message: 'Jed block', timestamp: edt(24, '12:02:24 PM') }, + { type: 'note', message: 'Mason steal', timestamp: edt(24, '12:02:57 PM') }, + { type: 'goal', team: 'them', message: '6-6', timestamp: edt(24, '12:03:34 PM') }, + { type: 'goal', team: 'them', message: '6-7', timestamp: edt(24, '12:06:18 PM') }, + { type: 'timeout', team: 'them', message: 'Timeout', timestamp: edt(24, '12:07:13 PM') }, + { type: 'goal', team: 'them', message: '6-8', timestamp: edt(24, '12:11:48 PM') }, + { type: 'halftime', timestamp: edt(24, '12:11:52 PM') }, + { type: 'second_half_start', timestamp: edt(24, '12:18:00 PM') }, + { type: 'goal', team: 'us', message: 'Jake to Foster', timestamp: edt(24, '12:19:13 PM') }, + { type: 'note', message: 'Jake steal', timestamp: edt(24, '12:21:36 PM') }, + { type: 'note', message: 'Nico block', timestamp: edt(24, '12:22:11 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', defensivePlay: 'block', timestamp: edt(24, '12:22:22 PM') }, + { type: 'note', message: 'Jake steal', timestamp: edt(24, '12:24:32 PM') }, + { type: 'goal', team: 'them', message: '8-9', timestamp: edt(24, '12:25:24 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', timestamp: edt(24, '12:28:16 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Gus', timestamp: edt(24, '12:37:47 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Foster ftw', timestamp: edt(24, '12:41:13 PM') }, + { type: 'game_end', message: 'Tech win 11-9', timestamp: edt(24, '12:41:16 PM') }, + ], + }, + + // ---------------- Game 5: vs Bethlehem (FINAL) — W 15-5 (5/24) ---------------- + { + chatId: 'states-bethlehem-2026-05-24', + opponentName: 'Bethlehem', + gameDate: '2026-05-24', + gameOrder: 5, + events: [ + { type: 'game_start', startingOnOffense: true, timestamp: edt(24, '1:04:19 PM') }, + { type: 'goal', team: 'us', message: 'Mason deep to Nate', timestamp: edt(24, '1:11:57 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Nico', timestamp: edt(24, '1:14:40 PM') }, + { type: 'goal', team: 'us', message: 'Cyrus to sliding Asher', timestamp: edt(24, '1:17:23 PM') }, + { type: 'goal', team: 'them', message: '3-1', timestamp: edt(24, '1:23:30 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Corbin', timestamp: edt(24, '1:25:59 PM') }, + { type: 'note', message: 'Gus block', timestamp: edt(24, '1:29:42 PM') }, + { type: 'note', message: 'Mason block', timestamp: edt(24, '1:31:28 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Teyo diving', defensivePlay: 'block', timestamp: edt(24, '1:33:56 PM') }, + { type: 'timeout', team: 'them', message: 'Timeout', timestamp: edt(24, '1:34:43 PM') }, + { type: 'note', message: 'Nico steal', timestamp: edt(24, '1:38:05 PM') }, + { type: 'note', message: 'Max block', timestamp: edt(24, '1:41:18 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Max', defensivePlay: 'block', timestamp: edt(24, '1:41:21 PM') }, + { type: 'goal', team: 'us', message: 'Gus to Nate', timestamp: edt(24, '1:44:11 PM') }, + { type: 'goal', team: 'us', message: 'Nico to Asher', timestamp: edt(24, '1:46:25 PM') }, + { type: 'halftime', timestamp: edt(24, '1:47:02 PM') }, + { type: 'second_half_start', timestamp: edt(24, '1:54:00 PM') }, + { type: 'goal', team: 'them', message: '8-2', timestamp: edt(24, '1:54:52 PM') }, + { type: 'goal', team: 'us', message: 'Mason huck to leaping Gus', timestamp: edt(24, '1:57:07 PM') }, + { type: 'note', message: 'Asher block', timestamp: edt(24, '1:59:28 PM') }, + { type: 'goal', team: 'them', message: '9-3', timestamp: edt(24, '1:59:58 PM') }, + { type: 'goal', team: 'us', message: 'Nico to Gus', timestamp: edt(24, '2:02:39 PM') }, + { type: 'goal', team: 'us', message: 'Jed to Noah', timestamp: edt(24, '2:05:24 PM') }, + { type: 'goal', team: 'us', message: 'Nico to Cyrus (senior line)', timestamp: edt(24, '2:08:24 PM') }, + { type: 'note', message: 'Cyrus diving block', timestamp: edt(24, '2:10:52 PM') }, + { type: 'goal', team: 'us', message: 'Cyrus to Noah', defensivePlay: 'block', timestamp: edt(24, '2:16:47 PM') }, + { type: 'note', message: 'Asher block', timestamp: edt(24, '2:19:27 PM') }, + { type: 'goal', team: 'us', message: 'Nate to Foster', defensivePlay: 'block', timestamp: edt(24, '2:20:41 PM') }, + { type: 'goal', team: 'them', message: '14-4', timestamp: edt(24, '2:23:19 PM') }, + { type: 'goal', team: 'them', message: '14-5', timestamp: edt(24, '2:25:39 PM') }, + { type: 'goal', team: 'us', message: 'Jake to Nate ftw — REPEAT CHAMPS', timestamp: edt(24, '2:27:48 PM') }, + { type: 'game_end', message: 'Tech win 15-5 — NY State Champions!', timestamp: edt(24, '2:27:52 PM') }, + ], + }, +]; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function loadGame(g: Game) { + console.log(`\n📊 Creating game: Tech Support vs ${g.opponentName} (${g.gameDate})`); + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: g.chatId, + ourTeamName: 'Tech Support', + opponentName: g.opponentName, + tournamentName: 'NY State Championships', + gameDate: g.gameDate, + gameOrder: g.gameOrder, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`✅ Game created: ${game.id}`); + + for (let i = 0; i < g.events.length; i++) { + const event = g.events[i]; + console.log(` [${i + 1}/${g.events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } +} + +async function main() { + console.log(`Using API: ${API_URL}`); + for (const g of games) { + try { + await loadGame(g); + } catch (err) { + console.error(`❌ Error loading game vs ${g.opponentName}:`, err); + } + } + console.log('\n🏆 All States games loaded. Repeat champs!'); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-yula-day1.ts b/packages/bot/scripts/archive/load-yula-day1.ts new file mode 100644 index 0000000..9500dde --- /dev/null +++ b/packages/bot/scripts/archive/load-yula-day1.ts @@ -0,0 +1,162 @@ +/** + * Load YULA Day 1 games (3/21/26) + * Game 1: Tech Support vs Montclair — 9-7 W + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +/** Convert "H:MM:SS AM/PM" EDT to Unix ms (EDT = UTC-4, DST active) */ +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 2, 21, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function loadGame(chatId: string, opponent: string, gameOrder: number, events: Event[]) { + console.log(`\n=== Loading Game ${gameOrder}: Tech Support vs ${opponent} ===`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId, + ourTeamName: 'Tech Support', + opponentName: opponent, + tournamentName: 'YULA', + gameDate: '2026-03-21', + gameOrder, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`Done! ${events.length} events loaded.`); + return game.id; +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + // === GAME 1: Tech Support vs Montclair — 9-7 W === + const game1Events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('8:29:54 AM') }, + + // Point 1: 1-0 Tech (Cyrus steal → Ellis to Corbin) + { type: 'note', message: 'Cyrus steal', timestamp: edt('8:32:57 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Corbin', defensivePlay: 'steal', timestamp: edt('8:34:11 AM') }, + + // Point 2: 1-1 Montclair (Toby block but they still scored) + { type: 'note', message: 'Toby block', timestamp: edt('8:36:50 AM') }, + { type: 'goal', team: 'them', message: '1-1', timestamp: edt('8:37:47 AM') }, + + // Point 3: 2-1 Tech (Ellis deep to Corbin) + { type: 'goal', team: 'us', message: 'Ellis deep to Corbin', timestamp: edt('8:39:53 AM') }, + + // Point 4: 3-1 Tech (Toby block → Mason deep to Jake) + { type: 'note', message: 'Toby block', timestamp: edt('8:42:25 AM') }, + { type: 'goal', team: 'us', message: 'Mason deep to Jake', defensivePlay: 'block', timestamp: edt('8:43:06 AM') }, + + // Point 5: 3-2 Montclair + { type: 'goal', team: 'them', message: '3-2', timestamp: edt('8:46:23 AM') }, + + // Point 6: 4-2 Tech (Ellis to Cyrus) + { type: 'goal', team: 'us', message: 'Ellis to Cyrus', timestamp: edt('8:48:46 AM') }, + + // Point 7: 4-3 Montclair + { type: 'goal', team: 'them', message: '4-3', timestamp: edt('8:51:37 AM') }, + + // Point 8: 5-3 Tech (Gus block → Ellis to Alex) + { type: 'note', message: 'Gus block', timestamp: edt('8:56:55 AM') }, + { type: 'goal', team: 'us', message: 'Ellis to Alex', defensivePlay: 'block', timestamp: edt('8:57:12 AM') }, + + // Point 9: 6-3 Tech (Jake steal, Jake block → Mason huck to Jake) + { type: 'note', message: 'Jake steal', timestamp: edt('8:59:59 AM') }, + { type: 'note', message: 'Jake block', timestamp: edt('9:02:09 AM') }, + { type: 'goal', team: 'us', message: 'Mason huck to Jake', defensivePlay: 'block', timestamp: edt('9:04:55 AM') }, + + // HALFTIME 6-3 + { type: 'halftime', timestamp: edt('9:07:03 AM') }, + + // SECOND HALF + { type: 'second_half_start', timestamp: edt('9:13:00 AM') }, + + // Point 10: 6-4 Montclair (Teyo block, Toby block but they still scored) + { type: 'note', message: 'Teyo block', timestamp: edt('9:14:09 AM') }, + { type: 'note', message: 'Toby block', timestamp: edt('9:14:35 AM') }, + { type: 'goal', team: 'them', message: '6-4', timestamp: edt('9:16:11 AM') }, + + // Point 11: 6-5 Montclair + { type: 'goal', team: 'them', message: '6-5', timestamp: edt('9:20:11 AM') }, + + // Point 12: 6-6 Montclair + { type: 'goal', team: 'them', message: '6-6', timestamp: edt('9:29:35 AM') }, + + // Timeout Tech + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('9:30:16 AM') }, + + // Point 13: 7-6 Tech (Jake to Mason) + { type: 'goal', team: 'us', message: 'Jake to Mason', timestamp: edt('9:36:50 AM') }, + + // Note: 7-7 was posted then corrected — not a score + { type: 'note', message: 'Correction: 7-7 was not a score, still 7-6', timestamp: edt('9:43:03 AM') }, + + // Point 14: 8-6 Tech (Toby block → Toby to Jake) + { type: 'note', message: 'Toby block', timestamp: edt('9:43:21 AM') }, + { type: 'goal', team: 'us', message: 'Toby to Jake', defensivePlay: 'block', timestamp: edt('9:43:47 AM') }, + + // Note: soft cap + { type: 'note', message: 'Soft cap in effect, game to 9', timestamp: edt('9:44:24 AM') }, + + // Point 15: 8-7 Montclair + { type: 'goal', team: 'them', message: '8-7', timestamp: edt('9:46:16 AM') }, + + // Point 16: 9-7 Tech — GAME WINNER (Alex to Nico) + { type: 'goal', team: 'us', message: 'Alex to Nico', timestamp: edt('9:50:00 AM') }, + + // GAME END + { type: 'game_end', timestamp: edt('9:50:00 AM') }, + ]; + + await loadGame('yula-montclair-2026-03-21', 'Montclair', 1, game1Events); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-yula-day2-game1.ts b/packages/bot/scripts/archive/load-yula-day2-game1.ts new file mode 100644 index 0000000..6b788eb --- /dev/null +++ b/packages/bot/scripts/archive/load-yula-day2-game1.ts @@ -0,0 +1,169 @@ +/** + * Load YULA Day 2 Game 1: Tech Support vs Jackson Reed — 11-10 W (universe point) + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 2, 22, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'yula-jackson-reed-2026-03-22', + ourTeamName: 'Tech Support', + opponentName: 'Jackson Reed', + tournamentName: 'YULA', + gameDate: '2026-03-22', + gameOrder: 1, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('10:04:09 AM') }, + + // Point 1: 1-0 Tech (Jake to Nico) + { type: 'goal', team: 'us', message: 'Jake to Nico', timestamp: edt('10:13:06 AM') }, + + // Point 2: 2-0 Tech (Jake block → Mason to Jake) + { type: 'note', message: 'Jake block', timestamp: edt('10:16:26 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Jake', defensivePlay: 'block', timestamp: edt('10:16:37 AM') }, + + // Point 3: 3-0 Tech (Alex block → Mason to Jake) + { type: 'note', message: 'Alex block', timestamp: edt('10:19:04 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Jake', defensivePlay: 'block', timestamp: edt('10:20:12 AM') }, + + // Point 4: 3-1 Jackson Reed + { type: 'goal', team: 'them', message: '3-1', timestamp: edt('10:23:47 AM') }, + + // Point 5: 3-2 Jackson Reed + { type: 'goal', team: 'them', message: '3-2', timestamp: edt('10:28:27 AM') }, + + // Point 6: 3-3 Jackson Reed + { type: 'goal', team: 'them', message: '3-3', timestamp: edt('10:31:36 AM') }, + + // Timeout Tech + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('10:32:35 AM') }, + + // Point 7: 4-3 Tech (Mason hammer to Jake) + { type: 'goal', team: 'us', message: 'Mason hammer to Jake', timestamp: edt('10:37:14 AM') }, + + // Point 8: 4-4 Jackson Reed + { type: 'goal', team: 'them', message: '4-4', timestamp: edt('10:39:45 AM') }, + + // Point 9: 4-5 Jackson Reed + { type: 'goal', team: 'them', message: '4-5', timestamp: edt('10:43:46 AM') }, + + // Point 10: 5-5 Tech (Ellis to Nico) + { type: 'goal', team: 'us', message: 'Ellis to Nico', timestamp: edt('10:47:26 AM') }, + + // Point 11: 5-6 Jackson Reed + { type: 'goal', team: 'them', message: '5-6', timestamp: edt('10:50:05 AM') }, + + // Point 12: 6-6 Tech + { type: 'goal', team: 'us', message: '6-6', timestamp: edt('10:54:22 AM') }, + + // Mason steal but JR still scores + { type: 'note', message: 'Mason steal', timestamp: edt('10:57:42 AM') }, + + // Point 13: 6-7 Jackson Reed + { type: 'goal', team: 'them', message: '6-7', timestamp: edt('10:58:39 AM') }, + + // HALFTIME 6-7 + { type: 'halftime', timestamp: edt('10:58:41 AM') }, + + // SECOND HALF + { type: 'second_half_start', timestamp: edt('11:08:00 AM') }, + + // Point 14: 7-7 Tech (Toby block → Mason to Ellis — break to start 2nd half) + { type: 'note', message: 'Toby block', timestamp: edt('11:09:11 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Ellis', defensivePlay: 'block', timestamp: edt('11:09:45 AM') }, + + // Point 15: 7-8 Jackson Reed + { type: 'goal', team: 'them', message: '7-8', timestamp: edt('11:13:52 AM') }, + + // Point 16: 8-8 Tech (Nico to Mason) + { type: 'goal', team: 'us', message: 'Nico to Mason', timestamp: edt('11:16:40 AM') }, + + // Point 17: 8-9 Jackson Reed + { type: 'goal', team: 'them', message: '8-9', timestamp: edt('11:19:49 AM') }, + + // Timeout Tech + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('11:21:09 AM') }, + + // Point 18: 9-9 Tech (Mason hammer to Corbin) + { type: 'goal', team: 'us', message: 'Mason hammer to Corbin', timestamp: edt('11:25:52 AM') }, + + // Point 19: 10-9 Tech (Toby block, Gus steal → Jake to Gus) + { type: 'note', message: 'Toby block', timestamp: edt('11:28:18 AM') }, + { type: 'note', message: 'Gus steal', timestamp: edt('11:29:39 AM') }, + { type: 'goal', team: 'us', message: 'Jake to Gus', defensivePlay: 'steal', timestamp: edt('11:29:53 AM') }, + + // Point 20: 10-10 Jackson Reed + { type: 'goal', team: 'them', message: '10-10', timestamp: edt('11:32:52 AM') }, + + // Soft cap — universe point + { type: 'note', message: 'Soft cap, universe point', timestamp: edt('11:33:01 AM') }, + + // Timeout Tech + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('11:33:10 AM') }, + + // Point 21: 11-10 Tech — UNIVERSE POINT WINNER (Alex to Mason) + { type: 'goal', team: 'us', message: 'Alex to Mason', timestamp: edt('11:37:19 AM') }, + + // GAME END + { type: 'game_end', timestamp: edt('11:37:19 AM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\nDone! Tech Support 11-10 Jackson Reed (universe point)`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-yula-day2-game2.ts b/packages/bot/scripts/archive/load-yula-day2-game2.ts new file mode 100644 index 0000000..944e5ee --- /dev/null +++ b/packages/bot/scripts/archive/load-yula-day2-game2.ts @@ -0,0 +1,151 @@ +/** + * Load YULA Day 2 Game 2: Tech Support vs Lexington — 7-8 L + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 2, 22, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'yula-lexington-2026-03-22', + ourTeamName: 'Tech Support', + opponentName: 'Lexington', + tournamentName: 'YULA', + gameDate: '2026-03-22', + gameOrder: 2, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('11:55:55 AM') }, + + // Point 1: 1-0 Tech (Ellis to Gus) + { type: 'goal', team: 'us', message: 'Ellis to Gus', timestamp: edt('11:56:37 AM') }, + + // Point 2: 1-1 Lex + { type: 'goal', team: 'them', message: '1-1', timestamp: edt('11:59:00 AM') }, + + // Point 3: 1-2 Lex + { type: 'goal', team: 'them', message: '1-2', timestamp: edt('12:02:27 PM') }, + + // Timeout Lex + { type: 'timeout', team: 'them', message: 'Timeout Lex', timestamp: edt('12:05:55 PM') }, + + // Point 4: 1-3 Lex + { type: 'goal', team: 'them', message: '1-3', timestamp: edt('12:09:43 PM') }, + + // Point 5: 2-3 Tech (Ellis to Gus) + { type: 'goal', team: 'us', message: 'Ellis to Gus', timestamp: edt('12:12:27 PM') }, + + // Point 6: 2-4 Lex + { type: 'goal', team: 'them', message: '2-4', timestamp: edt('12:14:50 PM') }, + + // Point 7: 3-4 Tech (Alex to Jed) + { type: 'goal', team: 'us', message: 'Alex to Jed', timestamp: edt('12:19:18 PM') }, + + // Point 8: 4-4 Tech (Jake steal → Toby to Jake — break) + { type: 'note', message: 'Jake steal', timestamp: edt('12:21:54 PM') }, + { type: 'goal', team: 'us', message: 'Toby to Jake', defensivePlay: 'steal', timestamp: edt('12:22:21 PM') }, + + // Point 9: 4-5 Lex + { type: 'goal', team: 'them', message: '4-5', timestamp: edt('12:25:55 PM') }, + + // Point 10: 5-5 Tech (Jake to Alex) + { type: 'goal', team: 'us', message: 'Jake to Alex', timestamp: edt('12:31:01 PM') }, + + // Jake block but Lex still scores + { type: 'note', message: 'Jake block', timestamp: edt('12:33:38 PM') }, + + // Point 11: 5-6 Lex + { type: 'goal', team: 'them', message: '5-6', timestamp: edt('12:35:54 PM') }, + + // Tech goal called back — travel on Alex to Corbin + { type: 'note', message: 'Goal called back, travel on Alex to Corbin', timestamp: edt('12:45:40 PM') }, + + // Timeout Lex + { type: 'timeout', team: 'them', message: 'Timeout Lex', timestamp: edt('12:47:04 PM') }, + + // Point 12: 5-7 Lex + { type: 'goal', team: 'them', message: '5-7', timestamp: edt('12:50:42 PM') }, + + // HALFTIME 5-7 + { type: 'halftime', timestamp: edt('12:50:46 PM') }, + + // SECOND HALF + { type: 'second_half_start', timestamp: edt('12:57:00 PM') }, + + // Point 13: 6-7 Tech (Mason block → Ellis to Jed) + { type: 'note', message: 'Mason block', timestamp: edt('12:58:40 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Jed', defensivePlay: 'block', timestamp: edt('12:59:08 PM') }, + + // Point 14: 7-7 Tech (Mason to Jake) + { type: 'goal', team: 'us', message: 'Mason to Jake', timestamp: edt('1:06:15 PM') }, + + // Timeout + { type: 'timeout', message: 'Timeout', timestamp: edt('1:08:00 PM') }, + + // Soft cap — playing to 9 + { type: 'note', message: 'Soft cap in effect, playing to 9', timestamp: edt('1:14:04 PM') }, + + // Point 15: 7-8 Lex — game over (hard cap) + { type: 'goal', team: 'them', message: '7-8', timestamp: edt('1:19:06 PM') }, + + // GAME END + { type: 'game_end', timestamp: edt('1:19:06 PM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\nDone! Tech Support 7-8 Lexington`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-yula-day2-game3.ts b/packages/bot/scripts/archive/load-yula-day2-game3.ts new file mode 100644 index 0000000..3f0eef8 --- /dev/null +++ b/packages/bot/scripts/archive/load-yula-day2-game3.ts @@ -0,0 +1,156 @@ +/** + * Load YULA Day 2 Game 3: Tech Support vs Haverford HUDA — 9-7 W (hard cap) + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 2, 22, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'yula-haverford-huda-2026-03-22', + ourTeamName: 'Tech Support', + opponentName: 'Haverford HUDA', + tournamentName: 'YULA', + gameDate: '2026-03-22', + gameOrder: 3, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('1:51:55 PM') }, + + // Point 1: 1-0 Tech (Mason to Gus) + { type: 'goal', team: 'us', message: 'Mason to Gus', timestamp: edt('1:54:13 PM') }, + + // Point 2: 1-1 HUDA + { type: 'goal', team: 'them', message: '1-1', timestamp: edt('1:57:01 PM') }, + + // Point 3: 1-2 HUDA + { type: 'goal', team: 'them', message: '1-2', timestamp: edt('1:59:38 PM') }, + + // Point 4: 2-2 Tech (Alex block → Ellis to Foster) + { type: 'note', message: 'Alex block', timestamp: edt('2:01:56 PM') }, + { type: 'goal', team: 'us', message: 'Ellis to Foster', defensivePlay: 'block', timestamp: edt('2:05:11 PM') }, + + // Point 5: 3-2 Tech (Toby to Max) + { type: 'goal', team: 'us', message: 'Toby to Max', timestamp: edt('2:10:16 PM') }, + + // Point 6: 3-3 HUDA + { type: 'goal', team: 'them', message: '3-3', timestamp: edt('2:13:41 PM') }, + + // Point 7: 3-4 HUDA (Anatole steal but HUDA still scored) + { type: 'note', message: 'Anatole steal', timestamp: edt('2:17:07 PM') }, + { type: 'goal', team: 'them', message: '3-4', timestamp: edt('2:17:24 PM') }, + + // Point 8: 4-4 Tech (Mason block, Nico steal → Mason huck to Gus) + { type: 'note', message: 'Mason block', timestamp: edt('2:20:23 PM') }, + { type: 'note', message: 'Nico steal', timestamp: edt('2:21:30 PM') }, + { type: 'goal', team: 'us', message: 'Mason huck to Gus', defensivePlay: 'steal', timestamp: edt('2:22:02 PM') }, + + // Point 9: 5-4 Tech (Toby steal → Jed to Anatole) + { type: 'note', message: 'Toby steal', timestamp: edt('2:24:14 PM') }, + { type: 'goal', team: 'us', message: 'Jed to Anatole', defensivePlay: 'steal', timestamp: edt('2:26:02 PM') }, + + // Point 10: 5-5 HUDA (Nico steals x2 but HUDA still scored) + { type: 'note', message: 'Nico steal', timestamp: edt('2:29:29 PM') }, + { type: 'note', message: 'Nico steal', timestamp: edt('2:31:43 PM') }, + { type: 'goal', team: 'them', message: '5-5', timestamp: edt('2:32:40 PM') }, + + // Timeout Tech + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('2:33:39 PM') }, + + // Point 11: 6-5 Tech (Alex to Cyrus) + { type: 'goal', team: 'us', message: 'Alex to Cyrus', timestamp: edt('2:38:45 PM') }, + + // Point 12: 7-5 Tech (Mason block → Toby to Teyo — break for half) + { type: 'note', message: 'Mason block', timestamp: edt('2:41:54 PM') }, + { type: 'goal', team: 'us', message: 'Toby to Teyo', defensivePlay: 'block', timestamp: edt('2:42:10 PM') }, + + // HALFTIME 7-5 + { type: 'halftime', timestamp: edt('2:44:05 PM') }, + + // SECOND HALF + { type: 'second_half_start', timestamp: edt('2:52:00 PM') }, + + // Point 13: 7-6 HUDA + { type: 'goal', team: 'them', message: '7-6', timestamp: edt('2:53:35 PM') }, + + // Point 14: 8-6 Tech (Mason block → Mason to Ellis) + { type: 'note', message: 'Mason block', timestamp: edt('2:57:22 PM') }, + { type: 'goal', team: 'us', message: 'Mason to Ellis', defensivePlay: 'block', timestamp: edt('2:59:05 PM') }, + + // D plays — Jed block, Alex steal + { type: 'note', message: 'Jed diving block', timestamp: edt('3:02:26 PM') }, + { type: 'note', message: 'Alex steal', timestamp: edt('3:03:16 PM') }, + + // Soft cap + { type: 'note', message: 'Soft cap in effect', timestamp: edt('3:07:20 PM') }, + + // Timeout Tech + { type: 'timeout', team: 'us', message: 'Timeout Tech', timestamp: edt('3:08:33 PM') }, + + // Point 15: 9-6 Tech (Alex to Jed — off the D plays above) + { type: 'goal', team: 'us', message: 'Alex to Jed', defensivePlay: 'steal', timestamp: edt('3:13:00 PM') }, + + // Point 16: 9-7 HUDA (hard cap) + { type: 'goal', team: 'them', message: '9-7', timestamp: edt('3:21:42 PM') }, + + // GAME END — hard cap + { type: 'game_end', timestamp: edt('3:21:42 PM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\nDone! Tech Support 9-7 Haverford HUDA (hard cap)`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-yula-game2.ts b/packages/bot/scripts/archive/load-yula-game2.ts new file mode 100644 index 0000000..d95227d --- /dev/null +++ b/packages/bot/scripts/archive/load-yula-game2.ts @@ -0,0 +1,140 @@ +/** + * Load YULA Day 1 Game 2: Tech Support vs Episcopal — 13-2 W + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 2, 21, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'yula-episcopal-2026-03-21', + ourTeamName: 'Tech Support', + opponentName: 'Episcopal', + tournamentName: 'YULA', + gameDate: '2026-03-21', + gameOrder: 2, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('10:04:59 AM') }, + + // Point 1: 1-0 Tech (Mason to Alex) + { type: 'goal', team: 'us', message: 'Mason to Alex', timestamp: edt('10:08:19 AM') }, + + // Point 2: 2-0 Tech (Asher to Teyo) + { type: 'goal', team: 'us', message: 'Asher to Teyo', timestamp: edt('10:12:29 AM') }, + + // Jed block — contested call + { type: 'note', message: 'Jed block (contested)', timestamp: edt('10:14:42 AM') }, + + // Point 3: 2-1 Episcopal + { type: 'goal', team: 'them', message: '2-1', timestamp: edt('10:18:01 AM') }, + + // Point 4: 3-1 Tech (Ellis hammer to Gus) + { type: 'goal', team: 'us', message: 'Ellis hammer to Gus', timestamp: edt('10:22:28 AM') }, + + // Point 5: 4-1 Tech (Foster to Teyo) + { type: 'goal', team: 'us', message: 'Foster to Teyo', timestamp: edt('10:25:47 AM') }, + + // Point 6: 5-1 Tech (Ben block → Nico to Ben) + { type: 'note', message: 'Ben block', timestamp: edt('10:27:35 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Ben', defensivePlay: 'block', timestamp: edt('10:29:39 AM') }, + + // Point 7: 6-1 Tech (Gus block, Jed steal → Marley to Jed) + { type: 'note', message: 'Gus block', timestamp: edt('10:32:12 AM') }, + { type: 'note', message: 'Jed steal', timestamp: edt('10:32:13 AM') }, + { type: 'goal', team: 'us', message: 'Marley to Jed', defensivePlay: 'steal', timestamp: edt('10:32:47 AM') }, + + // Point 8: 7-1 Tech (Mason to Foster) + { type: 'goal', team: 'us', message: 'Mason to Foster', timestamp: edt('10:35:05 AM') }, + + // HALFTIME 7-1 + { type: 'halftime', timestamp: edt('10:35:14 AM') }, + + // SECOND HALF + { type: 'second_half_start', timestamp: edt('10:44:00 AM') }, + + // Point 9: 7-2 Episcopal + { type: 'goal', team: 'them', message: '7-2', timestamp: edt('10:45:01 AM') }, + + // Point 10: 8-2 Tech (Cyrus to Ben) + { type: 'goal', team: 'us', message: 'Cyrus to Ben', timestamp: edt('10:47:42 AM') }, + + // Point 11: 9-2 Tech (Foster block, Toby block → Asher to Anatole) + { type: 'note', message: 'Foster block', timestamp: edt('10:49:48 AM') }, + { type: 'note', message: 'Toby block', timestamp: edt('10:50:31 AM') }, + { type: 'goal', team: 'us', message: 'Asher to Anatole', defensivePlay: 'block', timestamp: edt('10:51:54 AM') }, + + // Point 12: 10-2 Tech (Alex to Max) + { type: 'goal', team: 'us', message: 'Alex to Max', timestamp: edt('10:54:42 AM') }, + + // Point 13: 11-2 Tech (Mason hammer to Anatole) + { type: 'goal', team: 'us', message: 'Mason hammer to Anatole', timestamp: edt('10:57:38 AM') }, + + // Point 14: 12-2 Tech (Ben steal → Alex to Noah) + { type: 'note', message: 'Ben steal', timestamp: edt('11:00:00 AM') }, + { type: 'goal', team: 'us', message: 'Alex to Noah', defensivePlay: 'steal', timestamp: edt('11:00:29 AM') }, + + // Point 15: 13-2 Tech — GAME WINNER (Nico to Corbin) + { type: 'goal', team: 'us', message: 'Nico to Corbin', timestamp: edt('11:03:18 AM') }, + + // GAME END + { type: 'game_end', timestamp: edt('11:03:18 AM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\nDone! Tech Support 13-2 Episcopal`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/archive/load-yula-game3.ts b/packages/bot/scripts/archive/load-yula-game3.ts new file mode 100644 index 0000000..ce1b02e --- /dev/null +++ b/packages/bot/scripts/archive/load-yula-game3.ts @@ -0,0 +1,163 @@ +/** + * Load YULA Day 1 Game 3: Tech Support vs Blair — 12-9 W + */ + +const API_URL = process.env.API_URL || 'https://scorebot-api.siener.workers.dev'; + +function edt(time: string): number { + const match = time.match(/(\d+):(\d+):(\d+)\s*(AM|PM)/i); + if (!match) throw new Error(`Invalid time: ${time}`); + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const seconds = parseInt(match[3]); + const period = match[4].toUpperCase(); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return new Date(Date.UTC(2026, 2, 21, hours + 4, minutes, seconds)).getTime(); +} + +type Event = { + type: string; + team?: string; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; + timestamp: number; +}; + +async function addEvent(gameId: string, event: Event) { + const res = await fetch(`${API_URL}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to add event: ${res.status} ${text}`); + } + return res.json(); +} + +async function main() { + console.log(`Using API: ${API_URL}`); + + const createRes = await fetch(`${API_URL}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: 'yula-blair-2026-03-21', + ourTeamName: 'Tech Support', + opponentName: 'Blair', + tournamentName: 'YULA', + gameDate: '2026-03-21', + gameOrder: 3, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create game: ${createRes.status} ${await createRes.text()}`); + } + + const { game } = await createRes.json(); + console.log(`Created game: ${game.id}`); + + const events: Event[] = [ + { type: 'game_start', startingOnOffense: true, timestamp: edt('11:31:32 AM') }, + + // Point 1: 0-1 Blair (Tech on O, Gus block + Alex steal but Blair still scored) + { type: 'note', message: 'Gus block', timestamp: edt('11:34:42 AM') }, + { type: 'note', message: 'Alex steal', timestamp: edt('11:35:39 AM') }, + { type: 'goal', team: 'them', message: '0-1', timestamp: edt('11:40:51 AM') }, + + // Point 2: 1-1 Tech (Ellis block → Mason to Nico) + { type: 'note', message: 'Ellis block', timestamp: edt('11:44:24 AM') }, + { type: 'goal', team: 'us', message: 'Mason to Nico', defensivePlay: 'block', timestamp: edt('11:44:55 AM') }, + + // Point 3: 1-2 Blair + { type: 'goal', team: 'them', message: '1-2', timestamp: edt('11:47:31 AM') }, + + // Point 4: 2-2 Tech (Ellis to Alex) + { type: 'goal', team: 'us', message: 'Ellis to Alex', timestamp: edt('11:51:10 AM') }, + + // Point 5: 3-2 Tech (Mason block → Nico to Corbin) + { type: 'note', message: 'Mason block', timestamp: edt('11:55:11 AM') }, + { type: 'goal', team: 'us', message: 'Nico to Corbin', defensivePlay: 'block', timestamp: edt('11:56:03 AM') }, + + // Timeout Blair + { type: 'timeout', team: 'them', message: 'Timeout Blair', timestamp: edt('11:57:14 AM') }, + + // Point 6: 4-2 Tech (Nico block → Foster to Toby) + { type: 'note', message: 'Nico block', timestamp: edt('12:00:09 PM') }, + { type: 'goal', team: 'us', message: 'Foster to Toby', defensivePlay: 'block', timestamp: edt('12:00:45 PM') }, + + // Point 7: 4-3 Blair + { type: 'goal', team: 'them', message: '4-3', timestamp: edt('12:02:54 PM') }, + + // Point 8: 5-3 Tech (Ellis to Alex) + { type: 'goal', team: 'us', message: 'Ellis to Alex', timestamp: edt('12:05:26 PM') }, + + // Point 9: 6-3 Tech (Jake block → goal) + { type: 'note', message: 'Jake block', timestamp: edt('12:07:10 PM') }, + { type: 'goal', team: 'us', message: '6-3', defensivePlay: 'block', timestamp: edt('12:07:22 PM') }, + + // HALFTIME (no explicit halftime message — but with 6-3 at ~12:07 and 6-4 at 12:10, halftime likely here) + // Actually there's no halftime message in this game. Let me check the score progression: + // 6-3 then 6-4 then 7-4... no explicit halftime called. I'll skip halftime event. + + // Point 10: 6-4 Blair + { type: 'goal', team: 'them', message: '6-4', timestamp: edt('12:10:12 PM') }, + + // Point 11: 7-4 Tech (Ellis to Anatole) + { type: 'goal', team: 'us', message: 'Ellis to Anatole', timestamp: edt('12:13:56 PM') }, + + // HALFTIME at 7-4 (9-min gap suggests halftime here) + { type: 'halftime', timestamp: edt('12:14:30 PM') }, + { type: 'second_half_start', timestamp: edt('12:22:00 PM') }, + + // Point 12: 7-5 Blair + { type: 'goal', team: 'them', message: '7-5', timestamp: edt('12:23:07 PM') }, + + // Point 13: 8-5 Tech (Cyrus to Gus) + { type: 'goal', team: 'us', message: 'Cyrus to Gus', timestamp: edt('12:26:03 PM') }, + + // Point 14: 9-5 Tech (Toby to Foster) + { type: 'goal', team: 'us', message: 'Toby to Foster', timestamp: edt('12:28:41 PM') }, + + // Point 15: 9-6 Blair + { type: 'goal', team: 'them', message: '9-6', timestamp: edt('12:31:39 PM') }, + + // Point 16: 10-6 Tech (Ellis to Alex) + { type: 'goal', team: 'us', message: 'Ellis to Alex', timestamp: edt('12:34:38 PM') }, + + // Point 17: 10-7 Blair + { type: 'goal', team: 'them', message: '10-7', timestamp: edt('12:37:15 PM') }, + + // Point 18: 10-8 Blair + { type: 'goal', team: 'them', message: '10-8', timestamp: edt('12:39:17 PM') }, + + // Point 19: 11-8 Tech (Toby to Gus) + { type: 'goal', team: 'us', message: 'Toby to Gus', timestamp: edt('12:41:59 PM') }, + + // Note: soft cap + { type: 'note', message: 'Soft cap in effect, playing to 12', timestamp: edt('12:42:11 PM') }, + + // Point 20: 11-9 Blair + { type: 'goal', team: 'them', message: '11-9', timestamp: edt('12:45:48 PM') }, + + // Point 21: 12-9 Tech — GAME WINNER (Jake to Nico) + { type: 'goal', team: 'us', message: 'Jake to Nico', timestamp: edt('12:48:23 PM') }, + + // GAME END + { type: 'game_end', timestamp: edt('12:48:23 PM') }, + ]; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + console.log(`[${i + 1}/${events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(game.id, event); + } + + console.log(`\nDone! Tech Support 12-9 Blair`); +} + +main().catch(console.error); diff --git a/packages/bot/scripts/reload-spring-fling.ts b/packages/bot/scripts/archive/reload-spring-fling.ts similarity index 100% rename from packages/bot/scripts/reload-spring-fling.ts rename to packages/bot/scripts/archive/reload-spring-fling.ts diff --git a/packages/bot/scripts/reload-tournament-games.ts b/packages/bot/scripts/archive/reload-tournament-games.ts similarity index 100% rename from packages/bot/scripts/reload-tournament-games.ts rename to packages/bot/scripts/archive/reload-tournament-games.ts diff --git a/packages/bot/scripts/update-starting-possession.ts b/packages/bot/scripts/archive/update-starting-possession.ts similarity index 100% rename from packages/bot/scripts/update-starting-possession.ts rename to packages/bot/scripts/archive/update-starting-possession.ts diff --git a/packages/bot/scripts/lib/loader.test.ts b/packages/bot/scripts/lib/loader.test.ts new file mode 100644 index 0000000..7599beb --- /dev/null +++ b/packages/bot/scripts/lib/loader.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { EventType, TeamSide } from '@scorebot/shared'; +import { toEpochMs, toAddEventRequest, loadTournament, TournamentSpec } from './loader.js'; + +describe('toEpochMs — timezone conversion', () => { + it('resolves EDT (UTC-4) for a March date after spring-forward', () => { + // US DST 2026 begins Sun Mar 8. Mar 22 is firmly in EDT → UTC-4. + // 10:00 America/New_York => 14:00 UTC. + expect(toEpochMs('2026-03-22', '10:00', 'America/New_York')).toBe( + Date.UTC(2026, 2, 22, 14, 0, 0) + ); + }); + + it('resolves EST (UTC-5) for a November date after fall-back', () => { + // US DST 2026 ends Sun Nov 1. Nov 8 is firmly in EST → UTC-5. + // 10:00 America/New_York => 15:00 UTC. + expect(toEpochMs('2026-11-08', '10:00', 'America/New_York')).toBe( + Date.UTC(2026, 10, 8, 15, 0, 0) + ); + }); + + it('resolves EST for a mid-winter date', () => { + expect(toEpochMs('2026-01-15', '09:30', 'America/New_York')).toBe( + Date.UTC(2026, 0, 15, 14, 30, 0) + ); + }); + + it('resolves EDT for a mid-summer date', () => { + expect(toEpochMs('2026-07-15', '13:45', 'America/New_York')).toBe( + Date.UTC(2026, 6, 15, 17, 45, 0) + ); + }); + + it('honors a different IANA zone (US Pacific)', () => { + // PDT in July = UTC-7. 08:00 => 15:00 UTC. + expect(toEpochMs('2026-07-15', '08:00', 'America/Los_Angeles')).toBe( + Date.UTC(2026, 6, 15, 15, 0, 0) + ); + }); + + it('supports HH:MM:SS times', () => { + expect(toEpochMs('2026-07-15', '13:45:30', 'America/New_York')).toBe( + Date.UTC(2026, 6, 15, 17, 45, 30) + ); + }); + + it('rejects malformed input', () => { + expect(() => toEpochMs('07/15/2026', '10:00')).toThrow(); + expect(() => toEpochMs('2026-07-15', '10')).toThrow(); + }); +}); + +describe('toAddEventRequest — spec → shared AddEventRequest', () => { + const game = { + date: '2026-03-22', + ourTeam: 'Tech Support', + opponent: 'Haverford', + startingOnOffense: true, + events: [], + }; + + it('maps team strings to TeamSide and carries defensivePlay', () => { + const req = toAddEventRequest( + { time: '10:16', type: EventType.GOAL, team: 'us', message: 'Mason to Jake', defensivePlay: 'block' }, + game, + 'America/New_York' + ); + expect(req.type).toBe(EventType.GOAL); + expect(req.team).toBe(TeamSide.US); + expect(req.message).toBe('Mason to Jake'); + expect(req.defensivePlay).toBe('block'); + expect(req.timestamp).toBe(Date.UTC(2026, 2, 22, 14, 16, 0)); + }); + + it('attaches game-level startingOnOffense to the game_start event', () => { + const req = toAddEventRequest({ time: '10:04', type: EventType.GAME_START }, game, 'America/New_York'); + expect(req.startingOnOffense).toBe(true); + }); + + it('does not attach startingOnOffense to non-start events', () => { + const req = toAddEventRequest({ time: '10:04', type: EventType.GOAL, team: 'them' }, game, 'America/New_York'); + expect(req.startingOnOffense).toBeUndefined(); + }); + + it('lets an explicit event-level startingOnOffense override', () => { + const req = toAddEventRequest( + { time: '10:04', type: EventType.GAME_START, startingOnOffense: false }, + game, + 'America/New_York' + ); + expect(req.startingOnOffense).toBe(false); + }); +}); + +interface RecordedCall { + url: string; + method: string; + body: any; +} + +function makeFetchStub() { + const calls: RecordedCall[] = []; + let counter = 0; + const jsonResponse = (data: unknown) => + ({ + ok: true, + status: 200, + json: async () => data, + text: async () => JSON.stringify(data), + }) as unknown as Response; + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, method, body }); + if (method === 'POST' && /\/games$/.test(url)) { + counter += 1; + return jsonResponse({ game: { id: `game_test_${counter}` } }); + } + return jsonResponse({ game: {} }); + }) as unknown as typeof globalThis.fetch; + + return { fetchImpl, calls }; +} + +describe('loadTournament — HTTP mapping', () => { + const apiUrl = 'https://api.example.test'; + + it('creates a game, then adds events in order', async () => { + const { fetchImpl, calls } = makeFetchStub(); + const spec: TournamentSpec = { + tournament: 'Test Cup', + timezone: 'America/New_York', + games: [ + { + date: '2026-03-22', + ourTeam: 'Tech Support', + opponent: 'Haverford', + startingOnOffense: true, + gameOrder: 1, + events: [ + { time: '10:04', type: EventType.GAME_START }, + { time: '10:13', type: EventType.GOAL, team: 'us', message: 'Jake to Nico' }, + { time: '10:23', type: EventType.GOAL, team: 'them', message: '1-1' }, + { time: '11:37', type: EventType.GAME_END }, + ], + }, + ], + }; + + const ids = await loadTournament(spec, { apiUrl, fetch: fetchImpl, log: () => {} }); + + expect(ids).toEqual(['game_test_1']); + + // create, then 4 events + expect(calls).toHaveLength(5); + + const create = calls[0]; + expect(create.method).toBe('POST'); + expect(create.url).toBe(`${apiUrl}/games`); + expect(create.body).toEqual({ + chatId: 'test-cup-2026-03-22-game1', + ourTeamName: 'Tech Support', + opponentName: 'Haverford', + tournamentName: 'Test Cup', + gameDate: '2026-03-22', + gameOrder: 1, + }); + + // Events target the created id, in order. + for (let i = 1; i <= 4; i++) { + expect(calls[i].method).toBe('POST'); + expect(calls[i].url).toBe(`${apiUrl}/games/game_test_1/events`); + } + expect(calls[1].body.type).toBe(EventType.GAME_START); + expect(calls[1].body.startingOnOffense).toBe(true); + expect(calls[1].body.timestamp).toBe(Date.UTC(2026, 2, 22, 14, 4, 0)); + expect(calls[2].body).toMatchObject({ type: EventType.GOAL, team: TeamSide.US, message: 'Jake to Nico' }); + expect(calls[3].body).toMatchObject({ type: EventType.GOAL, team: TeamSide.THEM }); + expect(calls[4].body.type).toBe(EventType.GAME_END); + }); + + it('PATCHes videoUrl after events when present', async () => { + const { fetchImpl, calls } = makeFetchStub(); + const spec: TournamentSpec = { + tournament: 'Video Cup', + games: [ + { + date: '2026-07-15', + ourTeam: 'A', + opponent: 'B', + videoUrl: 'https://youtu.be/abc', + events: [{ time: '09:00', type: EventType.GOAL, team: 'us' }], + }, + ], + }; + + await loadTournament(spec, { apiUrl, fetch: fetchImpl, log: () => {} }); + + const patch = calls.find((c) => c.method === 'PATCH'); + expect(patch).toBeDefined(); + expect(patch!.url).toBe(`${apiUrl}/games/game_test_1`); + expect(patch!.body).toEqual({ videoUrl: 'https://youtu.be/abc' }); + }); + + it('replace mode deletes the existing game before creating', async () => { + const { fetchImpl, calls } = makeFetchStub(); + const spec: TournamentSpec = { + tournament: 'Fix Cup', + games: [ + { + date: '2026-07-15', + ourTeam: 'A', + opponent: 'B', + existingGameId: 'game_old_123', + events: [{ time: '09:00', type: EventType.GOAL, team: 'us' }], + }, + ], + }; + + await loadTournament(spec, { apiUrl, fetch: fetchImpl, log: () => {} }); + + expect(calls[0].method).toBe('DELETE'); + expect(calls[0].url).toBe(`${apiUrl}/games/game_old_123`); + expect(calls[1].method).toBe('POST'); + expect(calls[1].url).toBe(`${apiUrl}/games`); + }); + + it('uses an explicit chatId when provided', async () => { + const { fetchImpl, calls } = makeFetchStub(); + const spec: TournamentSpec = { + tournament: 'Chat Cup', + games: [ + { + date: '2026-07-15', + ourTeam: 'A', + opponent: 'B', + chatId: 'my-custom-chat', + events: [{ time: '09:00', type: EventType.GOAL, team: 'us' }], + }, + ], + }; + + await loadTournament(spec, { apiUrl, fetch: fetchImpl, log: () => {} }); + expect(calls[0].body.chatId).toBe('my-custom-chat'); + }); +}); diff --git a/packages/bot/scripts/lib/loader.ts b/packages/bot/scripts/lib/loader.ts new file mode 100644 index 0000000..67d1317 --- /dev/null +++ b/packages/bot/scripts/lib/loader.ts @@ -0,0 +1,269 @@ +/** + * Tournament loader harness. + * + * Replaces the ~22 near-identical one-off load/reload/fix scripts with a single + * data-driven loader. A tournament is described as PURE DATA (a `TournamentSpec`); + * this module handles timezone conversion, chatId generation, and the HTTP calls + * against the Scorebot API (create game -> add events in order -> optional video + * URL). Games may also REPLACE an existing game (see `existingGameId`). + * + * Run a spec with: SCOREBOT_API_URL=https://api.score.kcuda.org npx tsx scripts/.ts --run + */ + +import { AddEventRequest, EventType, TeamSide, Score } from '@scorebot/shared'; + +/** + * Base API URL. Defaults to local dev; pointing at production must be explicit + * via SCOREBOT_API_URL. This is the ONLY place the URL is defined. + */ +export const API_URL = process.env.SCOREBOT_API_URL ?? 'http://localhost:8787'; + +/** Default IANA timezone for game times when a spec doesn't set one. */ +export const DEFAULT_TIMEZONE = 'America/New_York'; + +/** A single event within a game. `time` is local wall-clock in the spec timezone. */ +export interface EventSpec { + time: string; // 'HH:MM' or 'HH:MM:SS' (24-hour), local to the spec timezone + type: EventType; + team?: 'us' | 'them'; + message?: string; + defensivePlay?: 'block' | 'steal'; + startingOnOffense?: boolean; // usually set at game level; overrides here if present + score?: Score; // optional explicit score for backfilling +} + +/** One game within a tournament. */ +export interface GameSpec { + date: string; // 'YYYY-MM-DD' + ourTeam: string; + opponent: string; + chatId?: string; // defaults to a slug of tournament + date + order/opponent + startingOnOffense?: boolean; // attached to the game_start event + gameOrder?: number; // order within the day/tournament + videoUrl?: string; // link to game video + existingGameId?: string; // if set, that game is deleted and re-created (replace mode) + events: EventSpec[]; +} + +/** A whole tournament: pure data. */ +export interface TournamentSpec { + tournament: string; + timezone?: string; // IANA zone, default America/New_York + games: GameSpec[]; +} + +export interface LoadOptions { + apiUrl?: string; + /** Injectable fetch (default globalThis.fetch) so tests can stub the network. */ + fetch?: typeof globalThis.fetch; + /** Injectable logger (default console.log). */ + log?: (message: string) => void; +} + +/** + * Compute the UTC offset (localWallTime - utc, in ms) that the given IANA zone + * had at a specific instant. Uses Intl.DateTimeFormat only — no hardcoded + * offsets, so EST vs EDT falls out of the actual date. + */ +function zoneOffsetMs(utcMs: number, timeZone: string): number { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const parts = dtf.formatToParts(new Date(utcMs)); + const map: Record = {}; + for (const p of parts) { + if (p.type !== 'literal') map[p.type] = parseInt(p.value, 10); + } + // Some engines render midnight as hour 24; normalize to 0. + const hour = map.hour === 24 ? 0 : map.hour; + const asIfUtc = Date.UTC(map.year, map.month - 1, map.day, hour, map.minute, map.second); + return asIfUtc - utcMs; +} + +/** + * Convert a wall-clock date + time in an IANA timezone to epoch milliseconds. + * Two-pass so DST transition days resolve correctly. + */ +export function toEpochMs(date: string, time: string, timeZone: string = DEFAULT_TIMEZONE): number { + const dateMatch = date.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!dateMatch) throw new Error(`Invalid date (expected YYYY-MM-DD): ${date}`); + const timeMatch = time.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (!timeMatch) throw new Error(`Invalid time (expected HH:MM or HH:MM:SS): ${time}`); + + const year = parseInt(dateMatch[1], 10); + const month = parseInt(dateMatch[2], 10); + const day = parseInt(dateMatch[3], 10); + const hour = parseInt(timeMatch[1], 10); + const minute = parseInt(timeMatch[2], 10); + const second = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0; + + // Treat the wall time as if it were UTC, then subtract the zone's offset. + const naiveUtc = Date.UTC(year, month - 1, day, hour, minute, second); + const firstGuess = naiveUtc - zoneOffsetMs(naiveUtc, timeZone); + const refinedOffset = zoneOffsetMs(firstGuess, timeZone); + return naiveUtc - refinedOffset; +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function defaultChatId(spec: TournamentSpec, game: GameSpec): string { + const suffix = game.gameOrder != null ? `game${game.gameOrder}` : slug(game.opponent); + return `${slug(spec.tournament)}-${game.date}-${suffix}`; +} + +/** Map an EventSpec (+ game context) onto the shared AddEventRequest. */ +export function toAddEventRequest(event: EventSpec, game: GameSpec, timeZone: string): AddEventRequest { + const request: AddEventRequest = { + type: event.type, + timestamp: toEpochMs(game.date, event.time, timeZone), + }; + if (event.team) request.team = event.team === 'us' ? TeamSide.US : TeamSide.THEM; + if (event.message !== undefined) request.message = event.message; + if (event.defensivePlay) request.defensivePlay = event.defensivePlay; + if (event.score) request.score = event.score; + + // startingOnOffense: an explicit event-level value wins; otherwise attach the + // game-level value to the game_start event (matching the load/reload scripts). + if (event.startingOnOffense !== undefined) { + request.startingOnOffense = event.startingOnOffense; + } else if (event.type === EventType.GAME_START && game.startingOnOffense !== undefined) { + request.startingOnOffense = game.startingOnOffense; + } + + return request; +} + +async function createGame( + doFetch: typeof globalThis.fetch, + apiUrl: string, + body: { + chatId: string; + ourTeamName: string; + opponentName: string; + tournamentName: string; + gameDate: string; + gameOrder?: number; + } +): Promise { + const res = await doFetch(`${apiUrl}/games`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Failed to create game: ${res.status} ${await res.text()}`); + return ((await res.json()) as { game: { id: string } }).game.id; +} + +async function addEvent( + doFetch: typeof globalThis.fetch, + apiUrl: string, + gameId: string, + event: AddEventRequest +): Promise { + const res = await doFetch(`${apiUrl}/games/${gameId}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (!res.ok) throw new Error(`Failed to add event: ${res.status} ${await res.text()}`); +} + +async function deleteGame( + doFetch: typeof globalThis.fetch, + apiUrl: string, + gameId: string +): Promise { + const res = await doFetch(`${apiUrl}/games/${gameId}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to delete game ${gameId}: ${res.status} ${await res.text()}`); + } +} + +async function updateGame( + doFetch: typeof globalThis.fetch, + apiUrl: string, + gameId: string, + updates: { videoUrl?: string; startingOnOffense?: boolean } +): Promise { + const res = await doFetch(`${apiUrl}/games/${gameId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error(`Failed to update game ${gameId}: ${res.status} ${await res.text()}`); +} + +async function loadGame( + spec: TournamentSpec, + game: GameSpec, + timeZone: string, + apiUrl: string, + doFetch: typeof globalThis.fetch, + log: (message: string) => void +): Promise { + log(`\n=== ${game.ourTeam} vs ${game.opponent} (${game.date}) ===`); + + // Replace mode: delete the old game first (the reload/fix scripts delete then + // re-create; the API has no in-place event replace). A fresh id is returned. + if (game.existingGameId) { + log(`Replacing existing game ${game.existingGameId} (deleting)...`); + await deleteGame(doFetch, apiUrl, game.existingGameId); + } + + const chatId = game.chatId ?? defaultChatId(spec, game); + const gameId = await createGame(doFetch, apiUrl, { + chatId, + ourTeamName: game.ourTeam, + opponentName: game.opponent, + tournamentName: spec.tournament, + gameDate: game.date, + gameOrder: game.gameOrder, + }); + log(`Created ${gameId}`); + + for (let i = 0; i < game.events.length; i++) { + const event = game.events[i]; + log(` [${i + 1}/${game.events.length}] ${event.type}${event.message ? ': ' + event.message : ''}`); + await addEvent(doFetch, apiUrl, gameId, toAddEventRequest(event, game, timeZone)); + } + + if (game.videoUrl) { + await updateGame(doFetch, apiUrl, gameId, { videoUrl: game.videoUrl }); + log(`Set videoUrl`); + } + + log(`Done! ${game.events.length} events loaded.`); + return gameId; +} + +/** + * Load an entire tournament. Returns the created game ids in spec order. + */ +export async function loadTournament(spec: TournamentSpec, opts: LoadOptions = {}): Promise { + const apiUrl = opts.apiUrl ?? API_URL; + const doFetch = opts.fetch ?? globalThis.fetch; + const log = opts.log ?? ((message: string) => console.log(message)); + const timeZone = spec.timezone ?? DEFAULT_TIMEZONE; + + log(`Loading "${spec.tournament}" — ${spec.games.length} game(s) → ${apiUrl}`); + + const gameIds: string[] = []; + for (const game of spec.games) { + gameIds.push(await loadGame(spec, game, timeZone, apiUrl, doFetch, log)); + } + + log(`\nAll done. ${gameIds.length} game(s) loaded.`); + return gameIds; +} diff --git a/packages/bot/scripts/load-example-spec.ts b/packages/bot/scripts/load-example-spec.ts new file mode 100644 index 0000000..398822d --- /dev/null +++ b/packages/bot/scripts/load-example-spec.ts @@ -0,0 +1,125 @@ +/** + * TEMPLATE for loading a new tournament via the loader harness. + * + * This is the data form that replaces the old one-off load/reload/fix scripts. + * Copy this file, edit the `spec` below, and run it: + * + * SCOREBOT_API_URL=https://api.score.kcuda.org npx tsx scripts/.ts --run + * + * Notes: + * - A spec is PURE DATA. Event `time` is local wall-clock in `spec.timezone`; + * EST/EDT is derived from the date automatically (no offset math needed). + * - `startingOnOffense` at the game level is attached to the game_start event. + * - To fix/replace an already-loaded game, set `existingGameId` on that game; + * it is deleted and re-created from this data. + * - Without `--run` this file only prints instructions (safe to import/parse), + * so it never touches a live API by accident. + * + * The data below is the real YULA Day 1 game (Tech Support vs Montclair, 3/21/26), + * converted verbatim from the legacy scripts/archive/load-yula-day1.ts. + */ + +import { EventType } from '@scorebot/shared'; +import { loadTournament, TournamentSpec } from './lib/loader.js'; + +const spec: TournamentSpec = { + tournament: 'YULA', + timezone: 'America/New_York', + games: [ + { + date: '2026-03-21', + ourTeam: 'Tech Support', + opponent: 'Montclair', + chatId: 'yula-montclair-2026-03-21', + gameOrder: 1, + startingOnOffense: true, + events: [ + { time: '08:29:54', type: EventType.GAME_START }, + + // 1-0 Tech (Cyrus steal → Ellis to Corbin) + { time: '08:32:57', type: EventType.NOTE, message: 'Cyrus steal' }, + { time: '08:34:11', type: EventType.GOAL, team: 'us', message: 'Ellis to Corbin', defensivePlay: 'steal' }, + + // 1-1 Montclair (Toby block but they still scored) + { time: '08:36:50', type: EventType.NOTE, message: 'Toby block' }, + { time: '08:37:47', type: EventType.GOAL, team: 'them', message: '1-1' }, + + // 2-1 Tech (Ellis deep to Corbin) + { time: '08:39:53', type: EventType.GOAL, team: 'us', message: 'Ellis deep to Corbin' }, + + // 3-1 Tech (Toby block → Mason deep to Jake) + { time: '08:42:25', type: EventType.NOTE, message: 'Toby block' }, + { time: '08:43:06', type: EventType.GOAL, team: 'us', message: 'Mason deep to Jake', defensivePlay: 'block' }, + + // 3-2 Montclair + { time: '08:46:23', type: EventType.GOAL, team: 'them', message: '3-2' }, + + // 4-2 Tech (Ellis to Cyrus) + { time: '08:48:46', type: EventType.GOAL, team: 'us', message: 'Ellis to Cyrus' }, + + // 4-3 Montclair + { time: '08:51:37', type: EventType.GOAL, team: 'them', message: '4-3' }, + + // 5-3 Tech (Gus block → Ellis to Alex) + { time: '08:56:55', type: EventType.NOTE, message: 'Gus block' }, + { time: '08:57:12', type: EventType.GOAL, team: 'us', message: 'Ellis to Alex', defensivePlay: 'block' }, + + // 6-3 Tech (Jake steal, Jake block → Mason huck to Jake) + { time: '08:59:59', type: EventType.NOTE, message: 'Jake steal' }, + { time: '09:02:09', type: EventType.NOTE, message: 'Jake block' }, + { time: '09:04:55', type: EventType.GOAL, team: 'us', message: 'Mason huck to Jake', defensivePlay: 'block' }, + + { time: '09:07:03', type: EventType.HALFTIME }, + { time: '09:13:00', type: EventType.SECOND_HALF_START }, + + // 6-4 Montclair (Teyo block, Toby block but they still scored) + { time: '09:14:09', type: EventType.NOTE, message: 'Teyo block' }, + { time: '09:14:35', type: EventType.NOTE, message: 'Toby block' }, + { time: '09:16:11', type: EventType.GOAL, team: 'them', message: '6-4' }, + + // 6-5 Montclair + { time: '09:20:11', type: EventType.GOAL, team: 'them', message: '6-5' }, + + // 6-6 Montclair + { time: '09:29:35', type: EventType.GOAL, team: 'them', message: '6-6' }, + + { time: '09:30:16', type: EventType.TIMEOUT, team: 'us', message: 'Timeout Tech' }, + + // 7-6 Tech (Jake to Mason) + { time: '09:36:50', type: EventType.GOAL, team: 'us', message: 'Jake to Mason' }, + + // 7-7 was posted then corrected — not a score + { time: '09:43:03', type: EventType.NOTE, message: 'Correction: 7-7 was not a score, still 7-6' }, + + // 8-6 Tech (Toby block → Toby to Jake) + { time: '09:43:21', type: EventType.NOTE, message: 'Toby block' }, + { time: '09:43:47', type: EventType.GOAL, team: 'us', message: 'Toby to Jake', defensivePlay: 'block' }, + + { time: '09:44:24', type: EventType.NOTE, message: 'Soft cap in effect, game to 9' }, + + // 8-7 Montclair + { time: '09:46:16', type: EventType.GOAL, team: 'them', message: '8-7' }, + + // 9-7 Tech — GAME WINNER (Alex to Nico) + { time: '09:50:00', type: EventType.GOAL, team: 'us', message: 'Alex to Nico' }, + + { time: '09:50:00', type: EventType.GAME_END }, + ], + }, + ], +}; + +async function main() { + if (!process.argv.includes('--run')) { + console.log('This is a template. Edit the `spec`, then run with --run to load it:'); + console.log(' SCOREBOT_API_URL=https://api.score.kcuda.org npx tsx scripts/load-example-spec.ts --run'); + console.log(`\nSpec: "${spec.tournament}" — ${spec.games.length} game(s), ${spec.games[0].events.length} events in game 1.`); + return; + } + await loadTournament(spec); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/bot/src/api/router.ts b/packages/bot/src/api/router.ts index 7120fab..92e56f8 100644 --- a/packages/bot/src/api/router.ts +++ b/packages/bot/src/api/router.ts @@ -1,12 +1,15 @@ /** * API Router for Scorebot - * Handles HTTP requests and routes them to appropriate handlers + * Thin HTTP adapter: parse request → call GameStore → serialize response. + * All DO/D1 coordination lives in GameStore; the Router does no store save + * calls, holds no DO stubs, and contains no hydration logic. */ import { Env } from '../types'; import { DatabaseService } from '../db/database'; -import { GameState } from '../durable-objects/GameState'; -import { CreateGameRequest, CreateGameResponse, AddEventRequest, AddEventResponse, GetAdvancedStatsResponse, GetAggregatedStatsResponse, Game } from '@scorebot/shared'; +import { GameStore } from '../store/GameStore'; +import { GameStateError } from '../durable-objects/GameState'; +import { GetAdvancedStatsResponse, GetAggregatedStatsResponse } from '@scorebot/shared'; import { StatsCalculator } from '../services/StatsCalculator'; import { CreateGameRequestSchema, AddEventRequestSchema, SetLineupsRequestSchema } from './validation'; @@ -25,10 +28,12 @@ function jsonResponse(data: unknown, status: number = 200): Response { export class Router { private db: DatabaseService; + private store: GameStore; private statsCalculator: StatsCalculator; constructor(private env: Env) { this.db = new DatabaseService(env.DB); + this.store = new GameStore(env.GAME_STATE, this.db); this.statsCalculator = new StatsCalculator(); } @@ -158,6 +163,11 @@ export class Router { return this.addCorsHeaders(response, corsHeaders); } catch (error) { + // Domain failures carry their intended HTTP status. + if (error instanceof GameStateError) { + return this.addCorsHeaders(jsonError(error.message, error.status), corsHeaders); + } + console.error('Router error:', error); return new Response( JSON.stringify({ @@ -172,129 +182,23 @@ export class Router { } } - /** - * Ensure a Durable Object has game state, rehydrating from D1 if needed. - * Returns the DO stub ready for use, or null if rehydration failed. - */ - private async ensureDOHydrated( - chatId: string, - gameForRehydration?: Game - ): Promise { - const id = this.env.GAME_STATE.idFromName(chatId); - const stub = this.env.GAME_STATE.get(id); - - // Check if DO already has state - const checkResponse = await stub.fetch('https://fake-host/'); - if (checkResponse.status !== 404) { - return stub; - } - - // DO was evicted — need full game data to rehydrate - const game = gameForRehydration || await this.db.getGame(chatId); - if (!game) return null; - - const rehydrateResponse = await stub.fetch( - new Request('https://fake-host/rehydrate', { - method: 'POST', - body: JSON.stringify(game), - }) - ); - - if (rehydrateResponse.ok) { - return stub; - } - - return null; - } - - /** - * Forward a mutation to a game's Durable Object and persist the result. - * Handles: lookup game → hydrate DO → forward request → save to DB. - */ - private async mutateGameViaDO( - gameId: string, - doPath: string, - doMethod: string, - body?: unknown, - saveStrategy: 'metadata' | 'events' = 'events', - ): Promise { - // Only need metadata (chatId) for routing — avoid fetching all events - const game = await this.db.getGameMetadata(gameId); - if (!game || !game.chatId) { - return jsonError('Game not found', 404); - } - - const stub = await this.ensureDOHydrated(game.chatId); - if (!stub) { - return jsonError('Failed to restore game state', 500); - } - - const response = await stub.fetch( - new Request(`https://fake-host${doPath}`, { - method: doMethod, - ...(body !== undefined && { body: JSON.stringify(body) }), - }) - ); - - const data = await response.json() as { game: Game }; - - if (response.ok) { - if (saveStrategy === 'metadata') { - await this.db.saveGameMetadata(data.game); - } else { - await this.db.saveGameWithEvents(data.game); - } - } - - return jsonResponse(data, response.status); - } - private async createGame(request: Request): Promise { const body = await request.json(); const parsed = CreateGameRequestSchema.safeParse(body); if (!parsed.success) { return jsonError('Validation error', 400); } - const { chatId, ourTeamName, opponentName, tournamentName, gameDate, gameOrder } = parsed.data; - - // Get or create Durable Object for this game - const id = this.env.GAME_STATE.idFromName(chatId); - const stub = this.env.GAME_STATE.get(id); - // Initialize game in Durable Object - const response = await stub.fetch( - new Request('https://fake-host/init', { - method: 'POST', - body: JSON.stringify({ chatId, ourTeamName, opponentName, tournamentName, gameDate, gameOrder }), - }) - ); - - const data = await response.json() as CreateGameResponse; - - // Save to database (full save for new game) - await this.db.saveGame(data.game); - - return jsonResponse(data); + const game = await this.store.createGame(parsed.data); + return jsonResponse({ game }); } private async getGame(gameId: string): Promise { - const game = await this.db.getGame(gameId); + const game = await this.store.getGame(gameId); if (!game) { return jsonError('Game not found', 404); } - // Try to get fresh state from Durable Object if chatId exists - if (game.chatId) { - try { - const stub = await this.ensureDOHydrated(game.chatId, game); - if (stub) { - return await stub.fetch('https://fake-host/'); - } - } catch { - // Fall back to database version - } - } - return jsonResponse({ game }); } @@ -302,7 +206,7 @@ export class Router { const url = new URL(request.url); const limit = parseInt(url.searchParams.get('limit') || '50', 10); - const games = await this.db.listGames(limit); + const games = await this.store.listGames(limit); return jsonResponse({ games }); } @@ -314,15 +218,18 @@ export class Router { return jsonError('Validation error', 400); } - return this.mutateGameViaDO(gameId, '/events', 'POST', parsed.data, 'events'); + const result = await this.store.addEvent(gameId, parsed.data); + return jsonResponse(result); } private async undoLastEvent(gameId: string): Promise { - return this.mutateGameViaDO(gameId, '/events/last', 'DELETE', undefined, 'events'); + const result = await this.store.undoLastEvent(gameId); + return jsonResponse(result); } private async deleteEvent(gameId: string, eventId: string): Promise { - return this.mutateGameViaDO(gameId, `/events/${eventId}`, 'DELETE', undefined, 'events'); + const result = await this.store.deleteEvent(gameId, eventId); + return jsonResponse(result); } private async setLineups(gameId: string, request: Request): Promise { @@ -332,70 +239,29 @@ export class Router { return jsonError('Validation error', 400); } - return this.mutateGameViaDO(gameId, '/lineups', 'PATCH', parsed.data, 'metadata'); + const game = await this.store.setLineups(gameId, parsed.data); + return jsonResponse({ game }); } - private async updateGame( - gameId: string, - request: Request - ): Promise { - const updates = await request.json() as { startingOnOffense?: boolean; videoUrl?: string; ourTeamName?: string; opponentName?: string; tournamentName?: string }; - - const game = await this.db.getGameMetadata(gameId); - if (!game) { - return jsonError('Game not found', 404); - } - - // Update fields - if (updates.startingOnOffense !== undefined) { - game.startingOnOffense = updates.startingOnOffense; - } - if (updates.videoUrl !== undefined) { - game.videoUrl = updates.videoUrl; - } - if (updates.ourTeamName !== undefined) { - game.teams.us.name = updates.ourTeamName; - } - if (updates.opponentName !== undefined) { - game.teams.them.name = updates.opponentName; - } - if (updates.tournamentName !== undefined) { - game.tournamentName = updates.tournamentName; - } - - game.updatedAt = Date.now(); - - // Update in database (metadata only) - await this.db.saveGameMetadata(game); - - // If game has a chatId, also update the Durable Object - if (game.chatId) { - try { - const stub = await this.ensureDOHydrated(game.chatId); - if (stub) { - await stub.fetch( - new Request('https://fake-host/update', { - method: 'PATCH', - body: JSON.stringify(updates), - }) - ); - } - } catch (error) { - console.warn('Failed to update Durable Object:', error); - } - } + private async updateGame(gameId: string, request: Request): Promise { + const updates = await request.json() as { + startingOnOffense?: boolean; + videoUrl?: string; + ourTeamName?: string; + opponentName?: string; + tournamentName?: string; + }; + const game = await this.store.updateGame(gameId, updates); return jsonResponse({ game }); } private async deleteGame(gameId: string): Promise { - const game = await this.db.getGameMetadata(gameId); - if (!game) { + const deleted = await this.store.deleteGame(gameId); + if (!deleted) { return jsonError('Game not found', 404); } - await this.db.deleteGame(gameId); - return jsonResponse({ success: true, deleted: gameId }); } diff --git a/packages/bot/src/durable-objects/GameState.test.ts b/packages/bot/src/durable-objects/GameState.test.ts index e8e2832..e446382 100644 --- a/packages/bot/src/durable-objects/GameState.test.ts +++ b/packages/bot/src/durable-objects/GameState.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { GameState } from './GameState.js'; import { GameStatus, EventType, TeamSide } from '@scorebot/shared'; -// Mock DurableObjectState +// Mock DurableObjectState — the DurableObject base wires this to `this.ctx`. class MockDurableObjectState { private storageMap = new Map(); @@ -36,42 +36,30 @@ describe('GameState', () => { gameState = new GameState(mockState as any, mockEnv); }); - describe('initGame', () => { + describe('init', () => { it('should initialize a new game', async () => { - const request = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + const game = await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game).toBeDefined(); - expect(data.game.status).toBe(GameStatus.NOT_STARTED); - expect(data.game.teams.us.name).toBe('Team A'); - expect(data.game.teams.them.name).toBe('Team B'); - expect(data.game.score).toEqual({ us: 0, them: 0 }); - expect(data.game.events).toHaveLength(0); - expect(data.game.chatId).toBe('chat123'); + expect(game).toBeDefined(); + expect(game.status).toBe(GameStatus.NOT_STARTED); + expect(game.teams.us.name).toBe('Team A'); + expect(game.teams.them.name).toBe('Team B'); + expect(game.score).toEqual({ us: 0, them: 0 }); + expect(game.events).toHaveLength(0); + expect(game.chatId).toBe('chat123'); }); it('should save game to storage', async () => { - const request = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(request); - expect(mockState.storage.put).toHaveBeenCalledWith( 'game', expect.objectContaining({ @@ -86,529 +74,288 @@ describe('GameState', () => { }); describe('getGame', () => { - it('should return 404 if game not initialized', async () => { - const request = new Request('http://localhost/', { - method: 'GET', - }); - - const response = await gameState.fetch(request); - expect(response.status).toBe(404); - - const data = await response.json(); - expect(data.error).toBe('Game not found'); + it('should return null if game not initialized', async () => { + const game = await gameState.getGame(); + expect(game).toBeNull(); }); it('should return game if initialized', async () => { - // Initialize game - const initRequest = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), - }); - await gameState.fetch(initRequest); - - // Get game - const getRequest = new Request('http://localhost/', { - method: 'GET', + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - const response = await gameState.fetch(getRequest); - expect(response.status).toBe(200); - const data = await response.json(); - expect(data.game).toBeDefined(); - expect(data.game.teams.us.name).toBe('Team A'); + const game = await gameState.getGame(); + expect(game).toBeDefined(); + expect(game!.teams.us.name).toBe('Team A'); }); }); - describe('startGame', () => { + describe('start', () => { beforeEach(async () => { - const request = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(request); }); it('should start a not started game', async () => { - const request = new Request('http://localhost/start', { - method: 'POST', - }); + const result = await gameState.start(); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.status).toBe(GameStatus.FIRST_HALF); - expect(data.game.startedAt).toBeDefined(); - expect(data.event.type).toBe(EventType.GAME_START); - expect(data.game.events).toHaveLength(1); + expect(result.game.status).toBe(GameStatus.FIRST_HALF); + expect(result.game.startedAt).toBeDefined(); + expect(result.event.type).toBe(EventType.GAME_START); + expect(result.game.events).toHaveLength(1); }); - it('should return error if game already started', async () => { - // Start game first time - const startRequest = new Request('http://localhost/start', { - method: 'POST', - }); - await gameState.fetch(startRequest); - - // Try to start again - const response = await gameState.fetch(startRequest); - expect(response.status).toBe(400); + it('should throw if game already started', async () => { + await gameState.start(); - const data = await response.json(); - expect(data.error).toBe('Game already started'); + await expect(gameState.start()).rejects.toMatchObject({ + status: 400, + message: 'Game already started', + }); }); }); describe('startingOnOffense', () => { beforeEach(async () => { - const request = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(request); }); it('should store startingOnOffense when adding GAME_START event', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GAME_START, - startingOnOffense: true, - }), + const result = await gameState.addEvent({ + type: EventType.GAME_START, + startingOnOffense: true, }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.startingOnOffense).toBe(true); - expect(data.game.status).toBe(GameStatus.FIRST_HALF); + expect(result.game.startingOnOffense).toBe(true); + expect(result.game.status).toBe(GameStatus.FIRST_HALF); }); it('should store startingOnOffense=false when opponent starts on offense', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GAME_START, - startingOnOffense: false, - }), + const result = await gameState.addEvent({ + type: EventType.GAME_START, + startingOnOffense: false, }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.startingOnOffense).toBe(false); + expect(result.game.startingOnOffense).toBe(false); }); it('should allow updating startingOnOffense field', async () => { - const request = new Request('http://localhost/update', { - method: 'PATCH', - body: JSON.stringify({ - startingOnOffense: true, - }), - }); + const game = await gameState.updateFields({ startingOnOffense: true }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.startingOnOffense).toBe(true); + expect(game.startingOnOffense).toBe(true); }); }); describe('addEvent', () => { beforeEach(async () => { - // Initialize and start game - const initRequest = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), - }); - await gameState.fetch(initRequest); - - const startRequest = new Request('http://localhost/start', { - method: 'POST', + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(startRequest); + await gameState.start(); }); it('should add a goal event and update score', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - message: 'Goal!', - parsedBy: 'test', - }), + const result = await gameState.addEvent({ + type: EventType.GOAL, + team: TeamSide.US, + message: 'Goal!', + parsedBy: 'test', }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.score).toEqual({ us: 1, them: 0 }); - expect(data.event.type).toBe(EventType.GOAL); - expect(data.event.team).toBe(TeamSide.US); - expect(data.game.events).toHaveLength(2); // GAME_START + GOAL + expect(result.game.score).toEqual({ us: 1, them: 0 }); + expect(result.event.type).toBe(EventType.GOAL); + expect(result.event.team).toBe(TeamSide.US); + expect(result.game.events).toHaveLength(2); // GAME_START + GOAL }); it('should add multiple goal events', async () => { - // Goal for us - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - }), - }) - ); - - // Goal for them - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.THEM, - }), - }) - ); - - // Another goal for us - const response = await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - }), - }) - ); + await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.US }); + await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.THEM }); + const result = await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.US }); - const data = await response.json(); - expect(data.game.score).toEqual({ us: 2, them: 1 }); + expect(result.game.score).toEqual({ us: 2, them: 1 }); }); it('should add halftime event and update status', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.HALFTIME, - }), - }); + const result = await gameState.addEvent({ type: EventType.HALFTIME }); - const response = await gameState.fetch(request); - const data = await response.json(); - - expect(data.game.status).toBe(GameStatus.HALFTIME); - expect(data.event.type).toBe(EventType.HALFTIME); + expect(result.game.status).toBe(GameStatus.HALFTIME); + expect(result.event.type).toBe(EventType.HALFTIME); }); it('should add second half start event and update status', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.SECOND_HALF_START, - }), - }); + const result = await gameState.addEvent({ type: EventType.SECOND_HALF_START }); - const response = await gameState.fetch(request); - const data = await response.json(); - - expect(data.game.status).toBe(GameStatus.SECOND_HALF); - expect(data.event.type).toBe(EventType.SECOND_HALF_START); + expect(result.game.status).toBe(GameStatus.SECOND_HALF); + expect(result.event.type).toBe(EventType.SECOND_HALF_START); }); it('should add game end event and update status', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GAME_END, - }), - }); + const result = await gameState.addEvent({ type: EventType.GAME_END }); - const response = await gameState.fetch(request); - const data = await response.json(); - - expect(data.game.status).toBe(GameStatus.FINISHED); - expect(data.game.finishedAt).toBeDefined(); - expect(data.event.type).toBe(EventType.GAME_END); + expect(result.game.status).toBe(GameStatus.FINISHED); + expect(result.game.finishedAt).toBeDefined(); + expect(result.event.type).toBe(EventType.GAME_END); }); it('should preserve event message and parsedBy', async () => { - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - message: 'Amazing goal!', - parsedBy: 'whatsapp:+1234567890', - }), + const result = await gameState.addEvent({ + type: EventType.GOAL, + team: TeamSide.US, + message: 'Amazing goal!', + parsedBy: 'whatsapp:+1234567890', }); - const response = await gameState.fetch(request); - const data = await response.json(); - - expect(data.event.message).toBe('Amazing goal!'); - expect(data.event.parsedBy).toBe('whatsapp:+1234567890'); + expect(result.event.message).toBe('Amazing goal!'); + expect(result.event.parsedBy).toBe('whatsapp:+1234567890'); }); }); - describe('endGame', () => { + describe('end', () => { beforeEach(async () => { - // Initialize and start game - const initRequest = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(initRequest); - - const startRequest = new Request('http://localhost/start', { - method: 'POST', - }); - await gameState.fetch(startRequest); + await gameState.start(); }); it('should end an active game', async () => { - const request = new Request('http://localhost/end', { - method: 'POST', - }); + const result = await gameState.end(); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.status).toBe(GameStatus.FINISHED); - expect(data.game.finishedAt).toBeDefined(); - expect(data.event.type).toBe(EventType.GAME_END); + expect(result.game.status).toBe(GameStatus.FINISHED); + expect(result.game.finishedAt).toBeDefined(); + expect(result.event.type).toBe(EventType.GAME_END); }); - it('should return error if game already finished', async () => { - // End game first time - const endRequest = new Request('http://localhost/end', { - method: 'POST', - }); - await gameState.fetch(endRequest); + it('should throw if game already finished', async () => { + await gameState.end(); - // Try to end again - const response = await gameState.fetch(endRequest); - expect(response.status).toBe(400); - - const data = await response.json(); - expect(data.error).toBe('Game already finished'); + await expect(gameState.end()).rejects.toMatchObject({ + status: 400, + message: 'Game already finished', + }); }); }); describe('undoLastEvent', () => { beforeEach(async () => { - // Initialize and start game - const initRequest = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), - }); - await gameState.fetch(initRequest); - - const startRequest = new Request('http://localhost/start', { - method: 'POST', + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(startRequest); + await gameState.start(); }); it('should undo the last event', async () => { - // Add a goal - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - }), - }) - ); + await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.US }); - // Undo - const undoRequest = new Request('http://localhost/events/last', { - method: 'DELETE', - }); - const response = await gameState.fetch(undoRequest); + const result = await gameState.undoLastEvent(); - expect(response.status).toBe(200); - const data = await response.json(); - expect(data.undone.type).toBe(EventType.GOAL); - expect(data.game.events).toHaveLength(1); // Only GAME_START remains - expect(data.game.score).toEqual({ us: 0, them: 0 }); + expect(result.undone.type).toBe(EventType.GOAL); + expect(result.game.events).toHaveLength(1); // Only GAME_START remains + expect(result.game.score).toEqual({ us: 0, them: 0 }); }); it('should recalculate score after undo', async () => { - // Add multiple goals - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - }), - }) - ); - - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.US, - }), - }) - ); + await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.US }); + await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.US }); + await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.THEM }); - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GOAL, - team: TeamSide.THEM, - }), - }) - ); - - // Undo last goal (them) - const undoRequest = new Request('http://localhost/events/last', { - method: 'DELETE', - }); - const response = await gameState.fetch(undoRequest); + const result = await gameState.undoLastEvent(); - const data = await response.json(); - expect(data.game.score).toEqual({ us: 2, them: 0 }); + expect(result.game.score).toEqual({ us: 2, them: 0 }); }); it('should update status when undoing halftime', async () => { - // Add halftime - await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.HALFTIME, - }), - }) - ); + await gameState.addEvent({ type: EventType.HALFTIME }); - // Undo halftime - const undoRequest = new Request('http://localhost/events/last', { - method: 'DELETE', - }); - const response = await gameState.fetch(undoRequest); + const result = await gameState.undoLastEvent(); - const data = await response.json(); - expect(data.game.status).toBe(GameStatus.FIRST_HALF); + expect(result.game.status).toBe(GameStatus.FIRST_HALF); }); it('should reset to NOT_STARTED when undoing game start', async () => { - // Undo game start - const undoRequest = new Request('http://localhost/events/last', { - method: 'DELETE', - }); - const response = await gameState.fetch(undoRequest); + const result = await gameState.undoLastEvent(); - const data = await response.json(); - expect(data.game.status).toBe(GameStatus.NOT_STARTED); - expect(data.game.startedAt).toBeUndefined(); - expect(data.game.events).toHaveLength(0); + expect(result.game.status).toBe(GameStatus.NOT_STARTED); + expect(result.game.startedAt).toBeUndefined(); + expect(result.game.events).toHaveLength(0); }); - it('should return error if no events to undo', async () => { - // Undo game start first - await gameState.fetch( - new Request('http://localhost/events/last', { - method: 'DELETE', - }) - ); + it('should throw if no events to undo', async () => { + // Undo game start first, leaving no events. + await gameState.undoLastEvent(); - // Try to undo again - const undoRequest = new Request('http://localhost/events/last', { - method: 'DELETE', + await expect(gameState.undoLastEvent()).rejects.toMatchObject({ + status: 400, + message: 'No events to undo', }); - const response = await gameState.fetch(undoRequest); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toBe('No events to undo'); }); }); - describe('duplicate halftime guard', () => { + describe('deleteEvent', () => { beforeEach(async () => { - // Initialize and start game - const initRequest = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(initRequest); + await gameState.start(); + }); - const startRequest = new Request('http://localhost/start', { - method: 'POST', - }); - await gameState.fetch(startRequest); + it('should delete a specific event by id and recalculate', async () => { + const goal = await gameState.addEvent({ type: EventType.GOAL, team: TeamSide.US }); + + const result = await gameState.deleteEvent(goal.event.id); + + expect(result.deleted.id).toBe(goal.event.id); + expect(result.game.score).toEqual({ us: 0, them: 0 }); + expect(result.game.events).toHaveLength(1); // Only GAME_START remains }); - it('should reject a second halftime event with 409', async () => { - // Add first halftime - const halftimeRequest = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.HALFTIME, - }), + it('should throw 404 if event not found', async () => { + await expect(gameState.deleteEvent('event_does_not_exist')).rejects.toMatchObject({ + status: 404, + message: 'Event not found', }); + }); + }); - const firstResponse = await gameState.fetch(halftimeRequest); - expect(firstResponse.status).toBe(200); + describe('duplicate halftime guard', () => { + beforeEach(async () => { + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + await gameState.start(); + }); - // Try to add another halftime - const secondResponse = await gameState.fetch( - new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.HALFTIME, - }), - }) - ); + it('should reject a second halftime event with 409', async () => { + await gameState.addEvent({ type: EventType.HALFTIME }); - expect(secondResponse.status).toBe(409); - const data = await secondResponse.json(); - expect(data.error).toBe('Halftime already recorded'); + await expect(gameState.addEvent({ type: EventType.HALFTIME })).rejects.toMatchObject({ + status: 409, + message: 'Halftime already recorded', + }); }); }); - describe('rehydrate endpoint', () => { + describe('rehydrate', () => { it('should restore game state from a full Game object', async () => { const mockGame: any = { id: 'game_rehydrated123', @@ -633,122 +380,68 @@ describe('GameState', () => { updatedAt: 2000, }; - const rehydrateRequest = new Request('http://localhost/rehydrate', { - method: 'POST', - body: JSON.stringify(mockGame), - }); - - const response = await gameState.fetch(rehydrateRequest); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.id).toBe('game_rehydrated123'); - expect(data.game.status).toBe(GameStatus.FIRST_HALF); - expect(data.game.score).toEqual({ us: 3, them: 2 }); - expect(data.game.teams.us.name).toBe('Rehydrated A'); - - // Verify GET / returns the rehydrated game - const getRequest = new Request('http://localhost/', { - method: 'GET', - }); - const getResponse = await gameState.fetch(getRequest); - expect(getResponse.status).toBe(200); + const game = await gameState.rehydrate(mockGame); + expect(game.id).toBe('game_rehydrated123'); + expect(game.status).toBe(GameStatus.FIRST_HALF); + expect(game.score).toEqual({ us: 3, them: 2 }); + expect(game.teams.us.name).toBe('Rehydrated A'); - const getData = await getResponse.json(); - expect(getData.game.id).toBe('game_rehydrated123'); - expect(getData.game.score).toEqual({ us: 3, them: 2 }); + // Verify getGame returns the rehydrated game + const fetched = await gameState.getGame(); + expect(fetched!.id).toBe('game_rehydrated123'); + expect(fetched!.score).toEqual({ us: 3, them: 2 }); }); }); describe('backfill status transitions', () => { beforeEach(async () => { - const request = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ - chatId: 'chat123', - ourTeamName: 'Team A', - opponentName: 'Team B', - }), + await gameState.init({ + chatId: 'chat123', + ourTeamName: 'Team A', + opponentName: 'Team B', }); - await gameState.fetch(request); }); it('should use provided timestamp for game_start startedAt', async () => { const customTimestamp = 1700000000000; - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GAME_START, - timestamp: customTimestamp, - }), + const result = await gameState.addEvent({ + type: EventType.GAME_START, + timestamp: customTimestamp, }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.status).toBe(GameStatus.FIRST_HALF); - expect(data.game.startedAt).toBe(customTimestamp); - expect(data.event.timestamp).toBe(customTimestamp); + expect(result.game.status).toBe(GameStatus.FIRST_HALF); + expect(result.game.startedAt).toBe(customTimestamp); + expect(result.event.timestamp).toBe(customTimestamp); }); it('should use provided timestamp for game_end finishedAt', async () => { - // Start the game first - await gameState.fetch( - new Request('http://localhost/start', { - method: 'POST', - }) - ); + await gameState.start(); const customTimestamp = 1700003600000; - const request = new Request('http://localhost/events', { - method: 'POST', - body: JSON.stringify({ - type: EventType.GAME_END, - timestamp: customTimestamp, - }), + const result = await gameState.addEvent({ + type: EventType.GAME_END, + timestamp: customTimestamp, }); - const response = await gameState.fetch(request); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.game.status).toBe(GameStatus.FINISHED); - expect(data.game.finishedAt).toBe(customTimestamp); - expect(data.event.timestamp).toBe(customTimestamp); + expect(result.game.status).toBe(GameStatus.FINISHED); + expect(result.game.finishedAt).toBe(customTimestamp); + expect(result.event.timestamp).toBe(customTimestamp); }); }); describe('error handling', () => { - it('should return 404 for unknown routes', async () => { - const request = new Request('http://localhost/unknown', { - method: 'GET', - }); - - const response = await gameState.fetch(request); - expect(response.status).toBe(404); - }); - - it('should handle internal errors gracefully', async () => { - // Create a scenario that causes an error + it('should propagate storage errors from a mutation', async () => { mockState.storage.put = vi.fn().mockRejectedValue(new Error('Storage error')); - const request = new Request('http://localhost/init', { - method: 'POST', - body: JSON.stringify({ + await expect( + gameState.init({ chatId: 'chat123', ourTeamName: 'Team A', opponentName: 'Team B', - }), - }); - - const response = await gameState.fetch(request); - expect(response.status).toBe(500); - - const data = await response.json(); - expect(data.error).toBe('Internal server error'); + }) + ).rejects.toThrow('Storage error'); }); }); }); diff --git a/packages/bot/src/durable-objects/GameState.ts b/packages/bot/src/durable-objects/GameState.ts index c424742..49fe6fd 100644 --- a/packages/bot/src/durable-objects/GameState.ts +++ b/packages/bot/src/durable-objects/GameState.ts @@ -1,110 +1,92 @@ /** * Durable Object for managing game state - * Provides real-time game state management with in-memory performance + * Provides real-time game state management with in-memory performance. + * + * Exposes typed RPC methods (init, rehydrate, getGame, start, addEvent, + * undoLastEvent, deleteEvent, updateFields, setLineups, end) instead of a + * hand-written fetch() switch. Callers invoke these directly through the + * Durable Object stub (see docs/adr/0002-do-rpc-transport.md). */ +import { DurableObject } from 'cloudflare:workers'; import { Game, GameStatus, GameEvent, EventType, TeamSide, - Score, generateId, calculateScoreFromEvents, CreateGameRequest, - CreateGameResponse, AddEventRequest, - AddEventResponse, SetLineupsRequest, } from '@scorebot/shared'; import { Env } from '../types'; -export class GameState implements DurableObject { - private state: DurableObjectState; - private game: Game | null = null; - - constructor(state: DurableObjectState, env: Env) { - this.state = state; - } +/** + * Fields that PATCH /games/:id (and the DO's updateFields) may change. + * This is the single source of truth for the metadata patch shape — it used + * to be duplicated in the Router. + */ +export interface GameFieldUpdates { + startingOnOffense?: boolean; + videoUrl?: string; + ourTeamName?: string; + opponentName?: string; + tournamentName?: string; +} - async fetch(request: Request): Promise { - const url = new URL(request.url); - const path = url.pathname; +/** Result of a mutation that appends a lifecycle event. */ +export interface GameEventResult { + game: Game; + event: GameEvent; +} - try { - // Allow /init to create a new game - if (request.method === 'POST' && path === '/init') { - return await this.initGame(request); - } +/** Result of undoing the last event. */ +export interface UndoResult { + game: Game; + undone: GameEvent; +} - // Allow /rehydrate to restore game state from D1 data - if (request.method === 'POST' && path === '/rehydrate') { - return await this.rehydrateGame(request); - } +/** Result of deleting a specific event. */ +export interface DeleteEventResult { + game: Game; + deleted: GameEvent; +} - // Initialize game if not already loaded - if (!this.game) { - this.game = (await this.state.storage.get('game')) || null; - if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); - } - } +/** + * Error carrying the HTTP status the Router should surface. Thrown by RPC + * methods (and by the GameStore) so the transport layer can map a domain + * failure to the historical status code without inspecting message strings. + */ +export class GameStateError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'GameStateError'; + } +} - switch (request.method) { - case 'GET': - if (path === '/') { - return this.getGame(); - } - break; - - case 'POST': - if (path === '/events') { - return await this.addEvent(request); - } - if (path === '/start') { - return await this.startGame(); - } - if (path === '/end') { - return await this.endGame(); - } - break; - - case 'PATCH': - if (path === '/update') { - return await this.updateFields(request); - } - if (path === '/lineups') { - return await this.setLineups(request); - } - break; - - case 'DELETE': - if (path === '/events/last') { - return await this.undoLastEvent(); - } - if (path.match(/^\/events\/[^/]+$/)) { - const eventId = path.split('/')[2]; - return await this.deleteEvent(eventId); - } - break; - } +export class GameState extends DurableObject { + private game: Game | null = null; - return new Response('Not Found', { status: 404 }); - } catch (error) { - console.error('Error in GameState:', error); - return new Response( - JSON.stringify({ error: 'Internal server error' }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); + /** + * Lazily load persisted game state into memory. Mirrors the historical + * fetch() behaviour of hydrating `this.game` from storage on first touch. + */ + private async ensureLoaded(): Promise { + if (!this.game) { + this.game = (await this.ctx.storage.get('game')) || null; } } - private async initGame(request: Request): Promise { - const { chatId, ourTeamName, opponentName, tournamentName, gameDate, gameOrder } = await request.json() as CreateGameRequest; + /** + * Create a new game in this Durable Object. + */ + async init(input: CreateGameRequest): Promise { + const { chatId, ourTeamName, opponentName, tournamentName, gameDate, gameOrder } = input; this.game = { id: generateId('game'), @@ -125,45 +107,38 @@ export class GameState implements DurableObject { await this.saveGame(); - return new Response(JSON.stringify({ game: this.game }), { - headers: { 'Content-Type': 'application/json' }, - }); + return this.game; } /** - * Rehydrate game state from D1 data - * Called by the router when the DO has been evicted and needs to restore state + * Rehydrate game state from a full Game object (sourced from D1). + * Called by the GameStore when the DO has been evicted and needs to + * restore state before a subsequent operation. */ - private async rehydrateGame(request: Request): Promise { - const game = await request.json() as Game; - + async rehydrate(game: Game): Promise { this.game = game; await this.saveGame(); - return new Response(JSON.stringify({ game: this.game }), { - headers: { 'Content-Type': 'application/json' }, - }); + return this.game; } - private getGame(): Response { - return new Response(JSON.stringify({ game: this.game }), { - headers: { 'Content-Type': 'application/json' }, - }); + /** + * Return the current game, or null if this DO holds no game (evicted or + * never initialized). The store treats null as a signal to rehydrate. + */ + async getGame(): Promise { + await this.ensureLoaded(); + return this.game; } - private async startGame(): Promise { + async start(): Promise { + await this.ensureLoaded(); if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Game not found'); } if (this.game.status !== GameStatus.NOT_STARTED) { - return new Response( - JSON.stringify({ error: 'Game already started' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ); + throw new GameStateError(400, 'Game already started'); } this.game.status = GameStatus.FIRST_HALF; @@ -181,29 +156,22 @@ export class GameState implements DurableObject { this.game.events.push(event); await this.saveGame(); - return new Response(JSON.stringify({ game: this.game, event }), { - headers: { 'Content-Type': 'application/json' }, - }); + return { game: this.game, event }; } - private async addEvent(request: Request): Promise { + async addEvent(input: AddEventRequest & { parsedBy?: string }): Promise { + await this.ensureLoaded(); if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Game not found'); } - const { type, team, message, parsedBy, defensivePlay, startingOnOffense, timestamp, score } = await request.json() as AddEventRequest & { parsedBy?: string }; + const { type, team, message, parsedBy, defensivePlay, startingOnOffense, timestamp, score } = input; // Prevent duplicate halftime events if (type === EventType.HALFTIME) { const existingHalftime = this.game.events.some(e => e.type === EventType.HALFTIME); if (existingHalftime) { - return new Response( - JSON.stringify({ error: 'Halftime already recorded' }), - { status: 409, headers: { 'Content-Type': 'application/json' } } - ); + throw new GameStateError(409, 'Halftime already recorded'); } } @@ -252,24 +220,17 @@ export class GameState implements DurableObject { this.game.updatedAt = Date.now(); await this.saveGame(); - return new Response(JSON.stringify({ game: this.game, event }), { - headers: { 'Content-Type': 'application/json' }, - }); + return { game: this.game, event }; } - private async endGame(): Promise { + async end(): Promise { + await this.ensureLoaded(); if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Game not found'); } if (this.game.status === GameStatus.FINISHED) { - return new Response( - JSON.stringify({ error: 'Game already finished' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ); + throw new GameStateError(400, 'Game already finished'); } this.game.status = GameStatus.FINISHED; @@ -287,63 +248,46 @@ export class GameState implements DurableObject { this.game.events.push(event); await this.saveGame(); - return new Response(JSON.stringify({ game: this.game, event }), { - headers: { 'Content-Type': 'application/json' }, - }); + return { game: this.game, event }; } - private async undoLastEvent(): Promise { + async undoLastEvent(): Promise { + await this.ensureLoaded(); if (!this.game || this.game.events.length === 0) { - return new Response( - JSON.stringify({ error: 'No events to undo' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ); + throw new GameStateError(400, 'No events to undo'); } - const lastEvent = this.game.events.pop(); + const lastEvent = this.game.events.pop()!; this.recalculateGameState(); await this.saveGame(); - return new Response( - JSON.stringify({ game: this.game, undone: lastEvent }), - { headers: { 'Content-Type': 'application/json' } } - ); + return { game: this.game, undone: lastEvent }; } - private async deleteEvent(eventId: string): Promise { + async deleteEvent(eventId: string): Promise { + await this.ensureLoaded(); if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Game not found'); } const idx = this.game.events.findIndex(e => e.id === eventId); if (idx === -1) { - return new Response(JSON.stringify({ error: 'Event not found' }), { - status: 404, headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Event not found'); } const [removed] = this.game.events.splice(idx, 1); this.recalculateGameState(); await this.saveGame(); - return new Response( - JSON.stringify({ game: this.game, deleted: removed }), - { headers: { 'Content-Type': 'application/json' } } - ); + return { game: this.game, deleted: removed }; } - private async updateFields(request: Request): Promise { + async updateFields(updates: GameFieldUpdates): Promise { + await this.ensureLoaded(); if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Game not found'); } - const updates = await request.json() as { startingOnOffense?: boolean; videoUrl?: string; ourTeamName?: string; opponentName?: string; tournamentName?: string }; - if (updates.startingOnOffense !== undefined) { this.game.startingOnOffense = updates.startingOnOffense; } @@ -363,26 +307,20 @@ export class GameState implements DurableObject { this.game.updatedAt = Date.now(); await this.saveGame(); - return new Response(JSON.stringify({ game: this.game }), { - headers: { 'Content-Type': 'application/json' }, - }); + return this.game; } - private async setLineups(request: Request): Promise { + async setLineups(input: SetLineupsRequest): Promise { + await this.ensureLoaded(); if (!this.game) { - return new Response(JSON.stringify({ error: 'Game not found' }), { - status: 404, headers: { 'Content-Type': 'application/json' }, - }); + throw new GameStateError(404, 'Game not found'); } - const { lineups } = await request.json() as SetLineupsRequest; - this.game.lineups = lineups; + this.game.lineups = input.lineups; this.game.updatedAt = Date.now(); await this.saveGame(); - return new Response(JSON.stringify({ game: this.game }), { - headers: { 'Content-Type': 'application/json' }, - }); + return this.game; } private recalculateGameState(): void { @@ -412,8 +350,7 @@ export class GameState implements DurableObject { private async saveGame(): Promise { if (this.game) { - await this.state.storage.put('game', this.game); + await this.ctx.storage.put('game', this.game); } } - } diff --git a/packages/bot/src/durable-objects/cloudflare-workers.shim.ts b/packages/bot/src/durable-objects/cloudflare-workers.shim.ts new file mode 100644 index 0000000..cf28b0d --- /dev/null +++ b/packages/bot/src/durable-objects/cloudflare-workers.shim.ts @@ -0,0 +1,19 @@ +/** + * Test-only shim for the `cloudflare:workers` virtual module. + * + * Vitest runs in Node, which cannot resolve the real Workers-runtime module, + * so vitest.config.ts aliases `cloudflare:workers` to this file. It provides a + * minimal `DurableObject` base whose constructor wires up `ctx`/`env` exactly + * like the runtime does — which is all the unit tests exercise. TypeScript + * itself resolves `cloudflare:workers` against @cloudflare/workers-types, not + * this file, so production type-checking still uses the real declarations. + */ +export class DurableObject { + protected ctx: any; + protected env: Env; + + constructor(ctx: any, env: Env) { + this.ctx = ctx; + this.env = env; + } +} diff --git a/packages/bot/src/services/StatsCalculator.ts b/packages/bot/src/services/StatsCalculator.ts index e929ed1..451efec 100644 --- a/packages/bot/src/services/StatsCalculator.ts +++ b/packages/bot/src/services/StatsCalculator.ts @@ -23,7 +23,7 @@ import { PlayerChemistry, AggregateLineStats, } from '@scorebot/shared'; -import { calculateLineStats } from '@scorebot/shared'; +import { calculateLineStats, buildPointLedger } from '@scorebot/shared'; import { PlayerNameParser } from './PlayerNameParser.js'; export class StatsCalculator { @@ -139,14 +139,17 @@ export class StatsCalculator { } if (game.lineups && game.lineups.length > 0) { - // Authoritative calculation from lineup data - const goalEvents = game.events.filter(e => e.type === EventType.GOAL); + // Authoritative calculation from lineup data. Match each lineup to its + // point via the ledger's pointNumber rather than raw goal indexing. + const pointsByNumber = new Map( + buildPointLedger(game).points.map(p => [p.pointNumber, p]) + ); for (const lineup of game.lineups) { - const goalEvent = goalEvents[lineup.pointNumber - 1]; - if (!goalEvent) continue; + const point = pointsByNumber.get(lineup.pointNumber); + if (!point) continue; - const scoreDiff = goalEvent.team === TeamSide.US ? 1 : -1; + const scoreDiff = point.scoringTeam === TeamSide.US ? 1 : -1; for (const playerName of lineup.players) { const stats = this.getOrCreatePlayerStats(playerMap, playerName); diff --git a/packages/bot/src/store/GameStore.test.ts b/packages/bot/src/store/GameStore.test.ts new file mode 100644 index 0000000..20e0731 --- /dev/null +++ b/packages/bot/src/store/GameStore.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { GameStore } from './GameStore.js'; +import { GameState } from '../durable-objects/GameState.js'; +import { Game, GameStatus, EventType, TeamSide, GameSummary } from '@scorebot/shared'; + +const clone = (value: T): T => structuredClone(value); + +/** + * Build a real GameState backed by an in-memory storage map, exactly like the + * DO test harness. Using real GameState instances gives the store realistic + * DO behavior (score updates, guards, recalculation) rather than stubs. + */ +function makeGameState(): GameState { + const storageMap = new Map(); + const ctx = { + storage: { + get: async (key: string) => storageMap.get(key), + put: async (key: string, value: any) => { + storageMap.set(key, value); + }, + delete: async (key: string) => { + storageMap.delete(key); + }, + }, + }; + return new GameState(ctx as any, {} as any); +} + +/** + * Wrap a GameState in a stub that records each RPC call into a shared log + * before delegating, so tests can assert DO-before-D1 ordering. + */ +function makeLoggingStub(game: GameState, callLog: string[]) { + return { + init: (input: any) => (callLog.push('do.init'), game.init(input)), + getGame: () => (callLog.push('do.getGame'), game.getGame()), + rehydrate: (g: any) => (callLog.push('do.rehydrate'), game.rehydrate(g)), + addEvent: (input: any) => (callLog.push('do.addEvent'), game.addEvent(input)), + undoLastEvent: () => (callLog.push('do.undoLastEvent'), game.undoLastEvent()), + deleteEvent: (id: string) => (callLog.push('do.deleteEvent'), game.deleteEvent(id)), + updateFields: (u: any) => (callLog.push('do.updateFields'), game.updateFields(u)), + setLineups: (input: any) => (callLog.push('do.setLineups'), game.setLineups(input)), + start: () => (callLog.push('do.start'), game.start()), + end: () => (callLog.push('do.end'), game.end()), + }; +} + +/** Fake DurableObjectNamespace: one persistent GameState per chatId. */ +class FakeNamespace { + private instances = new Map(); + + constructor(private readonly callLog: string[]) {} + + idFromName(name: string): string { + return name; // id is the chatId itself + } + + get(id: string) { + if (!this.instances.has(id)) { + this.instances.set(id, makeGameState()); + } + return makeLoggingStub(this.instances.get(id)!, this.callLog); + } +} + +/** Fake DatabaseService: in-memory games keyed by id, recording call order. */ +class FakeDatabaseService { + games = new Map(); + failSaveWithEvents = false; + failSaveMetadata = false; + + constructor(private readonly callLog: string[]) {} + + async getGame(gameId: string): Promise { + this.callLog.push('db.getGame'); + const game = this.games.get(gameId); + return game ? clone(game) : null; + } + + async getGameMetadata(gameId: string): Promise { + this.callLog.push('db.getGameMetadata'); + const game = this.games.get(gameId); + return game ? clone(game) : null; + } + + async saveGame(game: Game): Promise { + this.callLog.push('db.saveGame'); + this.games.set(game.id, clone(game)); + } + + async saveGameWithEvents(game: Game): Promise { + this.callLog.push('db.saveGameWithEvents'); + if (this.failSaveWithEvents) throw new Error('D1 write failed'); + this.games.set(game.id, clone(game)); + } + + async saveGameMetadata(game: Game): Promise { + this.callLog.push('db.saveGameMetadata'); + if (this.failSaveMetadata) throw new Error('D1 write failed'); + this.games.set(game.id, clone(game)); + } + + async listGames(_limit: number): Promise { + this.callLog.push('db.listGames'); + return [...this.games.values()].map(g => ({ + id: g.id, + status: g.status, + teams: g.teams, + score: g.score, + })) as GameSummary[]; + } + + async deleteGame(gameId: string): Promise { + this.callLog.push('db.deleteGame'); + this.games.delete(gameId); + } +} + +function makeGame(overrides: Partial = {}): Game { + return { + id: 'g1', + status: GameStatus.FIRST_HALF, + teams: { + us: { name: 'Team A', side: TeamSide.US }, + them: { name: 'Team B', side: TeamSide.THEM }, + }, + score: { us: 0, them: 0 }, + events: [ + { + id: 'evt_start', + gameId: 'g1', + type: EventType.GAME_START, + timestamp: 1000, + score: { us: 0, them: 0 }, + }, + ], + chatId: 'c1', + createdAt: 1000, + updatedAt: 1000, + ...overrides, + }; +} + +describe('GameStore', () => { + let callLog: string[]; + let namespace: FakeNamespace; + let db: FakeDatabaseService; + let store: GameStore; + + beforeEach(() => { + callLog = []; + namespace = new FakeNamespace(callLog); + db = new FakeDatabaseService(callLog); + store = new GameStore(namespace as any, db as any); + }); + + describe('createGame', () => { + it('creates in the DO first, then persists a full save to D1', async () => { + const game = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + + expect(game.teams.us.name).toBe('Team A'); + expect(game.status).toBe(GameStatus.NOT_STARTED); + // DO write precedes the D1 write, and creation uses the full save. + expect(callLog).toEqual(['do.init', 'db.saveGame']); + expect(db.games.get(game.id)).toBeDefined(); + }); + }); + + describe('addEvent', () => { + it('writes DO before D1 and uses saveGameWithEvents', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + callLog.length = 0; + + const result = await store.addEvent(created.id, { + type: EventType.GOAL, + team: TeamSide.US, + }); + + expect(result.game.score).toEqual({ us: 1, them: 0 }); + expect(result.event.type).toBe(EventType.GOAL); + + // DO mutation strictly before the D1 persist. + const doIdx = callLog.indexOf('do.addEvent'); + const dbIdx = callLog.indexOf('db.saveGameWithEvents'); + expect(doIdx).toBeGreaterThanOrEqual(0); + expect(dbIdx).toBeGreaterThan(doIdx); + // Event-mutating verb never touches the metadata-only save. + expect(callLog).not.toContain('db.saveGameMetadata'); + + // D1 reflects the DO's returned state. + expect(db.games.get(created.id)!.score).toEqual({ us: 1, them: 0 }); + }); + + it('propagates a D1 failure as an error (no silent success)', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + db.failSaveWithEvents = true; + + await expect( + store.addEvent(created.id, { type: EventType.GOAL, team: TeamSide.US }) + ).rejects.toThrow('D1 write failed'); + }); + + it('does not save to D1 when the DO rejects the mutation', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + await store.addEvent(created.id, { type: EventType.HALFTIME }); + callLog.length = 0; + + await expect( + store.addEvent(created.id, { type: EventType.HALFTIME }) + ).rejects.toMatchObject({ status: 409, message: 'Halftime already recorded' }); + + // The failed DO mutation must not have triggered a D1 write. + expect(callLog).not.toContain('db.saveGameWithEvents'); + }); + + it('throws 404 when the game does not exist', async () => { + await expect( + store.addEvent('missing', { type: EventType.GOAL, team: TeamSide.US }) + ).rejects.toMatchObject({ status: 404, message: 'Game not found' }); + }); + }); + + describe('rehydration of a cold DO', () => { + it('seeds an evicted DO from the D1 row before mutating', async () => { + // Seed D1 only; the DO for c1 has never been touched (cold/evicted). + db.games.set('g1', makeGame()); + + const result = await store.addEvent('g1', { + type: EventType.GOAL, + team: TeamSide.US, + }); + + // Existing GAME_START survived and the new goal was appended on top. + expect(result.game.events).toHaveLength(2); + expect(result.game.score).toEqual({ us: 1, them: 0 }); + + // Ordering: probe DO (null) → load full row → rehydrate → mutate → save. + const order = callLog.filter(c => + ['do.getGame', 'db.getGame', 'do.rehydrate', 'do.addEvent', 'db.saveGameWithEvents'].includes(c) + ); + expect(order).toEqual([ + 'do.getGame', + 'db.getGame', + 'do.rehydrate', + 'do.addEvent', + 'db.saveGameWithEvents', + ]); + }); + }); + + describe('undoLastEvent', () => { + it('uses saveGameWithEvents', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + await store.addEvent(created.id, { type: EventType.GOAL, team: TeamSide.US }); + callLog.length = 0; + + const result = await store.undoLastEvent(created.id); + + expect(result.undone.type).toBe(EventType.GOAL); + expect(callLog.indexOf('do.undoLastEvent')).toBeGreaterThanOrEqual(0); + expect(callLog).toContain('db.saveGameWithEvents'); + expect(callLog).not.toContain('db.saveGameMetadata'); + }); + }); + + describe('deleteEvent', () => { + it('uses saveGameWithEvents', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + const goal = await store.addEvent(created.id, { type: EventType.GOAL, team: TeamSide.US }); + callLog.length = 0; + + const result = await store.deleteEvent(created.id, goal.event.id); + + expect(result.deleted.id).toBe(goal.event.id); + const doIdx = callLog.indexOf('do.deleteEvent'); + const dbIdx = callLog.indexOf('db.saveGameWithEvents'); + expect(dbIdx).toBeGreaterThan(doIdx); + expect(callLog).not.toContain('db.saveGameMetadata'); + }); + }); + + describe('updateGame', () => { + it('goes through the DO first, then saveGameMetadata (no divergence path)', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + callLog.length = 0; + + const game = await store.updateGame(created.id, { + videoUrl: 'https://example.com/v', + ourTeamName: 'Renamed A', + }); + + expect(game.videoUrl).toBe('https://example.com/v'); + expect(game.teams.us.name).toBe('Renamed A'); + + // DO write strictly precedes D1, and it is a metadata-only save. + const doIdx = callLog.indexOf('do.updateFields'); + const dbIdx = callLog.indexOf('db.saveGameMetadata'); + expect(doIdx).toBeGreaterThanOrEqual(0); + expect(dbIdx).toBeGreaterThan(doIdx); + expect(callLog).not.toContain('db.saveGameWithEvents'); + + // D1 reflects the DO-applied patch (no silent divergence). + expect(db.games.get(created.id)!.videoUrl).toBe('https://example.com/v'); + expect(db.games.get(created.id)!.teams.us.name).toBe('Renamed A'); + }); + + it('propagates a D1 failure as an error', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + db.failSaveMetadata = true; + + await expect( + store.updateGame(created.id, { videoUrl: 'x' }) + ).rejects.toThrow('D1 write failed'); + }); + }); + + describe('setLineups', () => { + it('uses saveGameMetadata', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + callLog.length = 0; + + const game = await store.setLineups(created.id, { + lineups: [{ pointNumber: 1, players: ['Jake', 'Mason'] }], + }); + + expect(game.lineups).toHaveLength(1); + const doIdx = callLog.indexOf('do.setLineups'); + const dbIdx = callLog.indexOf('db.saveGameMetadata'); + expect(dbIdx).toBeGreaterThan(doIdx); + expect(callLog).not.toContain('db.saveGameWithEvents'); + }); + }); + + describe('startGame / endGame', () => { + it('start uses saveGameMetadata', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + callLog.length = 0; + + const result = await store.startGame(created.id); + + expect(result.game.status).toBe(GameStatus.FIRST_HALF); + expect(callLog.indexOf('do.start')).toBeLessThan(callLog.indexOf('db.saveGameMetadata')); + expect(callLog).not.toContain('db.saveGameWithEvents'); + }); + + it('end uses saveGameMetadata', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + await store.startGame(created.id); + callLog.length = 0; + + const result = await store.endGame(created.id); + + expect(result.game.status).toBe(GameStatus.FINISHED); + expect(callLog.indexOf('do.end')).toBeLessThan(callLog.indexOf('db.saveGameMetadata')); + }); + }); + + describe('getGame', () => { + it('returns null when the game is not in D1', async () => { + expect(await store.getGame('missing')).toBeNull(); + }); + + it('prefers live DO state over the D1 row', async () => { + const created = await store.createGame({ + chatId: 'c1', + ourTeamName: 'Team A', + opponentName: 'Team B', + }); + await store.addEvent(created.id, { type: EventType.GOAL, team: TeamSide.US }); + + const game = await store.getGame(created.id); + expect(game!.score).toEqual({ us: 1, them: 0 }); + }); + + it('rehydrates and returns the D1 row when the DO is cold', async () => { + db.games.set('g1', makeGame({ score: { us: 3, them: 2 } })); + + const game = await store.getGame('g1'); + expect(game!.score).toEqual({ us: 3, them: 2 }); + expect(callLog).toContain('do.rehydrate'); + }); + }); + + describe('deleteGame', () => { + it('deletes from D1 and returns true', async () => { + db.games.set('g1', makeGame()); + + const ok = await store.deleteGame('g1'); + expect(ok).toBe(true); + expect(db.games.has('g1')).toBe(false); + }); + + it('returns false for a missing game', async () => { + expect(await store.deleteGame('missing')).toBe(false); + }); + }); +}); diff --git a/packages/bot/src/store/GameStore.ts b/packages/bot/src/store/GameStore.ts new file mode 100644 index 0000000..43fc9d4 --- /dev/null +++ b/packages/bot/src/store/GameStore.ts @@ -0,0 +1,174 @@ +/** + * GameStore — the single coordinator of the DO + D1 dual-write. + * + * Per docs/adr/0001-do-first-dual-write.md the Durable Object is authoritative + * for a live game: every mutation goes DO-first, then the DO's returned state + * is persisted to D1 synchronously. A D1 failure surfaces as a thrown error — + * there is no fire-and-forget and no reversed ordering. This is the ONLY place + * that talks to both stores; the Router is a thin HTTP adapter over it. + */ + +import { + Game, + GameSummary, + CreateGameRequest, + AddEventRequest, + SetLineupsRequest, +} from '@scorebot/shared'; +import { DatabaseService } from '../db/database.js'; +import { + GameState, + GameStateError, + GameFieldUpdates, + GameEventResult, + UndoResult, + DeleteEventResult, +} from '../durable-objects/GameState.js'; + +export class GameStore { + constructor( + private readonly namespace: DurableObjectNamespace, + private readonly db: DatabaseService, + ) {} + + /** + * Create and persist a new game. New games have no events yet, so a full + * save (metadata + events) is used. + */ + async createGame(input: CreateGameRequest): Promise { + const stub = this.namespace.get(this.namespace.idFromName(input.chatId)); + const game = await stub.init(input); + await this.db.saveGame(game); + return game; + } + + /** + * Read a game. Prefers the live DO state (rehydrating a cold DO from D1), + * falling back to the D1 row if the DO is unavailable. + */ + async getGame(gameId: string): Promise { + const game = await this.db.getGame(gameId); + if (!game) return null; + + if (game.chatId) { + try { + const stub = this.namespace.get(this.namespace.idFromName(game.chatId)); + const current = await stub.getGame(); + if (current === null) { + // DO evicted — seed it from the D1 row and return that. + return await stub.rehydrate(game); + } + return current; + } catch { + // Fall back to the D1 version on any DO error. + } + } + + return game; + } + + async listGames(limit: number): Promise { + return this.db.listGames(limit); + } + + // --- Event-mutating verbs: persist with saveGameWithEvents --- + + async addEvent( + gameId: string, + input: AddEventRequest & { parsedBy?: string }, + ): Promise { + const stub = await this.hydratedStub(gameId); + const result = await stub.addEvent(input); + await this.db.saveGameWithEvents(result.game); + return result; + } + + async undoLastEvent(gameId: string): Promise { + const stub = await this.hydratedStub(gameId); + const result = await stub.undoLastEvent(); + await this.db.saveGameWithEvents(result.game); + return result; + } + + async deleteEvent(gameId: string, eventId: string): Promise { + const stub = await this.hydratedStub(gameId); + const result = await stub.deleteEvent(eventId); + await this.db.saveGameWithEvents(result.game); + return result; + } + + // --- Metadata-only verbs: persist with saveGameMetadata --- + + async updateGame(gameId: string, updates: GameFieldUpdates): Promise { + const stub = await this.hydratedStub(gameId); + const game = await stub.updateFields(updates); + await this.db.saveGameMetadata(game); + return game; + } + + async setLineups(gameId: string, input: SetLineupsRequest): Promise { + // Lineups live in a games-table column, so this is a metadata-only save. + const stub = await this.hydratedStub(gameId); + const game = await stub.setLineups(input); + await this.db.saveGameMetadata(game); + return game; + } + + async startGame(gameId: string): Promise { + const stub = await this.hydratedStub(gameId); + const result = await stub.start(); + await this.db.saveGameMetadata(result.game); + return result; + } + + async endGame(gameId: string): Promise { + const stub = await this.hydratedStub(gameId); + const result = await stub.end(); + await this.db.saveGameMetadata(result.game); + return result; + } + + /** + * Delete a game (and its events, via D1 cascade). D1-only: there is no live + * state to keep for a deleted game. Returns false if the game did not exist. + */ + async deleteGame(gameId: string): Promise { + const meta = await this.db.getGameMetadata(gameId); + if (!meta) return false; + + await this.db.deleteGame(gameId); + return true; + } + + /** + * Resolve the Durable Object stub for a game, ensuring it holds state. + * Probe semantics mirror the former ensureDOHydrated: if the DO reports no + * game (evicted), seed it from the full D1 row before returning. + * + * Throws GameStateError(404) when the game does not exist and + * GameStateError(500) when a cold DO cannot be restored from D1. + */ + private async hydratedStub( + gameId: string, + ): Promise> { + // Only metadata (chatId) is needed to route to the DO. + const meta = await this.db.getGameMetadata(gameId); + if (!meta || !meta.chatId) { + throw new GameStateError(404, 'Game not found'); + } + + const stub = this.namespace.get(this.namespace.idFromName(meta.chatId)); + + const current = await stub.getGame(); + if (current === null) { + // DO evicted — need the full game (with events) to rehydrate. + const full = await this.db.getGame(gameId); + if (!full) { + throw new GameStateError(500, 'Failed to restore game state'); + } + await stub.rehydrate(full); + } + + return stub; + } +} diff --git a/packages/bot/src/types.ts b/packages/bot/src/types.ts index 516340b..1fb6544 100644 --- a/packages/bot/src/types.ts +++ b/packages/bot/src/types.ts @@ -2,9 +2,11 @@ * Environment bindings for Cloudflare Workers */ +import type { GameState } from './durable-objects/GameState'; + export interface Env { - // Durable Object namespace - GAME_STATE: DurableObjectNamespace; + // Durable Object namespace (typed for RPC against GameState) + GAME_STATE: DurableObjectNamespace; // D1 Database DB: D1Database; diff --git a/packages/bot/vitest.config.ts b/packages/bot/vitest.config.ts index c0df5ca..8b75f0e 100644 --- a/packages/bot/vitest.config.ts +++ b/packages/bot/vitest.config.ts @@ -1,10 +1,21 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ + resolve: { + alias: { + // `cloudflare:workers` is a Workers-runtime virtual module that Node + // cannot resolve. Point it at a minimal shim so the DurableObject base + // class is available when unit tests run under Node. + 'cloudflare:workers': fileURLToPath( + new URL('./src/durable-objects/cloudflare-workers.shim.ts', import.meta.url) + ), + }, + }, test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], diff --git a/packages/bot/wrangler.toml b/packages/bot/wrangler.toml index 0dab8c1..828d4c8 100644 --- a/packages/bot/wrangler.toml +++ b/packages/bot/wrangler.toml @@ -2,7 +2,7 @@ name = "scorebot-api" main = "dist/index.js" -compatibility_date = "2024-01-01" +compatibility_date = "2025-10-01" compatibility_flags = ["nodejs_compat"] workers_dev = true diff --git a/packages/shared/package.json b/packages/shared/package.json index 421ac03..cf6c08c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,8 +7,9 @@ "types": "./dist/index.d.ts", "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "default": "./dist/index.js" } }, "files": [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0e5635f..277aef2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './types.js'; export * from './utils.js'; +export * from './pointLedger.js'; diff --git a/packages/shared/src/pointLedger.test.ts b/packages/shared/src/pointLedger.test.ts new file mode 100644 index 0000000..c90d136 --- /dev/null +++ b/packages/shared/src/pointLedger.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { buildPointLedger, isTechDefensivePlayNote } from './pointLedger.js'; +import { calculateLineStats } from './utils.js'; +import { Game, GameEvent, EventType, TeamSide, GameStatus } from './types.js'; + +type Entry = + | { goal: TeamSide; defensivePlay?: 'block' | 'steal' } + | { note: string } + | { halftime: true }; + +const makeGame = (startingOnOffense: boolean | undefined, sequence: Entry[]): Game => { + const events: GameEvent[] = sequence.map((entry, index) => { + const base = { + id: `event_${index}`, + gameId: 'game1', + timestamp: 1000 + index * 1000, + score: { us: 0, them: 0 }, // ledger derives its own running score + }; + if ('goal' in entry) { + return { + ...base, + type: EventType.GOAL, + team: entry.goal, + ...(entry.defensivePlay ? { defensivePlay: entry.defensivePlay } : {}), + }; + } + if ('halftime' in entry) { + return { ...base, type: EventType.HALFTIME }; + } + return { ...base, type: EventType.NOTE, message: entry.note }; + }); + return { + id: 'game1', + status: GameStatus.FINISHED, + teams: { + us: { name: 'Tech', side: TeamSide.US }, + them: { name: 'Opp', side: TeamSide.THEM }, + }, + score: { us: 0, them: 0 }, + events, + startingOnOffense, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +}; + +/** + * Verbatim copy of the old web isBreakScore (deleted breakDetection.ts) so we + * can assert the ledger reproduces its hold/break call exactly — the timeline + * must not change one pixel. + */ +function legacyIsBreakScore(event: GameEvent, allEvents: GameEvent[], game: Game): boolean { + const eventIndex = allEvents.findIndex(e => e.id === event.id); + let crossedHalftime = false; + for (let i = eventIndex - 1; i >= 0; i--) { + const prevEvent = allEvents[i]; + if (prevEvent.type === EventType.HALFTIME) { + crossedHalftime = true; + } + if (prevEvent.type === EventType.GOAL && prevEvent.team) { + if (crossedHalftime && game.startingOnOffense !== undefined && event.team) { + return (event.team === 'us') === game.startingOnOffense; + } + return prevEvent.team === event.team; + } + } + if (game.startingOnOffense !== undefined && event.team) { + const weOnOffense = crossedHalftime ? !game.startingOnOffense : game.startingOnOffense; + return event.team === 'us' ? !weOnOffense : weOnOffense; + } + return false; +} + +describe('buildPointLedger — seeded games', () => { + it('assigns definite holds and breaks for both teams', () => { + // Start on offense: US hold, US break, THEM hold (our D), US hold + const game = makeGame(true, [ + { goal: TeamSide.US }, + { goal: TeamSide.US }, + { goal: TeamSide.THEM }, + { goal: TeamSide.US }, + ]); + const { points } = buildPointLedger(game); + + expect(points.map(p => p.result)).toEqual(['hold', 'break', 'hold', 'hold']); + expect(points.map(p => p.ourLine)).toEqual(['O', 'D', 'D', 'O']); + expect(points.map(p => p.scoringTeam)).toEqual([ + TeamSide.US, TeamSide.US, TeamSide.THEM, TeamSide.US, + ]); + expect(points.every(p => p.inferred === false)).toBe(true); + expect(points.map(p => p.score)).toEqual([ + { us: 1, them: 0 }, + { us: 2, them: 0 }, + { us: 2, them: 1 }, + { us: 3, them: 1 }, + ]); + }); + + it('reflects the possession flip at halftime', () => { + // Start on O. First half we hold. After half we pull, so we're on D and a + // US goal is a break. + const game = makeGame(true, [ + { goal: TeamSide.US }, + { halftime: true }, + { goal: TeamSide.US }, + ]); + const { points } = buildPointLedger(game); + + expect(points[0]).toMatchObject({ result: 'hold', ourLine: 'O', firstAfterHalftime: false }); + expect(points[1]).toMatchObject({ result: 'break', ourLine: 'D', firstAfterHalftime: true }); + expect(points.filter(p => p.firstAfterHalftime)).toHaveLength(1); + }); + + it('flags a dirty hold: O-line forces a turn then scores', () => { + const game = makeGame(true, [ + { note: 'Mason block' }, + { goal: TeamSide.US }, + ]); + const point = buildPointLedger(game).points[0]; + expect(point).toMatchObject({ result: 'hold', ourLine: 'O', forcedTurn: true }); + + const stats = calculateLineStats(game); + expect(stats?.oLineHolds).toBe(1); + expect(stats?.oLineDirtyHolds).toBe(1); + }); + + it('flags a failed conversion: D-line forces a turn but concedes', () => { + const game = makeGame(false, [ + { note: 'Theo steal' }, + { goal: TeamSide.THEM }, + ]); + const point = buildPointLedger(game).points[0]; + expect(point).toMatchObject({ result: 'hold', ourLine: 'D', forcedTurn: true, scoringTeam: TeamSide.THEM }); + + const stats = calculateLineStats(game); + expect(stats?.dLineBreaks).toBe(0); + expect(stats?.dLineFailedConversions).toBe(1); + }); + + it('treats a goal tagged with a defensivePlay as a forced turn (D-line break)', () => { + const game = makeGame(false, [ + { goal: TeamSide.US, defensivePlay: 'block' }, + ]); + const point = buildPointLedger(game).points[0]; + expect(point).toMatchObject({ result: 'break', ourLine: 'D', forcedTurn: true }); + + const stats = calculateLineStats(game); + expect(stats?.dLineBreaks).toBe(1); + expect(stats?.dLineFailedConversions).toBe(0); + }); +}); + +describe('buildPointLedger — unseeded games (reproduces legacy isBreakScore)', () => { + it('guesses the first point of the game as an inferred hold', () => { + const game = makeGame(undefined, [{ goal: TeamSide.US }]); + const point = buildPointLedger(game).points[0]; + expect(point).toMatchObject({ result: 'hold', inferred: true }); + expect(point.ourLine).toBeUndefined(); + }); + + it('calls consecutive same-team scores a break', () => { + const game = makeGame(undefined, [{ goal: TeamSide.US }, { goal: TeamSide.US }]); + const { points } = buildPointLedger(game); + expect(points[0]).toMatchObject({ result: 'hold', inferred: true }); + expect(points[1]).toMatchObject({ result: 'break', inferred: false }); + }); + + it('calls alternating scores a hold', () => { + const game = makeGame(undefined, [{ goal: TeamSide.US }, { goal: TeamSide.THEM }]); + const { points } = buildPointLedger(game); + expect(points[1]).toMatchObject({ result: 'hold', inferred: false }); + }); + + it('carries the consecutive-scoring rule across halftime (crossed-halftime fallthrough)', () => { + // THEM scored just before half and again just after → break (same team), + // NOT a fresh inferred hold. This matches the legacy fallthrough branch. + const game = makeGame(undefined, [ + { goal: TeamSide.US }, + { goal: TeamSide.THEM }, + { halftime: true }, + { goal: TeamSide.THEM }, + ]); + const afterHalf = buildPointLedger(game).points[2]; + expect(afterHalf).toMatchObject({ result: 'break', inferred: false, firstAfterHalftime: true }); + + // Different team after half → hold. + const game2 = makeGame(undefined, [ + { goal: TeamSide.US }, + { goal: TeamSide.THEM }, + { halftime: true }, + { goal: TeamSide.US }, + ]); + expect(buildPointLedger(game2).points[2]).toMatchObject({ result: 'hold', inferred: false }); + }); + + it('leaves inferred results out of efficiency stats', () => { + const game = makeGame(undefined, [{ goal: TeamSide.US }, { goal: TeamSide.US }]); + // Ledger still produces display points... + expect(buildPointLedger(game).points).toHaveLength(2); + // ...but efficiency stats refuse to use them. + expect(calculateLineStats(game)).toBeNull(); + }); +}); + +describe('buildPointLedger — legacy isBreakScore parity', () => { + const sequences: Array<{ soo: boolean | undefined; seq: Entry[] }> = [ + { soo: true, seq: [{ goal: TeamSide.US }, { goal: TeamSide.US }, { goal: TeamSide.THEM }, { goal: TeamSide.US }] }, + { soo: false, seq: [{ goal: TeamSide.US }, { goal: TeamSide.THEM }, { goal: TeamSide.US }, { goal: TeamSide.THEM }] }, + { soo: true, seq: [{ goal: TeamSide.US }, { halftime: true }, { goal: TeamSide.THEM }, { goal: TeamSide.US }] }, + { soo: false, seq: [{ goal: TeamSide.THEM }, { halftime: true }, { goal: TeamSide.US }, { goal: TeamSide.US }] }, + { soo: undefined, seq: [{ goal: TeamSide.US }, { goal: TeamSide.US }, { goal: TeamSide.THEM }, { goal: TeamSide.THEM }] }, + { soo: undefined, seq: [{ goal: TeamSide.THEM }, { goal: TeamSide.US }, { halftime: true }, { goal: TeamSide.US }, { goal: TeamSide.THEM }] }, + { soo: undefined, seq: [{ halftime: true }, { goal: TeamSide.US }, { goal: TeamSide.THEM }] }, + ]; + + it('matches legacy break/hold for every goal in every scenario', () => { + for (const { soo, seq } of sequences) { + const game = makeGame(soo, seq); + const { points } = buildPointLedger(game); + for (const point of points) { + const legacyBreak = legacyIsBreakScore(point.goalEvent, game.events, game); + expect(point.result === 'break').toBe(legacyBreak); + } + } + }); +}); diff --git a/packages/shared/src/pointLedger.ts b/packages/shared/src/pointLedger.ts new file mode 100644 index 0000000..1f7b839 --- /dev/null +++ b/packages/shared/src/pointLedger.ts @@ -0,0 +1,134 @@ +/** + * Point Ledger — the single derived, per-point account of a game. + * + * One forward pass over game.events produces one Point per goal. All break/hold, + * line (O/D), and forced-turn questions are answered from this ledger; the three + * former algorithms (calculateLineStats, isBreakScore, findHalftimePointIndex) + * are now folds/lookups over it. + * + * Possession model (identical to the old calculateLineStats): + * - `weHavePossession` is seeded from game.startingOnOffense. + * - At HALFTIME the team that received first now pulls, so it resets to + * `!startingOnOffense`. + * - After each goal possession goes to the non-scoring team. + * + * When startingOnOffense is undefined we cannot seed possession, so the first + * point of the game is ambiguous: its result is guessed as a hold (inferred). + * Crucially, halftime does NOT re-introduce ambiguity in the unseeded case — + * possession is carried across the break — which reproduces the old + * isBreakScore behaviour of comparing to the pre-halftime goal (consecutive + * same-team scores = break). Inferred results are display-only and must never + * feed efficiency stats. + */ + +import { Game, GameEvent, Score, TeamSide, EventType } from './types.js'; + +export interface Point { + /** 1-based; every goal ends exactly one point. */ + pointNumber: number; + /** The goal event that ended this point. */ + goalEvent: GameEvent; + scoringTeam: TeamSide; + /** Relative to the scoring team: 'break' = they started the point on defense. */ + result: 'hold' | 'break'; + /** True when the result is a guess made without knowing starting possession. */ + inferred: boolean; + /** Which of our lines played it — only known when startingOnOffense is set. */ + ourLine?: 'O' | 'D'; + /** A Tech-forced turn happened this point (logged block/steal, or a scored one). */ + forcedTurn: boolean; + firstAfterHalftime: boolean; + /** Running score after this point. */ + score: Score; +} + +export interface PointLedger { + points: Point[]; +} + +/** Match note messages like "Mason block", "Theo steal", "Mason foot block". */ +const TECH_DEFENSIVE_NOTE_PATTERN = /^[A-Z][a-z]+\b.*\b(?:block|steal)\b/; + +export function isTechDefensivePlayNote(message: string | undefined): boolean { + if (!message) return false; + return TECH_DEFENSIVE_NOTE_PATTERN.test(message); +} + +export function buildPointLedger(game: Game): PointLedger { + const points: Point[] = []; + const seeded = game.startingOnOffense !== undefined; + + // Possession at the start of the current point. `undefined` means ambiguous — + // only possible in an unseeded game before its first goal. + let weHavePossession: boolean | undefined = game.startingOnOffense; + let forcedTurnThisPoint = false; + let pendingFirstAfterHalftime = false; + const score: Score = { us: 0, them: 0 }; + + for (const event of game.events) { + if (event.type === EventType.HALFTIME) { + // The team that received first now pulls. In the unseeded case we can't + // name that team, so we deliberately leave possession as carried from the + // last goal — this keeps the consecutive-scoring rule intact across the + // break, matching the old isBreakScore. + if (seeded) { + weHavePossession = !game.startingOnOffense; + } + forcedTurnThisPoint = false; + pendingFirstAfterHalftime = true; + continue; + } + + if (event.type === EventType.NOTE && isTechDefensivePlayNote(event.message)) { + forcedTurnThisPoint = true; + continue; + } + + if (event.type !== EventType.GOAL) continue; + + const scoringTeam = event.team as TeamSide; + const scoredByUs = event.team === TeamSide.US; + + // A goal with defensivePlay tagged is itself a Tech-forced turn that scored. + const goalHadDefensivePlay = scoredByUs && !!event.defensivePlay; + const forcedTurn = forcedTurnThisPoint || goalHadDefensivePlay; + + let result: 'hold' | 'break'; + let inferred: boolean; + let ourLine: 'O' | 'D' | undefined; + + if (weHavePossession === undefined) { + // Ambiguous first point of an unseeded game — guess a hold. + result = 'hold'; + inferred = true; + } else { + // The team holding possession at point start is on offense; a break is + // won by the team that started the point on defense. + const scoringTeamStartedOnOffense = scoredByUs === weHavePossession; + result = scoringTeamStartedOnOffense ? 'hold' : 'break'; + inferred = false; + ourLine = weHavePossession ? 'O' : 'D'; + } + + if (scoringTeam) score[scoringTeam]++; + + points.push({ + pointNumber: points.length + 1, + goalEvent: event, + scoringTeam, + result, + inferred, + ourLine, + forcedTurn, + firstAfterHalftime: pendingFirstAfterHalftime, + score: { ...score }, + }); + + // After each goal, possession switches to the team that didn't score. + weHavePossession = event.team !== TeamSide.US; + forcedTurnThisPoint = false; + pendingFirstAfterHalftime = false; + } + + return { points }; +} diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index ca6e083..0bdda16 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -2,7 +2,8 @@ * Shared utility functions */ -import { Game, GameEvent, Score, TeamSide, LineStats, EventType } from './types.js'; +import { Game, GameEvent, Score, TeamSide, LineStats } from './types.js'; +import { buildPointLedger } from './pointLedger.js'; /** * Generate a unique ID for games and events @@ -91,25 +92,12 @@ export function getGameDuration(game: Game): number | null { * or on goal events carrying a `defensivePlay` field. */ export function calculateLineStats(game: Game): LineStats | null { - // Can't calculate without knowing starting possession + // Can't calculate without knowing starting possession — inferred results + // from an unseeded ledger must never feed efficiency stats. if (game.startingOnOffense === undefined) { return null; } - const goalCount = game.events.filter(e => e.type === EventType.GOAL).length; - if (goalCount === 0) { - return { - oLinePoints: 0, - oLineHolds: 0, - oLineHoldPercentage: 0, - oLineDirtyHolds: 0, - dLinePoints: 0, - dLineBreaks: 0, - dLineBreakPercentage: 0, - dLineFailedConversions: 0, - }; - } - let oLinePoints = 0; let oLineHolds = 0; let oLineDirtyHolds = 0; @@ -117,48 +105,22 @@ export function calculateLineStats(game: Game): LineStats | null { let dLineBreaks = 0; let dLineFailedConversions = 0; - // Track who has possession at the start of each point and whether we logged - // a Tech defensive play during the current point. - let weHavePossession = game.startingOnOffense; - let forcedTurnThisPoint = false; - - for (const event of game.events) { - if (event.type === EventType.HALFTIME) { - // The team that received first now pulls - weHavePossession = !game.startingOnOffense; - forcedTurnThisPoint = false; - continue; - } - - if (event.type === EventType.NOTE && isTechDefensivePlayNote(event.message)) { - forcedTurnThisPoint = true; - continue; - } - - if (event.type !== EventType.GOAL) continue; - - // A goal with defensivePlay tagged is itself a Tech-forced turn that scored. - const goalHadDefensivePlay = event.team === TeamSide.US && !!event.defensivePlay; - const hadForcedTurn = forcedTurnThisPoint || goalHadDefensivePlay; - - if (weHavePossession) { + for (const point of buildPointLedger(game).points) { + const scoredByUs = point.scoringTeam === TeamSide.US; + if (point.ourLine === 'O') { oLinePoints++; - if (event.team === TeamSide.US) { + if (scoredByUs) { oLineHolds++; - if (hadForcedTurn) oLineDirtyHolds++; + if (point.forcedTurn) oLineDirtyHolds++; } } else { dLinePoints++; - if (event.team === TeamSide.US) { + if (scoredByUs) { dLineBreaks++; - } else if (hadForcedTurn) { + } else if (point.forcedTurn) { dLineFailedConversions++; } } - - // After each goal, possession switches to the team that didn't score - weHavePossession = event.team !== TeamSide.US; - forcedTurnThisPoint = false; } return { @@ -172,11 +134,3 @@ export function calculateLineStats(game: Game): LineStats | null { dLineFailedConversions, }; } - -/** Match note messages like "Mason block", "Theo steal", "Mason foot block". */ -const TECH_DEFENSIVE_NOTE_PATTERN = /^[A-Z][a-z]+\b.*\b(?:block|steal)\b/; - -function isTechDefensivePlayNote(message: string | undefined): boolean { - if (!message) return false; - return TECH_DEFENSIVE_NOTE_PATTERN.test(message); -} diff --git a/packages/web/src/api/gameClient.ts b/packages/web/src/api/gameClient.ts index 26610c6..c61911a 100644 --- a/packages/web/src/api/gameClient.ts +++ b/packages/web/src/api/gameClient.ts @@ -1,14 +1,29 @@ /** * API client for fetching game data + * The only module that knows the API base URL, endpoint paths, and HTTP error handling */ -import type { Game, GameSummary } from '@scorebot/shared'; +import type { + AdvancedStats, + AggregatedPlayerStats, + Game, + GameSummary, + PlayerChemistry, + TeamTrends, +} from '@scorebot/shared'; -export const API_BASE_URL = +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8787'; -export async function fetchGames(): Promise { - const response = await fetch(`${API_BASE_URL}/games`); +export interface AggregatedStatsResponse { + players: AggregatedPlayerStats[]; + totalGames: number; + teamTrends?: TeamTrends; + playerChemistry?: PlayerChemistry[]; +} + +export async function fetchGames(limit = 100): Promise { + const response = await fetch(`${API_BASE_URL}/games?limit=${limit}`); if (!response.ok) throw new Error('Failed to fetch games'); const data = await response.json(); @@ -22,3 +37,40 @@ export async function fetchGame(gameId: string): Promise { const data = await response.json(); return data.game; } + +export async function fetchGameStats(gameId: string): Promise { + const response = await fetch(`${API_BASE_URL}/games/${gameId}/stats`); + if (!response.ok) throw new Error('Failed to fetch game stats'); + + const data = await response.json(); + return data.stats; +} + +export async function fetchAggregatedStats( + tournament?: string, + limit = 100 +): Promise { + let url = `${API_BASE_URL}/stats/aggregated?limit=${limit}`; + if (tournament) { + url += `&tournament=${encodeURIComponent(tournament)}`; + } + + const response = await fetch(url); + if (!response.ok) throw new Error('Failed to fetch aggregated stats'); + + return response.json(); +} + +/** + * Repeatedly invoke fn every intervalMs. When immediate is true, fn also + * fires right away (fire-and-forget). Returns a stop function. + */ +export function poll( + fn: () => void, + intervalMs: number, + immediate = false +): () => void { + if (immediate) fn(); + const interval = window.setInterval(fn, intervalMs); + return () => clearInterval(interval); +} diff --git a/packages/web/src/components/breakDetection.ts b/packages/web/src/components/breakDetection.ts deleted file mode 100644 index b19f72a..0000000 --- a/packages/web/src/components/breakDetection.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Break detection logic for ultimate frisbee scoring - * Determines whether a goal is a "break" (scored while on defense) - * or a "hold" (scored while on offense). - */ - -import type { Game, GameEvent } from '@scorebot/shared'; -import { EventType } from '@scorebot/shared'; - -export function isBreakScore(event: GameEvent, allEvents: GameEvent[], game: Game): boolean { - const eventIndex = allEvents.findIndex(e => e.id === event.id); - - // Look backwards for the previous goal, checking if halftime occurred in between - let crossedHalftime = false; - for (let i = eventIndex - 1; i >= 0; i--) { - const prevEvent = allEvents[i]; - if (prevEvent.type === EventType.HALFTIME) { - crossedHalftime = true; - } - if (prevEvent.type === EventType.GOAL && prevEvent.team) { - if (crossedHalftime && game.startingOnOffense !== undefined && event.team) { - // After halftime, the team that received first now pulls (switches to D). - // So break = scoring team is the one that started on O (now on D after half). - return (event.team === 'us') === game.startingOnOffense; - } - // Same team scoring consecutively = break (they were on defense after pull) - const sameTeam = prevEvent.team === event.team; - return sameTeam; - } - } - - // No previous goal found — this is the first goal of the game - if (game.startingOnOffense !== undefined && event.team) { - const weOnOffense = crossedHalftime ? !game.startingOnOffense : game.startingOnOffense; - return event.team === 'us' ? !weOnOffense : weOnOffense; - } - - return false; -} diff --git a/packages/web/src/components/efficiencyStats.ts b/packages/web/src/components/efficiencyStats.ts index 7e285df..7e3551c 100644 --- a/packages/web/src/components/efficiencyStats.ts +++ b/packages/web/src/components/efficiencyStats.ts @@ -39,7 +39,16 @@ function setText(id: string, value: string) { if (el) el.textContent = value; } -export function toSummaryStats(line: LineStats, gameCount: number): SummaryStats { +/** + * Accepts any LineStats-shaped object (per-game LineStats or the season-aggregate + * AggregateLineStats) — only the raw point/hold/break counts are read. + */ +type LineStatCounts = Pick< + LineStats, + 'oLinePoints' | 'oLineHolds' | 'oLineDirtyHolds' | 'dLinePoints' | 'dLineBreaks' | 'dLineFailedConversions' +>; + +export function toSummaryStats(line: LineStatCounts, gameCount: number): SummaryStats { const themHolds = line.dLinePoints - line.dLineBreaks; const themOPoints = line.dLinePoints; const themBreaks = line.oLinePoints - line.oLineHolds; diff --git a/packages/web/src/components/eventFormatter.ts b/packages/web/src/components/eventFormatter.ts index 4e450fd..290089f 100644 --- a/packages/web/src/components/eventFormatter.ts +++ b/packages/web/src/components/eventFormatter.ts @@ -2,15 +2,14 @@ * Event formatting utilities for displaying game events */ -import type { Game, GameEvent } from '@scorebot/shared'; +import type { Game, GameEvent, Point } from '@scorebot/shared'; import { EventType } from '@scorebot/shared'; -import { isBreakScore } from './breakDetection.js'; -export function getEventIcon(event: GameEvent, allEvents: GameEvent[], game: Game): string { +export function getEventIcon(event: GameEvent, pointsByGoalId: Map, game: Game): string { switch (event.type) { case EventType.GOAL: if (!event.team) return '\u26BD'; - const isBreak = isBreakScore(event, allEvents, game); + const isBreak = pointsByGoalId.get(event.id)?.result === 'break'; return isBreak ? '\u26A0\uFE0F' : '\u2713'; case EventType.GAME_START: return '\uD83C\uDFC1'; @@ -32,7 +31,7 @@ export function getEventIcon(event: GameEvent, allEvents: GameEvent[], game: Gam } } -export function formatEventType(event: GameEvent, game: Game, allEvents: GameEvent[]): string { +export function formatEventType(event: GameEvent, game: Game, pointsByGoalId: Map): string { switch (event.type) { case EventType.GAME_START: return 'Game Start'; @@ -41,7 +40,7 @@ export function formatEventType(event: GameEvent, game: Game, allEvents: GameEve // Determine if this is a hold or break const teamName = event.team === 'us' ? game.teams.us.name : game.teams.them.name; - const isBreak = isBreakScore(event, allEvents, game); + const isBreak = pointsByGoalId.get(event.id)?.result === 'break'; return isBreak ? `Break Score for ${teamName}` diff --git a/packages/web/src/components/gameUtils.ts b/packages/web/src/components/gameUtils.ts deleted file mode 100644 index 5e3f5eb..0000000 --- a/packages/web/src/components/gameUtils.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Shared utilities for game components - */ - -import type { GameEvent } from '@scorebot/shared'; -import { EventType } from '@scorebot/shared'; - -/** - * Find the index of the last goal before halftime. - * Returns -1 if no halftime event exists. - */ -export function findHalftimePointIndex(goalEvents: GameEvent[], allEvents: GameEvent[]): number { - const halftimeEvent = allEvents.find(e => e.type === EventType.HALFTIME); - if (!halftimeEvent) return -1; - - let index = -1; - for (let i = 0; i < goalEvents.length; i++) { - if (goalEvents[i].timestamp < halftimeEvent.timestamp) { - index = i; - } else { - break; - } - } - return index; -} diff --git a/packages/web/src/components/lineupTable.ts b/packages/web/src/components/lineupTable.ts index 333501b..f48c117 100644 --- a/packages/web/src/components/lineupTable.ts +++ b/packages/web/src/components/lineupTable.ts @@ -4,8 +4,7 @@ */ import type { Game } from '@scorebot/shared'; -import { EventType, TeamSide } from '@scorebot/shared'; -import { findHalftimePointIndex } from './gameUtils.js'; +import { EventType, TeamSide, buildPointLedger } from '@scorebot/shared'; export function renderLineupTable(game: Game): void { const container = document.getElementById('lineup-table-container'); @@ -35,7 +34,9 @@ export function renderLineupTable(game: Game): void { const playerOrder = [...playerPoints.keys()] .sort((a, b) => playerPoints.get(b)!.size - playerPoints.get(a)!.size); - const halftimePointIndex = findHalftimePointIndex(goalEvents, game.events); + const points = buildPointLedger(game).points; + const firstAfterHalftimeIndex = points.findIndex(p => p.firstAfterHalftime); + const halftimePointIndex = firstAfterHalftimeIndex > 0 ? firstAfterHalftimeIndex - 1 : -1; const totalPoints = game.lineups.length; const table = document.getElementById('lineup-table') as HTMLTableElement; diff --git a/packages/web/src/components/progressionTable.ts b/packages/web/src/components/progressionTable.ts index ae5b15d..25e00bb 100644 --- a/packages/web/src/components/progressionTable.ts +++ b/packages/web/src/components/progressionTable.ts @@ -4,9 +4,7 @@ */ import type { Game } from '@scorebot/shared'; -import { EventType } from '@scorebot/shared'; -import { isBreakScore } from './breakDetection.js'; -import { findHalftimePointIndex } from './gameUtils.js'; +import { EventType, buildPointLedger } from '@scorebot/shared'; export function renderProgressionTable(game: Game): void { const table = document.getElementById('progression-table'); @@ -24,10 +22,14 @@ export function renderProgressionTable(game: Game): void { const container = document.getElementById('progression-table-container'); if (container) container.classList.remove('hidden'); - const halftimePointIndex = findHalftimePointIndex(goalEvents, game.events); + // Point ledger indexes 1:1 with goalEvents; break/hold and the halftime + // separator both come from it. + const points = buildPointLedger(game).points; + const firstAfterHalftimeIndex = points.findIndex(p => p.firstAfterHalftime); + const halftimePointIndex = firstAfterHalftimeIndex > 0 ? firstAfterHalftimeIndex - 1 : -1; const isBreak = (index: number): boolean => { - return isBreakScore(goalEvents[index], game.events, game); + return points[index]?.result === 'break'; }; // Build header row with point numbers diff --git a/packages/web/src/components/timeline.ts b/packages/web/src/components/timeline.ts index 19c74d9..e12e87f 100644 --- a/packages/web/src/components/timeline.ts +++ b/packages/web/src/components/timeline.ts @@ -3,8 +3,8 @@ * Displays events in reverse chronological order with WFDF-style layout */ -import type { Game, GameEvent } from '@scorebot/shared'; -import { EventType, formatTime } from '@scorebot/shared'; +import type { Game, GameEvent, Point } from '@scorebot/shared'; +import { EventType, formatTime, buildPointLedger } from '@scorebot/shared'; import { getEventIcon, formatEventType } from './eventFormatter.js'; export function renderTimeline(game: Game): void { @@ -20,16 +20,21 @@ export function renderTimeline(game: Game): void { timeline.innerHTML = ''; + // Build the point ledger once; break/hold indicators are looked up by goal id. + const pointsByGoalId = new Map( + buildPointLedger(game).points.map(p => [p.goalEvent.id, p]) + ); + // Filter out game end events and render in reverse order (most recent first) const filteredEvents = events.filter(e => e.type !== EventType.GAME_END); const reversedEvents = [...filteredEvents].reverse(); reversedEvents.forEach((event) => { - const eventEl = createEventElement(event, game, events); + const eventEl = createEventElement(event, game, pointsByGoalId); timeline.appendChild(eventEl); }); } -function createEventElement(event: GameEvent, game: Game, allEvents: GameEvent[]): HTMLElement { +function createEventElement(event: GameEvent, game: Game, pointsByGoalId: Map): HTMLElement { const div = document.createElement('div'); div.className = 'timeline-event'; @@ -51,11 +56,11 @@ function createEventElement(event: GameEvent, game: Game, allEvents: GameEvent[] const icon = document.createElement('span'); icon.className = 'event-icon'; - icon.textContent = getEventIcon(event, allEvents, game); + icon.textContent = getEventIcon(event, pointsByGoalId, game); const type = document.createElement('span'); type.className = 'event-type'; - type.textContent = formatEventType(event, game, allEvents); + type.textContent = formatEventType(event, game, pointsByGoalId); header.appendChild(time); header.appendChild(icon); diff --git a/packages/web/src/games.ts b/packages/web/src/games.ts index 735fc8d..69c776c 100644 --- a/packages/web/src/games.ts +++ b/packages/web/src/games.ts @@ -7,9 +7,8 @@ import { GameSummary, GameStatus, } from '@scorebot/shared'; +import { fetchGames, poll } from './api/gameClient.js'; -const API_BASE_URL = - import.meta.env.VITE_API_URL || 'http://localhost:8787'; const POLL_INTERVAL = 10000; interface TournamentGroup { @@ -18,7 +17,7 @@ interface TournamentGroup { } class GamesListApp { - private pollInterval: number | null = null; + private stopPolling: (() => void) | null = null; constructor() { this.init(); @@ -30,18 +29,13 @@ class GamesListApp { } private setupPolling() { - this.pollInterval = window.setInterval(() => { - this.loadGames(); - }, POLL_INTERVAL); + this.stopPolling = poll(() => this.loadGames(), POLL_INTERVAL); } private async loadGames() { try { - const response = await fetch(`${API_BASE_URL}/games?limit=100`); - if (!response.ok) throw new Error('Failed to fetch games'); - - const data = await response.json(); - this.renderGames(data.games); + const games = await fetchGames(); + this.renderGames(games); } catch (error) { console.error('Error loading games:', error); this.showError('Failed to load games. Please try again later.'); diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index b6edfda..6c06b43 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -5,7 +5,7 @@ import type { Game } from '@scorebot/shared'; import { formatScore } from '@scorebot/shared'; -import { fetchGames, fetchGame } from './api/gameClient.js'; +import { fetchGames, fetchGame, poll } from './api/gameClient.js'; import { renderGameHeader } from './components/gameHeader.js'; import { renderTimeline } from './components/timeline.js'; import { renderProgressionTable } from './components/progressionTable.js'; @@ -16,7 +16,7 @@ const POLL_INTERVAL = 3000; // 3 seconds class DiscoreApp { private currentGameId: string | null = null; - private pollInterval: number | null = null; + private stopPolling: (() => void) | null = null; constructor() { this.init(); @@ -135,8 +135,8 @@ class DiscoreApp { this.currentGameId = gameId; // Clear existing poll - if (this.pollInterval) { - clearInterval(this.pollInterval); + if (this.stopPolling) { + this.stopPolling(); } if (!gameId) { @@ -144,13 +144,8 @@ class DiscoreApp { return; } - // Load game immediately - this.loadGame(); - - // Start polling - this.pollInterval = window.setInterval(() => { - this.loadGame(); - }, POLL_INTERVAL); + // Load game immediately, then keep polling + this.stopPolling = poll(() => this.loadGame(), POLL_INTERVAL, true); } private async loadGame() { diff --git a/packages/web/src/stats.ts b/packages/web/src/stats.ts index 0699d91..6c0370b 100644 --- a/packages/web/src/stats.ts +++ b/packages/web/src/stats.ts @@ -12,9 +12,11 @@ import { } from '@scorebot/shared'; import { renderGameSummaryRows } from './components/gameSummaryRows.js'; import { toSummaryStats } from './components/efficiencyStats.js'; - -const API_BASE_URL = - import.meta.env.VITE_API_URL || 'http://localhost:8787'; +import { + fetchGames, + fetchGameStats, + fetchAggregatedStats, +} from './api/gameClient.js'; class StatsApp { private games: GameSummary[] = []; @@ -56,11 +58,7 @@ class StatsApp { private async loadGames() { try { - const response = await fetch(`${API_BASE_URL}/games?limit=100`); - if (!response.ok) throw new Error('Failed to fetch games'); - - const data = await response.json(); - this.games = data.games; + this.games = await fetchGames(); this.populateTournamentFilter(); this.updateGameFilter(); @@ -156,25 +154,13 @@ class StatsApp { } private async loadGameStats(gameId: string) { - const response = await fetch(`${API_BASE_URL}/games/${gameId}/stats`); - if (!response.ok) throw new Error('Failed to fetch game stats'); - - const data = await response.json(); - const stats: AdvancedStats = data.stats; + const stats = await fetchGameStats(gameId); this.renderGameStats(stats); } private async loadAggregatedStats() { - let url = `${API_BASE_URL}/stats/aggregated?limit=100`; - if (this.currentTournament) { - url += `&tournament=${encodeURIComponent(this.currentTournament)}`; - } - - const response = await fetch(url); - if (!response.ok) throw new Error('Failed to fetch aggregated stats'); - - const data = await response.json(); + const data = await fetchAggregatedStats(this.currentTournament || undefined); this.renderAggregatedStats(data.players, data.totalGames, data.teamTrends, data.playerChemistry); } @@ -516,6 +502,7 @@ class StatsApp { const body = document.getElementById('agg-gs-body'); if (!body) return; + // Opponent-by-symmetry math lives once, in toSummaryStats. renderGameSummaryRows(body, toSummaryStats(eff, eff.gamesIncluded)); }