442 lines
21 KiB
Markdown
442 lines
21 KiB
Markdown
# 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 4–6 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 1–4, 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 | 1–7 | ~2,650 | ~3,980 |
|
||
| B. Server | 8–12, 20 | ~610 | ~2,100 |
|
||
| C. Browser | 13–16 | ~1,250 | ~3,300 |
|
||
| D. Dev-side | 17–19 | ~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.
|