Files

442 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Component Inventory & Project Sizing
The buildable pieces: what code must be written, where each runs, when each runs, and how big each
is. The five sibling documents describe the system's *concerns*; this one describes its *parts*.
Sizes are **indicative, not estimates of effort**. T-shirt size for MVP and for finished, a one-line
driver of that size, and a LOC band for comparison between components.
---
## 1. What the MVP is
**Solitaire. One Office. A fixed number of Days. A server talking to a single browser. The display
functional, not pretty.**
```
DP(W) [ML] [ Office ] [ML] DP(E)
```
Two Mainline cards, one Office Area, and the player is permanently the Superintendent. Trains enter
at a Division Point, cross a Mainline card, reach the Office, get switched and worked, highball, and
exit at the far end.
### What solitaire simplifies — and what it does not
**It removes players, not rules.** Every rule still runs with one player: the six-Move switching
game, mandatory coupling, consist ordering, the four-Laborer freight pipeline, highball conditions,
automatic collisions, the twelve-Stage clock, and the Superintendent's following-train clearance
decision (you are the Superintendent, ruling on your own trains).
| Removed at MVP | Still required at MVP |
| --- | --- |
| Lobby, matchmaking, game codes | Full rules engine |
| Hiding hands from opponents | Phase driver |
| Turn arbitration between players | Card catalogue |
| Presence, reconnection of others | Track graph and Move legality |
| Fedora rotation between players | Board and action UI |
| Competitive/Co-op victory modes | Event log and persistence |
| Collision floor (Competitive only) | Server and protocol |
**Consequence: the engine is the dominant cost and it is front-loaded.** Components 46 barely differ
between MVP and finished. Most of the MVP-to-finished delta lives in the server and client layers,
not in the rules.
---
## 2. Component inventory
Execution locations: **Engine** (pure, runs wherever it is hosted — server-side at MVP), **Server**,
**Browser**, **Dev** (developer machine or CI; ships nowhere).
### A. Engine core
Pure, no I/O, no clock, deterministic given a seed. See [`overview.md`](overview.md) — this boundary
is what makes the whole system testable without a server.
---
**1. Card catalogue / static content**
- **Where:** Engine · **When:** loaded once at startup, immutable thereafter
- **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects,
train consist specs, track geometries. Source: [`../rules/card-reference.md`](../rules/card-reference.md)
- **MVP: S** · **Final: S** · ~200 → ~350 LOC
- **Size driver:** it is data, not logic. Grows only if card variants are added.
- **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files,
not code, so tuning never requires touching the engine.
**2. State model and types**
- **Where:** Engine · **When:** always resident
- **Does:** the entity model from [`game-state.md`](game-state.md) — Division, Office Areas, Crew
Trays, Facilities, decks, yards, clock, Revenue.
- **MVP: M** · **Final: M** · ~400 → ~600 LOC
- **Size driver:** breadth of the model. Nearly all of it is needed even for one Office; the delta is
multi-player and multi-Office bookkeeping.
**3. Track graph and movement**
- **Where:** Engine · **When:** per Move, and per train step in the Mainline Phase
- **Does:** the Office grid as a port graph — turnouts whose two legs are not joined to each other
(§A.1), Operational Rail, the no-reversal rule within a Move, mandatory coupling, the Office card's
plain junction stubs, and dynamic loss of Operational Rail while `MEN | AT | WORK` is occupied.
- **MVP: L** · **Final: L** · ~500 → ~700 LOC · *built: 285 LOC*
- **Size driver:** which port pairs each card joins, and the rule that a traversal never leaves by
the port it entered. The constraints in `game-state.md` §3 all land here. Barely changes from MVP
to finished.
**4. Intent validation and application**
- **Where:** Engine · **When:** per submitted player intent
- **Does:** `apply(state, intent) → events | rejection`. Every legal player action from
[`protocol.md`](protocol.md) §1 — the three-way Local Ops choice, Moves, drops, draws, card plays,
Freight Agent operations, car placement, Porter and Laborer actions, clearance rulings.
- **MVP: L** · **Final: L** · ~800 → ~1000 LOC
- **Size driver:** the number of distinct legal actions, which solitaire does not reduce.
**5. Phase driver**
- **Where:** Engine · **When:** continuously between player inputs
- **Does:** `advance(state) → events | needsInput`. Runs the whole automatic side: Mainline movement
in `(number, isExtra)` order, highball evaluation, collision resolution, train make-up, Stage and
Day advance, Fedora rotation, per-Stage resource reset, victory checks. Pauses for the
Superintendent's clearance decision.
- **MVP: M** · **Final: L** · ~400 → ~800 LOC · *built: 430 LOC*
- **Size driver:** the Mainline Phase. Ordering, highball conditions and collision triggers are all
here. Grows with multiplayer turn arbitration and the Competitive collision floor.
- **Note:** carries a deliberate safety net — if an actor somehow has no legal action, the turn ends
rather than the game hanging. It should never fire, but a hung game is far harder to diagnose than
a forfeited turn.
- **Note:** this is the component most likely to be missed entirely when planning, because it is not
a feature anyone asks for — it is simply how the game moves.
**6. Legal-action enumeration**
- **Where:** Engine · **When:** whenever the client needs affordances — every state change
- **Does:** `legalActions(state, actor) → Intent[]`. Which grid squares a Crew Tray can reach, which
cars may be dropped, whether a Laborer has work available, which cards are playable where.
- **MVP: M** · **Final: M** · ~300 → ~450 LOC
- **Size driver:** it must mirror component 4's rules exactly. The temptation is to reimplement them;
the discipline is to share predicates with 4 so the two can never disagree.
**7. Seeded RNG**
- **Where:** Engine · **When:** deck shuffles, the 1D12 timetable roll, setup rolls
- **Does:** deterministic pseudo-random from one stored seed, so any game is exactly replayable.
- **MVP: S** · **Final: S** · ~50 → ~80 LOC
- **Size driver:** trivial, but must be threaded through the engine rather than called ambiently —
a single stray `Math.random()` destroys replay.
### B. Server
---
**8. Game session host**
- **Where:** Server · **When:** per active game, for the game's lifetime
- **Does:** owns one game's state, pumps `advance` until it needs input, routes intents to `apply`,
emits events, serializes access so two intents never interleave.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with
concurrent games and per-player routing.
**9. Event log and persistence**
- **Where:** Server · **When:** append per event; full read on startup
- **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and
later reconnection and the replay viewer.
- **MVP: S** · **Final: M** · ~100 → ~350 LOC
- **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing.
**10. View projection / redaction**
- **Where:** Server · **When:** per state change, per connected client
- **Does:** builds each player's permitted view. Public board, private hand, deck **count** visible
but deck **order** never sent.
- **MVP: S** · **Final: M** · ~80 → ~300 LOC
- **Size driver:** nearly free at MVP — one player, so the only secret is deck order. Grows sharply
once opponents' hands must be hidden, and it is the trust boundary, so it must be exactly right.
**11. Transport**
- **Where:** Server · **When:** per connection; pushes on every event
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving.
Bidirectional — most traffic is server → client.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
and reconnect-with-replay.
**12. Lobby and session lifecycle**
- **Where:** Server · **When:** before a game starts; on reconnect
- **Does:** create/join by game code, seating order (which sets adjacency *and* Superintendent
rotation), config lock at start, session tokens, reconnection.
- **MVP: XS** · **Final: M** · ~50 → ~450 LOC
- **Size driver:** essentially absent at MVP — one player, one game, start immediately. This is the
single largest MVP-to-finished delta in the system.
**20. Process bootstrap and configuration**
- **Where:** Server · **When:** once, at process start
- **Does:** reads environment configuration (bind address, port, data directory), opens or creates the
event log, constructs the session host and transport, wires them together, handles shutdown.
- **MVP: XS** · **Final: S** · ~80 → ~200 LOC
- **Size driver:** trivial in itself, but it is where
[`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process
and single port, relative URLs only, all state under one configurable directory, configurable bind
address, and no assumption of public reachability. Those rules cost nothing here and are what keep
both the plain-web-host and StartOS-service paths open.
### C. Browser
---
**13. Client state store**
- **Where:** Browser · **When:** on every received event
- **Does:** applies events in order, detects gaps and requests replay, holds the current view and
affordance set. Never mutates state locally in a way that could drift.
- **MVP: S** · **Final: M** · ~150 → ~350 LOC
- **Size driver:** ordered application is simple; gap recovery and optimistic prediction are what grow.
**14. Board renderer**
- **Where:** Browser · **When:** on every view change
- **Does:** draws the Division, the Office grid, track geometry, trains and consists, facilities with
their boxes and `MEN | AT | WORK` track, the turn chart and timetable.
- **MVP: M** · **Final: L** · ~500 → ~1500 LOC
- **Size driver:** at MVP, one Office and "functional not pretty". Grows with several Offices, the
full Division chain, animation, and responsive layout.
**15. Action and affordance UI**
- **Where:** Browser · **When:** whenever the player may act
- **Does:** presents the three-way Local Ops choice; the switching interface — six Moves, reachable
squares, mandatory coupling, drop-in-seated-order, the four-slot limit; card play and placement;
Laborer and Porter assignment; the clearance prompt.
- **MVP: L** · **Final: L** · ~600 → ~1200 LOC
- **Size driver:** **the sleeper of the project.** The switching interface is the hardest UI in the
game and it is needed in full at MVP — a player cannot do anything useful without it. It barely
simplifies for solitaire.
**16. Replay viewer**
- **Where:** Browser · **When:** after a game ends — **pulled forward, built**
- **Does:** read-only projection over the event log, with playback controls. A Node script
precomputes one frame per visible event and writes a self-contained HTML file, so the browser
never runs the engine and there is no bundling or build step.
- **MVP: —** · **Final: S** · 0 → ~250 LOC · *built: 560 LOC (replay) + 330 (narration)*
- **Size driver:** the narration layer, not the rendering. Turning 30 event types into readable
sentences and deriving the "what is currently blocked" panel is most of it.
- **Why it came early:** the open balance questions stopped being measurable and became judgment
calls. It also exercises much of component 14, which is MVP-critical either way.
### D. Dev-side
Real code that must be written and maintained, but ships nowhere. This is the answer to "what might
need to run somewhere else."
---
**17. Heuristic bot players**
- **Where:** Dev · **When:** simulation runs and tests
- **Does:** plays legal games without a human — picks a Local Ops option, spots cars, works freight,
rules on clearances.
- **MVP: S** · **Final: M** · ~200 → ~500 LOC
- **Size driver:** a bot that plays *legally* is small; one that plays *well* enough to validate
balance numbers is much larger. Only the former is needed first.
**18. Balance simulation harness**
- **Where:** Dev · **When:** on demand, in batches
- **Does:** runs many seeded games, reports Revenue per Day, game length, collision frequency, Crew
Tray contention — to retune the provisional numbers.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** trivial to run games; the work is in reporting and in deciding what to measure.
- **Note:** this exists *only* because the phase driver lives inside the engine. It is a loop over
`advance` and `apply` with no server involved.
**19. Test fixtures and golden replays**
- **Where:** Dev · **When:** CI, every commit
- **Does:** hand-built states for tricky rules (facing-point switching, collision triggers, consist
ordering) plus recorded full games replayed to detect regressions.
- **MVP: M** · **Final: L** · ~400 → ~1000 LOC
- **Size driver:** proportional to rules surface, which is large. Under-investing here is the most
expensive mistake available, because rules bugs are silent.
---
### Estimate accuracy so far
Components 14, 6 and 7 are built. Actual against the MVP estimate:
| Component | Estimated | Actual | |
| --- | ---: | ---: | --- |
| 1 · card catalogue | ~200 | 465 | data-with-types is more verbose than allowed for |
| 2 · state model | ~400 | 349 | close |
| 3 · track graph | ~500 | 286 | the port-pair model turned out compact |
| 4 · apply | ~800 | 830 | close |
| 6 · legalActions | ~300 | 187 | small *because* it delegates every rule to `check` |
| 7 · seeded RNG | ~50 | 75 | close |
| **Engine subtotal** | **~2,250** | **2,192** | **within 3%** |
The individual figures move around by up to 2× in both directions, but the subtotal is close enough
that the group-level numbers below are worth keeping. The one systematic lesson: **static data costs
more than logic** — component 1 was the worst miss, and components 2 and 3 (types and graph) came in
under.
## 3. Dependency graph
```
[1] card catalogue
[2] state model ◄──── [7] seeded RNG
[3] track graph
┌───────┼───────┐
▼ ▼ ▼
[4]apply [5]advance [6]legalActions
└───────┼───────┘
[8] session host ──► [9] event log
[10] view projection
[11] transport ◄──── [12] lobby
│ wired together at startup by
[20] bootstrap / config ──► [9]
[13] client store
┌───────┴───────┐
▼ ▼
[14] renderer [15] action UI
[16] replay viewer
dev-side: [17] bots ──► [18] harness both depend on [4][5][6]
[19] fixtures depends on [4][5][6]
```
Acyclic. Everything flows from the engine outward.
---
## 4. Build order
Each step ends at something demonstrable.
| Step | Build | Testable when done |
| --- | --- | --- |
| 1 | 1, 2, 7 | Card data loads; a game state can be constructed and seeded |
| 2 | 3 | Move legality provable against Appendix A's worked switching examples |
| 3 | 4, 6 | Individual actions apply correctly; illegal ones rejected |
| 4 | 5 | **A full solitaire game runs to completion, headless** |
| 5 | 19, 17 | Rules regressions caught automatically; bots play legal games |
| 6 | 18 | **Provisional numbers validated or retuned against real data** |
| 7 | 8, 9, 20 | A game runs in a server process and survives restart |
| 8 | 10, 11 | A browser can connect and receive an ordered event stream |
| 9 | 13, 14 | The board renders and updates live |
| 10 | 15 | **MVP complete — a human plays a solitaire game end to end** |
| 11 | 12 | Multiple games, multiple players, lobby and reconnection |
| 12 | 16 | Post-game replay |
**Step 4 is the important milestone.** A full game running headless proves the rules before a single
pixel is drawn. **Step 6** answers whether the numbers are right — while changing them is still free.
---
## 5. Overall sizing
| Group | Components | MVP | Finished |
| --- | --- | ---: | ---: |
| A. Engine core | 17 | ~2,650 | ~3,980 |
| B. Server | 812, 20 | ~610 | ~2,100 |
| C. Browser | 1316 | ~1,250 | ~3,300 |
| D. Dev-side | 1719 | ~750 | ~1,900 |
| **Total** | **20** | **~5,260** | **~11,280** |
Read these as ratios, not promises. What they say:
- **The engine is half the MVP and it comes first.** No way around it — solitaire removes players,
not rules.
- **The MVP is roughly half the finished system.** Unusually high, because the rules do not shrink.
- **The server is the cheapest group at MVP and grows the most proportionally** (~3.5×), almost
entirely in lobby, redaction and fan-out — all multiplayer concerns.
- **Dev-side is ~15% of the work.** That is proportionate for a game whose rules bugs are silent.
---
## 6. Worked trace — one Move, end to end
Following a single action through every component, to show where the seams are. The player moves a
Crew Tray onto a card holding two standing cars, which couples them automatically (§A.4).
| # | Component | What happens |
| --- | --- | --- |
| 6 | Legal-action enumeration | Computes reachable Operational Rail squares from the tray's position, respecting turnout direction and the no-reversal rule. The occupied card is among them. |
| 15 | Action UI | Highlights those squares. Player clicks the occupied one. |
| 15 | Action UI | Emits `Switch.Move { trayId, to }`. Note it does **not** emit a couple intent — coupling is mandatory, not a choice. |
| 11 | Transport | Carries the intent to the server. |
| 8 | Session host | Checks it is this player's turn and the right phase; hands it to the engine. |
| 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. |
| 3 | Track graph | Confirms the path exists without a direction change. |
| 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. |
| 9 | Event log | Appends both events to disk. |
| 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. |
| 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. |
| 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. |
| 11 | Transport | Pushes events and the new view. |
| 13 | Client store | Applies both events in order. |
| 14 | Board renderer | Redraws the tray at its new position with the coupled cars in the consist. |
| 15 | Action UI | Updates the Moves counter and the highlighted squares. |
Two things this makes concrete. **Coupling never appears as an intent** — it is a consequence the
engine produces, so a client that offered it as a choice would be wrong. And **component 6 runs
twice**, before and after: affordances are a projection of state, not a response to an action.
## 7. Design decisions this surfaces
The point of doing this before coding — questions now answerable, or now visibly needing an answer.
**Settled by the shape above:**
- **The phase driver belongs in the engine.** Without it the balance harness (18) would have to
reimplement the phase loop, and the two would drift.
- **`legalActions` must share predicates with `apply`**, not reimplement them. Two independent copies
of turnout directionality will disagree eventually, and the failure is a legal move the UI refuses
or an illegal one it offers.
- **Persist events, not snapshots.** Components 9, 13 and 16 all assume it, and retrofitting it later
means re-instrumenting everything.
- **The RNG must be threaded, not ambient.** One stray global random call and replay silently breaks.
**Settled since:**
- **Stack: TypeScript**, chosen precisely so the engine can run in both places. Node 22 executes
TypeScript natively by type stripping, so the engine, server and tests need no build step during
development; `tsc --noEmit` provides typechecking separately. Note the constraint this implies:
**erasable syntax only** — no `enum`, no parameter properties, no namespaces.
- **The engine ships to the browser.** It is pure and has no I/O, so component 6 runs client-side for
instant affordances without a round-trip, and there is exactly one implementation of turnout
directionality rather than two that can disagree.
- **The client computes affordances locally**, following from the above. Component 10 sends state,
not affordance lists.
**Now visible, still open:**
- **How is the Office grid rendered — DOM, SVG or canvas?** Component 14's finished size varies by
roughly 2× across those choices, and component 15 depends on the hit-testing model the choice
implies.
- **What does the replay viewer show?** Hands were private during play. Revealing them afterwards is a
design question, not a technical one — noted in `overview.md` and still unanswered.