Skip to content

feat(scoring): record held / thrown-out runners on doubles and triples#184

Open
chanced58 wants to merge 6 commits into
mainfrom
claude/scoring-double-plays-JUXlx
Open

feat(scoring): record held / thrown-out runners on doubles and triples#184
chanced58 wants to merge 6 commits into
mainfrom
claude/scoring-double-plays-JUXlx

Conversation

@chanced58
Copy link
Copy Markdown
Owner

@chanced58 chanced58 commented May 28, 2026

Summary

Adds a way for the scorer to record plays where the batter's hit credit stands but an existing runner diverges from the default auto-advance — held short of the expected base, or thrown out advancing. Covers the two scenarios you raised:

  1. Double + R1 thrown out at 3B — batter still gets a double; R1 is off the bases; outs +1.
  2. Double + R2 holds at 3B (or is thrown out at home) — batter still gets a double; no RBI; R2 ends at 3B or is out.

The same pattern generalizes to any hit where the auto-advance can be overridden per runner.

How it works

  • A new RunnerOutcome concept (added to the Domain Glossary and OBR 5.06 docs) is modeled as a separate BASERUNNER_OUT or BASERUNNER_ADVANCE event whose relatedEventId points back to the parent HIT. No schema migration — the existing JSONB payload + relatedEventId field already supports it.
  • deriveGameState and the stats reducers build a pre-pass map of overrides keyed by parent event id and skip default scoring + base placement for any runner referenced by a linked outcome event. RBI auto-derivation honors the same map so a batter never gets RBI credit for a runner who was held or thrown out.
  • The play feed appends a parenthetical to the parent row: "Double (Alice thrown out advancing, Bob held at 3B)".
  • Mobile and web both get a follow-up modal on 2B/3B with runners on base. Singles, HRs, and the zero-runner case keep their one-tap path.
  • Cascade-undo: tapping Undo on a parent play also voids its linked outcome events in the same tap.

Commits

  1. feat(shared/scoring) — engine + stats override logic, plus a defensive fix to BASERUNNER_ADVANCE replay so a linked override doesn't wipe the batter the parent HIT just placed.
  2. feat(shared/game-history) — parenthetical on hit rows + at-bat running scoreline correction.
  3. feat(mobile/scoring) — runner-outcomes modal in PitchInput + cascade-undo in handleUndo. useRecordEvent now returns the persisted event id so callers can chain follow-ups.
  4. feat(web/scoring) — equivalent panel in ScoringBoard.
  5. docs — Domain Glossary + OBR 5.06 cross-reference.

Test plan

  • pnpm --filter @baseball/shared test — 319 tests pass (6 new for game-state + 3 new for game-history covering Scenarios 1, 2a, 2b, the regression fast path, a triple with held runner, and an HR with a trailing thrown-out runner).
  • pnpm --filter web exec tsc --noEmit — clean.
  • Mobile manual smoke: open a fresh game, walk a runner to 1B, next batter hits a 2B → modal appears → pick "Runner on 1B thrown out" → confirm. Play feed reads "Double (… thrown out advancing)"; line score adds 1 out; box score credits a double with 0 RBI.
  • Repeat with "Held at 3B" and verify R3 occupancy + no run.
  • Tap Undo and verify both events void atomically.
  • Web parity in the ScoringBoard.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Per-runner outcome flow for in-play doubles/triples: UI prompts to specify advance/held/thrown-out and records linked runner outcomes with the hit.
  • Bug Fixes

    • Undo now cascades and voids linked runner outcome events when a parent play is undone.
    • Scoring and game-state logic updated to honor linked runner outcomes so runs and base placement reflect actual runner results.
  • Documentation

    • Added rules section describing linked runner outcomes and their effect on scoring and stats.
  • Tests

    • Added tests covering history labels and game-state behavior for linked runner outcomes.

Review Change Stack

claude added 5 commits May 28, 2026 11:56
…EventId

Plays where the batter's hit credit is unaffected but an existing runner
diverges from the default auto-advance (held short, thrown out advancing)
can now be recorded via a linked BASERUNNER_OUT or BASERUNNER_ADVANCE
event with relatedEventId pointing back to the parent HIT.

deriveGameState builds a runner-overrides map in a single pre-pass and
consults it in the HIT branch — overridden runners are skipped from
default scoring and base placement so the linked event can remove them
(BASERUNNER_OUT, increments outs) or place them at the specified base
(BASERUNNER_ADVANCE).

Same override map is built per-game inside deriveBattingStats so RBI
auto-derivation no longer credits batters for runners who were held
short or thrown out.

Also hardens BASERUNNER_ADVANCE replay: the handler now only clears the
fromBase slot if the runner there matches runnerId, preventing a linked
override from wiping the batter that the parent HIT just placed.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7
When a HIT carries linked BASERUNNER_OUT or BASERUNNER_ADVANCE events via
relatedEventId, the at-bat result label now appends a parenthetical like
"Double (Alice thrown out advancing)" or "Double (Bob held at 3B)" so the
event feed clearly shows what happened on the basepaths.

The running scoreline carried on each at-bat node also discounts runs
that would have scored on a default auto-advance but were suppressed by
a linked thrown-out or held outcome, keeping the per-at-bat home/away
score consistent with deriveGameState.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7
When the scorer taps 2B or 3B with runners on base, a follow-up modal
prompts for each runner with three choices: "Advanced as expected"
(default, one-tap pass-through), "Held at base X" (runner stops short
of the default), or "Thrown out" (runner retired advancing). Confirming
emits the HIT plus a linked BASERUNNER_OUT or BASERUNNER_ADVANCE event
per non-default runner, attached via relatedEventId so the engine, RBI
derivation, and play-feed label all reflect the actual outcome.

Singles and home runs keep their one-tap path (no modal). Singles
because atypical runner outcomes are rare; HRs because everyone scores
by default.

Cascade-undo: tapping Undo on a parent play now voids any linked
runner-outcome events first, so a single Undo retires the full
multi-event play.

useRecordEvent now returns the persisted event id so callers can chain
follow-up events that reference the parent via relatedEventId.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7
Mirrors the mobile flow: when the scorer resolves an in-play double or
triple with runners on base, a follow-up panel asks for each runner's
outcome — "Advanced as expected" (default), "Held at base X", or
"Thrown out advancing". Confirming emits the HIT plus any linked
BASERUNNER_OUT / BASERUNNER_ADVANCE events with relatedEventId pointing
to the parent so the engine, RBI derivation, and play-feed label all
reflect the actual outcome.

Singles and HRs keep their one-tap path. When no runners are on base
the panel is skipped entirely.

recordEvent now returns the persisted row id so the runner-outcome flow
can attach linked events to the parent HIT.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7
Adds a Domain Glossary entry for RunnerOutcome in CLAUDE.md and a
matching subsection under OBR 5.06 in docs/baseball-rules.md explaining
the linked-event pattern (relatedEventId on BASERUNNER_OUT /
BASERUNNER_ADVANCE) that lets the scorer record held or thrown-out
runners on a hit while keeping the batter's hit credit intact.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dc896991-aebd-47ae-9040-3604e1a07377

📥 Commits

Reviewing files that changed from the base of the PR and between 479d888 and 4687aa7.

📒 Files selected for processing (2)
  • apps/web/src/app/(app)/games/[gameId]/score/ScoringBoard.tsx
  • packages/shared/src/utils/__tests__/game-history.test.ts

📝 Walkthrough

Walkthrough

This PR introduces a linked runner outcomes feature that allows scorekeepers to capture how runners actually advance on double/triple hits. When a hit occurs and runners are on base, a modal prompts for each runner's fate (auto-advance, held at a base, or thrown out), then records a parent HIT event and linked BASERUNNER_OUT/BASERUNNER_ADVANCE child events to override default scoring and base placement across the app.

Changes

Linked Runner Outcomes

Layer / File(s) Summary
RunnerOutcome contracts and domain types
CLAUDE.md, apps/mobile/src/features/scoring/PitchInput.tsx, packages/shared/src/utils/game-state.ts, docs/baseball-rules.md
Introduces RunnerOutcome union and RunnerOverrides interface; documents relatedEventId linkage and override behavior.
Event recording returns event ID for linking
apps/mobile/src/features/scoring/use-record-event.ts, apps/web/src/app/(app)/games/[gameId]/score/ScoringBoard.tsx
recordEvent now returns Promise<string> so callers can obtain the event id used as relatedEventId when emitting linked baserunner events.
Mobile PitchInput: capture per-runner outcomes
apps/mobile/src/features/scoring/PitchInput.tsx
Adds modal-driven two-step flow for doubles/triples when runners exist, seeding auto choices and exposing onRecordHitWithRunnerOutcomes.
Mobile submission & undo cascade
apps/mobile/app/(tabs)/games/[gameId]/score.tsx
handleHitWithRunnerOutcomes records parent HIT then conditionally records linked BASERUNNER_OUT/BASERUNNER_ADVANCE with relatedEventId; Undo voids matching child events when parent is voided.
Web ScoringBoard: pending outcomes modal
apps/web/src/app/(app)/games/[gameId]/score/ScoringBoard.tsx
Intercepts in-play doubles/triples with runners, stores pendingHitRunnerOutcomes, renders modal for per-runner choices, and on confirm records HIT then linked baserunner events linked by returned id.
Game state derivation honors outcome overrides
packages/shared/src/utils/game-state.ts
deriveGameState builds a runnerOverridesByParentId map and skips overridden runners during HIT scoring/advancement; refines baserunner advance removals to avoid accidental clears.
Batting stats honors outcome overrides
packages/shared/src/utils/batting-stats.ts
deriveBattingStats pre-indexes linked runner outcome events and excludes overridden runners from default HIT scoring/placement.
Game history displays and suppresses linked outcomes
packages/shared/src/utils/game-history.ts
Adds formatting helpers for per-runner parentheticals and counts suppressed runs; buildGameHistoryTree appends outcome text to HIT labels and subtracts suppressed runs.
Tests verify runner outcome handling
packages/shared/src/utils/__tests__/game-history.test.ts, packages/shared/src/utils/__tests__/game-state.test.ts
New tests cover HIT label augmentation for thrown-out/held outcomes and deriveGameState scenarios including double/triple/HR linked-outcome cases and the no-override regression.

Sequence Diagram

sequenceDiagram
  participant Scorer
  participant Recorder
  participant HIT
  participant Child
  participant Derive
  participant Stats
  participant History

  Scorer->>Recorder: recordEvent(HIT)
  Recorder->>Scorer: returns eventId
  Scorer->>HIT: emit HIT using eventId
  alt some runners overridden
    Scorer->>Child: emit BASERUNNER_* with relatedEventId
  end

  Derive->>Derive: build runnerOverridesByParentId
  Derive->>Derive: skip overridden runners on HIT

  Stats->>Stats: build override index
  Stats->>Stats: exclude overridden runners in scoring

  History->>History: format linked-runner parentheticals
  History->>Scorer: display HIT label with parenthetical
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • chanced58/diamondos#68: Overlaps deriveBattingStats run-attribution changes; both modify run attribution logic.
  • chanced58/diamondos#75: Related changes to baserunner handling and advance/out semantics in shared state logic.
  • chanced58/diamondos#72: Related to buildGameHistoryTree and game-history rendering that this PR augments with linked-runner parentheticals.

Poem

🐰 I hop to the scorebook with ink on my paw,
Runners choose paths—hold, go, or fall—
The app links the tale, no double-count flaw,
History and stats bow to each call.
Tiny rabbit tally, I stitch it all.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature: recording held/thrown-out runner outcomes on doubles and triples, which is the primary focus of all substantial changes across mobile and web scoring UIs, event recording, state derivation, and stats calculations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/scoring-double-plays-JUXlx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/app/`(app)/games/[gameId]/score/ScoringBoard.tsx:
- Around line 1268-1297: confirmHitWithRunnerOutcomes is dropping pitch metadata
from pendingHitRunnerOutcomes; destructure pitchExtra from
pendingHitRunnerOutcomes alongside hitType/trajectory/sprayExtra and (1) call
recordEvent('pitch_thrown', { ...pitchExtra, pitcherId, relatedEventId: <some
id?> }) before recording the hit to mirror the normal path, and (2) include
pitchExtra in the recordEvent('hit', { batterId, pitcherId, hitType, trajectory,
...sprayExtra, ...pitchExtra }) payload so pitch type/zone are preserved; update
references to pendingHitRunnerOutcomes, confirmHitWithRunnerOutcomes, and
recordEvent to implement this.

In `@packages/shared/src/utils/__tests__/game-history.test.ts`:
- Around line 260-299: The test declares constants named in camelCase (pitch,
hit, out, held, tree, atBat) which violate the SCREAMING_SNAKE_CASE rule; rename
those bindings to PITCH, HIT, OUT, HELD, TREE, AT_BAT across the same test block
and update all uses (including HIT.id passed to relatedEventId and the call to
buildGameHistoryTree and subsequent at-bat assertions) so mkEvent and
buildGameHistoryTree usages still reference the renamed symbols.
- Around line 287-301: The test uses a conditional guard around assertions on
atBat which lets the test pass vacuously if the node is missing or the shape
regresses; update the test that calls buildGameHistoryTree and references atBat
so it first asserts the node exists and has the expected shape (e.g., add
expect(atBat).toBeDefined() and expect(atBat?.type).toBe('at-bat') or similar)
before asserting atBat.result.label === 'Single' (or other expectations), so
failures are explicit; change the conditional-if to a required precondition
check on atBat and then perform the label assertion.

In `@packages/shared/src/utils/__tests__/game-state.test.ts`:
- Around line 455-566: Rename the newly introduced test-local constants to
SCREAMING_SNAKE_CASE and update their usages: e.g., pa1 -> PA1, pitch2 ->
PITCH2, hit2 -> HIT2, out1 -> OUT1, held -> HELD, thrownOut -> THROWN_OUT, hr ->
HR (and EVENTS -> EVENTS if you rename that array), keeping the same values and
references inside the test cases (look for variables created via batterHit,
e(EventType.*), and the events arrays passed into deriveGameState); ensure all
occurrences in this test suite (game-state.test.ts) are updated so the tests
still reference the renamed constants.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5b696aee-0bb3-4cce-838a-9169c66c8132

📥 Commits

Reviewing files that changed from the base of the PR and between 2ec8b15 and 479d888.

📒 Files selected for processing (11)
  • CLAUDE.md
  • apps/mobile/app/(tabs)/games/[gameId]/score.tsx
  • apps/mobile/src/features/scoring/PitchInput.tsx
  • apps/mobile/src/features/scoring/use-record-event.ts
  • apps/web/src/app/(app)/games/[gameId]/score/ScoringBoard.tsx
  • docs/baseball-rules.md
  • packages/shared/src/utils/__tests__/game-history.test.ts
  • packages/shared/src/utils/__tests__/game-state.test.ts
  • packages/shared/src/utils/batting-stats.ts
  • packages/shared/src/utils/game-history.ts
  • packages/shared/src/utils/game-state.ts

Comment thread apps/web/src/app/(app)/games/[gameId]/score/ScoringBoard.tsx
Comment on lines +260 to +299
const pitch = mkEvent(EventType.PITCH_THROWN, { batterId: 'p2', pitcherId: 'pit1', outcome: PitchOutcome.IN_PLAY });
const hit = mkEvent(EventType.HIT, { batterId: 'p2', pitcherId: 'pit1', hitType: HitType.DOUBLE });
const out = mkEvent(EventType.BASERUNNER_OUT, {
runnerId: 'p1',
fromBase: 1,
relatedEventId: hit.id,
});
const tree = buildGameHistoryTree([pitch, hit, out], players);
const atBat = tree.innings[0]?.top?.items[0];
expect(atBat?.type).toBe('at-bat');
if (atBat?.type === 'at-bat') {
expect(atBat.result?.label).toContain('Double');
expect(atBat.result?.label).toContain('Alice Atbat thrown out advancing');
}
});

it('appends "held at 3B" to the HIT label when a BASERUNNER_ADVANCE is linked with toBase < default', () => {
const pitch = mkEvent(EventType.PITCH_THROWN, { batterId: 'p2', pitcherId: 'pit1', outcome: PitchOutcome.IN_PLAY });
const hit = mkEvent(EventType.HIT, { batterId: 'p2', pitcherId: 'pit1', hitType: HitType.DOUBLE });
const held = mkEvent(EventType.BASERUNNER_ADVANCE, {
runnerId: 'p1',
fromBase: 2,
toBase: 3,
reason: 'on_play',
relatedEventId: hit.id,
});
const tree = buildGameHistoryTree([pitch, hit, held], players);
const atBat = tree.innings[0]?.top?.items[0];
if (atBat?.type === 'at-bat') {
expect(atBat.result?.label).toContain('Double');
expect(atBat.result?.label).toContain('Alice Atbat held at 3B');
}
});

it('does not append parenthetical when no linked outcome events exist', () => {
const pitch = mkEvent(EventType.PITCH_THROWN, { batterId: 'p2', pitcherId: 'pit1', outcome: PitchOutcome.IN_PLAY });
const hit = mkEvent(EventType.HIT, { batterId: 'p2', pitcherId: 'pit1', hitType: HitType.SINGLE });
const tree = buildGameHistoryTree([pitch, hit], players);
const atBat = tree.innings[0]?.top?.items[0];
if (atBat?.type === 'at-bat') {
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rename newly added constants to SCREAMING_SNAKE_CASE.

New constants in this block (pitch, hit, out, held, tree, atBat) violate the repo naming rule for constants in TS test files.

As per coding guidelines: "**/*.{ts,tsx,js,jsx}: Constants must use SCREAMING_SNAKE_CASE naming (e.g., MAX_ROSTER_SIZE)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/utils/__tests__/game-history.test.ts` around lines 260 -
299, The test declares constants named in camelCase (pitch, hit, out, held,
tree, atBat) which violate the SCREAMING_SNAKE_CASE rule; rename those bindings
to PITCH, HIT, OUT, HELD, TREE, AT_BAT across the same test block and update all
uses (including HIT.id passed to relatedEventId and the call to
buildGameHistoryTree and subsequent at-bat assertions) so mkEvent and
buildGameHistoryTree usages still reference the renamed symbols.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping. The MAX_ROSTER_SIZE rule in CLAUDE.md applies to module-level configuration constants, not local-scope bindings inside test cases. Every existing test in this file (and across packages/shared/src/utils/__tests__/) uses camelCase for const events, const tree, const atBat, const thrownOut, etc. — see lines 39–129 of this same file. Renaming only the new block would diverge from the established style.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chanced58, that's a valid distinction — the existing test file clearly uses camelCase for all local test-scope bindings (events, tree, atBat, thrownOut, etc.), and applying SCREAMING_SNAKE_CASE to those local variables would diverge from the established style. Acknowledged, and I'll discard this comment.


✏️ Learnings added
Learnt from: chanced58
Repo: chanced58/diamondos PR: 184
File: packages/shared/src/utils/__tests__/game-history.test.ts:0-0
Timestamp: 2026-05-28T23:03:36.979Z
Learning: In the `diamondos` repository, the SCREAMING_SNAKE_CASE constant naming rule (e.g., `MAX_ROSTER_SIZE`) in CLAUDE.md applies only to **module-level configuration constants**, not to local-scope bindings inside test cases. Test files under `packages/shared/src/utils/__tests__/` (and throughout the codebase) use camelCase for local test variables such as `const events`, `const tree`, `const atBat`, `const thrownOut`, etc. Do not flag camelCase local test bindings as a naming violation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread packages/shared/src/utils/__tests__/game-history.test.ts Outdated
Comment thread packages/shared/src/utils/__tests__/game-state.test.ts
- ScoringBoard: drop dead `pitchExtra` field from pendingHitRunnerOutcomes
  state. The `pitch_thrown` event was already recorded earlier in
  handleInPlay (line 1196) before the early return that opens the
  runner-outcomes panel, so capturing pitchExtra here was unused state.
  HitPayload has no pitchType field, so there is nothing to add to the
  HIT event either.
- game-history.test.ts: replace conditional `if (atBat?.type === 'at-bat')`
  guards with hard preconditions that throw on mismatch, so the at-bat
  assertions cannot pass vacuously if the tree shape regresses.

https://claude.ai/code/session_01MTbjN7Th2bm388VPs3pwC7
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants