Skip to content

Batch 4: wire Phase 2e + Phase 4 into fforge-game, make it readable, and roll seasons over - #18

Merged
lm98 merged 13 commits into
masterfrom
claude/handoff-document-tasks-18l623
Jul 28, 2026
Merged

Batch 4: wire Phase 2e + Phase 4 into fforge-game, make it readable, and roll seasons over#18
lm98 merged 13 commits into
masterfrom
claude/handoff-document-tasks-18l623

Conversation

@lm98

@lm98 lm98 commented Jul 27, 2026

Copy link
Copy Markdown
Owner

All of BATCH4_TASKS.mdU1–U7 and G1–G4 — plus the R18 write-up and season rollover.

The G-tasks turned out not to be gated: the handoff listed them as blocked on Batch 3's T9–T13, but those landed with a1f537b/d88226e. Every mechanic Phase 2e resolves now has somewhere to be seen.

Task
U1 split, purify, snapshot the presentation layer
U2 the Sem vocabulary and the render toolkit
U3 colour the existing screens
U4 finances, contracts, valuation, squad depth
U5 the inbox
U6 the tactics screen
U7 the menu restructure
G1 fitness & availability
G2 subs, cards, injuries in the match view
G3 the substitution plan editor
G4 ratings, form, man of the match
R18 Phase 6 toolkit evidence docs/UI_TOOLKIT_EVIDENCE.md
Season rollover ✅ (not in the batch; approved separately)

cargo test --workspace: 153 core + 46 game, green. All six knob-change tripwires re-run green (--features slow-tests, 162/162) — G2 touched the match stream, so that check was owed. Clippy clean at -D warnings; fforge-core/fforge-domain left unformatted-as-found so the diff carries no unrelated cargo fmt churn.


The foundation (U1–U3)

R17's split by role — main.rs / screens/ / flows/ / render/ / input.rs — with screens as pure functions returning String. That is what makes snapshots possible, and the snapshots earned their keep immediately: they caught trailing padding being trimmed after colour was applied, where the spaces sit inside the escape pair and trim_end can no longer see them.

render::sem is the only place Sem becomes a colour. Blue/orange rather than red/green; red reserved for genuine alarms; 256-colour indices rather than the 16 named ones, which every terminal theme re-maps. No global state — the policy is a Palette threaded from main, the same instinct the core applies to RNG and the clock.

Three whole-suite invariants matter more than any individual snapshot: no ANSI when colour is off, colour_changes_nothing_but_colour (strip the escapes and you are byte-for-byte back at the plain render — which is what makes the plain snapshots a complete record), and the_screens_with_an_axis_actually_colour.

U1's explicit decisions, both honoured: the stale-text sweep was verified output-identical by building both binaries and diffing a scripted run, and watch_friendly_flow was kept and wired back — it is now the tactics sandbox (your club, your XI, any shape, nothing recorded).

The screens (U4–U7, G1–G4)

Finances with the monthly FinanceTick trend read straight off the log. Squad with wage, contract, valuation, rating and form columns plus depth against the market's hard stabilizers. Fitness & availability ordered unavailable-first. The inbox. Tactics in the team sheet. The substitution plan editor. Cards, injuries and the man of the match in the match view. R14's grouped menu with bare enter advancing.

Decisions you should look at

1. Two signals were pushed off colour and onto glyphs/columns, deliberately. U4 asks for contract urgency "coloured by urgency" and G4 for form columns; R15 gives the squad screen one axis (ability). Colouring either as well would put two meanings on one channel — the exact failure R15 exists to prevent. So urgency is a glyph (! inside a year, !! inside six months) and form is a bare column. Both survive NO_COLOR, which R15 requires of the carrier anyway. Happy to flip either if you'd rather have the hue.

That this happened twice is now the most actionable finding in the R18 write-up (§4b): the squad screen wants more encodings than a terminal has channels, and the GUI answer — sortable headers plus per-column colour scales — is concrete and cheap.

2. The role-coverage reconciliation: worded as advisory, definitions not aligned. Widening club_ai's hard minimum from ≥2 GK to all eight roles would change which bids its role-coverage override ranks first — a Phase-4 market recalibration, not a presentation change. Narrowing the news check to goalkeepers would throw away the only signal a human gets about the other seven. So the item now reads "Advisory: nobody at FC Nerana rates Central Mid as his best role", replacing wording that implied both that nobody could play there and that something would be done about it. The reasoning is recorded on NewsKind::RoleCoverageGap.

3. The injury minute went into the Trace, not into InjuryOutcome. Widening the recorded outcome was the obvious move and the wrong one: when an injury happened is a fact about the match's telling, not about the state it leaves behind. Event::MatchPlayed's recorded shape is untouched.

fforge-core changes, all small and all justified

  • worldgen::SQUAD_TEMPLATEpub (its own doc comment already gives the reason club_ai reuses it: a second copy is the drift the constant exists to prevent).
  • match_engine::SUB_CHECKPOINTSpub, so the plan editor can name the decision points instead of lying about them.
  • match_engine::man_of_the_matchone rule, read by both the live match view and news. Two copies of a tie-break are two copies free to disagree about the same match.
  • MatchEventKind::Injury, and commentary gains an other: Option<&str> so fouls name who was booked and substitutions name who came off.
  • NewsKind::ManOfTheMatch, carrying the rating in tenths so the renderer decides how to show it.
  • The role-coverage wording above.

A ninth hard constraint now sits in fforge-game/CLAUDE.md: don't re-derive a rule the core owns. Five are read rather than recomputed — the ban rule, man of the match, the squad template, the substitution checkpoints, the form window.

R18 — the toolkit evidence

docs/UI_TOOLKIT_EVIDENCE.md, linked from DESIGN.md §10. Gather, not decide.

§4 was written as a pre-registered hypothesis before G3 existed, so it could be checked rather than confirmed after the fact. Both halves came in true: the editor invented eight top-level commands against the transfer draft's four, plus a four-level-deep picker chain to author one rule, and it is the first screen that cannot be used from its own contents.

It also records a counter-finding against its own thesis: the prose rendering works well — if it is 70' or later and Rossi is under 60% fitness — bring Bianchi on for Rossi is arguably clearer than a row of dropdowns. The terminal's deficit is in authoring, not display, and Phase 6 should discount §2.3 accordingly.

Strongest standing finding: four separate screens independently reinvented a workaround for "no second pane."


Findings filed, not fixed

Both are fforge-core calibration questions surfaced by looking at real output as a player rather than as a pooled mean — which is what R18 asked this batch to do.

1. Per-club goal dispersion is ~4× a real league's. Champion 150 GF / 7 GA; bottom club 3 GF / 134 GA. Every league-wide aggregate is in band (2.43 goals/match, H/D/A 43/31/26 — exactly what the pooled guards check).

2. The §18 rating clamp saturates. Three 10.0 ratings in five matches for one club, visible now that the inbox names a man of the match every game. Same likely root as (1): a strong side accumulates enough positive stream events to hit the [3.0, 10.0] ceiling routinely.

Neither is touched here.

claude added 13 commits July 27, 2026 23:02
`fforge-game` was one 1100-line `main.rs` holding the entry flow, six screens,
three multi-step flows, the match view, helpers, and every input primitive —
with no output tests at all. Batch 4 roughly doubles the presentation code and
adds a colour layer on top, so this lands the structure and the tests first.

R17's split, by role rather than by feature:

    main.rs      entry, game_loop, the menu
    screens/     read-only renders — each a pure fn returning String
    flows/       multi-step interactions (new game, lineup, transfers, advance, friendly)
    render/      shared formatting and derived readings
    input.rs     the only functions that touch stdin

Screens are now pure (R16): each returns a `String` and prints nothing; `main`
does the printing. That is the same discipline `MatchEvent::commentary` already
followed — build the string in a pure function, let the caller do the I/O —
extended from one function to the whole presentation layer, and it is what makes
the snapshots possible.

Snapshot tests for every screen (squad, table, fixtures, stats, header, season
end) against a fixed seed, as plain committed `.txt` files compared with
`assert_eq!` — no new dependency. `UPDATE_SNAPSHOTS=1` regenerates them. Two
extra cases cover the branchy screens: fixtures before any previous matchday
exists, and stats before any match is played. The load-bearing one is
`no_ansi_escapes_when_colour_is_disabled`: with colour off — every screen's state
today, and its state under `NO_COLOR`/`--no-color`/non-tty once U2 lands — no
screen may emit an escape. That single test protects every piped consumer.

R17's stale-text sweep: the "walking skeleton (Phase 1)" banner, the season-end
note claiming multi-season continuity arrives with Phase 3 (Phase 3 landed; the
CLI still has no rollover, so the note now says that instead), and
`squad_screen`'s empty `{:<4}` placeholder column.

`watch_friendly_flow`'s fate, decided explicitly: **kept and wired back**, not
deleted. U6's tactics picker is the argument — a friendly is the only place a
manager can try a shape without spending a matchday on it, which turns it from a
leftover demo into a tactics sandbox. It becomes reachable at U7, where R14's
grouped menu is built once against its final entry list; it stays
`#[allow(dead_code)]` until then rather than being deleted and re-added.

Verified output-identical to the pre-refactor binary apart from those three
strings, by diffing a scripted piped run of both builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
The colour layer, built as a vocabulary before any screen consumes it (R15).
Screens adopt it in U3 — landing both together would make the snapshot diffs
unreadable at exactly the moment they matter most, so `render/` carries a
short-lived `#![allow(dead_code)]` until then.

`render::sem` is the *only* place `Sem` becomes a colour. Nothing outside it
names a colour, a `crossterm::style` type, or an ANSI code — raw colour calls
scattered across screens are how green ends up meaning "healthy" on one screen
and "selected" on another.

Two choices worth recording:

- **Blue/orange, not red/green**, as R15 asks: the diverging pair survives the
  common colour deficiencies and reads on both light and dark terminals. Red is
  held back for `Sem::Bad` — genuine alarms, where a glyph carries it anyway.
  256-colour indices rather than the 16 named colours, because the named ones
  are re-mapped by every terminal theme and a palette that shifts per-theme is a
  palette you cannot reason about. `Sem::Mine` shares `Good`'s hue in bold,
  which is safe *because* of the one-axis-per-screen rule: a screen whose axis
  is `Mine` never also draws `Good`.
- **No global state.** The resolved policy is a `Palette` value threaded from
  `main` into the screens, not an ambient `static` — the same instinct the core
  applies to RNG and the clock, an impure source resolved once at the edge and
  passed in as data. It also lets the snapshot tests render both ways in one
  process without racing.

Colour is suppressed on `NO_COLOR` (any value, per R15 — stricter than
no-color.org's non-empty rule and never wrong in the direction that matters),
`--no-color`, or a non-tty stdout. `--color` forces it back on for `| less -R`;
an explicit off beats it, since the point of `NO_COLOR` is not having to audit
every tool's flags. The decision is factored into a pure `resolve()` so it is
testable without a terminal.

`render::table` is the alignment half. Every screen currently hand-rolls its
`{:<22}`, and four more Batch 4 screens would each hand-roll it again — but
hand-rolled widths and colour actively do not mix: an escape sequence has zero
visual width and several bytes of it, so `format!("{:<20}", paint(..))` pads to
the wrong place and the table shears one row at a time. This module pads first
and paints second, and `alignment_survives_colour` pins it: the coloured render
with escapes stripped must equal the plain render byte for byte.

That test earned its keep immediately — it caught trailing-padding trimming
happening *after* painting, where the spaces sit inside the escape pair and
`trim_end` can no longer see them. Widths count characters, not bytes, matching
what `{:<22}` already did, so "Atlético Rivemona" occupies 17 columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
Adopts `Sem` across the screens U1 purified, one semantic axis per screen, with
that axis stated in a comment at the top of each — the comment is what stops the
vocabulary drifting back into decoration.

| Screen | Axis |
|---|---|
| Squad | ability relative to *this* squad (its own CA quartiles) |
| Table | `Mine`, and nothing else — the ordering already carries the rest |
| Fixtures | `Mine` |
| Header | outstanding decisions: what still wants attention before you advance |
| Transfers | affordability against cash and wage headroom |
| Stats | none, deliberately — see below |

Every coloured distinction has a non-colour carrier, verified by reading the
plain snapshots: the squad's CA column and its ordering, the table's `>` marker,
the fixtures' `<— your match` tag, the header's note text, and a new `Fit`
column on the transfer browser naming the *blocking* half of the affordability
gate (`ok` / `fee` / `wage` / `both`) — which colour alone could not have said
anyway.

Two judgement calls worth recording:

- **Stats takes no `Palette` at all.** Goals-per-match of 2.4 is neither good
  nor bad, it is just what the league did; there is no axis a player could act
  on. Colouring it would be decoration, so the screen refuses the parameter
  rather than accepting one and ignoring it.
- **The transfers axis is affordability, not quality**, because quality is
  already the sort order — and because the fee and wage halves fail
  independently, which is exactly what `market::filter_affordable` drops a plan
  on at resolve time without ever saying so. The own-squad screen reads the same
  axis from the selling side (share of the wage bill), with no `Sem::Bad`: an
  expensive player is not an alarm, and R15 keeps red for things that are.

Two new whole-suite invariants alongside the updated snapshots:

- `colour_changes_nothing_but_colour` — strip the escapes from a coloured render
  and you are back at the plain one, byte for byte. That is what makes the plain
  snapshots a *complete* record of what a screen says, rather than a record of
  one of two possible outputs.
- `the_screens_with_an_axis_actually_colour` — a screen that silently stopped
  colouring would otherwise pass every other test in the file.

Snapshot diff is three things and nothing else: trailing padding dropped, the
league table's `Club` header finally aligned with the club names below it (it was
one column left of them), and the squad column widths from U1.

`render::table` gained a whole-row uniform semantic, so a `row_all` row paints as
one span instead of eleven — same pixels, a fraction of the escapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
Surfaces the Phase 4 state that had no screen at all.

**A Finances screen** (`[$]`), axis: headroom. Cash, the reserve floor the
market's affordability gate silently holds back, spendable, the wage *ceiling*
(a constraint, never a second pot — `TRANSFER_MODEL.md` §3), committed wages,
and headroom left. Below it, the last six monthly `FinanceTick`s as a signed
`+`/`-` bar around a `|` zero axis. The trend is read straight off the event log
— `FinanceTick` already records resolved per-club deltas, so nothing here
re-derives revenue or the wage bill.

Red appears here and is earned: a negative balance or a breached ceiling is one
of R15's genuine alarms. The figure itself is the non-colour carrier.

**Squad screen** gains Wage, Contract, and Value columns, plus a depth block.

**One deliberate deviation from U4's wording, worth reviewing.** U4 asks for the
contract columns "coloured by urgency". R15 assigns this screen a single axis —
ability relative to the squad — and colouring expiry as well would put two
meanings on one channel, which is exactly the failure R15 exists to prevent. So
urgency is carried by a **glyph** instead: `!` inside a year, `!!` inside six
months or already expired. In a monospace column that reads as loudly as a hue,
and it survives `NO_COLOR` — which R15 requires of the carrier anyway. Say the
word and it flips to colour.

The depth block does use red, and that is not a second axis: the two colour sets
are disjoint by construction — the player list never emits `Bad`, the depth block
emits nothing else — so a red on this screen has exactly one meaning, a `club_ai`
hard stabilizer breached (`≥2` GK, squad size inside `[18, 30]`). Those
stabilizers govern every AI club and a human could otherwise breach them in
silence.

`worldgen::SQUAD_TEMPLATE` goes from `pub(crate)` to `pub` and is re-exported,
for the same reason its doc comment already gives for `club_ai` reusing it: a
second copy of the headcounts in the CLI is precisely the drift the shared
constant exists to prevent. That is the only `fforge-core` change in this batch
so far.

**The valuation asymmetry is labelled, not left implicit.** The `Value*` column
is the omniscient ground truth (`TRANSFER_MODEL.md` §2.6) — every club prices off
the same central `value()` and scouting fog-of-war is Phase 5 — so the footnote
says so. Otherwise the column quietly becomes a promise the fogged game has to
break.

Also `render::money`: `1_500_000` → `1.5M`. Wage bills run to eight digits and a
column of raw integers is unreadable at a glance — you end up counting digits to
tell 1.2M from 12M. Magnitude is the point on these screens, never precision.

Two new snapshots (with and without a fired tick), and the finances screen joins
the whole-suite colour invariants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
Surfaces `fforge-core::news` as a real screen, and wires the observer into the
live game loop for the first time — the core module has been finished and tested
since Batch 2 but never connected to anything.

`Observers` bundles `SeasonTelemetry` and `NewsObserver` so every `execute` call
site notifies both. That is not tidiness: an observer that misses events produces
a quietly wrong inbox rather than a compile error, so the list of who must see
the stream is written down in exactly one place.

State-condition news is pumped once per game-loop iteration, which is once per
command — every menu path executes at most one before returning. Event-derived
news needs no pump; it rides `EventObserver` and therefore rebuilds for free on
load. The state-condition half does not survive a cold load — `check_conditions`
runs at command boundaries and a reload has none to replay, so it re-fires for
whatever is true *now*. That is the same asymmetry match commentary already has,
and it is noted at the call site rather than papered over.

The screen orders and colours by salience (`Emphasis` / `Ok` / `Muted` — an
importance axis has no good/bad direction, so it never touches the diverging pair
and never uses red). Carriers: the ordering itself, `!` for must-see, `*` for
unread. Unread count rides in the header.

**The background band is capped separately, and that is the load-bearing
detail.** A 20-club league produces 10 results a matchday, all `Audience::League`,
so an inbox that merely sorts by salience is still four-fifths other clubs'
scorelines. Notable items (salience >= 40) and background get their own caps.

Read state is a CLI-local cursor, deliberately not recorded — "read" is a fact
about this session, never a fact of the game. Same reasoning that keeps the news
items themselves out of the log.

**The reconciliation BATCH3_TASKS.md flagged, decided: word it as advisory, do
not align the definitions.** `news::check_conditions` flags any of the eight
roles with no best-role player; the market's only hard role stabilizer is >=2 GK.
Widening `club_ai`'s hard minimum to all eight would change which bids its
role-coverage override ranks first — a Phase-4 market recalibration, not a
presentation change. Narrowing the check to goalkeepers would throw away the only
signal a human gets about the other seven. So the item stays broad and its wording
now promises nothing:

    Advisory: nobody at FC Nerana rates Central Mid as his best role.

replacing "has no recognised Central Mid on the books", which implied both that
nobody could play there (a centre-back covers defensive midfield fine) and that
something would be done about it. The reasoning is recorded on
`NewsKind::RoleCoverageGap` itself, where the next person to ask will find it.

Nothing is pre-rendered outside `TemplateRenderer` — every line comes from the
structured `NewsKind`, so the Phase-5 journalist stays a peer implementation of
`NewsRenderer` rather than a patch over someone else's strings (Batch 2 R2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
Tactics join the lineup flow rather than getting their own menu entry: they ride
the same `Lineup` decision value as the XI (`TACTICS_MODEL.md` §6), and a team
sheet is one submission. Splitting them would let a player submit a new XI and
silently keep last week's shape.

Four ternary instructions, each cycled with one key, each level described in one
line of plain language. The assistant's `ai_pick_tactics` read for *this* fixture
seeds the picker and is marked `<`, so a player who just hits [d] fields a real
shape rather than four `Balanced` shrugs — which is the actual point of showing
the policy's output rather than merely offering it.

**No effect magnitudes on screen, and a test that enforces it.** U6's gate says
not to put numbers up that a later calibration pass may change; `TACTICS_MODEL.md`
§3's magnitudes have already been re-fitted twice (§5's T7-R finding, §9 items
6–7), so the rule holds even now that T7 is resolved.
`no_meaning_quotes_a_number` fails the build if a description ever grows a digit.
The descriptions give direction and cost instead — which is what a manager wants
anyway.

Colour axis on the picker is *departure from neutral*, not quality: an
instruction moved off `Balanced` is a deliberate choice and reads `Emphasis`, the
untouched ones recede. Deliberately not a good/bad axis — §9 item 6's whole
finding is that non-dominance is squad-conditional, and colouring a level "good"
would assert exactly the dominance that was fitted out. The `<` marker carries
the assistant's pick without colour.

Aborting the picker aborts the whole submission, since the XI and the tactics are
one decision. Falls back to the last submitted lineup's tactics when the club has
no fixture to advise on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
**U7 — R14's grouped menu, built once against its final entry list.**

    SQUAD  [s] Squad   [l] Lineup & tactics
    CLUB   [t] Table   [x] Fixtures   [$] Finances   [m] Transfers
    DESK   [i] Inbox (2)   [r] Reports   [k] Friendly (tactics sandbox)
    ── [enter] Advance matchday ──          [w] Save   [q] Quit

Bare `enter` advances the matchday. That is the largest single legibility win in
the batch and it costs nothing: it is the only entry a player hits *every* turn,
and it was one of ten equals. Mnemonic letters replace numbers because a numbered
menu renumbers every time an entry lands and `[s]` for squad does not.

Grouping is presentational only — **no nested menu tree**. Sub-menus would add a
navigation step to every action to save one line of screen; the keyspace stays
flat and every action is one keystroke from the main screen.

Save & quit and quit-without-saving collapse into one `[q]` that asks. Unread
inbox count rides on the `[i]` entry as well as in the header.

`[f] Fitness & availability` is absent rather than stubbed: it is G1, and a menu
entry that says "not built yet" is worse than no entry.

**The friendly comes back as the tactics sandbox** — closing out U1's explicit
`watch_friendly_flow` decision. It is no longer two arbitrary clubs: it is your
club, your submitted XI, an opponent you choose, and any shape you want to try,
for the cost of nothing recorded. That is the only place a manager can find out
what `Defensive`/`Direct` does to *his* squad before staking three points on it,
and it is why the flow was worth keeping.

**R18 — `docs/UI_TOOLKIT_EVIDENCE.md`**, the record `DESIGN.md` §10 has been
waiting on, linked from that section. Gather, not decide.

The strongest finding is not a taste argument: **three separate screens
independently reinvented a workaround for "no second pane"** — lineup selection
cannot show the XI taking shape beside the pool it draws from, the tactics picker
cannot show one instruction's effect on the others, and the transfer flow splits
one decision (can I afford him / who would I sell) across three screens, which is
exactly why U3's `Fit` column had to be invented.

Also recorded: forms outnumber browses where the decisions live and forms are the
weak side; each CLI form grew its own small invented command vocabulary and
together they are a dialect; linear prompt chains lose work and have no undo. And
the things that should survive any rewrite regardless — the `Sem` vocabulary with
its single mapping module, screens as pure functions, and colour never being the
sole carrier.

The document is explicit about the measurement it could not take: **G3, the
substitution rule builder, is the decisive screen and is not built.** A hypothesis
is stated in advance so it can be checked rather than confirmed after the fact.

`fforge-game/CLAUDE.md` fully rewritten — the old per-function map was invalidated
wholesale by the module split. It now carries the eight hard constraints the batch
established (including the four new presentation ones), the colour-axis table, and
an honest note on what is *not* wired: the gated G-tasks, and season rollover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
Condition, injuries with return dates, suspensions, and this season's card
tally — the Phase 2e state that has been live in the simulation since T9-T11 and
entirely invisible to the player. `[f]` in R14's SQUAD group, where the menu
already had a slot reserved for it.

Colour axis is availability: fit / doubtful / injured / suspended. Red is earned
twice, since an injured player and a suspended one are both genuinely
unavailable. Carriers: the `Status` column says it in words, and the list is
ordered unavailable-first so the players you *cannot* pick are the ones you read
before anything else.

**The ban rule is deliberately not re-derived here.** `GameState::is_suspended`
is the authority — `MATCH_MODEL.md` §12's derived-suspension rule means a ban is
never stored, it is recomputed from `season_cards` on every call. This screen
asks it and separately shows the raw tally as a fact. It does not predict "one
more yellow and he's banned", tempting as that is: that would be a second copy of
the rule, free to drift from the one that actually decides.

A footer counts who is pickable, flagged red below eleven — the floor where there
is no legal XI at all.

`every_screen`'s fixture depth goes 5 → 12, and that is load-bearing rather than
incidental: `the_screens_with_an_axis_actually_colour` needs a session where each
axis has something to *say*, and a squad with nobody injured, suspended, or tired
is correctly all `Sem::Ok` — which costs no ink by design. Twelve matchdays is
deep enough for real cards and layoffs. Noted in the fixture's own comment so the
next person to shorten it finds out why not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
The match view showed a stream that could not say who got booked or who came
off, and never mentioned injuries at all — all three are resolved by the engine
and were being thrown away at the presentation edge.

**Two `fforge-core` changes, both to the Trace side only.**

`MatchEvent::commentary` gains an `other: Option<&str>` and a matching
`MatchEvent::other_player()` that names whichever second player the beat is
about — the fouling defender, the departing substitute, or the contesting
opponent. A card with nobody's name on it is no use to a manager, and
"Substitution: X comes on" without saying who went off is half a sentence. The
lookup stays in `fforge-game`, which owns the `World`; `commentary` stays
name-resolved and I/O-free (`MATCH_MODEL.md` §9).

`MatchEventKind::Injury { days_out }` joins the stream alphabet, pushed wherever
`InjuryOutcome` is already pushed. **The minute lives in the Trace, not in
`InjuryOutcome`** — widening the recorded outcome was the obvious move and the
wrong one: when an injury happened is a fact about the match's *telling*, not
about the state it leaves behind, so `Event::MatchPlayed`'s recorded shape is
untouched. That is the same reasoning that keeps the whole stream out of the
fold (§7).

`compute_ratings` scores the new kind at zero, deliberately: §18's table rates
what a player *did*, and being hurt is not that — the minutes-share regression
already discounts a shortened match.

All six knob-change tripwires re-run green (`--features slow-tests`, 162/162),
which is the expected result and worth having checked: the new beat draws no
RNG and changes no resolution, and at the identity `injury_rate: 0.0` the golden
baseline emits none at all.

**Game side:** a full-time aftermath block collecting cards (chronological),
injuries, and the man of the match — the stream tells you these as they happen,
but at full time a manager wants the list he has to pick next week's team
around. A zero-day knock reads as "a knock, no games missed" rather than "out
for 0 day(s)", in both the stream and the block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
The bench and a condition→action rule list, as the third step of the team sheet
— they ride the same `Lineup` value as the XI and the tactics
(`MATCH_MODEL.md` §16), so they are one submission, not a separate menu entry.

R18 called this the hardest interface in the batch and the most informative for
the toolkit question, and it was right on both counts. A `SubRule` is a
composite value (a list of conditions plus one action), rules are evaluated in
list order, and the whole plan is authored before kickoff with no chance to
correct it mid-match — that is deliberate (§16: the plan *is* the decision,
which is what keeps evaluation RNG- and I/O-free), and it means the editor needs
everything a terminal is worst at simultaneously: a persistent form
representation, per-row editing, reordering, and undo.

Two concessions carry most of the weight, both aimed at what the player has to
hold in his head:

- **The plan is always shown in full, rendered back as English** before every
  prompt — "if it is 70' or later and Rossi is under 60% fitness — bring Bianchi
  on for Rossi". There is no "current rule" hidden in editor state, because
  there is nowhere to display one.
- **Everything is seeded from last week's plan**, so the common case is
  reviewing rather than authoring.

Colour axis is **whether a rule can still fire**. The engine silently no-ops a
rule naming a player no longer on the team sheet — correct, since a plan is
authored before kickoff and the manager does not control who is available by
then — but a silent no-op is exactly what an editor should surface. Stale rules
read `Warn` and are labelled `(stale)`. There is also an advisory when more
substitution rules exist than `MAX_SUBSTITUTIONS` can use.

`[a] auto-fill bench` covers each role first, then the best of the rest —
mirroring the XI picker's own `[a]`, since bench selection is the mechanical
half of the job.

`match_engine::SUB_CHECKPOINTS` goes public so the editor can name the decision
points in its header. Without it a `MinuteAtLeast(65)` rule looks like it fires
at 65 when it actually fires at 70, and an editor that hides half the mechanism
is worse than no editor.

Verified end to end: a rule authored in the CLI fires in a real match and shows
up in the stream, named on both sides — "70' Substitution: Andrea Rinaldi on for
Thiago Vitale."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
`Rtg` (the most recent match rating) and `Form` (the mean of the rolling
`recent_ratings` window) join the squad screen. Read from
`GameState::recent_ratings`, never re-derived, so this and the `form_mult` the
transfer market prices with are literally the same numbers
(`TRANSFER_MODEL.md` §2.5).

**Both columns are uncoloured, on purpose.** This screen's one axis is ability,
and form is a *second* reading of quality — two hues for two flavours of "how
good is he" is precisely the ambiguity R15 exists to forbid. Form gets a column
and no ink. Same resolution as U4's contract urgency, for the same reason.

Man of the match reaches both surfaces the task asked for, from **one rule**:
`match_engine::man_of_the_match` in `fforge-core`. The match view reads
`MatchOutcome.ratings` live, `news` reads `Event::MatchPlayed.ratings` off the
log — same data, and two copies of a tie-break are two copies free to disagree
about the same match. The game's own copy, written at G2, is deleted in favour
of it.

`NewsKind::ManOfTheMatch` carries the rating in tenths exactly as recorded, so
the *renderer* decides how to show it — the structured-not-pre-rendered rule
(Batch 2 R2) holding for a new item type. Salience sits just under the result
itself: worth reading if he is yours, background otherwise.

Also trims the finances screen's reserve-floor note so no screen but the inbox
exceeds 80 columns, and the inbox only does so on long news lines, which are
data-driven and in a trailing free-text column that wraps rather than shears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
`Command::StartNextSeason` has existed in the core since Phase 3 and the whole
development fold rides on it, but `game_loop` still ended the run at the final
whistle — so a single-season game was all the CLI could play, and Phase 3's
career arcs were invisible to a human.

The season-end screen now offers `[enter] Start next season` alongside save and
quit, matching the main menu's own default-action shape. `SeasonStarted`'s fold
arm already resets results, matchday, champion, pending lineup and cards, so the
loop falls straight back into matchday 1 on the developed world.

**The rollover reports what it did**, which is the point of it: a before/after
snapshot of the squad's headline CA across the offseason ticks, biggest risers
and fallers. `StartNextSeason` running the summer's development is the *reason*
to press the key, and a bare "season 2027" tells a manager nothing about the
squad he now has. Growth reads `Good`, decline `Warn`, no red — a veteran losing
a point is the model working, not a problem. Players who joined or left across
the boundary are skipped rather than reported as a full-CA swing.

**The unread inbox count now counts notable items only**, which rollover forced
into the open: a 20-club league produces ten results a matchday, so after one
season the header read "765 unread" — a number nobody can act on. It now reads
70, and `inbox::len` counts without collecting, since it runs once per game-loop
iteration and the item list grows for a whole multi-season run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
`docs/UI_TOOLKIT_EVIDENCE.md` was written with §4 as a pre-registered
hypothesis, deliberately, so it could be checked rather than confirmed after the
fact. G3 is now built and §4 records what it cost.

**Both halves of the hypothesis came in true.** The plan editor invented eight
top-level commands against the transfer draft's four, plus a four-level-deep
nested picker chain to author one rule — 536 lines against transfers' 473, and
transfers is three screens where subs is one. And it is the first screen in the
game that cannot be used from its own contents: it needs a header line
explaining when rules are evaluated, which is why `SUB_CHECKPOINTS` had to be
made public at all.

The section also tabulates the four compensations the screen needed and what
each substitutes for — prose-rendering the whole plan on every keystroke stands
in for a persistent form widget, seeding from last week stands in for a document
that stays open, and so on.

**One honest counter-finding, recorded against the document's own thesis:** the
prose rendering works *well*. Reading "if it is 70' or later and Rossi is under
60% fitness — bring Bianchi on for Rossi" is arguably clearer than a row of
dropdowns. So the terminal's deficit here is entirely in authoring and revising,
not in display, and Phase 6 should weight §2.3 accordingly.

New §4b, and it may be the most actionable thing in the document: **the one-axis
colour rule has now forced a real signal off colour twice** — contract urgency
(U4) and form (G4), both on the squad screen. Both were the right call, and twice
is a pattern. The squad screen carries three independent things a manager reads
it for and a terminal offers one colour channel. The GUI answer is concrete and
cheap — sortable headers plus per-column colour scales — which makes the
constraint one of the medium rather than the domain. §1 had already guessed
sortable headers were the highest-value affordance; this is a second independent
route to it.

§2.1's count goes three → four screens reinventing the no-second-pane workaround
(the match aftermath joins), and §2.4 notes that the all-or-nothing team sheet
got worse with every step G3 added to it.

`fforge-game/CLAUDE.md` updated to match: current state, the new modules, the new
colour axes, and a ninth hard constraint — don't re-derive a rule the core owns.
Five of them are now read rather than recomputed (the ban rule, man of the match,
the squad template, the substitution checkpoints, the form window), and a second
copy in the presentation layer is a copy free to disagree with the one that
decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6984fgzMyD6hpcGvyJyj
@lm98 lm98 changed the title Batch 4: Wire Phase 4 into fforge-game; make it readable Batch 4: wire Phase 2e + Phase 4 into fforge-game, make it readable, and roll seasons over Jul 28, 2026
@lm98
lm98 merged commit 64e6d44 into master Jul 28, 2026
2 checks passed
@lm98
lm98 deleted the claude/handoff-document-tasks-18l623 branch July 28, 2026 08:45
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