Batch 4: wire Phase 2e + Phase 4 into fforge-game, make it readable, and roll seasons over - #18
Merged
Merged
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
All of
BATCH4_TASKS.md— U1–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.Semvocabulary and the render toolkitdocs/UI_TOOLKIT_EVIDENCE.mdcargo 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-domainleft unformatted-as-found so the diff carries no unrelatedcargo fmtchurn.The foundation (U1–U3)
R17's split by role —
main.rs/screens//flows//render//input.rs— with screens as pure functions returningString. 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 andtrim_endcan no longer see them.render::semis the only placeSembecomes 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 aPalettethreaded frommain, 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), andthe_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_flowwas 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
FinanceTicktrend 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 bareenteradvancing.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 surviveNO_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 GKto 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 onNewsKind::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-corechanges, all small and all justifiedworldgen::SQUAD_TEMPLATE→pub(its own doc comment already gives the reasonclub_aireuses it: a second copy is the drift the constant exists to prevent).match_engine::SUB_CHECKPOINTS→pub, so the plan editor can name the decision points instead of lying about them.match_engine::man_of_the_match— one rule, read by both the live match view andnews. Two copies of a tie-break are two copies free to disagree about the same match.MatchEventKind::Injury, andcommentarygains another: 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.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 fromDESIGN.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 Rossiis 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-corecalibration 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.