Compare commits
@@ -50,3 +50,9 @@ __pycache__/
|
|||||||
# one that is worth publishing.
|
# one that is worth publishing.
|
||||||
/playtests/*
|
/playtests/*
|
||||||
!/playtests/README.md
|
!/playtests/README.md
|
||||||
|
|
||||||
|
# The Jitsi harness is NOT scrubbed — four of its files carry a real domain and real
|
||||||
|
# participant names (workspace AGENTS.local.md). Ignored so that a `git add -A` cannot sweep it
|
||||||
|
# into a history that would be permanently exposed if this repo is ever made public. When it is
|
||||||
|
# promoted in Phase 1, scrub it FIRST, then `git add -f tools/`.
|
||||||
|
tools/
|
||||||
|
|||||||
+1278
File diff suppressed because it is too large
Load Diff
@@ -35,8 +35,19 @@ deliberately no longer names one: it went stale for six releases.
|
|||||||
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
||||||
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
||||||
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
||||||
under Multiplayer — chiefly that **a player cannot see what the others did**, and that a lost
|
under Multiplayer — chiefly that a lost
|
||||||
session token still locks someone out of a running game from a genuinely fresh browser.
|
session token still locks someone out of a running game from a genuinely fresh browser.
|
||||||
|
- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does
|
||||||
|
anything, becomes an ordered **presentation step**: the board replays other people's turns instead
|
||||||
|
of arriving already rearranged. This is what closes "a player cannot see what the others did",
|
||||||
|
which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
|
||||||
|
`driveBots()` plays it out before the push goes back; now it arrives as a run of steps, the
|
||||||
|
district panel follows whoever is acting, and a `[N behind] … [Skip]` row says how far the board is
|
||||||
|
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||||
|
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||||
|
where its automatic phases finally get a visible beat.
|
||||||
|
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||||
|
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||||
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
||||||
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||||||
@@ -131,6 +142,18 @@ is the thing this machinery exists to prevent.
|
|||||||
and they do not reconstruct the position. The phase driver mutates state and then describes it, so
|
and they do not reconstruct the position. The phase driver mutates state and then describes it, so
|
||||||
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||||||
replays the intents.
|
replays the intents.
|
||||||
|
- **A save is only guaranteed to replay on the version that wrote it.** This is the cost of the
|
||||||
|
property above and is not a bug to be fixed case by case: a save is a list of moves, so it reopens
|
||||||
|
by being *re-played through the current rules*. Any rules change that makes a once-legal move
|
||||||
|
illegal will stop an older save at that move — **a change to the deck is the likeliest breaker**,
|
||||||
|
since a history that names a card the deck no longer deals has no legal answer at all, but any
|
||||||
|
narrowing of what is permitted does it. It fails safe in every case: the load is declined, the
|
||||||
|
offending move is named, and the file is left untouched, so nothing a player has is destroyed.
|
||||||
|
Assume an older save may not open, tell players so wherever a build is announced, and read "this
|
||||||
|
save will not load" in a bug report as this before treating it as a fault. **Versioned, migratable
|
||||||
|
replays are a post-1.0 question** — deliberately not worth the effort while the rules are still
|
||||||
|
moving this fast, since every migration would have to be written against rules that changed again
|
||||||
|
next release.
|
||||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||||||
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
||||||
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
|||||||
|
# Station Master — the cards as built
|
||||||
|
|
||||||
|
> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by
|
||||||
|
> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file
|
||||||
|
> and the code disagree.
|
||||||
|
|
||||||
|
This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything
|
||||||
|
else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)
|
||||||
|
transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in
|
||||||
|
them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and
|
||||||
|
[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the
|
||||||
|
reasoning; read this for the numbers.
|
||||||
|
|
||||||
|
The engine instantiates from the same constants this is emitted from, so a disagreement between
|
||||||
|
this page and the game is a bug in the generator, not a stale table.
|
||||||
|
|
||||||
|
**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with
|
||||||
|
play balance, so a document that prints them is answering a question that will have a different
|
||||||
|
answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at
|
||||||
|
all — which is a fact about the design rather than about the current tuning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trains
|
||||||
|
|
||||||
|
12 timetabled and 10 Extras, 22 in all.
|
||||||
|
Odd numbers run west, even run east; a pair shares a class and is the same card face in two
|
||||||
|
directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no
|
||||||
|
train with a caboose carries more than three revenue cars.
|
||||||
|
|
||||||
|
### Timetabled
|
||||||
|
|
||||||
|
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||||
|
| ---: | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||||
|
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||||
|
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||||
|
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||||
|
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
|
||||||
|
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
|
||||||
|
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||||
|
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||||
|
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
|
||||||
|
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
|
||||||
|
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
|
||||||
|
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
|
||||||
|
|
||||||
|
### Extras
|
||||||
|
|
||||||
|
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||||
|
| ---: | --- | --- | --- | --- | --- |
|
||||||
|
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
|
||||||
|
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
|
||||||
|
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
|
||||||
|
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
|
||||||
|
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
|
||||||
|
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
|
||||||
|
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
|
||||||
|
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
|
||||||
|
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
|
||||||
|
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mainline cards
|
||||||
|
|
||||||
|
A card is divided into **regions**, and a train advances one region per Stage — so the regions a
|
||||||
|
card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast
|
||||||
|
train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than
|
||||||
|
part of the road all change the entry point rather than the card's length.
|
||||||
|
|
||||||
|
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |
|
||||||
|
| --- | ---: | ---: | --- | :---: | :---: |
|
||||||
|
| Plains | 1 | 0 | — | — | — |
|
||||||
|
| Curves | 2 | 0 | — | — | — |
|
||||||
|
| Hilly | 2 | 0 | 1 / 0 | — | — |
|
||||||
|
| Heavy Grade | 3 | 0 | — | — | — |
|
||||||
|
| Double Track | 1 | 0 | — | yes | — |
|
||||||
|
| Uncontrolled Siding | 2 | 1 | — | — | — |
|
||||||
|
| Tunnel | 2 | 0 | — | — | — |
|
||||||
|
| Trestle | 1 | 0 | — | — | — |
|
||||||
|
| Interchange | 2 | 1 | — | — | yes |
|
||||||
|
|
||||||
|
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
|
||||||
|
the Division and are not dealt. What each card does, in the words the game uses on screen:
|
||||||
|
|
||||||
|
- **Plains** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
|
||||||
|
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
|
||||||
|
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Office cards
|
||||||
|
|
||||||
|
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
|
||||||
|
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
|
||||||
|
to Porters rather than one more.
|
||||||
|
|
||||||
|
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
|
||||||
|
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
|
||||||
|
| Depot | yes | yes | 2 | 1 | 1 | 1 |
|
||||||
|
| Station | yes | yes | 3 | 2 | 2 | 2 |
|
||||||
|
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
|
||||||
|
|
||||||
|
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
|
||||||
|
supply of 8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freight facilities
|
||||||
|
|
||||||
|
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
|
||||||
|
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
|
||||||
|
build one end of a chain or the other, never both, which is what forces traffic to run between
|
||||||
|
districts rather than in circles inside one. No two of the same industry may share an Office Area,
|
||||||
|
and that rule is enforced for every kind rather than repeated in each row.
|
||||||
|
|
||||||
|
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
|
||||||
|
| --- | --- | --- | ---: | ---: | ---: | --- |
|
||||||
|
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
|
||||||
|
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
|
||||||
|
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
|
||||||
|
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
|
||||||
|
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
|
||||||
|
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modifier cards
|
||||||
|
|
||||||
|
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
|
||||||
|
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
|
||||||
|
is not one.
|
||||||
|
|
||||||
|
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
|
||||||
|
| --- | --- | ---: | ---: | ---: | ---: |
|
||||||
|
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
|
||||||
|
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
|
||||||
|
| Hotel | any Passenger Facility | 1 | — | — | 1 |
|
||||||
|
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
|
||||||
|
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
|
||||||
|
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
|
||||||
|
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
|
||||||
|
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
|
||||||
|
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
|
||||||
|
| Pipelines | Refinery | 1 | — | 1 | — |
|
||||||
|
| Oil Depot | Refinery | 1 | — | 1 | — |
|
||||||
|
| Viscosity breakers | Refinery | 1 | — | 1 | — |
|
||||||
|
| Transmission lines | Power Plant | — | — | 1 | — |
|
||||||
|
| Rotary Dumps | Power Plant | — | — | 1 | — |
|
||||||
|
| Steam Turbines | Power Plant | — | — | 1 | — |
|
||||||
|
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
|
||||||
|
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Track cards
|
||||||
|
|
||||||
|
Track is **in the Home Office deck** and is drawn and played like any other card — not a separate
|
||||||
|
per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout
|
||||||
|
may be run through but not stopped on.
|
||||||
|
|
||||||
|
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
|
||||||
|
| --- | --- | --- | :---: | ---: | :---: |
|
||||||
|
| Straight track | straight | none | yes | 1 | yes |
|
||||||
|
| Curved track (right) | curved | right | yes | 1 | yes |
|
||||||
|
| Curved track (left) | curved | left | yes | 1 | yes |
|
||||||
|
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
|
||||||
|
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
|
||||||
|
| Turnout (right) | turnout | right | — | 1 | yes |
|
||||||
|
| Turnout (left) | turnout | left | — | 1 | yes |
|
||||||
|
|
||||||
|
A row marked "no" is a shape the engine understands but the deck does not currently print.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enhancements
|
||||||
|
|
||||||
|
The column that only the implementation can fill in: **whether the printed effect actually
|
||||||
|
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
|
||||||
|
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
|
||||||
|
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
|
||||||
|
cannot carry this column, which is the argument for generating the page rather than writing it.
|
||||||
|
|
||||||
|
| Enhancement | Placement | Requires | Effect resolves |
|
||||||
|
| --- | --- | --- | :---: |
|
||||||
|
| Interlocking | runningTrackStraight | — | **live** |
|
||||||
|
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
||||||
|
| Yard office | secondaryTrackStraight | — | **live** |
|
||||||
|
| Small yard | secondaryTrackStraight | — | **live** |
|
||||||
|
| Water column | runningTrackStraight | — | **dormantSolo** |
|
||||||
|
| Overpass | onCard | — | **unbuilt** |
|
||||||
|
| Telegraph | runningTrackStraight | — | **live** |
|
||||||
|
| Telephone | onCard | telegraph on the same card | **live** |
|
||||||
|
| Radio | onCard | telephone on the same card | **live** |
|
||||||
|
| ABS Signals | mainlineCard | — | **live** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opponent-directed cards, and what answers them
|
||||||
|
|
||||||
|
**None of these is dealt in any deck today.** A card that can only be played at another player
|
||||||
|
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
|
||||||
|
a draw as the attack — so both halves are held out until the attacks are implemented. They are
|
||||||
|
listed because they are the design, and because what a defence answers is the only record of why
|
||||||
|
it exists.
|
||||||
|
|
||||||
|
### Action cards — opponent-directed
|
||||||
|
|
||||||
|
| Card | Played on | Effect | Answers |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
|
||||||
|
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
|
||||||
|
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
|
||||||
|
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
|
||||||
|
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
|
||||||
|
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
|
||||||
|
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
|
||||||
|
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
|
||||||
|
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
|
||||||
|
|
||||||
|
### Space-use cards — opponent-directed
|
||||||
|
|
||||||
|
| Card | Played on | Effect | Answers |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||||
|
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||||
|
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
|
||||||
|
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
|
||||||
|
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||||
|
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||||
|
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||||
|
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||||
|
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||||
|
|
||||||
|
### Maneuver cards
|
||||||
|
|
||||||
|
| Card | Played on | Effect | Answers |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
|
||||||
|
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
|
||||||
|
| Poling | any time | TBD in the source. | — |
|
||||||
|
|
||||||
|
Mainline modifier cards, for completeness — these ARE dealt:
|
||||||
|
|
||||||
|
| Card | Played on | Effect | Answers |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
|
||||||
|
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
|
||||||
|
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
|
||||||
|
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
|
||||||
|
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
|
||||||
|
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed
|
> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed
|
||||||
> in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison.
|
> in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison.
|
||||||
>
|
>
|
||||||
|
> **For what the cards say today, read [`as-built.md`](as-built.md)** — generated from
|
||||||
|
> `content.ts` and checked against it by the test suite, so it cannot fall behind the way this file
|
||||||
|
> did. For several releases `content.ts` named *this* page as the current reference while the banner
|
||||||
|
> here said otherwise, and a reader following the code landed on the v0.4.5 deck.
|
||||||
|
>
|
||||||
> Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to
|
> Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to
|
||||||
> ask the design. **Do not use its numbers.**
|
> ask the design. **Do not use its numbers.**
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.7",
|
"version": "0.8.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
@@ -9,11 +9,12 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"pretest": "node scripts/build-web.ts",
|
"pretest": "tsc --noEmit && node scripts/build-web.ts",
|
||||||
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
||||||
"build:web": "node scripts/build-web.ts",
|
"build:web": "node scripts/build-web.ts",
|
||||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||||
"deploy:web": "node scripts/deploy-web.ts"
|
"deploy:web": "node scripts/deploy-web.ts",
|
||||||
|
"build:cards": "node scripts/build-card-reference.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Generate `docs/rules/as-built.md` — what the cards say, as the code actually has them.
|
||||||
|
*
|
||||||
|
* WHY THIS IS GENERATED RATHER THAN WRITTEN.
|
||||||
|
*
|
||||||
|
* Every other file in `docs/rules/` is a historical record and says so: `rules-v0.1.md` is a
|
||||||
|
* faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker,
|
||||||
|
* `rules-v0.2.md` and `card-reference.md` both carry SUPERSEDED banners. None of them describes the
|
||||||
|
* game as built, and none of them should be edited to — the record is worth more intact than
|
||||||
|
* patched.
|
||||||
|
*
|
||||||
|
* So there was no current reference at all, and `content.ts` spent several releases pointing at
|
||||||
|
* `card-reference.md` as "the place that now carries what the cards say" while that file's own
|
||||||
|
* banner said "do not use its numbers". A reader following the code's advice landed on the v0.4.5
|
||||||
|
* deck: twelve numbered trains, "3 / 4 Mail-Express, 3 coaches", against a `content.ts` whose train
|
||||||
|
* 3 is the Express with two freight cars and a per-location freight rule.
|
||||||
|
*
|
||||||
|
* A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing
|
||||||
|
* fails when a table falls behind a constant. So the reference is emitted from the same exported
|
||||||
|
* catalogues the engine instantiates from, and `test/card-reference.test.ts` re-runs this generator
|
||||||
|
* and asserts the checked-in file matches byte for byte. Change a card face and the suite goes red
|
||||||
|
* until the doc is regenerated — which is the only mechanism this project has found that keeps a
|
||||||
|
* document honest.
|
||||||
|
*
|
||||||
|
* `npm run build:cards` writes it. Nothing at runtime reads it; it is for people.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACTION_CARDS, ALL_TRAINS, ENHANCEMENT_CARDS, ENHANCEMENT_RULES, EXTRA_TRAINS, INDUSTRY_PROFILES,
|
||||||
|
LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS,
|
||||||
|
MODIFIER_PROFILES, OFFICE_PROFILES, SPACE_USE_CARDS, TIMETABLED_TRAINS, TRACK_CARDS,
|
||||||
|
WHISTLE_POST_SUPPLY, consistSize, isOpponentOnly, mainlineDescription,
|
||||||
|
} from '../src/engine/content.ts';
|
||||||
|
import type { ConsistSpec, SimpleCard, TrainProfile, TrainRules } from '../src/engine/content.ts';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
/** Title Case a camelCase key, so `oneFreightPerLocation` reads as a rule rather than an identifier. */
|
||||||
|
const words = (k: string): string => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
|
||||||
|
|
||||||
|
const consistOf = (c: ConsistSpec): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (c.freight > 0) {
|
||||||
|
const kinds = c.freightTypes ? c.freightTypes.join(' or ') : 'freight';
|
||||||
|
parts.push(`${c.freight} ${kinds}${c.freight === 1 ? '' : c.freightTypes ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
if (c.coach > 0) parts.push(`${c.coach} coach${c.coach === 1 ? '' : 'es'}`);
|
||||||
|
if (c.caboose > 0) parts.push(`${c.caboose} caboose`);
|
||||||
|
if (!parts.length) return 'engine only';
|
||||||
|
const n = consistSize(c);
|
||||||
|
// "Empties only" qualifies the whole consist rather than adding to it, so it reads after the count.
|
||||||
|
return `${parts.join(' + ')} (${n} piece${n === 1 ? '' : 's'})${c.emptiesOnly ? ', empties only' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rulesOf = (r: TrainRules): string => {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(r)) {
|
||||||
|
if (k === 'note' || v === false || v === undefined) continue;
|
||||||
|
out.push(words(k));
|
||||||
|
}
|
||||||
|
if (typeof r.note === 'string') out.push(`*"${r.note}"*`);
|
||||||
|
return out.length ? out.join('; ') : '—';
|
||||||
|
};
|
||||||
|
|
||||||
|
const trainRow = (t: TrainProfile): string =>
|
||||||
|
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
|
||||||
|
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const w = (s = ''): void => void lines.push(s);
|
||||||
|
|
||||||
|
w('# Station Master — the cards as built');
|
||||||
|
w();
|
||||||
|
w('> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by');
|
||||||
|
w('> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file');
|
||||||
|
w('> and the code disagree.');
|
||||||
|
w();
|
||||||
|
w('This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything');
|
||||||
|
w('else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)');
|
||||||
|
w('transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in');
|
||||||
|
w('them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and');
|
||||||
|
w('[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the');
|
||||||
|
w('reasoning; read this for the numbers.');
|
||||||
|
w();
|
||||||
|
w('The engine instantiates from the same constants this is emitted from, so a disagreement between');
|
||||||
|
w('this page and the game is a bug in the generator, not a stale table.');
|
||||||
|
w();
|
||||||
|
w('**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with');
|
||||||
|
w('play balance, so a document that prints them is answering a question that will have a different');
|
||||||
|
w('answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at');
|
||||||
|
w('all — which is a fact about the design rather than about the current tuning.');
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Trains');
|
||||||
|
w();
|
||||||
|
w(`${TIMETABLED_TRAINS.length} timetabled and ${EXTRA_TRAINS.length} Extras, ${ALL_TRAINS.length} in all.`);
|
||||||
|
w('Odd numbers run west, even run east; a pair shares a class and is the same card face in two');
|
||||||
|
w('directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no');
|
||||||
|
w('train with a caboose carries more than three revenue cars.');
|
||||||
|
w();
|
||||||
|
w('### Timetabled');
|
||||||
|
w();
|
||||||
|
w('| # | Class | Speed | Runs | Consist | Printed rules |');
|
||||||
|
w('| ---: | --- | --- | --- | --- | --- |');
|
||||||
|
for (const t of TIMETABLED_TRAINS) w(trainRow(t));
|
||||||
|
w();
|
||||||
|
w('### Extras');
|
||||||
|
w();
|
||||||
|
w('| # | Class | Speed | Runs | Consist | Printed rules |');
|
||||||
|
w('| ---: | --- | --- | --- | --- | --- |');
|
||||||
|
for (const t of EXTRA_TRAINS) w(trainRow(t));
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Mainline cards');
|
||||||
|
w();
|
||||||
|
w('A card is divided into **regions**, and a train advances one region per Stage — so the regions a');
|
||||||
|
w('card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast');
|
||||||
|
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
|
||||||
|
w('part of the road all change the entry point rather than the card\'s length.');
|
||||||
|
w();
|
||||||
|
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |');
|
||||||
|
w('| --- | ---: | ---: | --- | :---: | :---: |');
|
||||||
|
for (const m of MAINLINE_PROFILES) {
|
||||||
|
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
|
||||||
|
w(`| ${m.name} | ${m.regions} | ${m.defaultStart} | ${ss} | ${m.trainsMayPass ? 'yes' : '—'} | ${m.sortsCars ? 'yes' : '—'} |`);
|
||||||
|
}
|
||||||
|
w();
|
||||||
|
w(`The Mainline deck is ${MAINLINE_DECK.length} cards; the two Division Points are the fixed ends of`);
|
||||||
|
w('the Division and are not dealt. What each card does, in the words the game uses on screen:');
|
||||||
|
w();
|
||||||
|
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Office cards');
|
||||||
|
w();
|
||||||
|
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in');
|
||||||
|
w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
|
||||||
|
w('to Porters rather than one more.');
|
||||||
|
w();
|
||||||
|
w('| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |');
|
||||||
|
w('| --- | :---: | :---: | ---: | ---: | ---: | ---: |');
|
||||||
|
for (const o of OFFICE_PROFILES) {
|
||||||
|
w(`| ${o.name} | ${o.isControlPoint ? 'yes' : '—'} | ${o.isPassengerFacility ? 'yes' : '—'} | ` +
|
||||||
|
`${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} |`);
|
||||||
|
}
|
||||||
|
w();
|
||||||
|
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
|
||||||
|
w(`supply of ${LIMITS_SUPPLY}.`);
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Freight facilities');
|
||||||
|
w();
|
||||||
|
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit');
|
||||||
|
w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may');
|
||||||
|
w('build one end of a chain or the other, never both, which is what forces traffic to run between');
|
||||||
|
w('districts rather than in circles inside one. No two of the same industry may share an Office Area,');
|
||||||
|
w('and that rule is enforced for every kind rather than repeated in each row.');
|
||||||
|
w();
|
||||||
|
w('| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |');
|
||||||
|
w('| --- | --- | --- | ---: | ---: | ---: | --- |');
|
||||||
|
for (const f of INDUSTRY_PROFILES) {
|
||||||
|
const lo = f.lockouts.length
|
||||||
|
? f.lockouts.map((k) => INDUSTRY_PROFILES.find((p) => p.kind === k)?.name ?? k).join(', ')
|
||||||
|
: '—';
|
||||||
|
w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
|
||||||
|
}
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Modifier cards');
|
||||||
|
w();
|
||||||
|
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger');
|
||||||
|
w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
|
||||||
|
w('is not one.');
|
||||||
|
w();
|
||||||
|
w('| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |');
|
||||||
|
w('| --- | --- | ---: | ---: | ---: | ---: |');
|
||||||
|
for (const m of MODIFIER_PROFILES) {
|
||||||
|
const hosts = m.hosts
|
||||||
|
.map((h) => (h === 'office' ? 'any Passenger Facility' : INDUSTRY_PROFILES.find((p) => p.kind === h)?.name ?? h))
|
||||||
|
.join(', ');
|
||||||
|
w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
|
||||||
|
}
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Track cards');
|
||||||
|
w();
|
||||||
|
w('Track is **in the Home Office deck** and is drawn and played like any other card — not a separate');
|
||||||
|
w('per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout');
|
||||||
|
w('may be run through but not stopped on.');
|
||||||
|
w();
|
||||||
|
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
|
||||||
|
w('| --- | --- | --- | :---: | ---: | :---: |');
|
||||||
|
for (const t of TRACK_CARDS) {
|
||||||
|
w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck ? 'yes' : 'no'} |`);
|
||||||
|
}
|
||||||
|
w();
|
||||||
|
w('A row marked "no" is a shape the engine understands but the deck does not currently print.');
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Enhancements');
|
||||||
|
w();
|
||||||
|
w('The column that only the implementation can fill in: **whether the printed effect actually');
|
||||||
|
w('resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack');
|
||||||
|
w('but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a');
|
||||||
|
w('solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription');
|
||||||
|
w('cannot carry this column, which is the argument for generating the page rather than writing it.');
|
||||||
|
w();
|
||||||
|
w('| Enhancement | Placement | Requires | Effect resolves |');
|
||||||
|
w('| --- | --- | --- | :---: |');
|
||||||
|
for (const r of ENHANCEMENT_RULES) {
|
||||||
|
const card = ENHANCEMENT_CARDS.find((c) => c.key === r.key);
|
||||||
|
const needs = r.requiresOnSameCard
|
||||||
|
? `${r.requiresOnSameCard} on the same card`
|
||||||
|
: r.requiresInDistrict
|
||||||
|
? `${r.requiresInDistrict} in the district`
|
||||||
|
: '—';
|
||||||
|
w(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
|
||||||
|
}
|
||||||
|
w();
|
||||||
|
w('---');
|
||||||
|
w();
|
||||||
|
|
||||||
|
w('## Opponent-directed cards, and what answers them');
|
||||||
|
w();
|
||||||
|
w('**None of these is dealt in any deck today.** A card that can only be played at another player');
|
||||||
|
w('has no legal target in a solitaire game, and a defence with nothing to defend against is as dead');
|
||||||
|
w('a draw as the attack — so both halves are held out until the attacks are implemented. They are');
|
||||||
|
w('listed because they are the design, and because what a defence answers is the only record of why');
|
||||||
|
w('it exists.');
|
||||||
|
w();
|
||||||
|
const pvp = (title: string, cards: readonly SimpleCard[], category: string): void => {
|
||||||
|
w(`### ${title}${isOpponentOnly(category) ? ' — opponent-directed' : ''}`);
|
||||||
|
w();
|
||||||
|
w('| Card | Played on | Effect | Answers |');
|
||||||
|
w('| --- | --- | --- | --- |');
|
||||||
|
for (const c of cards) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
|
||||||
|
w();
|
||||||
|
};
|
||||||
|
pvp('Action cards', ACTION_CARDS, 'action');
|
||||||
|
pvp('Space-use cards', SPACE_USE_CARDS, 'spaceUse');
|
||||||
|
pvp('Maneuver cards', MANEUVER_CARDS, 'maneuver');
|
||||||
|
w('Mainline modifier cards, for completeness — these ARE dealt:');
|
||||||
|
w();
|
||||||
|
w('| Card | Played on | Effect | Answers |');
|
||||||
|
w('| --- | --- | --- | --- |');
|
||||||
|
for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
|
||||||
|
w();
|
||||||
|
|
||||||
|
writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`);
|
||||||
|
console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`);
|
||||||
+37
-8
@@ -688,8 +688,16 @@ function spendDispatchBonus(
|
|||||||
const mine = tray.trainNumber ?? 99;
|
const mine = tray.trainNumber ?? 99;
|
||||||
const theirs = other.trainNumber ?? 99;
|
const theirs = other.trainNumber ?? 99;
|
||||||
|
|
||||||
// The Fedora is held by a PLAYER, and the dispatch devices are installed in an Office Area, which
|
/**
|
||||||
// is keyed by SEAT. Indexing one with the other is right only while seating is the identity map.
|
* The Fedora is held by a PLAYER; the devices are installed in an Office Area, which is keyed by
|
||||||
|
* SEAT. `areaOf` is what reconciles the two — it resolves through `seatOf` — so this is correct
|
||||||
|
* under Employee Rotation and not only while seating happens to be the identity map.
|
||||||
|
*
|
||||||
|
* This comment used to say the opposite, warning that indexing one with the other was safe only
|
||||||
|
* while seating was identity. It read as a live bug and was not one: `areaOf(s, player)` IS
|
||||||
|
* `areaAtSeat(s, seatOf(s, player))`. Pinned by test rather than asserted here — see #101's
|
||||||
|
* "spends the Superintendent's own devices under non-identity seating".
|
||||||
|
*/
|
||||||
const area = areaOf(s, s.clock.superintendent);
|
const area = areaOf(s, s.clock.superintendent);
|
||||||
|
|
||||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
||||||
@@ -727,7 +735,11 @@ function spendDispatchBonus(
|
|||||||
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
||||||
* stop, so the train is exempt once and subject to the fault thereafter.
|
* stop, so the train is exempt once and subject to the fault thereafter.
|
||||||
*/
|
*/
|
||||||
function isExpedited(tray: CrewTray): boolean {
|
export function isExpedited(tray: {
|
||||||
|
trainNumber: number | null;
|
||||||
|
trainIsExtra: boolean;
|
||||||
|
speechMade?: boolean;
|
||||||
|
}): boolean {
|
||||||
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
||||||
if (!rules) return false;
|
if (!rules) return false;
|
||||||
if (rules.expedite) return true;
|
if (rules.expedite) return true;
|
||||||
@@ -1626,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
|
|
||||||
s.clock.day += 1;
|
s.clock.day += 1;
|
||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
|
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||||
|
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||||
|
s.collisionsPrevDay = s.collisionsToday;
|
||||||
s.collisionsToday = 0;
|
s.collisionsToday = 0;
|
||||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||||
rotateSeats(s, events);
|
rotateSeats(s, events);
|
||||||
@@ -1636,11 +1651,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
|
/**
|
||||||
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
|
* §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
|
||||||
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
|
* game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
|
||||||
// collide, not a bigger shared budget.
|
* by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
|
||||||
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
|
* not a bigger shared budget.
|
||||||
|
*
|
||||||
|
* SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode ===
|
||||||
|
* 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game
|
||||||
|
* dialog offered them as live settings — so a solitaire player could set a collision limit, read
|
||||||
|
* "the game ends in a loss" beside it, and crash as often as they liked. Found reviewing that
|
||||||
|
* screen's wording (Jesse, 2026-08-30); his ruling is that the settings should do what they say,
|
||||||
|
* so the gate is gone rather than the controls.
|
||||||
|
*
|
||||||
|
* A SOLITAIRE GAME CAN THEREFORE NOW END EARLY, which no measurement in `TODO.md` was taken
|
||||||
|
* under. At the shipped defaults (3 a Day, 5 total) it is a rare ending rather than a common one —
|
||||||
|
* the bot averages 0.06 collisions a game — but any figure quoted from a full-length run predates
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
{
|
||||||
const perDayBreach =
|
const perDayBreach =
|
||||||
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
||||||
const totalBreach =
|
const totalBreach =
|
||||||
|
|||||||
+24
-7
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
FREIGHT_PROFILES,
|
FREIGHT_PROFILES,
|
||||||
HAND_LIMIT,
|
|
||||||
LABORER_ACTIONS_PER_LOAD,
|
LABORER_ACTIONS_PER_LOAD,
|
||||||
MAX_CONSIST,
|
MAX_CONSIST,
|
||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
@@ -61,6 +60,7 @@ import {
|
|||||||
decisionActor,
|
decisionActor,
|
||||||
officeNodeFor,
|
officeNodeFor,
|
||||||
isOperationalRail,
|
isOperationalRail,
|
||||||
|
overHandLimit,
|
||||||
playerAtSeat,
|
playerAtSeat,
|
||||||
pooled,
|
pooled,
|
||||||
railFacingOf,
|
railFacingOf,
|
||||||
@@ -607,7 +607,8 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
|||||||
return `${trayId}@${coordKey(at)}`;
|
return `${trayId}@${coordKey(at)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
/** Exported so the Blocked panel counts a freight car the same way the reducers do (Gitea#21). */
|
||||||
|
export const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
|
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
|
||||||
@@ -657,6 +658,20 @@ function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
|||||||
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHETHER TRAINS 3/4's PRINTED RULE IS WHAT IS STOPPING THIS CREW WHERE IT STANDS (Gitea#21).
|
||||||
|
*
|
||||||
|
* Exported for the Blocked panel, which needs to say so — and asks `freightBudgetLeft`, the same
|
||||||
|
* predicate the reducer refuses on, rather than rebuilding the key for itself. `narrate.ts` cannot
|
||||||
|
* then drift from the rule it is describing, which is the whole premise of that panel.
|
||||||
|
*/
|
||||||
|
export function freightRuleSpentHere(s: GameState, player: PlayerIndex, trayId: TrayId): boolean {
|
||||||
|
const tray = s.trays.get(trayId);
|
||||||
|
if (!tray || !rulesOf(tray).oneFreightPerLocation) return false;
|
||||||
|
if (tray.position.at !== 'grid') return false;
|
||||||
|
return !freightBudgetLeft(s, player, tray, tray.position.coord, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge freight cars against this train's per-location budget (trains 3/4).
|
* Charge freight cars against this train's per-location budget (trains 3/4).
|
||||||
*
|
*
|
||||||
@@ -1084,10 +1099,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
case 'draw.end': {
|
case 'draw.end': {
|
||||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||||
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||||
// §6.2 — "the player must reduce his hand to no more than three cards".
|
// §6.2 — "the player must reduce his hand to no more than three cards". `overHandLimit`
|
||||||
const hand = s.decks.hands.get(player) ?? [];
|
// (state.ts) is the one copy of that test; the Frame and the page ask the same function.
|
||||||
const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
return overHandLimit(s, player) ? 'HAND_LIMIT' : null;
|
||||||
return hand.length > limit ? 'HAND_LIMIT' : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Freight Agent --------------------------------------------------------
|
// -- Freight Agent --------------------------------------------------------
|
||||||
@@ -1628,6 +1642,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'trayMoved',
|
type: 'trayMoved',
|
||||||
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
from,
|
from,
|
||||||
to: i.to,
|
to: i.to,
|
||||||
@@ -1692,6 +1707,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
// decides which car is next to come off.
|
// decides which car is next to come off.
|
||||||
events.push({
|
events.push({
|
||||||
type: 'carsCoupled',
|
type: 'carsCoupled',
|
||||||
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
at: i.to,
|
at: i.to,
|
||||||
stock: dest.couples,
|
stock: dest.couples,
|
||||||
@@ -1712,7 +1728,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
const stock = i.fromNose
|
const stock = i.fromNose
|
||||||
? tray.consist.slice(0, i.count)
|
? tray.consist.slice(0, i.count)
|
||||||
: tray.consist.slice(tray.consist.length - i.count);
|
: tray.consist.slice(tray.consist.length - i.count);
|
||||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
return [{ type: 'carsDropped', player, trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'switch.sortConsist': {
|
case 'switch.sortConsist': {
|
||||||
@@ -1721,6 +1737,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'consistSorted',
|
type: 'consistSorted',
|
||||||
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
at: here,
|
at: here,
|
||||||
before: tray.consist.map((c) => ({ ...c })),
|
before: tray.consist.map((c) => ({ ...c })),
|
||||||
|
|||||||
@@ -481,8 +481,11 @@ export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
|
|||||||
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
|
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
|
||||||
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
|
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
|
||||||
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
|
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
|
||||||
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
|
* still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what
|
||||||
* what the cards say.
|
* the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with
|
||||||
|
* `test/card-reference.test.ts` failing if the two disagree. This comment used to name
|
||||||
|
* `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its
|
||||||
|
* numbers; the code sent readers to a table it had itself superseded.
|
||||||
*/
|
*/
|
||||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
||||||
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
|
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ export type GameEvent =
|
|||||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||||
* choice the player made invisible in their own log.
|
* choice the player made invisible in their own log.
|
||||||
*/
|
*/
|
||||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||||
| {
|
| {
|
||||||
type: 'carsCoupled';
|
type: 'carsCoupled';
|
||||||
|
player: PlayerIndex;
|
||||||
trayId: TrayId;
|
trayId: TrayId;
|
||||||
at: GridCoord;
|
at: GridCoord;
|
||||||
stock: RollingStock[];
|
stock: RollingStock[];
|
||||||
@@ -71,8 +72,8 @@ export type GameEvent =
|
|||||||
*/
|
*/
|
||||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||||
}
|
}
|
||||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||||
/**
|
/**
|
||||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
|
|||||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||||
movedThisPhase: new Set(),
|
movedThisPhase: new Set(),
|
||||||
collisionsToday: 0,
|
collisionsToday: 0,
|
||||||
|
collisionsPrevDay: 0,
|
||||||
collisionsTotal: 0,
|
collisionsTotal: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
outcome: null,
|
outcome: null,
|
||||||
|
|||||||
+28
-1
@@ -17,7 +17,7 @@ import type {
|
|||||||
OfficeTier,
|
OfficeTier,
|
||||||
TrackGeometry,
|
TrackGeometry,
|
||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import { MAX_CONSIST, officeProfile } from './content.ts';
|
import { HAND_LIMIT, MAX_CONSIST, officeProfile } from './content.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identifiers
|
// Identifiers
|
||||||
@@ -1040,6 +1040,19 @@ export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §6.2 — IS THIS PLAYER HOLDING MORE THAN THEY MAY? Three cards, or four while a Red Flag is held.
|
||||||
|
*
|
||||||
|
* ONE answer, because there were three of them: `check('draw.end')` refused on it, `snapshot()`
|
||||||
|
* recomputed it inline for the Frame, and `web/game.ts` kept a third for the page. All three agreed
|
||||||
|
* — which is the state a disagreement starts from, and #96 is what that costs when the two halves
|
||||||
|
* are a screen and the server that refuses what the screen offered.
|
||||||
|
*/
|
||||||
|
export function overHandLimit(s: GameState, player: PlayerIndex): boolean {
|
||||||
|
const hand = s.decks.hands.get(player) ?? [];
|
||||||
|
return hand.length > (s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
export function freshTurn(moves: number): TurnState {
|
export function freshTurn(moves: number): TurnState {
|
||||||
return {
|
return {
|
||||||
option: null,
|
option: null,
|
||||||
@@ -1106,6 +1119,20 @@ export type GameState = {
|
|||||||
movedThisPhase: Set<TrayId>;
|
movedThisPhase: Set<TrayId>;
|
||||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||||
collisionsToday: number;
|
collisionsToday: number;
|
||||||
|
/**
|
||||||
|
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
|
||||||
|
* before the reset.
|
||||||
|
*
|
||||||
|
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
|
||||||
|
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
|
||||||
|
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
|
||||||
|
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
|
||||||
|
*
|
||||||
|
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
|
||||||
|
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
|
||||||
|
* may never see the ended Day's final count to remember it.
|
||||||
|
*/
|
||||||
|
collisionsPrevDay: number;
|
||||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||||
collisionsTotal: number;
|
collisionsTotal: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+70
-6
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
|
|||||||
import type { Game, Menu } from '../web/game.ts';
|
import type { Game, Menu } from '../web/game.ts';
|
||||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||||
import { snapshot, seatLabel } from '../sim/view.ts';
|
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||||
|
import { takeSteps } from '../sim/display-step.ts';
|
||||||
|
import type { DisplayStep } from '../sim/display-step.ts';
|
||||||
import { developerBot } from '../sim/bot.ts';
|
import { developerBot } from '../sim/bot.ts';
|
||||||
|
|
||||||
export type Push = {
|
export type Push = {
|
||||||
@@ -69,6 +71,29 @@ export type Push = {
|
|||||||
scheduled?: number | null;
|
scheduled?: number | null;
|
||||||
announcement?: string | null;
|
announcement?: string | null;
|
||||||
justDrawn?: string | null;
|
justDrawn?: string | null;
|
||||||
|
/**
|
||||||
|
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||||
|
*
|
||||||
|
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
|
||||||
|
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
|
||||||
|
* needs no change at all — `broadcastGame` forwards whatever this file builds.
|
||||||
|
*
|
||||||
|
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
|
||||||
|
* player's own hand, menu and objective are not animated: they arrive on the same push, already
|
||||||
|
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
|
||||||
|
* area — `test/redaction.test.ts` guards the projection these are built from.
|
||||||
|
*/
|
||||||
|
steps?: DisplayStep[];
|
||||||
|
/**
|
||||||
|
* The public board to start a step queue from — sent on a CONNECT, never on an update.
|
||||||
|
*
|
||||||
|
* Steps carry deltas against one chain shared by the whole table, so a client that has just
|
||||||
|
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
|
||||||
|
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
|
||||||
|
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
|
||||||
|
* panel is what carries the words, and it is already sent whole on connect (#97).
|
||||||
|
*/
|
||||||
|
publicReset?: PublicFrame;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,8 +199,17 @@ function buildSession(
|
|||||||
};
|
};
|
||||||
let open: OpenSpan | null = openSpanFor(Date.now());
|
let open: OpenSpan | null = openSpanFor(Date.now());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A seat's full Frame, with `lines` deliberately EMPTY (#97).
|
||||||
|
*
|
||||||
|
* Narration reaches a client by ONE path — `Push.lines` — because `RemoteSession`
|
||||||
|
* (`web/session.ts`) accumulates from that field alone and its `lines()` returns the accumulator.
|
||||||
|
* Passing `game.log` here serialised the entire log into every frame for every seat, where it grew
|
||||||
|
* all game and was thrown away on arrival, while `linesSince` correctly sent the same text beside
|
||||||
|
* it. The duplicate was not merely waste: it masked the reconnect bug `connect` fixes below.
|
||||||
|
*/
|
||||||
function frameFor(seat: PlayerIndex): Frame {
|
function frameFor(seat: PlayerIndex): Frame {
|
||||||
return snapshot(game.state, game.log, null, null, null, false, seat);
|
return snapshot(game.state, [], null, null, null, false, seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] {
|
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] {
|
||||||
@@ -202,11 +236,12 @@ function buildSession(
|
|||||||
return { cues, scheduled, announcement };
|
return { cues, scheduled, announcement };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
|
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
|
||||||
const frame = frameFor(seat);
|
const frame = frameFor(seat);
|
||||||
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
||||||
lastFrame.set(seat, frame);
|
lastFrame.set(seat, frame);
|
||||||
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
||||||
|
if (steps.length > 0) push.steps = steps;
|
||||||
if (moment) {
|
if (moment) {
|
||||||
if (moment.cues.length > 0) push.cues = moment.cues;
|
if (moment.cues.length > 0) push.cues = moment.cues;
|
||||||
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
||||||
@@ -219,9 +254,13 @@ function buildSession(
|
|||||||
|
|
||||||
function pushesForAll(): Map<PlayerIndex, Push> {
|
function pushesForAll(): Map<PlayerIndex, Push> {
|
||||||
const moment = takeMoment();
|
const moment = takeMoment();
|
||||||
|
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
|
||||||
|
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
|
||||||
|
// an empty list to everybody else.
|
||||||
|
const steps = takeSteps(game.display);
|
||||||
const out = new Map<PlayerIndex, Push>();
|
const out = new Map<PlayerIndex, Push>();
|
||||||
for (let seat = 0; seat < playerNames.length; seat++) {
|
for (let seat = 0; seat < playerNames.length; seat++) {
|
||||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
|
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -332,6 +371,19 @@ function buildSession(
|
|||||||
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
||||||
// bound on a server, which nothing was draining before this.)
|
// bound on a server, which nothing was draining before this.)
|
||||||
takeMoment();
|
takeMoment();
|
||||||
|
/**
|
||||||
|
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
|
||||||
|
*
|
||||||
|
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
|
||||||
|
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
|
||||||
|
* them onto it would draw positions the game had already left. The plan says as much: opening bot
|
||||||
|
* moves need no replay, and a later display simply receives the final reset.
|
||||||
|
*
|
||||||
|
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
|
||||||
|
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
|
||||||
|
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
|
||||||
|
*/
|
||||||
|
takeSteps(game.display);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerCount: playerNames.length,
|
playerCount: playerNames.length,
|
||||||
@@ -341,7 +393,19 @@ function buildSession(
|
|||||||
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
|
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
|
||||||
// (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta.
|
// (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta.
|
||||||
lastFrame.delete(seat);
|
lastFrame.delete(seat);
|
||||||
return pushFor(seat, null);
|
// ...and the NARRATION watermark with it (#97). The browser this answers has just reloaded
|
||||||
|
// from an empty accumulator, so a seat told "nothing new since your last push" came back to a
|
||||||
|
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
|
||||||
|
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||||
|
sentLines.delete(seat);
|
||||||
|
const push = pushFor(seat, null);
|
||||||
|
/**
|
||||||
|
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||||
|
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||||
|
* been collected there is no chain yet and a fresh projection is the same thing.
|
||||||
|
*/
|
||||||
|
push.publicReset = game.display.last ?? publicSnapshot(game.state);
|
||||||
|
return push;
|
||||||
},
|
},
|
||||||
|
|
||||||
intent(seat, seq, i) {
|
intent(seat, seq, i) {
|
||||||
|
|||||||
+148
-8
@@ -55,11 +55,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
*
|
*
|
||||||
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||||||
*
|
*
|
||||||
* SEATING. Players sit around a table, so the route is laid out the way they do: one row alone,
|
* ONE ROW AT EVERY SEAT COUNT (Gitea#18). It used to be laid out the way players sit — two rows
|
||||||
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
|
* facing, a horseshoe of three, a square of four — and the reasoning for dropping that is at the
|
||||||
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
|
* layout itself below. The Division is a LINE and not a loop — trains enter at one Division Point
|
||||||
* open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it
|
* and leave at the other — so the row is deliberately left open, with the two ends drawn as
|
||||||
* into a ring would promise a connection the rules do not have.
|
* buffer stops facing outward. Closing it into a ring would promise a connection the rules do
|
||||||
|
* not have.
|
||||||
*
|
*
|
||||||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||||
* anything outside its own body.
|
* anything outside its own body.
|
||||||
@@ -108,7 +109,6 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
* this is back to what a buffer stop actually needs.
|
* this is back to what a buffer stop actually needs.
|
||||||
*/
|
*/
|
||||||
const PAD = 22;
|
const PAD = 22;
|
||||||
const SIDE_GAP = 34;
|
|
||||||
|
|
||||||
const esc = (t: string): string =>
|
const esc = (t: string): string =>
|
||||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
@@ -144,8 +144,20 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
* register under the rail (Gitea#18).
|
* register under the rail (Gitea#18).
|
||||||
*/
|
*/
|
||||||
below?: Cell['trains'];
|
below?: Cell['trains'];
|
||||||
|
/**
|
||||||
|
* Office cells only: a Red Flag standing at this Office's Limits, and which approach it guards
|
||||||
|
* (#94). A token set out ON the board that holds the next train arriving from that side, so it
|
||||||
|
* is drawn like the other things standing on the map rather than left to the log.
|
||||||
|
*/
|
||||||
|
redFlag?: string | null;
|
||||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||||
regions: number;
|
regions: number;
|
||||||
|
/**
|
||||||
|
* Which way a Heavy Grade climbs, or null on every other card. Drawn as a wedge, because the
|
||||||
|
* tooltip said "climbs east" and the card itself showed nothing — so the one card whose
|
||||||
|
* orientation the PLAYER chooses was the one card you had to hover to read (Jesse, 2026-08-30).
|
||||||
|
*/
|
||||||
|
gradeUp?: string | null;
|
||||||
w: number;
|
w: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -217,10 +229,17 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
(owner?.isYou ? ' — this is your railroad' : '') +
|
(owner?.isYou ? ' — this is your railroad' : '') +
|
||||||
// "their move" is wrong when the reader is the one being waited on.
|
// "their move" is wrong when the reader is the one being waited on.
|
||||||
(owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') +
|
(owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') +
|
||||||
|
// #94 — first, and in full, because it is the one thing here that CHANGES what a train
|
||||||
|
// may do. Everything below it describes the cell; this describes a rule in force.
|
||||||
|
(n.redFlag === 'east' || n.redFlag === 'west'
|
||||||
|
? `\n\nRED FLAG set out at the ${n.redFlag === 'east' ? 'East' : 'West'} Limits — the ` +
|
||||||
|
`next train arriving from the ${n.redFlag} is held short, and the flag is spent doing it.`
|
||||||
|
: '') +
|
||||||
`\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` +
|
`\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` +
|
||||||
`trains standing in it: those holding an A/D track on the rail, and any crew switching in ` +
|
`trains standing in it: those holding an A/D track on the rail, and any crew switching in ` +
|
||||||
`the district below it.`,
|
`the district below it.`,
|
||||||
seat: n.seat ?? null,
|
seat: n.seat ?? null,
|
||||||
|
redFlag: n.redFlag ?? null,
|
||||||
// No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a
|
// No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a
|
||||||
// card outright rather than a part of one.
|
// card outright rather than a part of one.
|
||||||
regions: 0,
|
regions: 0,
|
||||||
@@ -259,6 +278,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
seat: null,
|
seat: null,
|
||||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||||
regions: dp ? 1 : (n.regions ?? 0),
|
regions: dp ? 1 : (n.regions ?? 0),
|
||||||
|
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||||
w: dp ? CW.dp : CW.ml,
|
w: dp ? CW.dp : CW.ml,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,6 +377,97 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
|
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RED FLAG STANDING AT THE LIMITS (#94).
|
||||||
|
*
|
||||||
|
* Drawn at the END IT GUARDS — west on the left, east on the right, since east is right on this
|
||||||
|
* map — because which approach it covers is the whole of the information. A flag in the middle
|
||||||
|
* of the cell would say a flag is out and leave the reader to hover for the half that decides
|
||||||
|
* whether to run a train.
|
||||||
|
*
|
||||||
|
* A staff with a pennant, at rail height, standing clear of the chips: it is beside the rail,
|
||||||
|
* which is where a flag is. Red is otherwise unused on this map (`bs-full` tints a cell, it does
|
||||||
|
* not draw), so the mark does not compete with anything for meaning.
|
||||||
|
*/
|
||||||
|
if (c.redFlag === 'east' || c.redFlag === 'west') {
|
||||||
|
const west = c.redFlag === 'west';
|
||||||
|
const fx = west ? c.x + 9 : c.x + c.w - 9;
|
||||||
|
const top = c.y + RAIL_Y - 22;
|
||||||
|
const dir = west ? 1 : -1;
|
||||||
|
out += `<g class="bs-flag">`;
|
||||||
|
out += `<line x1="${fx}" y1="${top}" x2="${fx}" y2="${c.y + RAIL_Y + 4}"/>`;
|
||||||
|
// The pennant flies INTO the cell, so it can never overhang the card edge at either end.
|
||||||
|
out += `<polygon points="${fx},${top} ${fx + dir * 13},${top + 5} ${fx},${top + 10}"/>`;
|
||||||
|
out += `</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||||||
|
*
|
||||||
|
* The tooltip has said "climbs east" since the Frame carried `gradeUp`, and the card showed
|
||||||
|
* nothing — so the one Mainline card whose orientation the PLAYER sets, and the one where a
|
||||||
|
* Helpers or Brakeman modifier means opposite things at opposite ends, was the one you had to
|
||||||
|
* hover to read (Jesse, 2026-08-30).
|
||||||
|
*
|
||||||
|
* A wedge rising toward the climb, with an arrow up its slope. Two cues rather than one: the
|
||||||
|
* wedge alone asks the reader to judge which end is taller, which at eleven pixels of rise is a
|
||||||
|
* comparison rather than a glance. Bottom-right, clear of the left-aligned capacity line and
|
||||||
|
* below the region bars, so it never lands under a train chip.
|
||||||
|
*
|
||||||
|
* `east` is RIGHT on this map and always has been (Gitea#18) — that is what makes a wedge
|
||||||
|
* readable without a compass, and it is why the row layout is worth its width.
|
||||||
|
*/
|
||||||
|
if (c.gradeUp === 'east' || c.gradeUp === 'west') {
|
||||||
|
const s = c.gradeUp === 'east' ? 1 : -1;
|
||||||
|
const GW = 58;
|
||||||
|
const RISE = 24;
|
||||||
|
const gx1 = c.x + c.w - 9 - GW;
|
||||||
|
const gx2 = c.x + c.w - 9;
|
||||||
|
const yb = c.y + CH - 6;
|
||||||
|
const peakX = s === 1 ? gx2 : gx1;
|
||||||
|
out += `<polygon class="bs-grade" points="${gx1},${yb} ${gx2},${yb} ${peakX},${yb - RISE}"/>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ARROW LIES ALONG THE WEDGE'S OWN SLOPE, CENTRED IN IT (Jesse, 2026-08-30).
|
||||||
|
*
|
||||||
|
* Parallel to the hypotenuse is the shape that fits: the perpendicular gap to the slope is
|
||||||
|
* then CONSTANT along the whole arrow, instead of closing at one end the way a steeper line
|
||||||
|
* does. The earlier 30° pass had to be tucked into the fat half to survive, because 30° is
|
||||||
|
* steeper than this wedge climbs — at 58×24 the slope is 22.5°, and the arrow simply lies on
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* Centred on the TRIANGLE'S CENTROID (2/3 along the base, 1/3 up), which is the balance point
|
||||||
|
* of the form rather than of its bounding box — centring on the box would push the arrow into
|
||||||
|
* the thin corner where there is no height for it.
|
||||||
|
*
|
||||||
|
* The wedge grew 50×22 → 58×24 to pay for that: the centroid sits only ~7px from the
|
||||||
|
* hypotenuse, so a centred arrow has less room than an off-centre one and needs a bigger form
|
||||||
|
* to keep it. Sizes are the best fit found by search, clearing every edge by 2.88px;
|
||||||
|
* `web.test.ts` re-derives it and fails under 2px.
|
||||||
|
*
|
||||||
|
* Worked in the wedge's own frame — `u` along the base from the thin corner, `h` up from it —
|
||||||
|
* so a westward climb is one sign on `u` rather than a second set of coordinates.
|
||||||
|
*/
|
||||||
|
const L = 20;
|
||||||
|
const HL = 6;
|
||||||
|
const HW = 3.5;
|
||||||
|
const T = 1.4;
|
||||||
|
const A = Math.atan2(RISE, GW);
|
||||||
|
const cos = Math.cos(A);
|
||||||
|
const sin = Math.sin(A);
|
||||||
|
const cu = (2 * GW) / 3;
|
||||||
|
const ch = RISE / 3;
|
||||||
|
const pt = (lx: number, ly: number): string => {
|
||||||
|
const u = cu + lx * cos - ly * sin;
|
||||||
|
const h = ch + lx * sin + ly * cos;
|
||||||
|
return `${s === 1 ? gx1 + u : gx2 - u},${yb - h}`;
|
||||||
|
};
|
||||||
|
const H = L / 2;
|
||||||
|
out +=
|
||||||
|
`<polygon class="bs-gradeup" points="${pt(-H, -T)} ${pt(H - HL, -T)} ${pt(H - HL, -HW)} ` +
|
||||||
|
`${pt(H, 0)} ${pt(H - HL, HW)} ${pt(H - HL, T)} ${pt(-H, T)}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
|
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
|
||||||
*
|
*
|
||||||
@@ -378,6 +489,14 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
* things the Division map is for — where a train is and which way it is going — and leaves the
|
* things the Division map is for — where a train is and which way it is going — and leaves the
|
||||||
* cars to the tooltip and to the district.
|
* cars to the tooltip and to the district.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* THE REGION NUMBER IS A PLACE ON THE MAP, NOT A DISTANCE RUN (Gitea#22).
|
||||||
|
*
|
||||||
|
* `view.ts` mirrors a westbound train before it gets here, so this counts boxes west to east
|
||||||
|
* for every train regardless of which way it is going — and the tooltip says so, because
|
||||||
|
* "region 2 of 2" beside "2 Stages still to run" reads as a contradiction otherwise. It is the
|
||||||
|
* second box from the west end; a westbound train in it has its whole crossing ahead of it.
|
||||||
|
*/
|
||||||
const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => {
|
const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => {
|
||||||
const cars = t.cars ?? [];
|
const cars = t.cars ?? [];
|
||||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||||
@@ -391,7 +510,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
: '';
|
: '';
|
||||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||||
cars.length ? ` (${loaded} loaded)` : ''
|
cars.length ? ` (${loaded} loaded)` : ''
|
||||||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}${
|
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${
|
||||||
// What the card prints. A train on the Mainline is exactly where "why did that leave without
|
// What the card prints. A train on the Mainline is exactly where "why did that leave without
|
||||||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||||||
t.what ? `\n\n${esc(t.what)}` : ''
|
t.what ? `\n\n${esc(t.what)}` : ''
|
||||||
@@ -979,9 +1098,18 @@ export function officeSvg(
|
|||||||
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||||||
// Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word
|
// Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word
|
||||||
// on the card with nothing to say what it did, or that it does not do it yet.
|
// on the card with nothing to say what it did, or that it does not do it yet.
|
||||||
|
// A SPENT dispatch device is struck through (#101). Telegraph/Telephone/Radio are "once a
|
||||||
|
// day", and the label said the same thing before and after the Superintendent spent one — so
|
||||||
|
// the card advertised a +12 that was not there. Per-`tspan` rather than per-`text` so the
|
||||||
|
// names still read as one row, and `?? []` because `piecePreview` builds a cell literal by
|
||||||
|
// hand and has no flags.
|
||||||
|
const spentFlags = cell.enhancementsSpent ?? [];
|
||||||
|
const names = cell.enhancements
|
||||||
|
.map((n, i) => (spentFlags[i] ? `<tspan class="bs-enh-spent">${esc(n)}</tspan>` : esc(n)))
|
||||||
|
.join(' · ');
|
||||||
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
||||||
(cell.enhancementsWhat ?? []).join(' · '),
|
(cell.enhancementsWhat ?? []).join(' · '),
|
||||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
)}">${names}</text>`;
|
||||||
}
|
}
|
||||||
if (selectedTrain) {
|
if (selectedTrain) {
|
||||||
/**
|
/**
|
||||||
@@ -1123,6 +1251,17 @@ export const BOARD_CSS = `
|
|||||||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||||
train is measured against, not something to look at instead of the train. */
|
train is measured against, not something to look at instead of the train. */
|
||||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||||
|
/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */
|
||||||
|
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
|
||||||
|
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
|
||||||
|
/* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning.
|
||||||
|
SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated
|
||||||
|
fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is
|
||||||
|
happening here" made a fixed piece of landscape look like a live alert. One flat brown recedes
|
||||||
|
into scenery; the arrow is bone so the DIRECTION, which is the fact being reported, is the part
|
||||||
|
that carries. */
|
||||||
|
.bs-grade{fill:#6b5334;stroke:#6b5334;stroke-width:1}
|
||||||
|
.bs-gradeup{fill:#f2e8d5}
|
||||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||||
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
||||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
||||||
@@ -1206,6 +1345,7 @@ export const BOARD_CSS = `
|
|||||||
.bs-mod{font:10px ui-monospace,monospace}
|
.bs-mod{font:10px ui-monospace,monospace}
|
||||||
text.bs-mod{fill:#c8a04a}
|
text.bs-mod{fill:#c8a04a}
|
||||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||||
|
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
|
||||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||||
/* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it
|
/* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it
|
||||||
reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */
|
reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3.
|
||||||
|
*
|
||||||
|
* One ordered, watchable presentation step per accepted intent, so a player can see what everyone
|
||||||
|
* else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn
|
||||||
|
* and have magic happen in the background and then have to figure out what others did."*
|
||||||
|
*
|
||||||
|
* ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and
|
||||||
|
* `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. It
|
||||||
|
* does not need to: `src/server/session.ts` imports `submit` from `src/web/game.ts`, so solitaire,
|
||||||
|
* live multiplayer and every bot turn already funnel through ONE function. Collecting there is what
|
||||||
|
* makes solitaire a special case of multiplayer rather than a second implementation, which is the
|
||||||
|
* standing design direction for this codebase.
|
||||||
|
*
|
||||||
|
* AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling
|
||||||
|
* `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt
|
||||||
|
* undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the
|
||||||
|
* plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured
|
||||||
|
* it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved
|
||||||
|
* onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it.
|
||||||
|
*
|
||||||
|
* WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because
|
||||||
|
* the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a
|
||||||
|
* projected frame instead.
|
||||||
|
*
|
||||||
|
* PHASES EARN THEIR OWN STEPS, and that is TODO #18. `pump()` runs every automatic phase between one
|
||||||
|
* click and the next and `drain()` records the whole batch at once, so New Train, the Mainline and
|
||||||
|
* the shift change "look like they are being skipped entirely" — trains cross the Division in one
|
||||||
|
* jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps
|
||||||
|
* `advance()` one call at a time instead, and collects a step for each phase that actually DID
|
||||||
|
* something. A phase that did nothing adds no narration and therefore produces no step at all, which
|
||||||
|
* is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time
|
||||||
|
* to it."
|
||||||
|
*
|
||||||
|
* `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the
|
||||||
|
* replay-inertness property below depends on their staying off this path. The stepped version lives
|
||||||
|
* in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is
|
||||||
|
* identical — only the collection differs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Intent } from '../engine/intents.ts';
|
||||||
|
import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts';
|
||||||
|
import { seatOf } from '../engine/state.ts';
|
||||||
|
import { publicSnapshot } from './view.ts';
|
||||||
|
import type { PublicFrame } from './view.ts';
|
||||||
|
import { deltaPublicFrame } from './public-delta.ts';
|
||||||
|
import type { PublicFrameDelta } from './public-delta.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wire format's version, on the ENVELOPE rather than on the projection.
|
||||||
|
*
|
||||||
|
* The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there:
|
||||||
|
* `PublicFrame` is a projection of the game and its property list is an allow-list that
|
||||||
|
* `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be
|
||||||
|
* allow-listed as public game state, which it is not. The step is the message; the message carries
|
||||||
|
* the version.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
/** What produced a step: somebody's intent, or the Division advancing a phase by itself. */
|
||||||
|
export type StepCause = Intent['type'] | 'phase';
|
||||||
|
|
||||||
|
/** One watchable thing that happened, in order. */
|
||||||
|
export type DisplayStep = {
|
||||||
|
protocolVersion: typeof DISPLAY_PROTOCOL_VERSION;
|
||||||
|
/** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */
|
||||||
|
seq: number;
|
||||||
|
/**
|
||||||
|
* Who acted — NULL for an automatic phase, which nobody did.
|
||||||
|
*
|
||||||
|
* Both are carried because Employee Rotation makes "which seat" and "which player" different
|
||||||
|
* questions.
|
||||||
|
*/
|
||||||
|
player: PlayerIndex | null;
|
||||||
|
seat: SeatIndex | null;
|
||||||
|
/** What caused it — the input to pacing's kind classification. */
|
||||||
|
cause: StepCause;
|
||||||
|
/** The public board after this intent and everything it drained, against the previous step. */
|
||||||
|
frame: PublicFrameDelta;
|
||||||
|
/** The narration this intent added, in order, including any phase lines drained behind it. */
|
||||||
|
lines: { text: string; tone: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-game collector state.
|
||||||
|
*
|
||||||
|
* Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the
|
||||||
|
* established convention in this codebase for "the model accumulated something, the view takes it".
|
||||||
|
*/
|
||||||
|
export type DisplayCollector = {
|
||||||
|
/** Undrained steps, oldest first. */
|
||||||
|
steps: DisplayStep[];
|
||||||
|
/** The last public frame a step was built against, so the next delta has something to diff. */
|
||||||
|
last: PublicFrame | null;
|
||||||
|
/** Next sequence number to assign. */
|
||||||
|
seq: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function newCollector(): DisplayCollector {
|
||||||
|
return { steps: [], last: null, seq: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one accepted intent as a step.
|
||||||
|
*
|
||||||
|
* Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent
|
||||||
|
* finally produced and `lines` is everything it caused to be said. The frame is projected
|
||||||
|
* immediately and never from a retained `GameState` reference — a retained reference would resolve
|
||||||
|
* to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent.
|
||||||
|
*/
|
||||||
|
export function collectStep(
|
||||||
|
collector: DisplayCollector,
|
||||||
|
state: GameState,
|
||||||
|
player: PlayerIndex | null,
|
||||||
|
cause: StepCause,
|
||||||
|
lines: { text: string; tone: string }[],
|
||||||
|
): void {
|
||||||
|
const next = publicSnapshot(state);
|
||||||
|
collector.steps.push({
|
||||||
|
protocolVersion: DISPLAY_PROTOCOL_VERSION,
|
||||||
|
seq: collector.seq++,
|
||||||
|
player,
|
||||||
|
seat: player === null ? null : seatOf(state, player),
|
||||||
|
cause,
|
||||||
|
frame: deltaPublicFrame(collector.last, next),
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
collector.last = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */
|
||||||
|
export function takeSteps(collector: DisplayCollector): DisplayStep[] {
|
||||||
|
return collector.steps.splice(0, collector.steps.length);
|
||||||
|
}
|
||||||
+102
-10
@@ -15,10 +15,10 @@
|
|||||||
* panel cannot drift from the rules.
|
* panel cannot drift from the rules.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MAX_CONSIST } from '../engine/content.ts';
|
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -155,7 +155,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.to,
|
where: e.to,
|
||||||
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||||
};
|
};
|
||||||
case 'carsCoupled': {
|
case 'carsCoupled': {
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
return {
|
return {
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||||
};
|
};
|
||||||
case 'carsDropped':
|
case 'carsDropped':
|
||||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||||
@@ -746,6 +746,42 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
|
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
|
||||||
* squares the movement walk reached and refused, not every square on the board.
|
* squares the movement walk reached and refused, not every square on the board.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* WHY A CAR WILL NOT COME OFF (Gitea#21).
|
||||||
|
*
|
||||||
|
* "I dropped the first tank car, but that was all I was allowed to do" — and this panel, asked
|
||||||
|
* why, talked about the refinery's green box. The rule that actually refused is printed on the
|
||||||
|
* train: trains 3/4, the Express, "may drop or pick up one freight car at every location". The
|
||||||
|
* refusal was correct. Nothing said it.
|
||||||
|
*
|
||||||
|
* That is a worse failure than a missing button, because the panel did not stay silent — it
|
||||||
|
* offered a true statement about the FACILITY, which sent the player to spend a Freight Agent
|
||||||
|
* action that could not have helped. The rule was on the train card's tooltip, which is not
|
||||||
|
* where anyone looks when a button they expected is simply absent.
|
||||||
|
*
|
||||||
|
* Only when the crew has a freight car it could otherwise set out. A budget spent by a train
|
||||||
|
* with nothing left to drop is not blocking anything, and this panel earns its keep by being
|
||||||
|
* short enough to read.
|
||||||
|
*/
|
||||||
|
const turnNow = turnOf(s, player);
|
||||||
|
if (s.clock.phase === 'localOps' && turnNow.option === 'switch') {
|
||||||
|
for (const [id, tray] of s.trays) {
|
||||||
|
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||||
|
if (!tray.consist.some(isFreight)) continue;
|
||||||
|
if (!freightRuleSpentHere(s, player, id)) continue;
|
||||||
|
const { row, col } = tray.position.coord;
|
||||||
|
out.push({
|
||||||
|
where: `Train ${tray.trainNumber} at (${col},${row})`,
|
||||||
|
why:
|
||||||
|
'ONE FREIGHT CAR PER LOCATION — this train has already worked a freight car on this ' +
|
||||||
|
'square, so no more come off or on here until next turn. It may still work one at the ' +
|
||||||
|
'next square it reaches.',
|
||||||
|
// The printed rule doing its job, and it lifts by itself. Amber, not red.
|
||||||
|
severity: 'waiting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const turn = turnOf(s, player);
|
const turn = turnOf(s, player);
|
||||||
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
|
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
|
||||||
for (const [id, tray] of s.trays) {
|
for (const [id, tray] of s.trays) {
|
||||||
@@ -832,13 +868,69 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
|
/**
|
||||||
const due = s.timetable[s.clock.stage - 1];
|
* TRAINS HELD FOR WANT OF A CREW TRAY (§7) — the scarcity mechanic, made visible (#98).
|
||||||
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
|
*
|
||||||
|
* This covered the TIMETABLED train due out this Stage and nothing else, which meant the two
|
||||||
|
* other things that queue for the same pool reported nothing at all. A player who spent a card on
|
||||||
|
* an Extra, or ordered a second section, got an EMPTY panel while their train sat behind an
|
||||||
|
* exhausted pool — and each had been announced once in the log in a line that promised a future
|
||||||
|
* event ("as soon as a Crew Tray frees up") which nothing then confirmed.
|
||||||
|
*
|
||||||
|
* All three are one condition, so they are written as one block: no free tray, and something
|
||||||
|
* waiting for one. The count rides along because "no free Crew Tray" reads like a permanent fact
|
||||||
|
* about the game rather than a state that will pass.
|
||||||
|
*/
|
||||||
|
// `crewTrayCount` is the pool's size, asked rather than re-derived. `trays.size + freeTrays.length`
|
||||||
|
// gives the same number in play — `retireTrain` moves a tray back — but it is a second way to know
|
||||||
|
// one fact, which is the shape of every bug this release fixed.
|
||||||
|
const trays = `${s.freeTrays.length} of ${crewTrayCount(s.players.length)} Crew Trays free`;
|
||||||
|
if (s.freeTrays.length === 0) {
|
||||||
|
const due = s.timetable[s.clock.stage - 1];
|
||||||
|
if (due !== null && due !== undefined) {
|
||||||
|
out.push({
|
||||||
|
where: `Train ${due}`,
|
||||||
|
why: `due to depart but HELD — no free Crew Tray (${trays})`,
|
||||||
|
severity: 'stuck',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Extra belongs to the player who played the card (§7), so it is their errand and it is
|
||||||
|
// reported to them. A second section is the table's, like any Timetabled train.
|
||||||
|
for (const x of s.pendingExtras) {
|
||||||
|
if (x.player !== player) continue;
|
||||||
|
out.push({
|
||||||
|
where: `Extra X${x.trainNumber}`,
|
||||||
|
why: `played and waiting to be made up — no free Crew Tray (${trays})`,
|
||||||
|
severity: 'stuck',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of s.pendingSecondSections) {
|
||||||
|
out.push({
|
||||||
|
where: `Train ${n}`,
|
||||||
|
why: `second section ordered and waiting to be made up — no free Crew Tray (${trays})`,
|
||||||
|
severity: 'stuck',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TRAIN HELD AT THE LIMITS BY AN INTERLOCKING (#99).
|
||||||
|
*
|
||||||
|
* It is inside the player's Limits, not on an A/D track, and it takes the first track that frees
|
||||||
|
* ahead of any newcomer. The map now draws it on the Limits square; this says what it is waiting
|
||||||
|
* for, which is the Office emptying rather than anything the held train itself can do.
|
||||||
|
*/
|
||||||
|
for (const id of area.heldAtLimits) {
|
||||||
|
const t = s.trays.get(id);
|
||||||
|
if (!t) continue;
|
||||||
out.push({
|
out.push({
|
||||||
where: `Train ${due}`,
|
where: `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber ?? '—'}`,
|
||||||
why: 'due to depart but HELD — no free Crew Tray',
|
why:
|
||||||
severity: 'stuck',
|
'held at your Limits by the Interlocking instead of colliding — it takes the first A/D ' +
|
||||||
|
'track that frees, ahead of any train arriving after it',
|
||||||
|
severity: 'waiting',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||||
|
*
|
||||||
|
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
|
||||||
|
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
|
||||||
|
* than either alone.
|
||||||
|
*
|
||||||
|
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
|
||||||
|
* and divide it by the queue length. Measured against real games, that does exactly the wrong
|
||||||
|
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
|
||||||
|
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
|
||||||
|
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
|
||||||
|
* is worth watching is rare and clustered. Two of the three published replays contain no
|
||||||
|
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
|
||||||
|
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
|
||||||
|
* player's attention on `draw.end` and rushes the switching.
|
||||||
|
*
|
||||||
|
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
|
||||||
|
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
|
||||||
|
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
|
||||||
|
* with the switching exercises … I don't think reading the switching in the log will be anywhere
|
||||||
|
* nearly as interesting as watching the trains actually move on the board."*
|
||||||
|
*
|
||||||
|
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
|
||||||
|
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StepCause } from './display-step.ts';
|
||||||
|
|
||||||
|
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE TUNING TABLE — dwell in milliseconds per kind.
|
||||||
|
*
|
||||||
|
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
|
||||||
|
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
|
||||||
|
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||||||
|
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||||||
|
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||||||
|
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
|
||||||
|
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||||||
|
* their relative weighting survives.
|
||||||
|
*
|
||||||
|
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||||||
|
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||||||
|
* table's multiplier — the table, the classification and the queue do not change.
|
||||||
|
*/
|
||||||
|
export const DWELL: Record<StepKind, number> = {
|
||||||
|
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||||
|
switching: 1000,
|
||||||
|
/**
|
||||||
|
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
|
||||||
|
* player is about to do.
|
||||||
|
*
|
||||||
|
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||||
|
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||||
|
* Jesse, from the first real play on `phoenix.local`: *"bot play was way too fast. I briefly saw
|
||||||
|
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||||
|
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||||
|
*/
|
||||||
|
action: 700,
|
||||||
|
/**
|
||||||
|
* An automatic phase that DID something — TODO #18.
|
||||||
|
*
|
||||||
|
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
|
||||||
|
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
|
||||||
|
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
|
||||||
|
* the Division and is the clearest case of "stuff just happened without being able to see how".
|
||||||
|
*/
|
||||||
|
phase: 600,
|
||||||
|
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
|
||||||
|
bookkeeping: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which kind an intent is.
|
||||||
|
*
|
||||||
|
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
|
||||||
|
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
|
||||||
|
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
|
||||||
|
* member of the union so a new intent cannot land here unclassified.
|
||||||
|
*/
|
||||||
|
export function kindOf(cause: StepCause): StepKind {
|
||||||
|
switch (cause) {
|
||||||
|
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
|
||||||
|
case 'phase':
|
||||||
|
return 'phase';
|
||||||
|
|
||||||
|
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
|
||||||
|
case 'switch.move':
|
||||||
|
case 'switch.dropCars':
|
||||||
|
case 'switch.sortConsist':
|
||||||
|
case 'maneuver.flyingSwitch':
|
||||||
|
case 'maneuver.redFlags':
|
||||||
|
return 'switching';
|
||||||
|
|
||||||
|
// Something visible changed hands or position, but no train drove anywhere.
|
||||||
|
case 'card.play':
|
||||||
|
case 'card.discard':
|
||||||
|
case 'draw.fromHomeOffice':
|
||||||
|
case 'draw.fromDepartment':
|
||||||
|
case 'newTrain.placeCar':
|
||||||
|
case 'newTrain.passCar':
|
||||||
|
case 'newTrain.secondSection':
|
||||||
|
case 'newTrain.startExtra':
|
||||||
|
case 'porter.board':
|
||||||
|
case 'porter.detrain':
|
||||||
|
case 'laborer.startLoad':
|
||||||
|
case 'laborer.advanceLoad':
|
||||||
|
case 'laborer.beginUnload':
|
||||||
|
case 'freightAgent.stockOutbound':
|
||||||
|
case 'freightAgent.clearInbound':
|
||||||
|
case 'freightAgent.unjam':
|
||||||
|
case 'mainline.clearance':
|
||||||
|
case 'mainline.modify':
|
||||||
|
case 'mainline.redFlag':
|
||||||
|
case 'mainline.yardOffice':
|
||||||
|
case 'redFlag.play':
|
||||||
|
return 'action';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||||||
|
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||||||
|
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||||||
|
* it, so a bot's turn began with no indication of what it was about to do.
|
||||||
|
*/
|
||||||
|
case 'localOps.choose':
|
||||||
|
return 'action';
|
||||||
|
|
||||||
|
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||||||
|
// there are more of these than of anything else.
|
||||||
|
case 'loadUnload.end':
|
||||||
|
case 'draw.end':
|
||||||
|
case 'switch.end':
|
||||||
|
case 'freightAgent.end':
|
||||||
|
case 'game.extend':
|
||||||
|
return 'bookkeeping';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The widest multiplier that is a speed rather than a mistake.
|
||||||
|
*
|
||||||
|
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||||
|
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||||
|
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
|
||||||
|
* 3 are the ones actually asked for) and well short of unusable.
|
||||||
|
*/
|
||||||
|
export const MAX_PACE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The speeds the on-screen control offers, slowest last.
|
||||||
|
*
|
||||||
|
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
|
||||||
|
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
|
||||||
|
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
|
||||||
|
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
|
||||||
|
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||||
|
* takes.
|
||||||
|
*/
|
||||||
|
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to show one step, in ms, at a given speed.
|
||||||
|
*
|
||||||
|
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||||||
|
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||||||
|
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||||||
|
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||||||
|
*/
|
||||||
|
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||||
|
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to show one STEP — the form the queue actually uses.
|
||||||
|
*
|
||||||
|
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
|
||||||
|
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
|
||||||
|
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
|
||||||
|
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
|
||||||
|
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||||
|
*/
|
||||||
|
export function dwellForStep(
|
||||||
|
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
|
||||||
|
pace = 1,
|
||||||
|
): number {
|
||||||
|
if (step.lines.length > 0) return dwellFor(step.cause, pace);
|
||||||
|
/**
|
||||||
|
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||||
|
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||||
|
*
|
||||||
|
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
|
||||||
|
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||||
|
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||||
|
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||||
|
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
|
||||||
|
* is being shown, and there are about 180 of those in a full game.
|
||||||
|
*/
|
||||||
|
const table = step.frame.table as Record<string, unknown>;
|
||||||
|
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||||
|
return turned ? dwellFor(step.cause, pace) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many steps in a queue are actually going to be WATCHED.
|
||||||
|
*
|
||||||
|
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
|
||||||
|
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
|
||||||
|
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
|
||||||
|
* Thirteen dwelling steps means thirteen things you are going to see.
|
||||||
|
*/
|
||||||
|
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
|
||||||
|
return causes.filter((c) => dwellFor(c, pace) > 0).length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||||
|
*
|
||||||
|
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
|
||||||
|
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
|
||||||
|
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
|
||||||
|
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
|
||||||
|
* and that is where nearly all of the bytes are.
|
||||||
|
*
|
||||||
|
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
|
||||||
|
* one district; comparing the whole array as a unit would resend every other player's board on
|
||||||
|
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
|
||||||
|
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
|
||||||
|
*
|
||||||
|
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
|
||||||
|
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
|
||||||
|
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
|
||||||
|
*
|
||||||
|
* The convention that does carry over, kept identical so a reader of one file can read the other:
|
||||||
|
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
|
||||||
|
* receiving side merges against the last full frame it actually holds. A first connect or a
|
||||||
|
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
|
||||||
|
* (§ v0.8.0).
|
||||||
|
*
|
||||||
|
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One district with its two heavy fields nulled when unchanged.
|
||||||
|
*
|
||||||
|
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
|
||||||
|
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
|
||||||
|
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
|
||||||
|
* reason about whether a relabelling was missed.
|
||||||
|
*/
|
||||||
|
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
|
||||||
|
cells: CellView[] | null;
|
||||||
|
facilities: FacilityView[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
|
||||||
|
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `PublicFrame` reduced to WHAT CHANGED.
|
||||||
|
*
|
||||||
|
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
|
||||||
|
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
|
||||||
|
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
|
||||||
|
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
|
||||||
|
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
|
||||||
|
* ~11 KB each**. As a partial they are a few dozen bytes.
|
||||||
|
*/
|
||||||
|
export type PublicFrameDelta = {
|
||||||
|
/** Only the shared-table fields whose value differs from the previous frame. */
|
||||||
|
table: Partial<PublicTable>;
|
||||||
|
/** The Division, only when it changed. */
|
||||||
|
division: DivisionView[] | null;
|
||||||
|
/** Only the districts that changed, each carrying only the board fields that changed. */
|
||||||
|
districts: PublicDistrictDelta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
|
||||||
|
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
|
||||||
|
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
|
||||||
|
* or a reset — in which case everything is sent in full.
|
||||||
|
*/
|
||||||
|
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
|
||||||
|
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
|
||||||
|
const table: Partial<PublicTable> = {};
|
||||||
|
for (const key of TABLE_KEYS(next)) {
|
||||||
|
if (previous === null || !same(previous[key], next[key])) {
|
||||||
|
(table as Record<string, unknown>)[key] = next[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const districts: PublicDistrictDelta[] = [];
|
||||||
|
for (const d of next.districts) {
|
||||||
|
const was = before.get(d.seat);
|
||||||
|
const cells = was && same(was.cells, d.cells) ? null : d.cells;
|
||||||
|
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
|
||||||
|
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
|
||||||
|
// four-player table three of them are unchanged on every single step.
|
||||||
|
if (was && cells === null && facilities === null && same(was, d)) continue;
|
||||||
|
districts.push({ ...d, cells, facilities });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
table,
|
||||||
|
division: previous !== null && same(previous.division, next.division) ? null : next.division,
|
||||||
|
districts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
|
||||||
|
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
|
||||||
|
const base = previous ?? (delta.table as PublicTable);
|
||||||
|
const merged = { ...base, ...delta.table } as PublicTable;
|
||||||
|
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
|
||||||
|
for (const d of delta.districts) {
|
||||||
|
const was = bySeat.get(d.seat);
|
||||||
|
bySeat.set(d.seat, {
|
||||||
|
...d,
|
||||||
|
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
|
||||||
|
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
division: delta.division ?? need(previous?.division, 'division'),
|
||||||
|
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
|
||||||
|
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
|
||||||
|
* been delivered, and quietly producing a frame with a missing board would put a blank district in
|
||||||
|
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
|
||||||
|
*/
|
||||||
|
function need<T>(value: T | undefined, what: string): T {
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
+22
-3
@@ -35,6 +35,7 @@ import { legalActions } from '../engine/legal.ts';
|
|||||||
import { createGame } from '../engine/setup.ts';
|
import { createGame } from '../engine/setup.ts';
|
||||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||||
import { actingPlayer } from '../engine/state.ts';
|
import { actingPlayer } from '../engine/state.ts';
|
||||||
|
import { reasonSentence } from '../web/panels.ts';
|
||||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||||
@@ -148,12 +149,26 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
push(applied.events);
|
push(applied.events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IN WORDS, NOT AS AN ENUM (`TODO.md` #34). This heading read `loss — revenueFloor`, which is
|
||||||
|
* exactly the defect Gitea#16 was filed about on the playable page — it just outlived the fix
|
||||||
|
* here, because nothing player-facing pointed at it. `reasonSentence` is shared rather than
|
||||||
|
* reimplemented, so the replay and the results screen cannot end up explaining the same ending
|
||||||
|
* two different ways.
|
||||||
|
*
|
||||||
|
* Fed the LAST frame, which is the state the outcome was decided in and is already recorded.
|
||||||
|
* Tags are stripped: this lands in an `<h1>` and in a console line, neither of which wants markup.
|
||||||
|
*/
|
||||||
const o = s.outcome;
|
const o = s.outcome;
|
||||||
|
const last = frames[frames.length - 1];
|
||||||
|
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
|
||||||
return {
|
return {
|
||||||
seed,
|
seed,
|
||||||
length,
|
length,
|
||||||
frames,
|
frames,
|
||||||
outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
|
outcome: o
|
||||||
|
? `${o.result === 'win' ? 'won' : 'lost'} — ${why} Final Revenue ${s.players[0]?.revenue ?? 0}.`
|
||||||
|
: 'unfinished',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +230,7 @@ export function compress(frames: Frame[]): Packed {
|
|||||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||||
// `trains` rides whole rather than being interned: it changes almost every frame, so a table
|
// `trains` rides whole rather than being interned: it changes almost every frame, so a table
|
||||||
// of them would be as long as the frames are and buy nothing.
|
// of them would be as long as the frames are and buy nothing.
|
||||||
return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest];
|
return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest, c.enhancementsSpent];
|
||||||
});
|
});
|
||||||
return { ...f, cells } as unknown as Frame;
|
return { ...f, cells } as unknown as Frame;
|
||||||
});
|
});
|
||||||
@@ -249,7 +264,7 @@ export function rehydrateCells(
|
|||||||
facs: unknown[],
|
facs: unknown[],
|
||||||
): unknown[] {
|
): unknown[] {
|
||||||
return packed.map((row) => {
|
return packed.map((row) => {
|
||||||
const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number];
|
const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number, boolean[]];
|
||||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||||
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
|
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
|
||||||
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
|
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
|
||||||
@@ -269,6 +284,10 @@ export function rehydrateCells(
|
|||||||
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
||||||
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
||||||
standingWest: p[8] ?? 0,
|
standingWest: p[8] ?? 0,
|
||||||
|
// Which dispatch devices were spent (#101), so a replay strikes a used Radio through exactly
|
||||||
|
// as the live board does. Absent in recordings made before it existed, which read as no
|
||||||
|
// device spent — the same thing they drew at the time, so an old replay is unchanged.
|
||||||
|
enhancementsSpent: p[9] ?? [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-5
@@ -19,6 +19,8 @@ export type TurnChartFrame = {
|
|||||||
phase: string;
|
phase: string;
|
||||||
phaseKey: string;
|
phaseKey: string;
|
||||||
actor: number | null;
|
actor: number | null;
|
||||||
|
/** What the game has stopped to ask, when it has. Null while a phase is simply running. */
|
||||||
|
awaiting?: { asks: string; train: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,8 +95,21 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
|||||||
);
|
);
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
|
/**
|
||||||
|
* WAITING ON WHOM, AND FOR WHAT.
|
||||||
|
*
|
||||||
|
* "nobody — the Division is running itself" is true of an automatic phase and was being printed
|
||||||
|
* over the top of three interruptions that are emphatically waiting on a person: §8.1's clearance
|
||||||
|
* ruling, the Yard Office offer and the Red Flag prompt. The Frame carried the phase's actor,
|
||||||
|
* which is null throughout the Mainline Phase, so a game stopped on a named player's decision
|
||||||
|
* reported that nobody was holding it up (Jesse, 2026-08-30). `Frame.actor` is `actingPlayer` now
|
||||||
|
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
|
||||||
|
* game that looks stuck to everyone except Bob.
|
||||||
|
*/
|
||||||
const who = actorName ?? 'nobody — the Division is running itself';
|
const who = actorName ?? 'nobody — the Division is running itself';
|
||||||
|
const asked = f.awaiting
|
||||||
|
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
|
||||||
|
: '';
|
||||||
const fedora =
|
const fedora =
|
||||||
superName === null
|
superName === null
|
||||||
? ''
|
? ''
|
||||||
@@ -105,9 +120,12 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
|||||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||||
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
|
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
|
||||||
fedora +
|
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||||
`<ol class="tc-phases">${chips}</ol>`
|
// between the phases and everything above them, which put a thing that changes every third
|
||||||
|
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||||
|
// one whose last chip is Supervisor Shift — the phase that passes it.
|
||||||
|
`<div class="tc-row"><ol class="tc-phases">${chips}</ol>${fedora}</div>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +148,18 @@ export const TURNCHART_CSS = `
|
|||||||
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
||||||
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
||||||
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
|
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
|
||||||
|
.tc-asks{color:#a99ac4;font-style:italic}
|
||||||
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||||
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
||||||
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
||||||
that changes every turn, while this changes four times a Day. */
|
that changes every turn, while this changes four times a Day. */
|
||||||
.tc-super{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
|
/* The phase row and the Fedora on one line, the hat pushed to the far end (TODO.md #29): the row
|
||||||
|
is the Stage, and the Superintendent is who holds it. Wraps under the phases on a narrow screen
|
||||||
|
rather than squeezing the chips. */
|
||||||
|
.tc-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
|
.tc-row ol.tc-phases{flex:1 1 auto}
|
||||||
|
.tc-super{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
|
||||||
.tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px}
|
.tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px}
|
||||||
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
||||||
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
|
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
|
||||||
|
|||||||
+438
-69
@@ -10,7 +10,7 @@
|
|||||||
* drift into two different pictures of the same board.
|
* drift into two different pictures of the same board.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { regionOfTransit } from '../engine/advance.ts';
|
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||||
import {
|
import {
|
||||||
areaAtSeat,
|
areaAtSeat,
|
||||||
areaOf,
|
areaOf,
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
ACTION_CARDS,
|
ACTION_CARDS,
|
||||||
ENHANCEMENT_CARDS,
|
ENHANCEMENT_CARDS,
|
||||||
HAND_LIMIT,
|
|
||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
@@ -35,6 +34,8 @@ import {
|
|||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
OFFICE_ORDER,
|
OFFICE_ORDER,
|
||||||
SPACE_USE_CARDS,
|
SPACE_USE_CARDS,
|
||||||
|
STAGES_PER_SHIFT,
|
||||||
|
crewTrayCount,
|
||||||
enhancementRule,
|
enhancementRule,
|
||||||
enhancementText,
|
enhancementText,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
@@ -46,9 +47,9 @@ import {
|
|||||||
mainlineDescription,
|
mainlineDescription,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
import type { Facility, GameConfig, GameState, OfficeArea, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
import { actingPlayer, carsOn, overHandLimit, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
import type { Direction, Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||||
import type { Impediment } from './narrate.ts';
|
import type { Impediment } from './narrate.ts';
|
||||||
@@ -73,6 +74,14 @@ export type CellView = {
|
|||||||
* `toString()`), so it cannot reach the card catalogue itself.
|
* `toString()`), so it cannot reach the card catalogue itself.
|
||||||
*/
|
*/
|
||||||
enhancementsWhat: string[];
|
enhancementsWhat: string[];
|
||||||
|
/**
|
||||||
|
* Which of those enhancements is SPENT for today, in the same order (#101).
|
||||||
|
*
|
||||||
|
* Only ever true of a dispatch device — Telegraph, Telephone, Radio — which is "once a day". The
|
||||||
|
* reason and the Fedora caveat are already written into `enhancementsWhat`; this is the flag the
|
||||||
|
* board styles from, because `board-svg.ts` imports nothing and cannot work it out for itself.
|
||||||
|
*/
|
||||||
|
enhancementsSpent: boolean[];
|
||||||
/**
|
/**
|
||||||
* EVERY TRAIN STANDING HERE, in order, each with the engine in it and which way it points.
|
* EVERY TRAIN STANDING HERE, in order, each with the engine in it and which way it points.
|
||||||
*
|
*
|
||||||
@@ -104,6 +113,15 @@ export type CellView = {
|
|||||||
* standing in front of them — the card is face down in a box somewhere by then.
|
* standing in front of them — the card is face down in a box somewhere by then.
|
||||||
*/
|
*/
|
||||||
what: string;
|
what: string;
|
||||||
|
/**
|
||||||
|
* HELD AT THE LIMITS BY AN INTERLOCKING, rather than standing on this square (#99).
|
||||||
|
*
|
||||||
|
* The engine keeps these in `OfficeArea.heldAtLimits` and deliberately does NOT move
|
||||||
|
* `tray.position` onto the grid — a held train is not on a square anything may switch it from.
|
||||||
|
* So the map has to draw it from the held list, and mark it, or it reads as an ordinary arrival
|
||||||
|
* the player could work.
|
||||||
|
*/
|
||||||
|
heldAtLimits?: true;
|
||||||
}[];
|
}[];
|
||||||
/**
|
/**
|
||||||
* Office card only: how many A/D tracks the tier has — null everywhere else.
|
* Office card only: how many A/D tracks the tier has — null everywhere else.
|
||||||
@@ -320,6 +338,16 @@ export type DivisionView = {
|
|||||||
what?: string;
|
what?: string;
|
||||||
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
|
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
|
||||||
regions?: number;
|
regions?: number;
|
||||||
|
/**
|
||||||
|
* Office nodes only: a Red Flag standing at this Office's Limits, and which approach it guards
|
||||||
|
* (#94). `null` when none is out.
|
||||||
|
*
|
||||||
|
* PUBLIC STATE, and the reason it has to be here: the flag is a token set out ON the board that
|
||||||
|
* holds the next train arriving from that side until it is spent. It was announced once in the
|
||||||
|
* log and then drawn nowhere, so a train would stop short with its only explanation scrolled out
|
||||||
|
* of the panel.
|
||||||
|
*/
|
||||||
|
redFlag?: string | null;
|
||||||
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
||||||
running?: RunningCardView[];
|
running?: RunningCardView[];
|
||||||
/** Office nodes only: whose district this is. */
|
/** Office nodes only: whose district this is. */
|
||||||
@@ -354,7 +382,21 @@ export type Frame = {
|
|||||||
* replay recorder, which sees the events; the live game keeps its own on the Game object.
|
* replay recorder, which sees the events; the live game keeps its own on the Game object.
|
||||||
*/
|
*/
|
||||||
cues?: string[];
|
cues?: string[];
|
||||||
|
/**
|
||||||
|
* WHO THE GAME IS WAITING ON — the phase's actor, or the owner of a pending interruption when
|
||||||
|
* there is one. It carried `clock.currentActor` alone until 2026-08-30, which is null during the
|
||||||
|
* Mainline Phase, so a game stopped dead on a Superintendent's clearance ruling reported "waiting
|
||||||
|
* on nobody — the Division is running itself" while it waited on a named person to click
|
||||||
|
* (reported by Jesse). The engine had the answer the whole time in `actingPlayer`.
|
||||||
|
*/
|
||||||
actor: number | null;
|
actor: number | null;
|
||||||
|
/**
|
||||||
|
* WHAT that player is being asked, when the game is stopped on a question rather than a turn.
|
||||||
|
* Null whenever the phase is simply running. Naming the person is not enough on its own: three
|
||||||
|
* different interruptions can be waiting, and "waiting on Bob" with no more than that is a game
|
||||||
|
* that looks stuck to everyone except Bob.
|
||||||
|
*/
|
||||||
|
awaiting: { asks: string; train: string } | null;
|
||||||
superintendent: number;
|
superintendent: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
/**
|
/**
|
||||||
@@ -395,6 +437,8 @@ export type Frame = {
|
|||||||
maxCollisionsPerDay: number;
|
maxCollisionsPerDay: number;
|
||||||
maxCollisionsTotal: number;
|
maxCollisionsTotal: number;
|
||||||
collisionsToday: number;
|
collisionsToday: number;
|
||||||
|
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||||||
|
collisionsPrevDay: number;
|
||||||
collisionsTotal: number;
|
collisionsTotal: number;
|
||||||
status: GameState['status'];
|
status: GameState['status'];
|
||||||
outcome: GameState['outcome'];
|
outcome: GameState['outcome'];
|
||||||
@@ -433,7 +477,17 @@ export type Frame = {
|
|||||||
* there is only one; the first thing you want to know at a four-player table.
|
* there is only one; the first thing you want to know at a four-player table.
|
||||||
*/
|
*/
|
||||||
viewer: number;
|
viewer: number;
|
||||||
/** The viewer's position in the west-to-east chain, which is not their player index (§4.4). */
|
/**
|
||||||
|
* The viewer's position in the west-to-east chain, which is not their player index (§4.4).
|
||||||
|
*
|
||||||
|
* CARRIED AHEAD OF ITS CALLER, DELIBERATELY (#45). Nothing renders this today — the 2026-08-30
|
||||||
|
* dead-field audit found it read only by one test, and Jesse deferred the delete-or-document
|
||||||
|
* call. Documenting rather than deleting, because Gitea#20's common board keys every district by
|
||||||
|
* SEAT and resolves the player through `playerAtSeat` (§Employee Rotation moves players between
|
||||||
|
* districts), so a client that must pick its own district out of a seat-keyed board needs exactly
|
||||||
|
* this and cannot derive it from `viewer`. If step 2 ships without using it, delete it then —
|
||||||
|
* this note is the reason it survived one audit, not a permanent exemption from the next.
|
||||||
|
*/
|
||||||
viewerSeat: number;
|
viewerSeat: number;
|
||||||
/**
|
/**
|
||||||
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
|
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
|
||||||
@@ -726,6 +780,39 @@ function baseOf(
|
|||||||
*/
|
*/
|
||||||
function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
|
function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
|
||||||
const out: CellView['trains'] = [];
|
const out: CellView['trains'] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TRAINS HELD AT THE LIMITS — drawn here or drawn nowhere (#99).
|
||||||
|
*
|
||||||
|
* `arriveAtOffice` takes the tray out of the Mainline node's `transits` and, when an Interlocking
|
||||||
|
* saves it from Gap 2d's collision, pushes it onto `heldAtLimits` without giving it a grid
|
||||||
|
* position. The map draws mainline nodes from `transits` and squares from `position.at === 'grid'`
|
||||||
|
* — so between the two the train was drawn in NEITHER, and simply vanished off the board until an
|
||||||
|
* A/D track freed some Stages later.
|
||||||
|
*
|
||||||
|
* At WHICH Limits: the end it came in by. An eastbound train entered from the west, so it is held
|
||||||
|
* at `limitsWest`; a westbound one at `limitsEast`.
|
||||||
|
*/
|
||||||
|
const area = areaAtSeat(s, viewerSeat);
|
||||||
|
for (const id of area.heldAtLimits) {
|
||||||
|
const t = s.trays.get(id);
|
||||||
|
if (!t) continue;
|
||||||
|
const at = t.direction === 'east' ? area.limitsWest : area.limitsEast;
|
||||||
|
if (`${at.row},${at.col}` !== key) continue;
|
||||||
|
out.push({
|
||||||
|
trayId: id,
|
||||||
|
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
|
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
|
||||||
|
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||||
|
facing: railFacingOf(t),
|
||||||
|
what:
|
||||||
|
'HELD AT THE LIMITS — the Interlocking stopped it on the Limit Track instead of letting it ' +
|
||||||
|
'collide with a full Office. It takes the first A/D track that frees, ahead of any train ' +
|
||||||
|
`arriving after it. ${trainRules(t)}`,
|
||||||
|
heldAtLimits: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const [id, t] of s.trays) {
|
for (const [id, t] of s.trays) {
|
||||||
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
|
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
|
||||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||||
@@ -1157,28 +1244,76 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
* replay cannot drift into two different pictures of the same board.
|
* replay cannot drift into two different pictures of the same board.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* The board as ONE SEAT sees it.
|
* ONE DISTRICT'S BOARD, BY SEAT — the cards on the table and the cars standing on them (Gitea#20
|
||||||
|
* step 1).
|
||||||
*
|
*
|
||||||
* `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything
|
* A district's BOARD is public. Everyone at the table can see the cards somebody has laid, the cars
|
||||||
* else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which
|
* standing on them and the trains in the Office Area; what is private is a player's HAND, their
|
||||||
* is what solitaire and every replay want, so existing callers are unaffected.
|
* objective and their Revenue detail, none of which is here. That split is why this can be handed to
|
||||||
|
* a seatless spectator unchanged.
|
||||||
*
|
*
|
||||||
* This was hardcoded to 0 throughout. That was correct while there was one player and would have
|
* **KEYED BY SEAT, NOT BY PLAYER, and that is not a detail.** Employee Rotation moves players
|
||||||
* been a quiet disaster with more: every seat would have been shown player 0's railroad, including
|
* between districts, so district ownership cannot be assumed to match player index — the board
|
||||||
* player 0's hand, which is the one thing the state model calls secret.
|
* belongs to the POSITION on the Division and the player is whoever is currently sitting there
|
||||||
|
* (`playerAtSeat`). Taking a player here would silently draw the wrong district the first time
|
||||||
|
* anybody rotated.
|
||||||
|
*
|
||||||
|
* `seat` is also the "home seat" for `carLabel`, which marks a load THIS district made — the printed
|
||||||
|
* game turns the chip upside down in the tray, and a load may not be broken in the Office Area that
|
||||||
|
* made it. For a player's own view that seat is theirs; for a spectator's view of district N it is
|
||||||
|
* N, which is the same fact asked from outside.
|
||||||
*/
|
*/
|
||||||
export function snapshot(
|
/**
|
||||||
|
* WHAT AN ENHANCEMENT DOES — AND WHETHER IT CAN DO IT RIGHT NOW (#101).
|
||||||
|
*
|
||||||
|
* `enhancementText(key)` takes only the key, so it says the same thing for ever. That is right for
|
||||||
|
* every enhancement except the three dispatch devices, which are "once a day": a spent Radio read
|
||||||
|
* "Once a day, add +12…" all Day after it was gone, which is `trainRules` before #100 in a different
|
||||||
|
* corner of the same view.
|
||||||
|
*
|
||||||
|
* AND THE FEDORA, which is the half that actually surprises. `spendDispatchBonus` (advance.ts) reads
|
||||||
|
* the SUPERINTENDENT's own devices, not the train owner's, and the Fedora moves every
|
||||||
|
* `STAGES_PER_SHIFT` Stages — so a device does nothing at all while somebody else is dispatching,
|
||||||
|
* and is spent automatically, without its owner being asked, while they are.
|
||||||
|
*
|
||||||
|
* `dispatchBonus` decides what counts as a device, rather than a list of three keys written out
|
||||||
|
* here: the ladder lives in `ENHANCEMENT_RULES` and a fourth rung would otherwise be silently
|
||||||
|
* exempt.
|
||||||
|
*/
|
||||||
|
function enhancementState(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
lines: { text: string; tone: string }[],
|
area: OfficeArea,
|
||||||
where: { row: number; col: number } | null,
|
seat: SeatIndex,
|
||||||
whereFrom: { row: number; col: number } | null = null,
|
key: string,
|
||||||
decision: Decision | null = null,
|
): { what: string; spent: boolean } {
|
||||||
wasted = false,
|
const base = enhancementText(key) ?? prettyKey(key);
|
||||||
viewer: PlayerIndex = 0,
|
if (enhancementRule(key)?.dispatchBonus === undefined) return { what: base, spent: false };
|
||||||
): Frame {
|
|
||||||
const area = areaOf(s, viewer);
|
|
||||||
const viewerSeat = seatOf(s, viewer);
|
|
||||||
|
|
||||||
|
const spent = area.dispatchUsedToday.includes(key);
|
||||||
|
if (spent) {
|
||||||
|
return {
|
||||||
|
what: `${base} SPENT for today — it comes back at the start of the next Day.`,
|
||||||
|
spent: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Available, but only to whoever is dispatching. Naming the shift length is the difference
|
||||||
|
// between "not now" and knowing how long "not now" lasts.
|
||||||
|
if (seatOf(s, s.clock.superintendent) !== seat) {
|
||||||
|
return {
|
||||||
|
what:
|
||||||
|
`${base} Unspent, but IDLE: a device is only used by the district holding the Fedora, ` +
|
||||||
|
`which moves every ${STAGES_PER_SHIFT} Stages.`,
|
||||||
|
spent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { what: `${base} Available today, and this district is dispatching.`, spent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectDistrict(
|
||||||
|
s: GameState,
|
||||||
|
seat: SeatIndex,
|
||||||
|
): { cells: CellView[]; facilities: FacilityView[]; runningRow: number; limits: { west: number; east: number } } {
|
||||||
|
const area = areaAtSeat(s, seat);
|
||||||
const cells: CellView[] = [];
|
const cells: CellView[] = [];
|
||||||
const facilities: FacilityView[] = [];
|
const facilities: FacilityView[] = [];
|
||||||
for (const [key, card] of area.grid) {
|
for (const [key, card] of area.grid) {
|
||||||
@@ -1196,7 +1331,7 @@ export function snapshot(
|
|||||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||||
else label = geometryLabel(g.geometry);
|
else label = geometryLabel(g.geometry);
|
||||||
|
|
||||||
const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
|
const fv = facilityView(card as never, officeProfile(area.tier).name, seat);
|
||||||
if (fv) facilities.push(fv);
|
if (fv) facilities.push(fv);
|
||||||
|
|
||||||
cells.push({
|
cells.push({
|
||||||
@@ -1208,16 +1343,34 @@ export function snapshot(
|
|||||||
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
||||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||||
enhancements: card.enhancements.map(prettyKey),
|
enhancements: card.enhancements.map(prettyKey),
|
||||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
enhancementsWhat: card.enhancements.map((k) => enhancementState(s, area, seat, k).what),
|
||||||
trains: trainsOnCard(s, viewerSeat, key),
|
enhancementsSpent: card.enhancements.map((k) => enhancementState(s, area, seat, k).spent),
|
||||||
|
trains: trainsOnCard(s, seat, key),
|
||||||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||||
cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
|
cars: carsOn(card).map((c) => carLabel(c, seat)),
|
||||||
standingWest: card.standingWest,
|
standingWest: card.standingWest,
|
||||||
facility: fv,
|
facility: fv,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const division: DivisionView[] = s.division.nodes.map((n) => {
|
return {
|
||||||
|
cells,
|
||||||
|
facilities,
|
||||||
|
runningRow: area.runningRow,
|
||||||
|
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DIVISION — every district's cell, the Mainline between them, and both Division Points.
|
||||||
|
*
|
||||||
|
* Wholly public and always was: it reads no hand, no objective and no per-viewer state, so a
|
||||||
|
* spectator's Division map and a player's are the same picture. It is extracted rather than
|
||||||
|
* rewritten for exactly that reason — the public view must not be a second implementation that can
|
||||||
|
* drift from the one players look at.
|
||||||
|
*/
|
||||||
|
export function projectDivision(s: GameState): DivisionView[] {
|
||||||
|
return s.division.nodes.map((n) => {
|
||||||
if (n.kind === 'divisionPoint') {
|
if (n.kind === 'divisionPoint') {
|
||||||
return {
|
return {
|
||||||
kind: 'dp',
|
kind: 'dp',
|
||||||
@@ -1245,10 +1398,31 @@ export function snapshot(
|
|||||||
* one per Stage — and the entry point is what the rules actually move. There is nothing left
|
* one per Stage — and the entry point is what the rules actually move. There is nothing left
|
||||||
* to reconstruct.
|
* to reconstruct.
|
||||||
*/
|
*/
|
||||||
// One region per Stage, straight off the card's own count: what a train has LEFT to run says
|
/**
|
||||||
// where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the
|
* AND WHICH WAY IT CAME IN (Gitea#22). `regionOfTransit` counts from the end the train
|
||||||
// collision rule cannot disagree about who is where.
|
* ENTERED — everything still to run is region 0 — and both directions share that one index
|
||||||
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining);
|
* space, which is what the collision rules want and why the engine asks it directly.
|
||||||
|
*
|
||||||
|
* The map is asking a different question: which printed box, LEFT TO RIGHT. East is right
|
||||||
|
* here and always has been, so for an eastbound train the two questions have the same answer
|
||||||
|
* by luck — it enters at the west end, so "just entered" and "leftmost box" coincide. A
|
||||||
|
* westbound train enters at the EAST end, so its region 0 is the right-hand box, and using
|
||||||
|
* the travel index directly drew the whole card mirrored.
|
||||||
|
*
|
||||||
|
* That cost a collision (seed 550943578, undo 187): a westbound TX17 that had just entered
|
||||||
|
* was drawn WEST of a westbound T5 that was nearly across, so the train physically behind
|
||||||
|
* appeared to be the one in front. Train 3 was cleared to follow T5 and ran into TX17 —
|
||||||
|
* where the rules had always had it.
|
||||||
|
*
|
||||||
|
* So the engine's index is turned into a place on the map here, once, at the boundary the
|
||||||
|
* map is drawn from. `regionOfTransit` keeps its meaning and the collision rules are
|
||||||
|
* untouched; only the picture changes.
|
||||||
|
*/
|
||||||
|
const place = (t: { stagesRemaining: number; direction: Direction }): number => {
|
||||||
|
const travelled = regionOfTransit(n.card, t.stagesRemaining);
|
||||||
|
const regions = mainlineProfile(n.card).regions;
|
||||||
|
return t.direction === 'west' ? regions - 1 - travelled : travelled;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
kind: 'ml',
|
kind: 'ml',
|
||||||
label: name,
|
label: name,
|
||||||
@@ -1331,39 +1505,65 @@ export function snapshot(
|
|||||||
modifiers: [],
|
modifiers: [],
|
||||||
gradeUp: null,
|
gradeUp: null,
|
||||||
seat: n.seat,
|
seat: n.seat,
|
||||||
|
// Straight off the node the engine sets (#94). Public to every seat — a flag on the table is
|
||||||
|
// seen by everyone at it — so this is not redacted by viewer and must not become so.
|
||||||
|
redFlag: n.redFlag ?? null,
|
||||||
running,
|
running,
|
||||||
switching: below,
|
switching: below,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHO THE GAME IS WAITING ON — the one answer, asked one way (Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* TWO THINGS HAVE TO BE TRUE AT ONCE, and each was somewhere else before #96 put them together.
|
||||||
|
*
|
||||||
|
* `clock.currentActor` alone is not it: the engine sets that null while an interruption is standing
|
||||||
|
* — a §8.1 clearance goes to the Superintendent, a Yard Office offer to the district's owner — so a
|
||||||
|
* view reading the raw field reports "nobody" during exactly the moments a player is being waited
|
||||||
|
* on. `actingPlayer` knows that rule and is the engine's own answer to it.
|
||||||
|
*
|
||||||
|
* `actingPlayer` alone is not it either, and THIS is what #96 was: it has no status guard, so when
|
||||||
|
* the game is not running it hands back whatever `clock.currentActor` was left holding — the last
|
||||||
|
* seat to move before the timetable ran out. The §3.3 vote is the state that exposed it. That vote
|
||||||
|
* is PARALLEL, open to every un-voted seat at once, and `apply.ts` says in as many words that there
|
||||||
|
* is no actor to be; the screen named the last mover anyway, beside a tally correctly showing three
|
||||||
|
* seats outstanding.
|
||||||
|
*
|
||||||
|
* So: nobody is acting unless the game is `active`, and when it is, `actingPlayer` decides who.
|
||||||
|
*
|
||||||
|
* `currentActor(game)` in `web/game.ts` is this function taking a `Game`, and delegates to it —
|
||||||
|
* ONE answer, not two that agree until they don't. That matters more than it looks: `currentActor`
|
||||||
|
* is what refuses an intent, so a screen answering differently tells the table to wait on a player
|
||||||
|
* the server would turn away.
|
||||||
|
*/
|
||||||
|
export function currentActorOfState(s: GameState): PlayerIndex | null {
|
||||||
|
if (s.status !== 'active') return null;
|
||||||
|
return actingPlayer(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE TABLE, AS EVERY SEAT SEES IT IDENTICALLY (Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* The clock, the phase, whose turn it is, the timetable, the yards, the deck COUNTS, the score and
|
||||||
|
* the house rules. Nothing here is redacted, and nothing here may become redacted: the whole point
|
||||||
|
* is that a spectator and a player read the same table state, so a field that has to differ by seat
|
||||||
|
* belongs in the player's own frame instead.
|
||||||
|
*
|
||||||
|
* Deck contents are counts and top-of-pile names only. A Department pile is FACE UP — a discard goes
|
||||||
|
* onto one precisely so a rival can take it — so naming its top card gives nothing away; the Home
|
||||||
|
* Office deck is face down and appears here as a length and nothing else.
|
||||||
|
*/
|
||||||
|
export function projectSharedTable(s: GameState) {
|
||||||
return {
|
return {
|
||||||
day: s.clock.day,
|
day: s.clock.day,
|
||||||
stage: s.clock.stage,
|
stage: s.clock.stage,
|
||||||
clock: clockTime(s.clock.stage),
|
clock: clockTime(s.clock.stage),
|
||||||
phase: phaseLabel(s.clock.phase),
|
phase: phaseLabel(s.clock.phase),
|
||||||
phaseKey: s.clock.phase,
|
phaseKey: s.clock.phase,
|
||||||
actor: s.clock.currentActor,
|
actor: currentActorOfState(s),
|
||||||
superintendent: s.clock.superintendent,
|
superintendent: s.clock.superintendent,
|
||||||
revenue: s.players[viewer]?.revenue ?? 0,
|
|
||||||
lines,
|
|
||||||
where,
|
|
||||||
whereFrom,
|
|
||||||
division,
|
|
||||||
cells,
|
|
||||||
facilities,
|
|
||||||
/**
|
|
||||||
* NEWEST FIRST, matching the play page (`actionMenu`).
|
|
||||||
*
|
|
||||||
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
|
|
||||||
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
|
|
||||||
* iteration — and every revenue measurement taken with it — is left alone.
|
|
||||||
*
|
|
||||||
* Both lines must reverse together or the descriptions come apart from the names.
|
|
||||||
*/
|
|
||||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
|
||||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
|
||||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
|
|
||||||
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
|
|
||||||
deck: s.decks.homeOffice.length,
|
deck: s.decks.homeOffice.length,
|
||||||
departments: s.decks.departments.map((pile) => {
|
departments: s.decks.departments.map((pile) => {
|
||||||
const top = pile[pile.length - 1];
|
const top = pile[pile.length - 1];
|
||||||
@@ -1388,9 +1588,6 @@ export function snapshot(
|
|||||||
},
|
},
|
||||||
timetable: [...s.timetable],
|
timetable: [...s.timetable],
|
||||||
timetableWhat: s.timetable.map((n) => (n === null ? null : trainRules({ trainNumber: n, trainIsExtra: false }))),
|
timetableWhat: s.timetable.map((n) => (n === null ? null : trainRules({ trainNumber: n, trainIsExtra: false }))),
|
||||||
decision,
|
|
||||||
wasted,
|
|
||||||
option: turnOf(s, viewer).option,
|
|
||||||
houseRules: houseRules(s.config),
|
houseRules: houseRules(s.config),
|
||||||
mode: s.config.mode,
|
mode: s.config.mode,
|
||||||
optionalRules: s.config.optionalRules,
|
optionalRules: s.config.optionalRules,
|
||||||
@@ -1399,6 +1596,7 @@ export function snapshot(
|
|||||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||||
collisionsToday: s.collisionsToday,
|
collisionsToday: s.collisionsToday,
|
||||||
|
collisionsPrevDay: s.collisionsPrevDay,
|
||||||
collisionsTotal: s.collisionsTotal,
|
collisionsTotal: s.collisionsTotal,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
outcome: s.outcome,
|
outcome: s.outcome,
|
||||||
@@ -1411,23 +1609,14 @@ export function snapshot(
|
|||||||
seat: seatOf(s, p.index),
|
seat: seatOf(s, p.index),
|
||||||
name: p.name,
|
name: p.name,
|
||||||
revenue: p.revenue,
|
revenue: p.revenue,
|
||||||
|
// A COUNT, never the cards. Hand SIZE is public — you can see how many cards somebody holds
|
||||||
|
// across a table — and this is the only thing about another player's hand that may be here.
|
||||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||||||
})),
|
})),
|
||||||
viewer,
|
|
||||||
viewerSeat,
|
|
||||||
openingRolls: {
|
openingRolls: {
|
||||||
division: [...s.openingRolls.division],
|
division: [...s.openingRolls.division],
|
||||||
superintendent: [...s.openingRolls.superintendent],
|
superintendent: [...s.openingRolls.superintendent],
|
||||||
},
|
},
|
||||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
|
||||||
overHandLimit:
|
|
||||||
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
|
||||||
objective: objectiveOf(s, viewer),
|
|
||||||
runningRow: area.runningRow,
|
|
||||||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
|
||||||
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
|
|
||||||
moves: switchingMoves(s, viewer),
|
|
||||||
blocked: impediments(s, viewer),
|
|
||||||
trains: [...s.trays.values()].map((t) => ({
|
trains: [...s.trays.values()].map((t) => ({
|
||||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
where:
|
where:
|
||||||
@@ -1437,6 +1626,166 @@ export function snapshot(
|
|||||||
? `Mainline card ${t.position.index}`
|
? `Mainline card ${t.position.index}`
|
||||||
: `Office Area (${t.position.coord.col},${t.position.coord.row})`,
|
: `Office Area (${t.position.coord.col},${t.position.coord.row})`,
|
||||||
})),
|
})),
|
||||||
|
/**
|
||||||
|
* THE CREW TRAY POOL, WHICH IS §7's SCARCITY MECHANIC (#98).
|
||||||
|
*
|
||||||
|
* `state.ts` calls it explicit, and it was explicit only in the engine: there are fewer trays
|
||||||
|
* than there are trains wanting one, and nothing said how many were left. Public without
|
||||||
|
* question — the trays are physical objects in the middle of the table, and this is a count
|
||||||
|
* beside the deck and yard counts already here.
|
||||||
|
*/
|
||||||
|
crewTrays: { free: s.freeTrays.length, total: crewTrayCount(s.players.length) },
|
||||||
|
/**
|
||||||
|
* THE TRAINS QUEUED FOR ONE — the other half, and the half that had been promised in words.
|
||||||
|
*
|
||||||
|
* Playing an Extra says "it runs once as soon as a Crew Tray frees up"; ordering a second
|
||||||
|
* section says "an identical train will run right behind it". Both were announced once in the
|
||||||
|
* log and then existed only in the engine, so neither promise was ever visibly kept. Who played
|
||||||
|
* an Extra is public: §7 gives the train to the player who played the card, in the open.
|
||||||
|
*/
|
||||||
|
queued: {
|
||||||
|
extras: s.pendingExtras.map((x) => ({ trainNumber: x.trainNumber, player: x.player })),
|
||||||
|
secondSections: [...s.pendingSecondSections],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One district as a spectator sees it: whose seat it is, who is sitting there, and its board. */
|
||||||
|
export type PublicDistrict = {
|
||||||
|
seat: SeatIndex;
|
||||||
|
player: PlayerIndex;
|
||||||
|
name: string;
|
||||||
|
cells: CellView[];
|
||||||
|
facilities: FacilityView[];
|
||||||
|
runningRow: number;
|
||||||
|
limits: { west: number; east: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What a seatless viewer may be shown: the table, the Division, and every district's board. */
|
||||||
|
export type PublicFrame = ReturnType<typeof projectSharedTable> & {
|
||||||
|
division: DivisionView[];
|
||||||
|
districts: PublicDistrict[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WHOLE GAME AS A SPECTATOR MAY SEE IT — no seat, no hand, no secrets (Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* **Built from the same lower-level projections a player's frame is, and deliberately NOT by calling
|
||||||
|
* `snapshot()` once per seat.** That shortcut is the trap: `snapshot` exists to assemble one
|
||||||
|
* player's view and carries their hand, their objective, their Revenue detail and their legal moves,
|
||||||
|
* so a public view made of player views starts by constructing everything it then has to remember to
|
||||||
|
* strip. It also defaults its viewer to player zero, which means a careless spectator call today
|
||||||
|
* serves seat 0's hand. Composing upward instead means a private field cannot arrive here by
|
||||||
|
* accident: it would have to be added to a projection that has no business holding one.
|
||||||
|
*
|
||||||
|
* **Districts are keyed by SEAT and the player is resolved through `playerAtSeat`.** Employee
|
||||||
|
* Rotation moves players between districts, so seat and player index are not interchangeable, and
|
||||||
|
* a public board that assumed they were would relabel every district the first time anybody rotated.
|
||||||
|
*
|
||||||
|
* What is NOT here, and why each: `hand`/`handWhat`/`handDiscardable`/`handKeepWhy` and `handCount`
|
||||||
|
* (the cards a seat holds), `objective` (a private goal), `option`/`movesLeft`/`moves` (one player's
|
||||||
|
* legal actions, which describe what they are ABOUT to do), `blocked` (computed per viewer and
|
||||||
|
* partly about their own crews), `decision`, `viewer`/`viewerSeat`, and the narration log — which
|
||||||
|
* `session.ts` sends incrementally and which is checked separately, because two of the leaks found
|
||||||
|
* in v0.7.9.2 lived there rather than in any frame.
|
||||||
|
*/
|
||||||
|
export function publicSnapshot(s: GameState): PublicFrame {
|
||||||
|
return {
|
||||||
|
...projectSharedTable(s),
|
||||||
|
division: projectDivision(s),
|
||||||
|
districts: [...s.officeAreas.keys()].sort((a, b) => a - b).map((seat) => {
|
||||||
|
const player = playerAtSeat(s, seat);
|
||||||
|
return {
|
||||||
|
seat,
|
||||||
|
player,
|
||||||
|
// Through `seatLabel`, like every other seat a person reads: the internal index is
|
||||||
|
// zero-based and the spoken number is not (`session.test.ts` guards the conversion).
|
||||||
|
name: s.players[player]?.name ?? `Seat ${seatLabel(seat)}`,
|
||||||
|
...projectDistrict(s, seat),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board as ONE SEAT sees it.
|
||||||
|
*
|
||||||
|
* `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything
|
||||||
|
* else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which
|
||||||
|
* is what solitaire and every replay want, so existing callers are unaffected.
|
||||||
|
*
|
||||||
|
* This was hardcoded to 0 throughout. That was correct while there was one player and would have
|
||||||
|
* been a quiet disaster with more: every seat would have been shown player 0's railroad, including
|
||||||
|
* player 0's hand, which is the one thing the state model calls secret.
|
||||||
|
*/
|
||||||
|
export function snapshot(
|
||||||
|
s: GameState,
|
||||||
|
lines: { text: string; tone: string }[],
|
||||||
|
where: { row: number; col: number } | null,
|
||||||
|
whereFrom: { row: number; col: number } | null = null,
|
||||||
|
decision: Decision | null = null,
|
||||||
|
wasted = false,
|
||||||
|
viewer: PlayerIndex = 0,
|
||||||
|
): Frame {
|
||||||
|
const viewerSeat = seatOf(s, viewer);
|
||||||
|
|
||||||
|
const { cells, facilities, runningRow, limits } = projectDistrict(s, viewerSeat);
|
||||||
|
const division = projectDivision(s);
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* THE SHARED TABLE COMES FROM THE SAME PROJECTION THE COMMON BOARD USES (Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* Spread rather than restated, so a player's frame and a spectator's cannot come to disagree
|
||||||
|
* about the clock, the phase, whose turn it is or the score. Everything after this point is
|
||||||
|
* either private to `viewer` or a viewer-specific slice; none of it shadows a shared field, and
|
||||||
|
* one that did would be exactly the bug this arrangement exists to make visible.
|
||||||
|
*/
|
||||||
|
...projectSharedTable(s),
|
||||||
|
/**
|
||||||
|
* The three interruptions §8.1 and Gitea#5/#19 can raise, said in the words the prompt itself
|
||||||
|
* uses. `decisionActor` above decides WHO; this is only what they are looking at.
|
||||||
|
*/
|
||||||
|
awaiting: (() => {
|
||||||
|
const d = s.clock.pendingDecision;
|
||||||
|
if (!d) return null;
|
||||||
|
const train = trainName(s, d.train);
|
||||||
|
if (d.kind === 'clearance') return { asks: 'a clearance ruling', train };
|
||||||
|
if (d.kind === 'yardOffice') return { asks: 'the Yard Office offer', train };
|
||||||
|
return { asks: 'a Red Flag', train };
|
||||||
|
})(),
|
||||||
|
revenue: s.players[viewer]?.revenue ?? 0,
|
||||||
|
lines,
|
||||||
|
where,
|
||||||
|
whereFrom,
|
||||||
|
division,
|
||||||
|
cells,
|
||||||
|
facilities,
|
||||||
|
/**
|
||||||
|
* NEWEST FIRST, matching the play page (`actionMenu`).
|
||||||
|
*
|
||||||
|
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
|
||||||
|
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
|
||||||
|
* iteration — and every revenue measurement taken with it — is left alone.
|
||||||
|
*
|
||||||
|
* Both lines must reverse together or the descriptions come apart from the names.
|
||||||
|
*/
|
||||||
|
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||||
|
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||||
|
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
|
||||||
|
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
|
||||||
|
decision,
|
||||||
|
wasted,
|
||||||
|
option: turnOf(s, viewer).option,
|
||||||
|
viewer,
|
||||||
|
viewerSeat,
|
||||||
|
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||||
|
overHandLimit: overHandLimit(s, viewer),
|
||||||
|
objective: objectiveOf(s, viewer),
|
||||||
|
runningRow,
|
||||||
|
limits,
|
||||||
|
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
|
||||||
|
moves: switchingMoves(s, viewer),
|
||||||
|
blocked: impediments(s, viewer),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,6 +1956,12 @@ export function cardDescription(s: GameState, id: string): string {
|
|||||||
export function trainRules(t: {
|
export function trainRules(t: {
|
||||||
trainNumber: number | null;
|
trainNumber: number | null;
|
||||||
trainIsExtra: boolean;
|
trainIsExtra: boolean;
|
||||||
|
/**
|
||||||
|
* X17 only — whether the speeches are made, which is what decides which HALF of its printed rule
|
||||||
|
* the train is currently living under (#100). Optional because the timetable renders a train
|
||||||
|
* number with no tray behind it; absent means "not yet", which is the state a train starts in.
|
||||||
|
*/
|
||||||
|
speechMade?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra);
|
const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra);
|
||||||
if (!p) return '';
|
if (!p) return '';
|
||||||
@@ -1651,11 +2006,25 @@ export function trainRules(t: {
|
|||||||
if (p.rules.pickUpEmptiesOnly) {
|
if (p.rules.pickUpEmptiesOnly) {
|
||||||
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
|
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* WHICH HALF OF ITS RULE THE CAMPAIGN TRAIN IS IN (#100).
|
||||||
|
*
|
||||||
|
* "One turn at station (speeches) then expedite" is two states, not one sentence. This used to
|
||||||
|
* print the sentence and stop, so the chip read identically before and after the speeches — while
|
||||||
|
* the fault the second half creates was warned about only under `expedite`, i.e. to every train
|
||||||
|
* EXCEPT the one that had just become subject to it.
|
||||||
|
*/
|
||||||
if (p.rules.stopThenExpedite) {
|
if (p.rules.stopThenExpedite) {
|
||||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
parts.push(
|
||||||
|
t.speechMade
|
||||||
|
? 'SPEECHES MADE — it runs EXPEDITED from here on'
|
||||||
|
: 'STOPS ONCE FOR SPEECHES at its first Office, then runs expedited from the next one onward',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.rules.expedite) {
|
// `isExpedited` (advance.ts) is the engine's own test, borrowed rather than restated: a card that
|
||||||
|
// described a rule the engine did not apply — or the reverse — is the whole failure this is in.
|
||||||
|
if (isExpedited(t)) {
|
||||||
// It is released and switched exactly like any other train — the restriction is on where it may
|
// It is released and switched exactly like any other train — the restriction is on where it may
|
||||||
// be LEFT, not on when it leaves.
|
// be LEFT, not on when it leaves.
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|||||||
+178
-28
@@ -23,21 +23,24 @@
|
|||||||
* folding events does not rebuild a game — `protocol.md` §3.)
|
* folding events does not rebuild a game — `protocol.md` §3.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { pump } from '../engine/advance.ts';
|
import { advance, pump } from '../engine/advance.ts';
|
||||||
import { applyIntent } from '../engine/apply.ts';
|
import { applyIntent } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { createGame } from '../engine/setup.ts';
|
import { createGame } from '../engine/setup.ts';
|
||||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||||
import { actingPlayer } from '../engine/state.ts';
|
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||||
import { playerAtSeat } from '../engine/state.ts';
|
import { playerAtSeat } from '../engine/state.ts';
|
||||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||||
|
import { collectStep, newCollector } from '../sim/display-step.ts';
|
||||||
|
import type { DisplayCollector } from '../sim/display-step.ts';
|
||||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||||
// which would pull node:fs into a browser bundle.
|
// which would pull node:fs into a browser bundle.
|
||||||
import {
|
import {
|
||||||
cardDescription,
|
cardDescription,
|
||||||
cardName,
|
cardName,
|
||||||
|
currentActorOfState,
|
||||||
describeIntent,
|
describeIntent,
|
||||||
geometryLabel,
|
geometryLabel,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -49,7 +52,6 @@ import {
|
|||||||
DEFAULT_HOUSE_RULES,
|
DEFAULT_HOUSE_RULES,
|
||||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
HAND_LIMIT,
|
|
||||||
LEGACY_HOUSE_RULES,
|
LEGACY_HOUSE_RULES,
|
||||||
collectiveRevenueFloor,
|
collectiveRevenueFloor,
|
||||||
houseRules,
|
houseRules,
|
||||||
@@ -134,10 +136,25 @@ export type NewGameOptions = {
|
|||||||
|
|
||||||
/** The same config with the New Game dialog's answers in it. */
|
/** The same config with the New Game dialog's answers in it. */
|
||||||
export function configWith(opts: NewGameOptions): GameConfig {
|
export function configWith(opts: NewGameOptions): GameConfig {
|
||||||
|
const days = opts.days ?? SOLO_CONFIG.days;
|
||||||
return {
|
return {
|
||||||
...SOLO_CONFIG,
|
...SOLO_CONFIG,
|
||||||
days: opts.days ?? SOLO_CONFIG.days,
|
days,
|
||||||
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
|
/**
|
||||||
|
* DERIVED FROM THE DAYS ACTUALLY IN PLAY, not from `SOLO_CONFIG`'s five-Day constant.
|
||||||
|
*
|
||||||
|
* It fell back to the constant until 2026-08-30, so `configWith({ days: 1 })` asked a one-Day
|
||||||
|
* game to clear **15** — a floor a five-Day game averages barely half of — and
|
||||||
|
* `configWith({ days: 10 })` asked for the same 15 a five-Day game does. The two fields silently
|
||||||
|
* disagreed, which is the one thing a "build me a config" helper must not let happen.
|
||||||
|
*
|
||||||
|
* Not a live fault when it was found: `createLocalSession` is the only caller, and the page
|
||||||
|
* always writes `minCombinedRevenue` itself (`solitaireDefaults` re-derives it from the preset).
|
||||||
|
* Found by a throwaway probe that passed only `days` — which is exactly how the next caller
|
||||||
|
* would use this. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own
|
||||||
|
* floor is this same formula at `DEFAULT_DAYS`.
|
||||||
|
*/
|
||||||
|
minCombinedRevenue: opts.minCombinedRevenue ?? collectiveRevenueFloor(1, days),
|
||||||
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
||||||
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
||||||
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
|
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
|
||||||
@@ -230,7 +247,6 @@ export type Game = {
|
|||||||
* had already been played and the hand held three or fewer. Derived from the hand each render, so
|
* had already been played and the hand held three or fewer. Derived from the hand each render, so
|
||||||
* it cannot drift out of step with what is actually held.
|
* it cannot drift out of step with what is actually held.
|
||||||
*/
|
*/
|
||||||
mustPlayCard: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Sounds the last batch of events earned, for the page to play and clear.
|
* Sounds the last batch of events earned, for the page to play and clear.
|
||||||
*
|
*
|
||||||
@@ -262,6 +278,16 @@ export type Game = {
|
|||||||
* having taken a turn to cause it.
|
* having taken a turn to cause it.
|
||||||
*/
|
*/
|
||||||
announced: string | null;
|
announced: string | null;
|
||||||
|
/**
|
||||||
|
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
|
||||||
|
* what everyone else did rather than find the board already rearranged.
|
||||||
|
*
|
||||||
|
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
|
||||||
|
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
|
||||||
|
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
|
||||||
|
* see `sim/display-step.ts`.
|
||||||
|
*/
|
||||||
|
display: DisplayCollector;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||||
@@ -297,7 +323,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
|||||||
|
|
||||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
|
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||||
// first, then let the clock take over.
|
// first, then let the clock take over.
|
||||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||||
@@ -316,9 +342,23 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
*/
|
*/
|
||||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
|
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||||
game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' });
|
/**
|
||||||
|
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* `game.log` is one shared list and `linesSince(seat)` (`server/session.ts`) slices it with no
|
||||||
|
* per-seat filter, so every line here reaches every player. Announcing the seed therefore handed
|
||||||
|
* each of them the whole future of the deal — every card order, every die — in the opening line
|
||||||
|
* of the game. Found while planning the public common board; it is a multiplayer leak with or
|
||||||
|
* without that display, which is why it is fixed here rather than waiting for it.
|
||||||
|
*
|
||||||
|
* `newGame` still records it, deliberately: a solitaire table has nobody to leak to, and the seed
|
||||||
|
* in the log is what a bug report quotes. The rule is "do not tell the OTHER seats", not "write
|
||||||
|
* less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the
|
||||||
|
* lobby record, so nothing administrative or replayable loses it.
|
||||||
|
*/
|
||||||
|
game.log.push({ text: `${config.mode} · ${playerNames.length} players`, tone: 'quiet' });
|
||||||
drain(game);
|
drain(game);
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
@@ -333,12 +373,18 @@ export function drain(game: Game): void {
|
|||||||
record(game, pump(game.state));
|
record(game, pump(game.state));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
/**
|
||||||
|
* Whose turn it is, or null if the game is over or waiting on nothing.
|
||||||
|
*
|
||||||
|
* `currentActorOfState` (sim/view.ts) IS this, taking the state rather than the `Game` — so this is
|
||||||
|
* the adapter and not a second copy. It used to be the second copy: it carried the status guard and
|
||||||
|
* the view's version did not, which is #96 — the turn chart named the last seat to move all the way
|
||||||
|
* through the §3.3 vote, while this function correctly refused every intent that seat could send.
|
||||||
|
* Two functions that agree until they don't are worse than one, because the disagreement surfaces
|
||||||
|
* as a screen nobody can square with the server.
|
||||||
|
*/
|
||||||
export function currentActor(game: Game): PlayerIndex | null {
|
export function currentActor(game: Game): PlayerIndex | null {
|
||||||
if (game.state.status !== 'active') return null;
|
return currentActorOfState(game.state);
|
||||||
// `actingPlayer` (state.ts) knows which player each kind of interruption goes to — the
|
|
||||||
// Superintendent for a §8.1 clearance, the district's owner for a Yard Office offer.
|
|
||||||
return actingPlayer(game.state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||||||
@@ -1024,9 +1070,7 @@ function makeUpAdvice(
|
|||||||
* button with a reason instead of hiding a move that has simply become illegal.
|
* button with a reason instead of hiding a move that has simply become illegal.
|
||||||
*/
|
*/
|
||||||
export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
||||||
const hand = game.state.decks.hands.get(seat) ?? [];
|
return overHandLimitOf(game.state, seat);
|
||||||
const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
|
||||||
return hand.length > limit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1073,12 +1117,69 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
game.history.push(intent);
|
game.history.push(intent);
|
||||||
|
/**
|
||||||
|
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
|
||||||
|
*
|
||||||
|
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
|
||||||
|
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
|
||||||
|
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
|
||||||
|
* that append, and the slice after them is exactly this intent's narration including whatever
|
||||||
|
* automatic phases it drained.
|
||||||
|
*/
|
||||||
|
const saidFrom = game.log.length;
|
||||||
record(game, result.events, actor);
|
record(game, result.events, actor);
|
||||||
game.mustPlayCard = overHandLimit(game);
|
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
|
||||||
drain(game);
|
drainStepping(game);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
|
||||||
|
*
|
||||||
|
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||||
|
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
|
||||||
|
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
|
||||||
|
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
|
||||||
|
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
|
||||||
|
*
|
||||||
|
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
|
||||||
|
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
|
||||||
|
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
|
||||||
|
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
|
||||||
|
*
|
||||||
|
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
|
||||||
|
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
|
||||||
|
* no lines, so it is skipped rather than given a dwell to sit through.
|
||||||
|
*
|
||||||
|
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
|
||||||
|
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
|
||||||
|
* re-emitting a whole game as steps.
|
||||||
|
*/
|
||||||
|
function drainStepping(game: Game): void {
|
||||||
|
for (let i = 0; i < 10_000; i++) {
|
||||||
|
const from = game.log.length;
|
||||||
|
const r = advance(game.state);
|
||||||
|
record(game, r.events);
|
||||||
|
/**
|
||||||
|
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
|
||||||
|
*
|
||||||
|
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
|
||||||
|
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
|
||||||
|
* therefore skipped those, and the last step's frame was then a position behind the real one:
|
||||||
|
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
|
||||||
|
*
|
||||||
|
* A step whose narration is empty still carries the board. It simply costs no time to show —
|
||||||
|
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
|
||||||
|
* empty phase, arrived at from the other direction.
|
||||||
|
*/
|
||||||
|
if (r.events.length > 0) {
|
||||||
|
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
|
||||||
|
}
|
||||||
|
if (r.needsInput || game.state.status === 'finished') return;
|
||||||
|
}
|
||||||
|
throw new Error('phase driver failed to settle — probable infinite loop');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which cards in hand can be played RIGHT NOW, in hand order.
|
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||||
*
|
*
|
||||||
@@ -1107,6 +1208,24 @@ export function view(game: Game, seat: PlayerIndex = 0): Frame {
|
|||||||
return snapshot(game.state, [], null, null, null, false, seat);
|
return snapshot(game.state, [], null, null, null, false, seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a narration's opening word into the middle of a sentence — "Chose to draw" after a name has
|
||||||
|
* to read "Player Bob chose to draw".
|
||||||
|
*
|
||||||
|
* ONLY A SENTENCE-CASED WORD, which is the whole point. It used to be a flat
|
||||||
|
* `text.charAt(0).toLowerCase()`, so every line opening with an all-caps keyword came out mangled:
|
||||||
|
* `EXTRA X18 started…` rendered as `Player Solitaire eXTRA X18 started…`, and the same happened to
|
||||||
|
* `TRAIN 1 MADE UP` and `COLLISION`. Those words are shouted deliberately.
|
||||||
|
*
|
||||||
|
* `^[A-Z][a-z]` is the test — a capital followed by a lower-case letter is an ordinary word that was
|
||||||
|
* capitalised because it began a sentence, and nothing else is. It leaves all-caps keywords alone,
|
||||||
|
* and it also leaves alone a word whose second character is a digit or a hyphen (`X22 Pee-Dee`),
|
||||||
|
* which a naive "is it uppercase?" check would get wrong because `'2'.toUpperCase() === '2'`.
|
||||||
|
*/
|
||||||
|
function uncapitalise(text: string): string {
|
||||||
|
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||||
|
}
|
||||||
|
|
||||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
@@ -1116,10 +1235,28 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
cardName: (id) => cardName(game.state, id),
|
cardName: (id) => cardName(game.state, id),
|
||||||
trainName: (id) => trainName(game.state, id),
|
trainName: (id) => trainName(game.state, id),
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* Everybody at the table sees a hand go to the Home Office deck, so the draw itself belongs in
|
||||||
|
* the shared log. The card's NAME does not: the deck is face down, and this log goes to every
|
||||||
|
* seat unfiltered, so naming it told three opponents exactly what the fourth was holding.
|
||||||
|
*
|
||||||
|
* A DEPARTMENT SLOT IS NOT THE SAME and stays named. Those piles are face up — a discard goes
|
||||||
|
* onto one precisely so a rival can take it — so the card was public before it was drawn, and
|
||||||
|
* hiding it would lose real information for no gain.
|
||||||
|
*
|
||||||
|
* The drawing seat still learns what it got. `justDrawn` below is the owner-only channel and
|
||||||
|
* `session.ts` sends it to that seat alone, so this costs the drawer nothing. Solitaire keeps
|
||||||
|
* the name for the same reason it keeps the seed: a one-seat table has nobody to leak to, and
|
||||||
|
* a solo player's history naming their own draw is the record rather than a leak.
|
||||||
|
*/
|
||||||
|
const blindDraw = e.type === 'cardDrawn' && e.source === 'homeOffice' && game.state.players.length > 1;
|
||||||
|
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
|
||||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||||
const mine = who !== null && 'player' in e;
|
const mine = who !== null && 'player' in e;
|
||||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
|
||||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1220,7 +1357,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
const result = applyIntent(game.state, actor, intent);
|
const result = applyIntent(game.state, actor, intent);
|
||||||
if (!result.ok) break;
|
if (!result.ok) break;
|
||||||
game.history.push(intent);
|
game.history.push(intent);
|
||||||
record(game, result.events);
|
/**
|
||||||
|
* `actor` IS PASSED HERE, so a replayed game narrates exactly as the live one did.
|
||||||
|
*
|
||||||
|
* It was omitted, and the omission was invisible in solitaire for a reason worth keeping: the
|
||||||
|
* only test that compares logs ("leaves nothing in the log describing a move that was taken
|
||||||
|
* back") compares one `fromSave`-built log against ANOTHER, so the missing attribution cancelled
|
||||||
|
* out on both sides. Live play attributes (`submit` passes `actor`) and so does multiplayer's
|
||||||
|
* replay (`fromMultiplayerSave`) — this was the one path of the three that did not, which meant
|
||||||
|
* a restored save, an undone game (undo rebuilds through here) and the replay viewer all
|
||||||
|
* described the same moves in different words from the game that produced them.
|
||||||
|
*/
|
||||||
|
record(game, result.events, actor);
|
||||||
drain(game);
|
drain(game);
|
||||||
}
|
}
|
||||||
return game;
|
return game;
|
||||||
@@ -1234,16 +1382,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
* this function only ever reconstructs from history that is already known to have been recorded
|
* this function only ever reconstructs from history that is already known to have been recorded
|
||||||
* under the currently-running rules.
|
* under the currently-running rules.
|
||||||
*
|
*
|
||||||
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
* LIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||||
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
||||||
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
||||||
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
||||||
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
|
* resumed game hits every time.
|
||||||
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
|
*
|
||||||
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
|
* `fromSave` HAD THE SAME GAP AND NO LONGER DOES (fixed 2026-08-30). It predated multiplayer, and
|
||||||
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
|
* nothing ever compared its output against a LIVE-played log: `undo`'s rebuilt game is itself
|
||||||
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
* `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compared one unattributed
|
||||||
* touch-in-passing.
|
* replay against another and the gap cancelled out on both sides. The test that now pins it plays a
|
||||||
|
* game live, restores it from its own save, and asserts the two logs are identical — which is the
|
||||||
|
* comparison that had been missing rather than a new requirement.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Why the intent a replay stopped at is reported rather than swallowed.
|
* Why the intent a replay stopped at is reported rather than swallowed.
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>build <span id="build">__BUILD__</span></span>
|
<span>build <span id="build">__BUILD__</span></span>
|
||||||
<span>solitaire runs entirely in your browser — no server code required</span>
|
<span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+18
-25
@@ -256,25 +256,21 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
|
|||||||
else if (type === 'custom') type = base;
|
else if (type === 'custom') type = base;
|
||||||
for (const r of typeRadios()) r.checked = r.value === type;
|
for (const r of typeRadios()) r.checked = r.value === type;
|
||||||
|
|
||||||
const scoring = preset(base).scoring;
|
/**
|
||||||
const note = $('lb-type-note');
|
* NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person
|
||||||
if (type === 'custom') {
|
* who had just chosen it, and the row is already labelled and already carries its own
|
||||||
note.textContent =
|
* description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on
|
||||||
`${gameTypeLabel('custom', scoring)} · ${differing.length} ` +
|
* each row that actually differs, which is where a Custom game's differences can be acted on.
|
||||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`;
|
*/
|
||||||
note.className = 'ng-note changed-note';
|
// A Custom game is nobody's default: open the block that says how it differs.
|
||||||
// A Custom game is nobody's default: open the block that says how it differs.
|
if (type === 'custom') $<HTMLDetailsElement>('lb-settings').open = true;
|
||||||
$<HTMLDetailsElement>('lb-settings').open = true;
|
|
||||||
} else {
|
|
||||||
note.textContent = preset(type as PresetName).blurb;
|
|
||||||
note.className = 'ng-note';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of typeRadios()) {
|
for (const r of typeRadios()) {
|
||||||
// Solitaire is on this screen so the two screens read as one list, but there is nothing here to
|
// Solitaire is on this screen so the two screens read as one list, but there is nothing here to
|
||||||
// deal it with — the New Game dialog is where a solitaire game comes from.
|
// deal it with. Dimmed and left to speak for itself: the heading says "Game type
|
||||||
if (r.value === 'solitaire') markUnavailable(r, 'dealt with the New game button, not here');
|
// (multi-player)", which is the explanation (Jesse, 2026-08-30).
|
||||||
|
if (r.value === 'solitaire') markUnavailable(r);
|
||||||
r.onchange = () => {
|
r.onchange = () => {
|
||||||
if (!r.checked) return;
|
if (!r.checked) return;
|
||||||
if (r.value === 'custom') {
|
if (r.value === 'custom') {
|
||||||
@@ -615,18 +611,15 @@ export function prefillCode(code: string): void {
|
|||||||
*
|
*
|
||||||
* Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled`
|
* Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled`
|
||||||
* on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads
|
* on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads
|
||||||
* as a broken control rather than an unavailable one. Dims the row and says why, once.
|
* as a broken control rather than an unavailable one.
|
||||||
|
*
|
||||||
|
* The dimming is the whole signal now. It used to append a reason to the row as well, and dropped
|
||||||
|
* that in 2026-08-30 along with the same text on the solitaire screen: one heading naming which
|
||||||
|
* game the screen deals says it once, where three dimmed rows each said it again.
|
||||||
*/
|
*/
|
||||||
function markUnavailable(radio: HTMLInputElement, why: string): void {
|
function markUnavailable(radio: HTMLInputElement): void {
|
||||||
radio.disabled = true;
|
radio.disabled = true;
|
||||||
const row = radio.closest('label');
|
radio.closest('label')?.classList.add('disabled');
|
||||||
if (!row) return;
|
|
||||||
row.classList.add('disabled');
|
|
||||||
if (row.querySelector('.lb-why')) return;
|
|
||||||
const note = document.createElement('span');
|
|
||||||
note.className = 'lb-why';
|
|
||||||
note.textContent = ` — ${why}`;
|
|
||||||
row.querySelector('span')?.appendChild(note);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
|
|||||||
+658
-196
File diff suppressed because it is too large
Load Diff
+40
-6
@@ -180,7 +180,7 @@ export function dayEndHtml(f: Frame): string {
|
|||||||
ahead +
|
ahead +
|
||||||
standingsHtml(f) +
|
standingsHtml(f) +
|
||||||
targetHtml(f) +
|
targetHtml(f) +
|
||||||
collisionsHtml(f)
|
collisionsHtml(f, ended)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +238,28 @@ function targetHtml(f: Frame): string {
|
|||||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||||
* screen that this game does not have.
|
* screen that this game does not have.
|
||||||
*/
|
*/
|
||||||
function collisionsHtml(f: Frame): string {
|
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||||
const scoredOnCollisions =
|
const scoredOnCollisions =
|
||||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||||
return scoredOnCollisions
|
if (!scoredOnCollisions) return '';
|
||||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
/**
|
||||||
: '';
|
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
|
||||||
|
*
|
||||||
|
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
|
||||||
|
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
|
||||||
|
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
|
||||||
|
* today ... that does seem to be a contradiction."
|
||||||
|
*
|
||||||
|
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
|
||||||
|
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
|
||||||
|
* not turned over and the word is accurate.
|
||||||
|
*/
|
||||||
|
const [count, when] =
|
||||||
|
endedDay === undefined
|
||||||
|
? [f.collisionsToday, 'today']
|
||||||
|
: [f.collisionsPrevDay, `on Day ${endedDay}`];
|
||||||
|
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,8 +269,13 @@ function collisionsHtml(f: Frame): string {
|
|||||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
||||||
* the game has the player's whole attention. Each reason gets a sentence that says what actually
|
* the game has the player's whole attention. Each reason gets a sentence that says what actually
|
||||||
* happened, with this game's own numbers in it.
|
* happened, with this game's own numbers in it.
|
||||||
|
*
|
||||||
|
* EXPORTED for the developer replay recorder (`sim/replay.ts`), which was still printing
|
||||||
|
* `loss — revenueFloor` into its own heading a release after this was written — the same defect the
|
||||||
|
* issue was filed about, surviving in the one place nobody had looked (`TODO.md` #34). One
|
||||||
|
* implementation, so the two cannot say the game ended for different reasons.
|
||||||
*/
|
*/
|
||||||
function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
|
export function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
|
||||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||||
switch (o.reason) {
|
switch (o.reason) {
|
||||||
case 'daysElapsed':
|
case 'daysElapsed':
|
||||||
@@ -347,7 +367,17 @@ function tallyHtml(t: Frame['tally']): string {
|
|||||||
};
|
};
|
||||||
push('Loads made up', t.loadsCompleted);
|
push('Loads made up', t.loadsCompleted);
|
||||||
push('Loads broken', t.unloadsCompleted);
|
push('Loads broken', t.unloadsCompleted);
|
||||||
|
/**
|
||||||
|
* BOTH HALVES OF THE MEN | AT | WORK PIPELINE, not just the loading one.
|
||||||
|
*
|
||||||
|
* §9.1 makes loading and unloading the same shape — begun, then carried through — and the Tally
|
||||||
|
* has counted both since it was written. The screen reported only the loading side, so a player
|
||||||
|
* with three unloads part-finished at the final whistle was told nothing about them while the
|
||||||
|
* equivalent loads were listed. Found 2026-08-30 auditing which Frame fields nothing reads:
|
||||||
|
* `unloadsBegun` was one of four, and the only one whose absence was visible on screen.
|
||||||
|
*/
|
||||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||||
|
push('Unloads still in the pipeline', t.unloadsBegun - t.unloadsCompleted);
|
||||||
push('Passengers boarded', t.passengersBoarded);
|
push('Passengers boarded', t.passengersBoarded);
|
||||||
push('Passengers detrained', t.passengersDetrained);
|
push('Passengers detrained', t.passengersDetrained);
|
||||||
push('Cars coupled', t.carsCoupled);
|
push('Cars coupled', t.carsCoupled);
|
||||||
@@ -381,6 +411,10 @@ function tallyHtml(t: Frame['tally']): string {
|
|||||||
}
|
}
|
||||||
push('Cards drawn', t.cardsDrawn);
|
push('Cards drawn', t.cardsDrawn);
|
||||||
push('Cards played', t.cardsPlayed);
|
push('Cards played', t.cardsPlayed);
|
||||||
|
// Gitea#9 made throwing a Timetabled train away a legal and deliberate move, so a discard is a
|
||||||
|
// CHOICE the player made rather than an accident of the hand limit — and the engine has counted
|
||||||
|
// it all along while the screen listed only draws and plays beside it.
|
||||||
|
push('Cards discarded', t.cardsDiscarded);
|
||||||
|
|
||||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-215
@@ -76,6 +76,19 @@ dialog input:focus{outline:none;border-color:#4d6fa8}
|
|||||||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||||||
.ng-buttons button:hover{border-color:#4d6fa8}
|
.ng-buttons button:hover{border-color:#4d6fa8}
|
||||||
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
||||||
|
/* The save warning is the one thing on this screen that describes something IRREVERSIBLE, and it
|
||||||
|
sat in `.ng-note` — the same dim 11px grey as the twenty explanatory notes above it, which is
|
||||||
|
where the eye has already learned there is nothing to act on. Sized and coloured to be read
|
||||||
|
(Jesse, 2026-08-30). Amber rather than red: losing a saved game is a real cost, not a danger, and
|
||||||
|
red here would outrank the actual rules of the game sitting above it. */
|
||||||
|
#ss-saved-note{font-size:15px;font-weight:500;line-height:1.55;color:#ffcf70;background:#332a15;
|
||||||
|
border:1px solid #b8912c;border-left:5px solid #e0a83c;border-radius:5px;padding:12px 14px;
|
||||||
|
margin:18px 0 0}
|
||||||
|
#ss-saved-note b{color:#ffe3a6}
|
||||||
|
/* Two live choices, so neither is the quiet one: `Create new game` keeps the primary blue it has when
|
||||||
|
it is the only button, and `Continue` is given the same weight rather than reading as a cancel. */
|
||||||
|
#ss-deal{background:#31527f;border-color:#4d6fa8}
|
||||||
|
#ss-resume{background:#2f5340;border-color:#4f8a68}
|
||||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||||
@@ -95,13 +108,23 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
|||||||
lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */
|
lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */
|
||||||
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
||||||
.ng-radio.disabled:hover{background:none}
|
.ng-radio.disabled:hover{background:none}
|
||||||
.lb-why{color:#e0b060;font-size:11px}
|
|
||||||
#lobby h2,#solitairesetup h2{margin-top:0}
|
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||||
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
||||||
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||||
.lb-seat:last-child{border-bottom:none}
|
.lb-seat:last-child{border-bottom:none}
|
||||||
.lb-seat .who{flex:1}
|
.lb-seat .who{flex:1}
|
||||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||||||
|
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
|
||||||
|
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
|
||||||
|
board is behind and has a button you have to be able to hit. Amber on the button because amber
|
||||||
|
already means clickable everywhere else on this page; the row itself stays quiet so it does not
|
||||||
|
compete with the three banners it sits under. */
|
||||||
|
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
|
||||||
|
#watching[hidden]{display:none}
|
||||||
|
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||||
|
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||||
|
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||||
#presence:empty{display:none}
|
#presence:empty{display:none}
|
||||||
/* division strip */
|
/* division strip */
|
||||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||||
@@ -203,6 +226,17 @@ button.act{display:inline-block}
|
|||||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||||
|
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
|
||||||
|
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
|
||||||
|
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
|
||||||
|
from "always showing" without waiting for the right phase in between. Three controls, one per
|
||||||
|
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
|
||||||
|
is doing, which was the earlier fix and is worth keeping. */
|
||||||
|
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
|
||||||
|
border:1px solid #4a5361}
|
||||||
|
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
|
||||||
|
.seg button.ghost:first-child{border-left:0}
|
||||||
|
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
|
||||||
#district.folded #grid{display:none}
|
#district.folded #grid{display:none}
|
||||||
#district.folded .districtrule{display:none}
|
#district.folded .districtrule{display:none}
|
||||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||||
@@ -212,6 +246,16 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
|||||||
.districtrule b{color:#cfd6e0}
|
.districtrule b{color:#cfd6e0}
|
||||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
#districtsummary{display:none}
|
#districtsummary{display:none}
|
||||||
|
/* The This Game card folds the same way the district does, and for the same reason: a panel that
|
||||||
|
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
|
||||||
|
#gamecard.folded #gamecardbody{display:none}
|
||||||
|
#gamecard #gamecardsummary{display:none}
|
||||||
|
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
|
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
|
||||||
|
#gamecardbody dt{color:#8b94a3;font-size:11px}
|
||||||
|
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||||
|
#gamecardbody dd.changed{color:#f0b64a}
|
||||||
|
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||||
so a disabled button must not wear it. */
|
so a disabled button must not wear it. */
|
||||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||||
@@ -307,9 +351,10 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
||||||
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
|
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
|
||||||
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
|
`main.ts` only decides whether THIS div, `#solitairesetup` or `#gameui` is the one visible.
|
||||||
`#newgamedlg` at the very end of the body is solitaire-only, and asks the same questions through
|
`#solitairesetup` asks the same questions of a solitaire player, through the same shared module
|
||||||
the same shared module (`settings-form.ts`) — the two blocks are generated from one template. -->
|
(`settings-form.ts`) — the two blocks are generated from one template, and since 2026-08-30
|
||||||
|
they are the ONLY two: the in-game dialog that was a third copy is gone. -->
|
||||||
<div id="lobby" hidden>
|
<div id="lobby" hidden>
|
||||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||||||
|
|
||||||
@@ -402,12 +447,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||||
<!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found
|
<!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found
|
||||||
it separated from the control it explains by fifteen settings. -->
|
it separated from the control it explains by fifteen settings. -->
|
||||||
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a
|
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||||
bot. Pick the size of the table now; it cannot change once the game is created.</p>
|
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||||
|
bot. The number of players cannot be changed once the game is created.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lb-col">
|
<div class="lb-col">
|
||||||
<h3>Game type</h3>
|
<h3>Game type (multi-player)</h3>
|
||||||
<div class="set-row" id="lb-type-row">
|
<div class="set-row" id="lb-type-row">
|
||||||
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
|
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
|
||||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||||
@@ -421,7 +467,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="ng-note" id="lb-type-note"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
|
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
|
||||||
@@ -431,7 +477,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<summary>Game settings</summary>
|
<summary>Game settings</summary>
|
||||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||||
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||||
started from; clicking a type again resets all of them back to it. They are fixed when the
|
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||||
game is created and cannot be changed once it starts.</p>
|
game is created and cannot be changed once it starts.</p>
|
||||||
<div class="set-groups">
|
<div class="set-groups">
|
||||||
|
|
||||||
@@ -505,13 +551,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="lb-colday-row">
|
<div class="set-row" id="lb-colday-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
|
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
|
||||||
<span>The game ends and everyone loses if collisions in one Day reach</span>
|
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||||
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
|
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="lb-colday-hint"></span>
|
<span class="set-hint" id="lb-colday-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="lb-coltotal-row">
|
<div class="set-row" id="lb-coltotal-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
|
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
|
||||||
<span>The game ends and everyone loses after this many collisions in the whole game</span>
|
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||||
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="lb-coltotal-hint"></span>
|
<span class="set-hint" id="lb-coltotal-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,7 +568,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Optional rules</h3>
|
<h3>Optional rules</h3>
|
||||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
<p class="ng-note">Each one changes how the game plays.</p>
|
||||||
<div class="set-row" id="lb-visibility-row">
|
<div class="set-row" id="lb-visibility-row">
|
||||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
night Stages (1–3 and 11–12)</span>
|
night Stages (1–3 and 11–12)</span>
|
||||||
@@ -555,7 +601,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="lb-span">
|
<div class="lb-span">
|
||||||
<button id="lb-create" type="button">Create game</button>
|
<button id="lb-create" type="button">Create new game</button>
|
||||||
<p class="lb-error" id="lb-create-err" role="alert"></p>
|
<p class="lb-error" id="lb-create-err" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,16 +662,29 @@ ul.blocked li{padding:2px 0}
|
|||||||
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
||||||
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
||||||
|
|
||||||
|
<!-- THE SAME THREE PARAMETERS THE LOBBY ASKS, in the same order, with the same note under them
|
||||||
|
(Jesse, 2026-08-30: "everything beneath that should be the same"). The table size is here
|
||||||
|
rather than hidden because it is one of the three things that describe a game, and leaving
|
||||||
|
it out made this screen a different form that happened to share a rules block. It is LOCKED
|
||||||
|
at one: a `LocalSession` runs the engine in this browser and a table needs a server, which
|
||||||
|
is the same reason the four multiplayer game types are shown disabled below. -->
|
||||||
<div class="lb-params">
|
<div class="lb-params">
|
||||||
<label class="ng-num"><span>Seed</span>
|
<label class="ng-num"><span>Seed</span>
|
||||||
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||||
|
<label class="ng-num"><span>Players at the table</span>
|
||||||
|
<select id="ss-players" disabled>
|
||||||
|
<option value="1" selected>1</option>
|
||||||
|
</select></label>
|
||||||
<label class="ng-num"><span>Days</span>
|
<label class="ng-num"><span>Days</span>
|
||||||
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||||
|
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||||
|
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||||
|
bot. The number of players cannot be changed once the game is created.</p>
|
||||||
|
|
||||||
<h3>Game type</h3>
|
<h3>Game type (solitaire)</h3>
|
||||||
<div class="set-row" id="ss-type-row">
|
<div class="set-row" id="ss-type-row">
|
||||||
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
||||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||||
@@ -639,19 +698,20 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="ng-note" id="ss-type-note"></p>
|
|
||||||
|
|
||||||
<details id="ss-settings" open>
|
<details id="ss-settings" open>
|
||||||
<summary>Game settings</summary>
|
<summary>Game settings</summary>
|
||||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||||
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back
|
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||||
to it.</p>
|
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||||
|
game is created and cannot be changed once it starts.</p>
|
||||||
<div class="set-groups">
|
<div class="set-groups">
|
||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Starting hand</h3>
|
<h3>Starting hand</h3>
|
||||||
<p class="ng-note">What you are dealt before the first turn. The hand limit is three either
|
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||||
way — deal six and the first turn is spent choosing which of them to keep.</p>
|
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||||
<div class="set-row" id="ss-hand-row">
|
<div class="set-row" id="ss-hand-row">
|
||||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
||||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||||
@@ -708,7 +768,8 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Victory conditions</h3>
|
<h3>Victory conditions</h3>
|
||||||
<p class="ng-note">The ways this game can end badly. How long it runs is set above, in Days.</p>
|
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
|
||||||
|
right; how long the game runs is set above, with the table size.</p>
|
||||||
<div class="set-row" id="ss-minrev-row">
|
<div class="set-row" id="ss-minrev-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||||
<span>You lose if Revenue at the end is under</span>
|
<span>You lose if Revenue at the end is under</span>
|
||||||
@@ -717,13 +778,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="ss-colday-row">
|
<div class="set-row" id="ss-colday-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||||
<span>The game ends in a loss if collisions in one Day reach</span>
|
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||||
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="ss-colday-hint"></span>
|
<span class="set-hint" id="ss-colday-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="ss-coltotal-row">
|
<div class="set-row" id="ss-coltotal-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||||
<span>The game ends in a loss after this many collisions in the whole game</span>
|
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||||
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="ss-coltotal-hint"></span>
|
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -734,7 +795,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Optional rules</h3>
|
<h3>Optional rules</h3>
|
||||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
<p class="ng-note">Each one changes how the game plays.</p>
|
||||||
<div class="set-row" id="ss-visibility-row">
|
<div class="set-row" id="ss-visibility-row">
|
||||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
night Stages (1–3 and 11–12)</span>
|
night Stages (1–3 and 11–12)</span>
|
||||||
@@ -742,8 +803,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span class="set-hint" id="ss-visibility-hint"></span>
|
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="ss-rotation-row">
|
<div class="set-row" id="ss-rotation-row">
|
||||||
<label class="ng-num"><span>Employee Rotation — meaningless at a table of one, shown here so
|
<label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
|
||||||
this screen and the lobby read as one list</span>
|
|
||||||
<input id="ss-rotation" type="checkbox" disabled></label>
|
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||||
<span class="set-hint" id="ss-rotation-hint"></span>
|
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -765,8 +825,17 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<!-- Shown only when `station-master.save.v1` holds a game. Dealing from this screen CLEARS that
|
||||||
|
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
||||||
|
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||||
|
reads as "discard what I was playing". -->
|
||||||
|
<p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Creating a new game
|
||||||
|
replaces it permanently — there is no undo. Choose <b>Continue saved game</b> to pick it
|
||||||
|
up where you left off.</p>
|
||||||
|
|
||||||
<menu class="ng-buttons">
|
<menu class="ng-buttons">
|
||||||
<button id="ss-deal" type="button">Deal</button>
|
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||||
|
<button id="ss-deal" type="button">Create new game</button>
|
||||||
</menu>
|
</menu>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -779,20 +848,23 @@ ul.blocked li{padding:2px 0}
|
|||||||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
||||||
<span id="objective" class="pace">—</span>
|
<span id="objective" class="pace">—</span>
|
||||||
<span class="dim">seed <span id="seed">—</span></span>
|
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
|
||||||
|
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
|
||||||
|
them, so the one victory condition that can end a game early was invisible while it ran.
|
||||||
|
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
|
||||||
|
counting towards it. The limits themselves live in the This Game card; this is progress. -->
|
||||||
|
<span class="dim" id="collisions" title=""></span>
|
||||||
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
||||||
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
||||||
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
||||||
Empty (and collapsed) in solitaire, where there is no code. -->
|
Empty (and collapsed) in solitaire, where there is no code. -->
|
||||||
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
||||||
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
|
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
|
||||||
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
|
(TODO #28). Jesse: "we can give complete information about all the game options and not take
|
||||||
<span class="dim" id="gametype" title=""></span>
|
up valuable real estate at the top of the screen… it is not something that they're likely to
|
||||||
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
|
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
|
||||||
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
|
the collision counts — plus the game code, which is identity rather than settings: it is how
|
||||||
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
|
you say WHICH game you are in, out loud, without opening anything. -->
|
||||||
to keep the header on one line; the tooltip spells it out. -->
|
|
||||||
<span class="dim" id="houserules" title="">—</span>
|
|
||||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||||
@@ -800,9 +872,17 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
|
||||||
|
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
|
||||||
|
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||||
|
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||||
|
game believing he was at 7x when he was at 1x. -->
|
||||||
|
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
|
||||||
|
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||||
|
</span>
|
||||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||||
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||||
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
|
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
|
||||||
<!-- LEAVING A RUNNING GAME. Reported by Jesse 2026-08-23: "if I'm a player in the middle of the
|
<!-- LEAVING A RUNNING GAME. Reported by Jesse 2026-08-23: "if I'm a player in the middle of the
|
||||||
game and I need to leave, how do I leave the game, clear the token from my browser so I can
|
game and I need to leave, how do I leave the game, clear the token from my browser so I can
|
||||||
@@ -829,14 +909,24 @@ ul.blocked li{padding:2px 0}
|
|||||||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||||
<div id="presence"></div>
|
<div id="presence"></div>
|
||||||
|
|
||||||
|
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
|
||||||
|
One row rather than three additions: the countdown, the caption naming the action being shown,
|
||||||
|
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
||||||
|
almost always. -->
|
||||||
|
<div id="watching" hidden>
|
||||||
|
<span id="watching-behind" class="wbehind"></span>
|
||||||
|
<span id="watching-what"></span>
|
||||||
|
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||||
<p class="ng-note" id="seating-chain"></p></section>
|
<p class="ng-note" id="seating-chain"></p></section>
|
||||||
<section id="district">
|
<section id="district">
|
||||||
<h2>Your Office Area
|
<h2><span id="districtwho">Your Office Area</span>
|
||||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="districtsummary" class="dim"></div>
|
<div id="districtsummary" class="dim"></div>
|
||||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||||
@@ -880,190 +970,34 @@ ul.blocked li{padding:2px 0}
|
|||||||
</section>
|
</section>
|
||||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||||
|
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
|
||||||
|
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
|
||||||
|
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
|
||||||
|
and the seating screen draw, so what you agreed to in the lobby and what you can read
|
||||||
|
mid-game cannot drift apart. -->
|
||||||
|
<section id="gamecard" class="folded"><h2>This Game
|
||||||
|
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
|
||||||
|
</h2>
|
||||||
|
<div id="gamecardsummary" class="dim"></div>
|
||||||
|
<div id="gamecardbody"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ===================================================================
|
<!-- THE IN-GAME "NEW GAME" BUTTON GOES TO THE SOLITAIRE SETUP SCREEN — there is no second
|
||||||
NEW GAME — the seed, the opening hand, and what the three economies pay.
|
dialog any more (Jesse, 2026-08-30: "it should not go to a separate screen. We should reuse the
|
||||||
|
Solitaire New Game Screen… in general we should reuse what we already have").
|
||||||
|
|
||||||
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
|
`#newgamedlg` was a third copy of the same questions, and the one that drifted: it kept the
|
||||||
you are about to play had no way in at all: the opening hand had been changed twice with no
|
multiplayer wording ("Everyone loses if COMBINED Revenue…") on a screen only ever shown to a
|
||||||
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
|
solitaire player, and explained Employee Rotation in full beside a control it had disabled.
|
||||||
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
|
Deleting it removes the drift rather than re-wording it.
|
||||||
at different settings — which needs a dialog, not a rebuild.
|
|
||||||
|
|
||||||
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
|
Nothing is lost by navigating away mid-game: `render()` calls `save()` on every frame, so the
|
||||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
game in progress is always on disk, and the setup screen offers "Continue saved game"
|
||||||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
to come straight back to it. -->
|
||||||
==================================================================== -->
|
|
||||||
</div><!-- /gameui -->
|
</div><!-- /gameui -->
|
||||||
|
|
||||||
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
|
||||||
<form method="dialog" id="newgameform">
|
|
||||||
<h2 class="big" id="ng-title">New game</h2>
|
|
||||||
|
|
||||||
<!-- THE SAME BLOCK THE LOBBY USES, same shared module, same order — the two screens are one
|
|
||||||
design. Only Solitaire can be dealt here; the multiplayer types are shown disabled rather
|
|
||||||
than hidden, so what this screen offers and what the lobby offers read as one list. -->
|
|
||||||
<div class="lb-params">
|
|
||||||
<label class="ng-num"><span>Seed</span>
|
|
||||||
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
|
||||||
<label class="ng-num"><span>Days</span>
|
|
||||||
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
|
|
||||||
</div>
|
|
||||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
|
||||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
|
||||||
|
|
||||||
<h3>Game type</h3>
|
|
||||||
<div class="set-row" id="ng-type-row">
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-type" value="solitaire">
|
|
||||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-type" value="coop" checked>
|
|
||||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-type" value="competitive">
|
|
||||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-type" value="cutthroat">
|
|
||||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-type" value="custom">
|
|
||||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="ng-note" id="ng-type-note"></p>
|
|
||||||
|
|
||||||
<details id="ng-settings" open>
|
|
||||||
<summary>Game settings</summary>
|
|
||||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
|
||||||
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back
|
|
||||||
to it.</p>
|
|
||||||
<div class="set-groups">
|
|
||||||
|
|
||||||
<div class="set-group">
|
|
||||||
<h3>Starting hand</h3>
|
|
||||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
|
||||||
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
|
||||||
<div class="set-row" id="ng-hand-row">
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom">
|
|
||||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom" checked>
|
|
||||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
|
|
||||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
|
||||||
<span class="set-hint" id="ng-hand-hint"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="set-group">
|
|
||||||
<h3>Where an Extra may start</h3>
|
|
||||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
|
||||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
|
||||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
|
||||||
belong to nobody and are always available. Starting one inside a district is the part that
|
|
||||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
|
||||||
Whistle Post never qualifies.</p>
|
|
||||||
<div class="set-row" id="ng-extra-row">
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
|
|
||||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
|
|
||||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
|
||||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice">
|
|
||||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
|
||||||
<span class="set-hint" id="ng-extra-hint"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="set-group">
|
|
||||||
<h3>Revenue</h3>
|
|
||||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
|
||||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
|
||||||
switches an economy off so the others can be read.</p>
|
|
||||||
<div class="set-row" id="ng-passenger-row">
|
|
||||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
|
||||||
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
|
||||||
<span class="set-hint" id="ng-passenger-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-freight-row">
|
|
||||||
<label class="ng-num"><span>Freight revenue per load</span>
|
|
||||||
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
|
||||||
<span class="set-hint" id="ng-freight-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-transit-row">
|
|
||||||
<label class="ng-num"><span>Train revenue per transit</span>
|
|
||||||
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
|
||||||
<span class="set-hint" id="ng-transit-hint"></span>
|
|
||||||
</div>
|
|
||||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
|
||||||
Division — the one thing nobody has to work for.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="set-group">
|
|
||||||
<h3>Victory conditions</h3>
|
|
||||||
<p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
|
|
||||||
right; how long the game runs is set above, with the table size.</p>
|
|
||||||
<div class="set-row" id="ng-minrev-row">
|
|
||||||
<label class="ng-gate"><input type="checkbox" id="ng-minrev-on" checked>
|
|
||||||
<span>Everyone loses if combined Revenue at the end is under</span>
|
|
||||||
<input id="ng-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
|
||||||
<span class="set-hint" id="ng-minrev-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-colday-row">
|
|
||||||
<label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked>
|
|
||||||
<span>The game ends and everyone loses if collisions in one Day reach</span>
|
|
||||||
<input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label>
|
|
||||||
<span class="set-hint" id="ng-colday-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-coltotal-row">
|
|
||||||
<label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked>
|
|
||||||
<span>The game ends and everyone loses after this many collisions in the whole game</span>
|
|
||||||
<input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
|
||||||
<span class="set-hint" id="ng-coltotal-hint"></span>
|
|
||||||
</div>
|
|
||||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
|
||||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
|
||||||
type deals them whatever else is set here.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="set-group">
|
|
||||||
<h3>Optional rules</h3>
|
|
||||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
|
||||||
<div class="set-row" id="ng-visibility-row">
|
|
||||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
|
||||||
night Stages (1–3 and 11–12)</span>
|
|
||||||
<input id="ng-visibility" type="checkbox"></label>
|
|
||||||
<span class="set-hint" id="ng-visibility-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-rotation-row">
|
|
||||||
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
|
|
||||||
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
|
|
||||||
you; the district stays where it is</span>
|
|
||||||
<input id="ng-rotation" type="checkbox"></label>
|
|
||||||
<span class="set-hint" id="ng-rotation-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-toolbox-row">
|
|
||||||
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
|
|
||||||
of four; play or discard down to three on the first turn</span>
|
|
||||||
<input id="ng-toolbox" type="checkbox"></label>
|
|
||||||
<span class="set-hint" id="ng-toolbox-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div class="set-row" id="ng-tossloco-row">
|
|
||||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
|
||||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
|
||||||
be played onto the timetable. An Extra is never discardable either way</span>
|
|
||||||
<input id="ng-tossloco" type="checkbox"></label>
|
|
||||||
<span class="set-hint" id="ng-tossloco-hint"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<menu class="ng-buttons">
|
|
||||||
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
|
|
||||||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
|
||||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
|
||||||
</menu>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
||||||
between one click and the next; the phase banner and the announcement flash both fade before
|
between one click and the next; the phase banner and the announcement flash both fade before
|
||||||
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||||
@@ -1080,10 +1014,19 @@ ul.blocked li{padding:2px 0}
|
|||||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
||||||
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
||||||
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
||||||
|
<!-- THE EXTENSION QUESTION IS ASKED HERE, not only behind this dialog (Gitea#11 + #16).
|
||||||
|
This opens ITSELF at every ending, and it is modal — so `renderEnding`'s "play one more Day"
|
||||||
|
buttons, which it writes into `#actions`, sit underneath it. A player saw a results screen whose
|
||||||
|
only control was Close and reasonably concluded the game was over: reported by Jesse
|
||||||
|
2026-08-30, "Solitaire game ended. I did not have an option to extend the game by a day."
|
||||||
|
Gitea#11 was verified over the HTTP API, which renders no dialog, so the browser never was.
|
||||||
|
The two extension buttons are hidden unless the game is actually awaiting a vote. -->
|
||||||
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<div id="resultsbody"></div>
|
<div id="resultsbody"></div>
|
||||||
<menu class="ng-buttons">
|
<menu class="ng-buttons">
|
||||||
|
<button value="extend-yes" id="rs-extend-yes" type="submit" hidden>Play One More Day</button>
|
||||||
|
<button value="extend-no" id="rs-extend-no" type="submit" hidden>End the Game Here</button>
|
||||||
<button value="ok" id="rs-ok" type="submit">Close</button>
|
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||||
</menu>
|
</menu>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+8
-2
@@ -119,8 +119,14 @@ export const PRESETS: readonly Preset[] = [
|
|||||||
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
||||||
rules: {
|
rules: {
|
||||||
startingHand: SIX,
|
startingHand: SIX,
|
||||||
// Nobody else's district exists, so "any Control Point" and "your own" are the same rule.
|
/**
|
||||||
extraStart: 'anyOffice',
|
* Nobody else's district exists, so "any Control Point" and "your own" are the same rule —
|
||||||
|
* `apply.ts` only ever rejects `ownOffice` when `start.seat !== seatOf(s, player)`, which
|
||||||
|
* cannot happen at one seat. It said `anyOffice` until 2026-08-30, which was true and read
|
||||||
|
* wrong: a solitaire player has no "any player" to contrast themselves with, so the permissive
|
||||||
|
* label described a permission nobody was being granted. Jesse's call; no gameplay effect.
|
||||||
|
*/
|
||||||
|
extraStart: 'ownOffice',
|
||||||
passengerPerCoach: 1,
|
passengerPerCoach: 1,
|
||||||
freightPerLoad: 1,
|
freightPerLoad: 1,
|
||||||
trainPerTransit: 0,
|
trainPerTransit: 0,
|
||||||
|
|||||||
+70
-6
@@ -16,7 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||||
|
import { publicSnapshot } from '../sim/view.ts';
|
||||||
|
import { takeSteps } from '../sim/display-step.ts';
|
||||||
|
import type { DisplayStep } from '../sim/display-step.ts';
|
||||||
import type { PlayerIndex } from '../engine/state.ts';
|
import type { PlayerIndex } from '../engine/state.ts';
|
||||||
import { applyDelta } from '../sim/frame-delta.ts';
|
import { applyDelta } from '../sim/frame-delta.ts';
|
||||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||||
@@ -29,7 +32,6 @@ import {
|
|||||||
handPlayable,
|
handPlayable,
|
||||||
isOutOfTurn,
|
isOutOfTurn,
|
||||||
newGame,
|
newGame,
|
||||||
overHandLimit,
|
|
||||||
submit,
|
submit,
|
||||||
toSave,
|
toSave,
|
||||||
undo,
|
undo,
|
||||||
@@ -58,8 +60,6 @@ export type Session = {
|
|||||||
seat(): PlayerIndex;
|
seat(): PlayerIndex;
|
||||||
/** Whose turn it is, or null when the game is over or waiting on nothing. */
|
/** Whose turn it is, or null when the game is over or waiting on nothing. */
|
||||||
actor(): PlayerIndex | null;
|
actor(): PlayerIndex | null;
|
||||||
/** True when the hand is over §6.2's limit and the turn cannot be ended. */
|
|
||||||
overHandLimit(): boolean;
|
|
||||||
/** Which cards in hand are playable right now, in hand order. */
|
/** Which cards in hand are playable right now, in hand order. */
|
||||||
handPlayable(): boolean[];
|
handPlayable(): boolean[];
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +108,28 @@ export type Session = {
|
|||||||
* starts — which is exactly when "is everyone here?" is the question.
|
* starts — which is exactly when "is everyone here?" is the question.
|
||||||
*/
|
*/
|
||||||
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||||
|
/**
|
||||||
|
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
|
||||||
|
* WATCHED rather than discovered.
|
||||||
|
*
|
||||||
|
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
|
||||||
|
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
|
||||||
|
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
|
||||||
|
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
|
||||||
|
*
|
||||||
|
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
|
||||||
|
* button. Different thing entirely, hence the longer name.
|
||||||
|
*/
|
||||||
|
takeDisplaySteps(): DisplayStep[];
|
||||||
|
/**
|
||||||
|
* A public frame to start the queue from, once — draining, and non-null only when the queue must
|
||||||
|
* be RESET rather than advanced.
|
||||||
|
*
|
||||||
|
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
|
||||||
|
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
|
||||||
|
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
|
||||||
|
*/
|
||||||
|
takeDisplayReset(): PublicFrame | null;
|
||||||
/**
|
/**
|
||||||
* Stop listening, for good.
|
* Stop listening, for good.
|
||||||
*
|
*
|
||||||
@@ -148,6 +170,12 @@ export type LocalSession = Session & {
|
|||||||
*/
|
*/
|
||||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||||
|
/**
|
||||||
|
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
|
||||||
|
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
|
||||||
|
* queue has to be told to start over rather than left holding a chain that no longer continues.
|
||||||
|
*/
|
||||||
|
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const changed = (): void => {
|
const changed = (): void => {
|
||||||
for (const fn of [...listeners]) fn();
|
for (const fn of [...listeners]) fn();
|
||||||
@@ -161,7 +189,6 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
menu: () => actionMenu(game),
|
menu: () => actionMenu(game),
|
||||||
seat: () => 0,
|
seat: () => 0,
|
||||||
actor: () => currentActor(game),
|
actor: () => currentActor(game),
|
||||||
overHandLimit: () => overHandLimit(game),
|
|
||||||
handPlayable: () => handPlayable(game),
|
handPlayable: () => handPlayable(game),
|
||||||
submit: async (intent: Intent) => {
|
submit: async (intent: Intent) => {
|
||||||
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
||||||
@@ -190,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
},
|
},
|
||||||
justDrawn: () => game.justDrawn,
|
justDrawn: () => game.justDrawn,
|
||||||
presence: () => [],
|
presence: () => [],
|
||||||
|
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
|
||||||
|
// multiplayer game. No separate code path — see `sim/display-step.ts`.
|
||||||
|
takeDisplaySteps: () => takeSteps(game.display),
|
||||||
|
takeDisplayReset: () => {
|
||||||
|
const reset = pendingReset;
|
||||||
|
pendingReset = null;
|
||||||
|
return reset;
|
||||||
|
},
|
||||||
|
|
||||||
seed: () => game.seed,
|
seed: () => game.seed,
|
||||||
save: () => toSave(game),
|
save: () => toSave(game),
|
||||||
@@ -203,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
back.scheduled = null;
|
back.scheduled = null;
|
||||||
back.justDrawn = null;
|
back.justDrawn = null;
|
||||||
back.announced = null;
|
back.announced = null;
|
||||||
|
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
|
||||||
|
pendingReset = publicSnapshot(back.state);
|
||||||
changed();
|
changed();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -211,6 +248,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
// Restoring replays the whole history and re-records every draw; none of it is news.
|
// Restoring replays the whole history and re-records every draw; none of it is news.
|
||||||
game.justDrawn = null;
|
game.justDrawn = null;
|
||||||
game.announced = null;
|
game.announced = null;
|
||||||
|
pendingReset = publicSnapshot(game.state);
|
||||||
changed();
|
changed();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -224,6 +262,10 @@ type Push = {
|
|||||||
lines: { text: string; tone: string }[];
|
lines: { text: string; tone: string }[];
|
||||||
/** One entry for a change; every other seat at once on the connect push. */
|
/** One entry for a change; every other seat at once on the connect push. */
|
||||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||||
|
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
|
||||||
|
steps?: DisplayStep[];
|
||||||
|
/** The baseline for the step queue, sent on a connect only. */
|
||||||
|
publicReset?: PublicFrame;
|
||||||
/**
|
/**
|
||||||
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
||||||
*
|
*
|
||||||
@@ -274,6 +316,8 @@ export function createRemoteSession(
|
|||||||
let menu: Menu | null = null;
|
let menu: Menu | null = null;
|
||||||
let lines: { text: string; tone: string }[] = [];
|
let lines: { text: string; tone: string }[] = [];
|
||||||
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
||||||
|
let displaySteps: DisplayStep[] = [];
|
||||||
|
let displayReset: PublicFrame | null = null;
|
||||||
let cues: string[] = [];
|
let cues: string[] = [];
|
||||||
let scheduled: number | null = null;
|
let scheduled: number | null = null;
|
||||||
let announcement: string | null = null;
|
let announcement: string | null = null;
|
||||||
@@ -328,6 +372,21 @@ export function createRemoteSession(
|
|||||||
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
||||||
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
||||||
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
||||||
|
/**
|
||||||
|
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
|
||||||
|
*
|
||||||
|
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
|
||||||
|
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
|
||||||
|
* them onto the new baseline would draw a board that never existed. The history panel is what
|
||||||
|
* carries what was missed; the animation does not replay it (§ v0.8.0).
|
||||||
|
*/
|
||||||
|
if (push.publicReset) {
|
||||||
|
displayReset = push.publicReset;
|
||||||
|
displaySteps = [];
|
||||||
|
}
|
||||||
|
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
|
||||||
|
// that happened.
|
||||||
|
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
|
||||||
changed();
|
changed();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,7 +400,6 @@ export function createRemoteSession(
|
|||||||
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
|
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
|
||||||
seat: () => seat,
|
seat: () => seat,
|
||||||
actor: () => need().actor,
|
actor: () => need().actor,
|
||||||
overHandLimit: () => need().overHandLimit,
|
|
||||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||||
async submit(intent: Intent): Promise<boolean> {
|
async submit(intent: Intent): Promise<boolean> {
|
||||||
const seq = nextSeq++;
|
const seq = nextSeq++;
|
||||||
@@ -381,6 +439,12 @@ export function createRemoteSession(
|
|||||||
},
|
},
|
||||||
justDrawn: () => justDrawnCard,
|
justDrawn: () => justDrawnCard,
|
||||||
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
||||||
|
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
|
||||||
|
takeDisplayReset: () => {
|
||||||
|
const reset = displayReset;
|
||||||
|
displayReset = null;
|
||||||
|
return reset;
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
||||||
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
|
|||||||
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
||||||
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
|
||||||
|
*
|
||||||
|
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
|
||||||
|
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
|
||||||
|
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
|
||||||
|
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
|
||||||
|
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
|
||||||
|
* for.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
const pace = new URLSearchParams(location.search).get('pace');
|
||||||
|
if (pace !== null) {
|
||||||
|
for (const door of Array.from(document.querySelectorAll('a.door'))) {
|
||||||
|
const href = door.getAttribute('href');
|
||||||
|
// Only the doors into the game, and only ones that have not been disabled above.
|
||||||
|
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A door that keeps its own href is the status quo, not a broken page.
|
||||||
|
}
|
||||||
|
|
||||||
const mpDoor = document.getElementById('door-multiplayer');
|
const mpDoor = document.getElementById('door-multiplayer');
|
||||||
if (mpDoor) {
|
if (mpDoor) {
|
||||||
const close = (): void => {
|
const close = (): void => {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6.
|
||||||
|
*
|
||||||
|
* Holds the public board the screen is currently showing, which is not always the board the game is
|
||||||
|
* actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in
|
||||||
|
* ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a
|
||||||
|
* burst into something watchable. TODO #13.
|
||||||
|
*
|
||||||
|
* TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from
|
||||||
|
* the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are
|
||||||
|
* never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here
|
||||||
|
* imports the DOM either, so it is testable without one.
|
||||||
|
*
|
||||||
|
* NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has
|
||||||
|
* — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need
|
||||||
|
* starting, stopping and cleaning up on every game replacement, and would be untestable without
|
||||||
|
* faking timers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PublicFrame } from '../sim/view.ts';
|
||||||
|
import type { DisplayStep } from '../sim/display-step.ts';
|
||||||
|
import { applyPublicDelta } from '../sim/public-delta.ts';
|
||||||
|
import { dwellForStep } from '../sim/pacing.ts';
|
||||||
|
|
||||||
|
export type StepQueue = {
|
||||||
|
/** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */
|
||||||
|
reset(frame: PublicFrame): void;
|
||||||
|
/** Queue steps to be shown in order. */
|
||||||
|
push(steps: readonly DisplayStep[]): void;
|
||||||
|
/**
|
||||||
|
* Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip
|
||||||
|
* a redraw when nothing did.
|
||||||
|
*/
|
||||||
|
advance(now: number): boolean;
|
||||||
|
/** Show everything immediately. Returns true if anything was skipped. */
|
||||||
|
skip(): boolean;
|
||||||
|
/** The board to draw, or null before any reset has arrived. */
|
||||||
|
current(): PublicFrame | null;
|
||||||
|
/**
|
||||||
|
* How many queued steps the player is still going to WATCH — the number the "N behind" counter
|
||||||
|
* shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`.
|
||||||
|
*/
|
||||||
|
behind(): number;
|
||||||
|
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||||
|
showing(): DisplayStep | null;
|
||||||
|
/** True while there is anything left to show. */
|
||||||
|
busy(): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
|
||||||
|
*
|
||||||
|
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
|
||||||
|
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
|
||||||
|
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
|
||||||
|
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
|
||||||
|
* at them. The step is still APPLIED, because the delta chain runs through it.
|
||||||
|
*
|
||||||
|
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
|
||||||
|
* solitaire where every intent is the viewer's own.
|
||||||
|
*/
|
||||||
|
export function createStepQueue(
|
||||||
|
pace: () => number = () => 1,
|
||||||
|
viewer: () => number | null = () => null,
|
||||||
|
): StepQueue {
|
||||||
|
let shown: PublicFrame | null = null;
|
||||||
|
let last: DisplayStep | null = null;
|
||||||
|
let pending: DisplayStep[] = [];
|
||||||
|
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||||
|
let dueAt: number | null = null;
|
||||||
|
|
||||||
|
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||||
|
const dwell = (step: DisplayStep): number =>
|
||||||
|
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
|
||||||
|
|
||||||
|
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||||
|
const show = (step: DisplayStep): void => {
|
||||||
|
shown = applyPublicDelta(shown, step.frame);
|
||||||
|
last = step;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset(frame) {
|
||||||
|
shown = frame;
|
||||||
|
pending = [];
|
||||||
|
dueAt = null;
|
||||||
|
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||||
|
// sentence describing the most recent action is still true.
|
||||||
|
},
|
||||||
|
|
||||||
|
push(steps) {
|
||||||
|
pending.push(...steps);
|
||||||
|
},
|
||||||
|
|
||||||
|
advance(now) {
|
||||||
|
if (pending.length === 0) {
|
||||||
|
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||||
|
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||||
|
// could look at it — see `busy()`.
|
||||||
|
if (dueAt !== null && now >= dueAt) dueAt = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
|
||||||
|
// player has not been shown yet.
|
||||||
|
if (dueAt === null) {
|
||||||
|
const first = pending.shift()!;
|
||||||
|
show(first);
|
||||||
|
dueAt = now + dwell(first);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let drew = false;
|
||||||
|
/**
|
||||||
|
* A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on
|
||||||
|
* this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must
|
||||||
|
* collapse within one call instead of costing a frame each. The board still passes through
|
||||||
|
* every state in order; nobody is shown a state that never existed.
|
||||||
|
*/
|
||||||
|
while (pending.length > 0 && now >= dueAt) {
|
||||||
|
const next = pending.shift()!;
|
||||||
|
show(next);
|
||||||
|
dueAt = dueAt + dwell(next);
|
||||||
|
drew = true;
|
||||||
|
}
|
||||||
|
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
||||||
|
return drew;
|
||||||
|
},
|
||||||
|
|
||||||
|
skip() {
|
||||||
|
if (pending.length === 0) return false;
|
||||||
|
for (const step of pending) show(step);
|
||||||
|
pending = [];
|
||||||
|
dueAt = null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
current: () => shown,
|
||||||
|
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||||
|
showing: () => last,
|
||||||
|
/**
|
||||||
|
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||||
|
*
|
||||||
|
* This was `pending.length > 0`, which went false the moment the last step of a burst was
|
||||||
|
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
|
||||||
|
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
|
||||||
|
* the bot's office area, then their turn was done and it pointed back to my office area."
|
||||||
|
*
|
||||||
|
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||||
|
* "there is more to come, or what is up has not had its moment yet".
|
||||||
|
*/
|
||||||
|
busy: () => pending.length > 0 || dueAt !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
+24
-1
@@ -655,13 +655,36 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
|||||||
assert.notEqual(s.status, 'finished');
|
assert.notEqual(s.status, 'finished');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('solitaire never checks the collision floor, whatever the counts', () => {
|
it('solitaire checks the collision floor too, like every other mode', () => {
|
||||||
|
/**
|
||||||
|
* REVERSED 2026-08-30, and this test previously asserted the opposite ("solitaire never checks
|
||||||
|
* the collision floor, whatever the counts").
|
||||||
|
*
|
||||||
|
* The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing on
|
||||||
|
* screen reflected it: `SOLO_CONFIG` carried both limits, the New Game dialog offered them as
|
||||||
|
* live settings, and the text beside them said the game would end in a loss. A solitaire player
|
||||||
|
* could set a limit of 1 and crash all game. Found reviewing that screen's wording; Jesse's
|
||||||
|
* ruling is that the settings do what they say.
|
||||||
|
*/
|
||||||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
||||||
s.collisionsToday = 99;
|
s.collisionsToday = 99;
|
||||||
s.collisionsTotal = 99;
|
s.collisionsTotal = 99;
|
||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
s.clock.phase = 'shiftChange';
|
s.clock.phase = 'shiftChange';
|
||||||
advance(s);
|
advance(s);
|
||||||
|
assert.equal(s.status, 'finished');
|
||||||
|
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets a solitaire game switch the collision floor off with 0', () => {
|
||||||
|
// The disable path is what a player who does not want the new ending reaches for, so it has to
|
||||||
|
// work at one seat exactly as it does at four.
|
||||||
|
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
|
||||||
|
s.collisionsToday = 99;
|
||||||
|
s.collisionsTotal = 99;
|
||||||
|
s.clock.stage = 1;
|
||||||
|
s.clock.phase = 'shiftChange';
|
||||||
|
advance(s);
|
||||||
assert.notEqual(s.status, 'finished');
|
assert.notEqual(s.status, 'finished');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -1679,7 +1679,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
|||||||
const build = (toNose: boolean): string[] => {
|
const build = (toNose: boolean): string[] => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
||||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||||
return s.trays.get(id)!.consist.map((c) => c.type);
|
return s.trays.get(id)!.consist.map((c) => c.type);
|
||||||
};
|
};
|
||||||
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
|
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
|
||||||
@@ -1694,11 +1694,11 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
|||||||
const tray = s.trays.get(id)!;
|
const tray = s.trays.get(id)!;
|
||||||
tray.engineAt = 0;
|
tray.engineAt = 0;
|
||||||
|
|
||||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||||
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
|
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
|
||||||
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
|
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
|
||||||
|
|
||||||
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
reduce(s, { type: 'carsDropped', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||||
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
|
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
|
||||||
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
|
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
|
||||||
});
|
});
|
||||||
@@ -1823,7 +1823,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
|||||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||||
*/
|
*/
|
||||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||||
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||||
|
|
||||||
it('carries the east-west sense across north-south track', () => {
|
it('carries the east-west sense across north-south track', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* The card reference must not drift from the cards.
|
||||||
|
*
|
||||||
|
* `docs/rules/card-reference.md` spent several releases describing the v0.4.5 deck — twelve numbered
|
||||||
|
* trains, "3 / 4 Mail-Express, 3 coaches" — while `content.ts` had train 3 as the Express with two
|
||||||
|
* freight cars and a per-location freight rule. Worse, `content.ts` named that file as "the place
|
||||||
|
* that now carries what the cards say", so the code sent readers to a table its own banner told them
|
||||||
|
* not to trust. Nothing failed, because nothing checked.
|
||||||
|
*
|
||||||
|
* `docs/rules/as-built.md` is emitted from the same exported catalogues the engine instantiates
|
||||||
|
* from, and this re-runs the generator and compares. Change a card face without regenerating and
|
||||||
|
* this goes red — which is the whole point: a document nothing verifies is a document that will be
|
||||||
|
* wrong, and this project's own history is the evidence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const doc = join(root, 'docs/rules/as-built.md');
|
||||||
|
|
||||||
|
describe('docs/rules/as-built.md is generated, and current', () => {
|
||||||
|
it('matches what the generator emits from content.ts today', () => {
|
||||||
|
const before = readFileSync(doc, 'utf8');
|
||||||
|
execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
|
||||||
|
const after = readFileSync(doc, 'utf8');
|
||||||
|
assert.equal(
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
'the checked-in card reference is stale — run `npm run build:cards` and commit the result',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the current train catalogue, not the v0.4.5 deck', () => {
|
||||||
|
// The specific drift that went unnoticed for several releases, asserted by name so a future
|
||||||
|
// regeneration against an old content.ts cannot quietly reintroduce it.
|
||||||
|
const md = readFileSync(doc, 'utf8');
|
||||||
|
assert.match(md, /Crack Limited/);
|
||||||
|
assert.match(md, /\| 3 \| Express \|/);
|
||||||
|
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back');
|
||||||
|
assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says it is generated, so nobody edits it by hand', () => {
|
||||||
|
const md = readFileSync(doc, 'utf8');
|
||||||
|
assert.match(md, /Generated from `src\/engine\/content\.ts`/);
|
||||||
|
assert.match(md, /Do not edit by/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
/**
|
||||||
|
* WHAT THE ENGINE KNOWS AND THE SCREEN DOES NOT — the 2026-09-07 sweep.
|
||||||
|
*
|
||||||
|
* Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gained a
|
||||||
|
* thing that changes what a train may do, and nothing drew it. Each was found by a player hitting
|
||||||
|
* it. So rather than wait for the fifth, every field of `GameState` and its nested types was
|
||||||
|
* enumerated and checked for a reader in `sim/view.ts`, `src/web/` and `sim/narrate.ts`, and the
|
||||||
|
* survivors were then verified by running the engine rather than by trusting the grep.
|
||||||
|
*
|
||||||
|
* Four fields had no reader anywhere. `movedThisPhase` is set and cleared inside one `advance` call
|
||||||
|
* and is genuinely nobody's business. The other three are these tests. The bookkeeping fields whose
|
||||||
|
* EFFECT is already visible as legality — `freightWorked`, `drawnThisTurn`, `freightAgentUsed`,
|
||||||
|
* `movesUsed` (its complement `movesRemaining` is on the Frame), `switchedSince` — are deliberately
|
||||||
|
* not here: a field is not a display gap merely because no one renders it.
|
||||||
|
*
|
||||||
|
* The method is worth more than the three fixes, and is written down in TODO Reference · #98.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
|
import { crewTrayCount, enhancementText } from '../src/engine/content.ts';
|
||||||
|
import { areaOf } from '../src/engine/apply.ts';
|
||||||
|
import { impediments } from '../src/sim/narrate.ts';
|
||||||
|
import { projectDistrict, projectSharedTable, publicSnapshot, trainRules } from '../src/sim/view.ts';
|
||||||
|
import type { CrewTray, GameConfig, GameState, PlayerIndex, SeatIndex } from '../src/engine/state.ts';
|
||||||
|
import { seatOf } from '../src/engine/state.ts';
|
||||||
|
|
||||||
|
const config: GameConfig = {
|
||||||
|
mode: 'competitive',
|
||||||
|
days: 3,
|
||||||
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const game = (): GameState => {
|
||||||
|
const s = createGame({ id: 'g', seed: 4242, config, playerNames: ['Ann', 'Bob'] });
|
||||||
|
s.status = 'active';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE CREW TRAY POOL IS A MECHANIC, AND IT WAS INVISIBLE (#98).
|
||||||
|
*
|
||||||
|
* `state.ts` calls tray scarcity "an explicit mechanic": there are fewer trays than there are trains
|
||||||
|
* wanting one, and which trains get held is the whole of §7. The engine knows three things about it
|
||||||
|
* — how many trays are free, which Extras are queued for one, and which second sections are — and
|
||||||
|
* the view read none of them.
|
||||||
|
*
|
||||||
|
* The panel that answers "why is nothing moving?" had exactly one tray rule, keyed off the train due
|
||||||
|
* out THIS Stage (`s.timetable[stage - 1]`). So a player who spent a card on an Extra, or ordered a
|
||||||
|
* second section, got a blocked panel that was completely EMPTY while their train sat behind an
|
||||||
|
* exhausted pool — and both had been announced once in the log, in a line that promised a future
|
||||||
|
* event ("as soon as a Crew Tray frees up") which nothing then confirmed.
|
||||||
|
*/
|
||||||
|
describe('the Crew Tray pool is a mechanic the player can see (#98)', () => {
|
||||||
|
/** A table whose trays are all out, with one Extra and one second section queued behind them. */
|
||||||
|
const jammed = (): GameState => {
|
||||||
|
const s = game();
|
||||||
|
s.freeTrays = [];
|
||||||
|
s.pendingExtras.push({ trainNumber: 17, player: 0 as PlayerIndex });
|
||||||
|
s.pendingSecondSections.push(8);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reports how many Crew Trays are free, and how many there are', () => {
|
||||||
|
const s = game();
|
||||||
|
const total = crewTrayCount(2);
|
||||||
|
assert.equal(s.freeTrays.length, total, 'the premise is gone: trays were already out at setup');
|
||||||
|
assert.deepEqual(projectSharedTable(s).crewTrays, { free: total, total });
|
||||||
|
|
||||||
|
s.freeTrays = s.freeTrays.slice(0, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
projectSharedTable(s).crewTrays,
|
||||||
|
{ free: 1, total },
|
||||||
|
'the pool emptied and the shared table did not notice',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the trains queued for a tray, so a promise made in the log is kept on the board', () => {
|
||||||
|
const q = projectSharedTable(jammed()).queued;
|
||||||
|
assert.deepEqual(q.extras, [{ trainNumber: 17, player: 0 }], 'a played Extra is waiting nowhere visible');
|
||||||
|
assert.deepEqual(q.secondSections, [8], 'an ordered second section is waiting nowhere visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the player who played the Extra that it is held for want of a crew', () => {
|
||||||
|
const blocked = impediments(jammed(), 0 as PlayerIndex);
|
||||||
|
const extra = blocked.find((b) => /X17/.test(b.where));
|
||||||
|
assert.ok(extra, 'the blocked panel said nothing about an Extra held for want of a Crew Tray');
|
||||||
|
assert.match(extra.why, /Crew Tray/, 'it was listed without naming the thing it is waiting for');
|
||||||
|
assert.equal(extra.severity, 'stuck');
|
||||||
|
// The POOL SIZE, not a second derivation of it. Written as `trays.size + freeTrays.length`
|
||||||
|
// first, which reads 0 of 0 for any state where a tray is neither free nor carrying a train.
|
||||||
|
assert.match(
|
||||||
|
extra.why,
|
||||||
|
new RegExp(`0 of ${crewTrayCount(2)} Crew Trays free`),
|
||||||
|
'the panel reported the wrong pool size',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the same for a second section, which waits on the identical pool', () => {
|
||||||
|
const blocked = impediments(jammed(), 0 as PlayerIndex);
|
||||||
|
const second = blocked.find((b) => /Train 8\b/.test(b.where) && /second section/i.test(b.why));
|
||||||
|
assert.ok(second, 'an ordered second section was queued invisibly');
|
||||||
|
assert.match(second.why, /Crew Tray/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says none of it once a tray is free, because then nothing is being waited on', () => {
|
||||||
|
const s = jammed();
|
||||||
|
s.freeTrays = ['tray0'];
|
||||||
|
const blocked = impediments(s, 0 as PlayerIndex);
|
||||||
|
assert.equal(
|
||||||
|
blocked.filter((b) => /Crew Tray/.test(b.why)).length,
|
||||||
|
0,
|
||||||
|
'a free tray still reported trains held for want of one',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is on the common board too — the pool is on the table, not in a hand', () => {
|
||||||
|
const pub = publicSnapshot(jammed()) as unknown as Record<string, unknown>;
|
||||||
|
assert.ok('crewTrays' in pub, 'a spectator cannot see the scarcity everyone at the table can');
|
||||||
|
assert.ok('queued' in pub);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TRAIN HELD AT THE LIMITS IS STILL ON THE BOARD (#99).
|
||||||
|
*
|
||||||
|
* The Interlocking enhancement is the designed answer to a full Office: instead of the automatic
|
||||||
|
* collision of Gap 2d, "may stop an inbound train on the Limit Track" — the train is held inside the
|
||||||
|
* player's Limits, and takes the first A/D track that frees, ahead of any newcomer.
|
||||||
|
*
|
||||||
|
* It was drawn NOWHERE. `arriveAtOffice` removes the tray from the Mainline node's `transits`
|
||||||
|
* (advance.ts) and the Interlocking branch pushes it onto `area.heldAtLimits` without assigning
|
||||||
|
* `tray.position` — so the map, which draws mainline nodes from `transits` and district squares from
|
||||||
|
* `position.at === 'grid'`, has nothing to draw it from in either place. The train vanished off the
|
||||||
|
* board on arrival and reappeared in the Office some Stages later.
|
||||||
|
*
|
||||||
|
* Measured before fixing: with the tray in `transits` the Interchange node carries its chip; with
|
||||||
|
* the tray moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is empty and
|
||||||
|
* no grid square has gained it.
|
||||||
|
*
|
||||||
|
* The fix is in the VIEW, not the engine. The engine's state is right — a held train is inside the
|
||||||
|
* Limits and not on an A/D track, which is what `heldAtLimits` says — and `position` is left alone
|
||||||
|
* deliberately, so nothing may treat the train as standing on a square it could be switched from.
|
||||||
|
*/
|
||||||
|
describe('a train held at the Limits is drawn at the Limits (#99)', () => {
|
||||||
|
const held = (direction: 'east' | 'west') => {
|
||||||
|
const s = game();
|
||||||
|
const seat = seatOf(s, 0 as PlayerIndex);
|
||||||
|
const area = areaOf(s, 0 as PlayerIndex);
|
||||||
|
const trayId = s.freeTrays.pop()!;
|
||||||
|
const tray: CrewTray = {
|
||||||
|
id: trayId,
|
||||||
|
trainNumber: 5,
|
||||||
|
trainIsExtra: false,
|
||||||
|
engineAt: 0,
|
||||||
|
consist: [],
|
||||||
|
direction,
|
||||||
|
movesUsed: 0,
|
||||||
|
position: { at: 'mainline', index: 1 },
|
||||||
|
} as CrewTray;
|
||||||
|
s.trays.set(trayId, tray);
|
||||||
|
area.heldAtLimits.push(trayId);
|
||||||
|
return { s, seat, trayId, area };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('draws it on the Limits square it is standing at, not nowhere', () => {
|
||||||
|
const { s, seat, trayId, area } = held('east');
|
||||||
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||||
|
const on = cells.filter((c) => c.trains.some((t) => t.trayId === trayId));
|
||||||
|
assert.equal(on.length, 1, 'a train held at the Limits is drawn on no square at all');
|
||||||
|
assert.equal(on[0]!.row, area.limitsWest.row, 'drawn at the wrong Limits');
|
||||||
|
assert.equal(on[0]!.col, area.limitsWest.col);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds an EASTBOUND train at the western Limits, because that is the end it came in by', () => {
|
||||||
|
const { s, seat, area } = held('east');
|
||||||
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||||
|
const on = cells.find((c) => c.trains.length > 0)!;
|
||||||
|
assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsWest.row, col: area.limitsWest.col });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('and a WESTBOUND train at the eastern Limits', () => {
|
||||||
|
const { s, seat, area } = held('west');
|
||||||
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||||
|
const on = cells.find((c) => c.trains.length > 0)!;
|
||||||
|
assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsEast.row, col: area.limitsEast.col });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says on the chip that it is HELD, so it is not read as a train free to switch', () => {
|
||||||
|
const { s, seat, trayId } = held('east');
|
||||||
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
||||||
|
const chip = cells.flatMap((c) => c.trains).find((t) => t.trayId === trayId)!;
|
||||||
|
assert.equal(chip.heldAtLimits, true, 'a held train looked exactly like one standing on the square');
|
||||||
|
assert.match(chip.what, /Interlocking|held/i, 'the chip does not say why it is standing there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the district owner it is waiting, and what for', () => {
|
||||||
|
const { s } = held('east');
|
||||||
|
const blocked = impediments(s, 0 as PlayerIndex);
|
||||||
|
const b = blocked.find((x) => /Train 5/.test(x.where) && /Limits/i.test(x.why));
|
||||||
|
assert.ok(b, 'the blocked panel said nothing about a train held at the Limits');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invent a train on a square when nothing is held', () => {
|
||||||
|
const s = game();
|
||||||
|
const { cells } = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex);
|
||||||
|
assert.equal(cells.flatMap((c) => c.trains).length, 0, 'a district with no trains drew one');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE CAMPAIGN TRAIN'S SPEECHES CHANGE ITS RULES, AND THE CARD HAS TO SAY WHICH HALF IT IS IN (#100).
|
||||||
|
*
|
||||||
|
* X17 is "one turn at station (speeches) then expedite". Its first Office arrival is an ordinary
|
||||||
|
* stop; every arrival after that runs EXPEDITED, which means that if it is not back on the Office
|
||||||
|
* square when the next Mainline Phase begins it is a Station Master fault costing 1 Revenue.
|
||||||
|
*
|
||||||
|
* `trainRules()` took `{ trainNumber, trainIsExtra }` — it could not see `speechMade` even though
|
||||||
|
* both of its tray-side callers pass a whole `CrewTray` that has it. So the chip read identically
|
||||||
|
* before and after, and worse: the "EXPEDITED — must be kept ready to highball… costs 1 Revenue"
|
||||||
|
* warning is printed only under `rules.expedite`, so X17 became subject to a fault whose warning the
|
||||||
|
* game shows to other trains and never to it.
|
||||||
|
*/
|
||||||
|
describe('the Campaign Train says whether its speeches are made (#100)', () => {
|
||||||
|
const X17 = { trainNumber: 17, trainIsExtra: true } as const;
|
||||||
|
|
||||||
|
it('before the speeches, says they are still to come and does not claim it is expedited yet', () => {
|
||||||
|
const t = trainRules({ ...X17 });
|
||||||
|
assert.match(t, /SPEECHES/i);
|
||||||
|
assert.doesNotMatch(t, /costs 1 Revenue/, 'it warned of a fault the train is not yet subject to');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after the speeches, says it is expedited NOW and carries the fault it is now subject to', () => {
|
||||||
|
const t = trainRules({ ...X17, speechMade: true });
|
||||||
|
assert.match(t, /EXPEDITED/, 'a train that is now expedited did not say so');
|
||||||
|
assert.match(t, /costs 1 Revenue/, 'the fault warning is shown to other trains and not to this one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads differently before and after — the whole of the bug was that it did not', () => {
|
||||||
|
assert.notEqual(trainRules({ ...X17 }), trainRules({ ...X17, speechMade: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still warns a permanently expedited train, which must not regress', () => {
|
||||||
|
const fast = trainRules({ trainNumber: 5, trainIsExtra: false });
|
||||||
|
assert.match(fast, /EXPEDITED/);
|
||||||
|
assert.match(fast, /costs 1 Revenue/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about speeches for a train that has no such rule', () => {
|
||||||
|
assert.doesNotMatch(trainRules({ trainNumber: 5, trainIsExtra: false }), /SPEECHES/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DISPATCH DEVICE SAYS WHETHER IT IS STILL AVAILABLE TODAY (#101).
|
||||||
|
*
|
||||||
|
* Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains,
|
||||||
|
* add +N to the other train's number". The device is drawn on its card and its effect text is in the
|
||||||
|
* tooltip — but `enhancementText(key)` takes only the KEY, so it could not vary with anything, and
|
||||||
|
* the Radio read "once a day, add +12…" all Day after it had been spent. That is `trainRules` before
|
||||||
|
* #100, in a different corner of the same view.
|
||||||
|
*
|
||||||
|
* THE SECOND HALF IS THE ONE THAT SURPRISES. `spendDispatchBonus` reads
|
||||||
|
* `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own devices, not the train owner's —
|
||||||
|
* and the Fedora moves every `STAGES_PER_SHIFT` Stages, four times a Day. So a player's Radio does
|
||||||
|
* nothing at all for three-quarters of the Day, and is spent automatically, without being asked,
|
||||||
|
* during the quarter it is theirs to use. The board said neither half.
|
||||||
|
*
|
||||||
|
* Shown on EVERY district (Jesse, 2026-09-07), not only the viewer's: it is public, and a rival's
|
||||||
|
* spent Radio is exactly what you want to know before forcing a meet. `projectDistrict` serves both
|
||||||
|
* the player's own cells and the common board's `districts`, so one change covers both.
|
||||||
|
*/
|
||||||
|
describe('a dispatch device says whether it is still available today (#101)', () => {
|
||||||
|
/** Puts `key` on the first card of seat 0's district and returns the pieces to assert on. */
|
||||||
|
const withDevice = (key: string, opts: { spent?: boolean; fedora?: boolean } = {}) => {
|
||||||
|
const s = game();
|
||||||
|
const area = areaOf(s, 0 as PlayerIndex);
|
||||||
|
const card = [...area.grid.values()][0]!;
|
||||||
|
card.enhancements.push(key);
|
||||||
|
if (opts.spent) area.dispatchUsedToday.push(key);
|
||||||
|
// The Fedora is a PLAYER; give it to somebody whose seat is not this district's.
|
||||||
|
s.clock.superintendent = (opts.fedora ? 0 : 1) as PlayerIndex;
|
||||||
|
const seat = seatOf(s, 0 as PlayerIndex);
|
||||||
|
const cells = projectDistrict(s, seat as SeatIndex).cells;
|
||||||
|
const cell = cells.find((c) => c.enhancements.length > 0)!;
|
||||||
|
const i = cell.enhancementsWhat.findIndex((w) => new RegExp(key, 'i').test(w));
|
||||||
|
return { s, area, cell, what: cell.enhancementsWhat[i] ?? '', spent: cell.enhancementsSpent[i] };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reads as available before it is used, and says what it is worth', () => {
|
||||||
|
const { what, spent } = withDevice('radio', { fedora: true });
|
||||||
|
assert.equal(spent, false, 'an unused device reported itself spent');
|
||||||
|
assert.match(what, /\+12/, 'the device no longer says what it is worth');
|
||||||
|
assert.doesNotMatch(what, /spent/i, 'an unused device claimed it had been spent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so once it has been spent, and says when it comes back', () => {
|
||||||
|
const { what, spent } = withDevice('radio', { spent: true, fedora: true });
|
||||||
|
assert.equal(spent, true, 'a spent device still reported itself available');
|
||||||
|
assert.match(what, /spent/i, 'a spent device read exactly as it did before it was spent');
|
||||||
|
assert.match(what, /next Day|tomorrow/i, 'it does not say the device comes back');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads differently spent and unspent — the whole of the bug was that it did not', () => {
|
||||||
|
assert.notEqual(
|
||||||
|
withDevice('telegraph', { fedora: true }).what,
|
||||||
|
withDevice('telegraph', { spent: true, fedora: true }).what,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a device is idle while somebody else holds the Fedora', () => {
|
||||||
|
const { what } = withDevice('telephone');
|
||||||
|
assert.match(what, /Fedora|Superintendent/i, 'nothing said the device is only used while dispatching');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not say that when this district IS the Superintendent', () => {
|
||||||
|
const { what } = withDevice('telephone', { fedora: true });
|
||||||
|
assert.doesNotMatch(what, /while .*holds? the Fedora/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXACT equality, not "does not mention the Fedora". The first draft asserted the absence of
|
||||||
|
* /spent|Fedora/i with the Fedora held, and a mutation that removed the `dispatchBonus` guard
|
||||||
|
* altogether PASSED it — because the leaked text in that case reads "Available today, and this
|
||||||
|
* district is dispatching", which contains neither word. A test for a field being left alone has
|
||||||
|
* to compare it to what it should be.
|
||||||
|
*/
|
||||||
|
it('leaves an enhancement that is not a dispatch device exactly as it was', () => {
|
||||||
|
for (const fedora of [true, false]) {
|
||||||
|
const { what, spent } = withDevice('interlocking', { fedora });
|
||||||
|
assert.equal(spent, false, 'a non-dispatch enhancement was marked spendable');
|
||||||
|
assert.equal(what, enhancementText('interlocking'), 'an Interlocking was given a dispatch caveat');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('comes back when the Day turns, which is what clears the record', () => {
|
||||||
|
const { s, area } = withDevice('radio', { spent: true, fedora: true });
|
||||||
|
area.dispatchUsedToday = [];
|
||||||
|
const cell = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex).cells.find(
|
||||||
|
(c) => c.enhancements.length > 0,
|
||||||
|
)!;
|
||||||
|
assert.equal(cell.enhancementsSpent.some((x) => x), false, 'a new Day did not restore the device');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEAT IS NOT PLAYER INDEX, and this is the one place the two are joined: the Fedora is held by a
|
||||||
|
* PLAYER and the devices sit in an Office Area keyed by SEAT. `advance.ts` carried a comment
|
||||||
|
* warning that indexing one with the other was safe only while seating was the identity map — it
|
||||||
|
* read as a live bug and was not one, because `areaOf` resolves through `seatOf`. The comment is
|
||||||
|
* corrected; this is the guard, because the next reader deserves better than a claim.
|
||||||
|
*
|
||||||
|
* §4.4's D12 makes seating a real permutation, so a two-player game where player 1 sits in seat 0
|
||||||
|
* is ordinary rather than contrived.
|
||||||
|
*/
|
||||||
|
it("marks the SUPERINTENDENT's own district as dispatching under non-identity seating", () => {
|
||||||
|
const s = game();
|
||||||
|
s.seating = [1, 0] as PlayerIndex[];
|
||||||
|
assert.notEqual(seatOf(s, 0 as PlayerIndex), 0, 'the premise is gone: seating is still identity');
|
||||||
|
s.clock.superintendent = 0 as PlayerIndex;
|
||||||
|
|
||||||
|
const mine = areaOf(s, 0 as PlayerIndex);
|
||||||
|
[...mine.grid.values()][0]!.enhancements.push('radio');
|
||||||
|
const other = areaOf(s, 1 as PlayerIndex);
|
||||||
|
[...other.grid.values()][0]!.enhancements.push('radio');
|
||||||
|
|
||||||
|
const whatAt = (player: PlayerIndex): string => {
|
||||||
|
const cells = projectDistrict(s, seatOf(s, player) as SeatIndex).cells;
|
||||||
|
return cells.find((c) => c.enhancements.length > 0)!.enhancementsWhat.join(' ');
|
||||||
|
};
|
||||||
|
// The Fedora is player 0's, whatever seat that is.
|
||||||
|
assert.doesNotMatch(whatAt(0 as PlayerIndex), /IDLE/, "the Superintendent's own device read as idle");
|
||||||
|
assert.match(whatAt(1 as PlayerIndex), /IDLE/, "somebody else's device read as dispatching");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is on every district of the common board, not only the viewer own', () => {
|
||||||
|
const { s } = withDevice('radio', { spent: true, fedora: true });
|
||||||
|
const pub = publicSnapshot(s);
|
||||||
|
const all = pub.districts.flatMap((d) => d.cells);
|
||||||
|
assert.ok(
|
||||||
|
all.some((c) => c.enhancementsSpent.some((x) => x)),
|
||||||
|
"a spectator cannot see which devices are spent in a player's district",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE RED FLAG HOLDER IS NOT A PER-PLAYER FACT, so it does not belong on the public player view.
|
||||||
|
*
|
||||||
|
* `docs/plans/jitsi-common-board.md` step 1 asks for one: "Add the Red Flag holder to the public
|
||||||
|
* player projection. It is public game state but is currently absent from `Frame`", and its
|
||||||
|
* `PublicPlayerView` carries `redFlagHeld: boolean`. Every other step-1 item shipped across v0.7.9.2
|
||||||
|
* to v0.7.9.5; this one is STRUCK OFF instead, because the premise does not hold in this codebase.
|
||||||
|
*
|
||||||
|
* `decks.redFlags` is written in exactly one place — `setup.ts`, from
|
||||||
|
* `config.optionalRules.emergencyToolbox` — and never again. There is no `.set` anywhere else, and
|
||||||
|
* `redFlag.play` (the intent gated on holding one) emits a `phaseEnded` event and does not spend it.
|
||||||
|
* So every player holds one or none of them does, decided before the first card is dealt.
|
||||||
|
*
|
||||||
|
* A `redFlagHeld` on each player would therefore be `optionalRules.emergencyToolbox` copied N times
|
||||||
|
* — already on the public projection — while implying to every reader of the common board that it
|
||||||
|
* varies by player and might change during a game. That is worse than the absence.
|
||||||
|
*
|
||||||
|
* This test exists so the plan item is not re-raised from the plan text: if the rule ever DOES
|
||||||
|
* become per-player, this fails and the projection is the right place to look.
|
||||||
|
*/
|
||||||
|
describe('the Red Flag holding is the Emergency Toolbox option, not a per-player fact', () => {
|
||||||
|
const dealt = (emergencyToolbox: boolean): GameState =>
|
||||||
|
createGame({
|
||||||
|
id: 'g',
|
||||||
|
seed: 99,
|
||||||
|
config: { ...config, optionalRules: { ...config.optionalRules, emergencyToolbox } },
|
||||||
|
playerNames: ['Ann', 'Bob', 'Cy'],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const toolbox of [true, false]) {
|
||||||
|
it(`gives every player the same answer with the toolbox ${toolbox ? 'on' : 'off'}`, () => {
|
||||||
|
const s = dealt(toolbox);
|
||||||
|
const held = s.players.map((p) => s.decks.redFlags.get(p.index) === true);
|
||||||
|
assert.deepEqual(held, [toolbox, toolbox, toolbox], 'the Red Flag has become a per-player fact');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is already public, through the option it comes from', () => {
|
||||||
|
const pub = publicSnapshot(dealt(true));
|
||||||
|
assert.equal(
|
||||||
|
pub.optionalRules.emergencyToolbox,
|
||||||
|
true,
|
||||||
|
'a spectator cannot tell whether the hand limit is three or four',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,8 @@ import { applyIntent, check } from '../src/engine/apply.ts';
|
|||||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||||
import { legalActions } from '../src/engine/legal.ts';
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
|
import { currentActorOfState, publicSnapshot, snapshot } from '../src/sim/view.ts';
|
||||||
|
import { currentActor } from '../src/web/game.ts';
|
||||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||||
|
|
||||||
@@ -254,3 +256,69 @@ describe('§3.3 extended play — bots play the timetable they were dealt (Gitea
|
|||||||
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
|
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §3.3 — WHO THE SCREEN SAYS THE TABLE IS WAITING ON, while the vote is open.
|
||||||
|
*
|
||||||
|
* The vote is PARALLEL, and `apply.ts` says so where it accepts one: "open to every seat at once:
|
||||||
|
* it is a table decision rather than a ruling, so THERE IS NO ACTOR TO BE". Any seat that has not
|
||||||
|
* voted may vote at any moment, in any order, and one refusal ends it. So the honest answer to "who
|
||||||
|
* are we waiting on" is every un-voted seat — which is exactly what the vote tally beside the chart
|
||||||
|
* already draws — and the honest answer to "whose turn is it" is nobody.
|
||||||
|
*
|
||||||
|
* The engine gave that answer and the screen did not. `currentActor(game)` guards on
|
||||||
|
* `status !== 'active'` and returned null; `actingPlayer(state)` has no such guard and returned
|
||||||
|
* `clock.currentActor`, which still holds whoever moved last before the timetable ran out. The
|
||||||
|
* frame took the second, so the turn chart named one arbitrary seat — the last to act, who has no
|
||||||
|
* more claim on the vote than anybody else — while the tally underneath correctly showed three
|
||||||
|
* seats outstanding.
|
||||||
|
*
|
||||||
|
* The fourth of these in a row after Gitea#21, #22 and #94, and the first found by asking the
|
||||||
|
* question of a state the game is not ACTIVE in. See TODO #96.
|
||||||
|
*/
|
||||||
|
describe('§3.3 extended play — the vote has no actor (#96)', () => {
|
||||||
|
const table = (): GameState => {
|
||||||
|
const s = atTheEnd(game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']));
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.status, 'awaitingExtension', 'the table is not being asked');
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reports nobody acting while the vote is open, to a player and to a spectator alike', () => {
|
||||||
|
const s = table();
|
||||||
|
assert.notEqual(s.clock.currentActor, null, 'the premise is gone: nothing was left on the clock');
|
||||||
|
|
||||||
|
assert.equal(currentActorOfState(s), null, 'the engine named an actor during a parallel vote');
|
||||||
|
assert.equal(
|
||||||
|
snapshot(s, [], null, null, null, false, 0).actor,
|
||||||
|
null,
|
||||||
|
'the turn chart named a seat while the whole table was voting',
|
||||||
|
);
|
||||||
|
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat during the vote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with the session, which is the half that decides what is legal', () => {
|
||||||
|
// The disagreement is the bug, not either answer on its own: `currentActor` is what refuses an
|
||||||
|
// intent, so a screen that names somebody it would refuse is telling the table to wait on a
|
||||||
|
// player who cannot act.
|
||||||
|
const s = table();
|
||||||
|
const g = { state: s, log: [] } as unknown as Parameters<typeof currentActor>[0];
|
||||||
|
assert.equal(currentActorOfState(s), currentActor(g), 'the screen and the session disagree');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still names the actor during ordinary play, which is the case that must not regress', () => {
|
||||||
|
const s = game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']);
|
||||||
|
pump(s);
|
||||||
|
assert.equal(s.status, 'active', 'the premise is gone: the game is not running');
|
||||||
|
assert.equal(currentActorOfState(s), s.clock.currentActor, 'an active game lost its actor');
|
||||||
|
assert.equal(snapshot(s, [], null, null, null, false, 0).actor, s.clock.currentActor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nobody once the table has declined and the game is finished', () => {
|
||||||
|
const s = table();
|
||||||
|
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||||
|
assert.equal(s.status, 'finished', 'a refusal did not end it');
|
||||||
|
assert.equal(currentActorOfState(s), null, 'a finished game still had somebody to move');
|
||||||
|
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat after the game ended');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1014,7 +1014,11 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
|||||||
|
|
||||||
describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
||||||
/** Put one train mid-crossing and ask the view where the map should draw it. */
|
/** Put one train mid-crossing and ask the view where the map should draw it. */
|
||||||
const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => {
|
const regionFor = (
|
||||||
|
stagesTotal: number,
|
||||||
|
stagesRemaining: number,
|
||||||
|
direction: 'east' | 'west' = 'east',
|
||||||
|
): { region: number; regions: number } => {
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: 'reg',
|
id: 'reg',
|
||||||
seed: 4,
|
seed: 4,
|
||||||
@@ -1027,7 +1031,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
|||||||
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||||
assert.ok(node && node.kind === 'mainline');
|
assert.ok(node && node.kind === 'mainline');
|
||||||
const tray = [...s.trays.keys()][0]!;
|
const tray = [...s.trays.keys()][0]!;
|
||||||
node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' });
|
node.transits.push({ tray, stagesRemaining, stagesTotal, direction });
|
||||||
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
|
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
|
||||||
assert.ok(ml, 'no Mainline node in the view');
|
assert.ok(ml, 'no Mainline node in the view');
|
||||||
const t = ml!.trains.flat()[0]!;
|
const t = ml!.trains.flat()[0]!;
|
||||||
@@ -1070,6 +1074,74 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never leaves the card it is on, whichever way it runs', () => {
|
||||||
|
for (const direction of ['east', 'west'] as const) {
|
||||||
|
for (let total = 1; total <= 4; total++) {
|
||||||
|
for (let left = total; left >= 1; left--) {
|
||||||
|
const r = regionFor(total, left, direction).region;
|
||||||
|
assert.ok(r >= 0 && r <= 1, `${direction}, total ${total}, ${left} left put the train in region ${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GITEA#22 — A WESTBOUND TRAIN WAS DRAWN IN THE WRONG HALF OF THE CARD.
|
||||||
|
*
|
||||||
|
* `regionOfTransit` answers "how far along its crossing is this train", counted from the end it
|
||||||
|
* ENTERED: a train with everything still to run is in region 0. That is the right question for the
|
||||||
|
* collision rules, which is what the engine asks it, and both directions share the one index space.
|
||||||
|
*
|
||||||
|
* The map asks a different question — WHICH PRINTED BOX, left to right — and used the same number
|
||||||
|
* for it. East is right on this map and always has been, so for an eastbound train the two agree by
|
||||||
|
* luck: it enters at the west end, so "just entered" and "leftmost box" are the same box. A
|
||||||
|
* westbound train enters at the EAST end, so its region 0 is the card's RIGHT-hand box, and drawing
|
||||||
|
* it at index 0 put it at the left — the whole card mirrored.
|
||||||
|
*
|
||||||
|
* Reported from seed 550943578, undo 187, and it cost a collision. Three westbound trains: TX17 had
|
||||||
|
* just entered (2 Stages still to run, so travel index 0) and T5 was nearly across (1 Stage left,
|
||||||
|
* index 1). Physically TX17 was BEHIND T5 — further east, the direction they had both come from.
|
||||||
|
* The map drew TX17 at the left and so put it further WEST, which reads as further ahead. Asked
|
||||||
|
* whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3
|
||||||
|
* entered behind — into TX17, exactly where the rules had it and nowhere near where the map did.
|
||||||
|
*
|
||||||
|
* The engine was right throughout. Only the picture lied, so the fix is one mirror in the view and
|
||||||
|
* the collision rules are untouched. This is the same class of bug as the consist row at the
|
||||||
|
* Whistle Post (`board-svg.ts`, seed 270861860), which came out mirrored for the same reason.
|
||||||
|
*/
|
||||||
|
describe('Gitea#22 — the map draws a westbound train where it actually is', () => {
|
||||||
|
it('mirrors a westbound train, because it entered from the east end', () => {
|
||||||
|
// Two-region card. Just entered, 2 Stages still to run: an eastbound train is in the WEST box
|
||||||
|
// and a westbound one is in the EAST box, because they came in at opposite ends.
|
||||||
|
assert.equal(regionFor(2, 2, 'east').region, 0);
|
||||||
|
assert.equal(regionFor(2, 2, 'west').region, 1);
|
||||||
|
|
||||||
|
// One Stage left, nearly across: the two swap.
|
||||||
|
assert.equal(regionFor(2, 1, 'east').region, 1);
|
||||||
|
assert.equal(regionFor(2, 1, 'west').region, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the follower behind the leader, not in front of it — the seed 550943578 collision', () => {
|
||||||
|
// TX17 had just entered; T5 was a Stage from the far end. Both westbound, so BEHIND means to
|
||||||
|
// the east, which is to the right, which is the higher index.
|
||||||
|
const tx17 = regionFor(2, 2, 'west').region;
|
||||||
|
const t5 = regionFor(2, 1, 'west').region;
|
||||||
|
assert.ok(
|
||||||
|
tx17 > t5,
|
||||||
|
`a westbound train that has just entered must be drawn east of one that is nearly across, ` +
|
||||||
|
`but TX17 was drawn at ${tx17} and T5 at ${t5}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an eastbound train where it has always been drawn', () => {
|
||||||
|
// The mirror must not disturb the direction that was right, which is every existing region test
|
||||||
|
// above — those all run east — and the case the printed rule was written for.
|
||||||
|
assert.equal(regionFor(2, 2, 'east').region, 0);
|
||||||
|
assert.equal(regionFor(2, 1, 'east').region, 1);
|
||||||
|
assert.equal(regionFor(1, 1, 'east').region, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Q13 — a train that catches the one ahead runs into it', () => {
|
describe('Q13 — a train that catches the one ahead runs into it', () => {
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { actionMenu } from '../src/web/game.ts';
|
import { actionMenu } from '../src/web/game.ts';
|
||||||
|
import { newCollector } from '../src/sim/display-step.ts';
|
||||||
import type { Game } from '../src/web/game.ts';
|
import type { Game } from '../src/web/game.ts';
|
||||||
|
|
||||||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
||||||
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||||
const wrap = (s: GameState): Game =>
|
const wrap = (s: GameState): Game =>
|
||||||
({ state: s, seed: s.seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null });
|
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
|
||||||
|
|
||||||
const competitive: GameConfig = {
|
const competitive: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||||
|
*
|
||||||
|
* The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a
|
||||||
|
* `StepKind` return and has no `default`, so a new intent breaks the build rather than landing
|
||||||
|
* silently in a fallback tier. These tests add the part the compiler cannot do — they read the
|
||||||
|
* intent union out of the source, so the guard survives someone later adding a `default:` that
|
||||||
|
* would swallow the very thing the exhaustiveness was protecting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
|
||||||
|
import type { StepKind } from '../src/sim/pacing.ts';
|
||||||
|
import type { Intent } from '../src/engine/intents.ts';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
/** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */
|
||||||
|
function declaredIntents(): string[] {
|
||||||
|
const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8');
|
||||||
|
return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping'];
|
||||||
|
|
||||||
|
describe('pacing — dwell by kind', () => {
|
||||||
|
it('classifies every intent the engine declares', () => {
|
||||||
|
const declared = declaredIntents();
|
||||||
|
assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`);
|
||||||
|
for (const intent of declared) {
|
||||||
|
const kind = kindOf(intent as Intent['type']);
|
||||||
|
assert.ok(
|
||||||
|
KINDS.includes(kind),
|
||||||
|
`${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('protects switching and collapses bookkeeping', () => {
|
||||||
|
// The two ends of the measured argument: a switching move is the thing worth watching, and
|
||||||
|
// `*.end` bookkeeping is over half of a real game's intents.
|
||||||
|
assert.equal(kindOf('switch.move'), 'switching');
|
||||||
|
assert.equal(kindOf('switch.dropCars'), 'switching');
|
||||||
|
assert.equal(kindOf('switch.sortConsist'), 'switching');
|
||||||
|
assert.equal(kindOf('draw.end'), 'bookkeeping');
|
||||||
|
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
|
||||||
|
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||||
|
/**
|
||||||
|
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||||
|
* play on `phoenix.local`. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||||
|
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||||
|
* to do.
|
||||||
|
*/
|
||||||
|
assert.equal(kindOf('localOps.choose'), 'action');
|
||||||
|
|
||||||
|
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
|
||||||
|
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts switching at a full second, per the 2026-09-09 decision', () => {
|
||||||
|
// Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than
|
||||||
|
// a drift, and so the number in the plan and the number in the code cannot disagree.
|
||||||
|
assert.equal(DWELL.switching, 1000);
|
||||||
|
assert.equal(dwellFor('switch.move'), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
|
||||||
|
/**
|
||||||
|
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
|
||||||
|
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
|
||||||
|
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
|
||||||
|
* relative weighting is the design and the multiplier is only how fast it runs.
|
||||||
|
*/
|
||||||
|
for (const pace of [0.5, 1, 1.5, 2, 3]) {
|
||||||
|
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
|
||||||
|
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
|
||||||
|
assert.ok(
|
||||||
|
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
|
||||||
|
`at ${pace}x a switching move no longer outlasts an ordinary action`,
|
||||||
|
);
|
||||||
|
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
|
||||||
|
}
|
||||||
|
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
|
||||||
|
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
|
||||||
|
|
||||||
|
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
|
||||||
|
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
|
||||||
|
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales with the viewer\'s pace, and 0 turns it off', () => {
|
||||||
|
assert.equal(dwellFor('switch.move', 1), 1000);
|
||||||
|
assert.equal(dwellFor('switch.move', 0.5), 500);
|
||||||
|
assert.equal(dwellFor('switch.move', 2), 2000);
|
||||||
|
// TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism.
|
||||||
|
for (const intent of declaredIntents()) {
|
||||||
|
assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`);
|
||||||
|
}
|
||||||
|
// A negative pace is a corrupt preference, not a request to run time backwards.
|
||||||
|
assert.equal(dwellFor('switch.move', -3), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only the steps a player will actually watch', () => {
|
||||||
|
/**
|
||||||
|
* The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17"
|
||||||
|
* followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide
|
||||||
|
* whether to press Skip.
|
||||||
|
*/
|
||||||
|
const queue: Intent['type'][] = [
|
||||||
|
...Array<Intent['type']>(12).fill('draw.end'),
|
||||||
|
...Array<Intent['type']>(5).fill('switch.move'),
|
||||||
|
];
|
||||||
|
assert.equal(queue.length, 17);
|
||||||
|
assert.equal(watchableCount(queue), 5);
|
||||||
|
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers speeds a player actually reached for, and none the code would clamp', () => {
|
||||||
|
/**
|
||||||
|
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
|
||||||
|
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
|
||||||
|
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
|
||||||
|
* this ladder — which must reach the speeds people ask for and must not offer one that
|
||||||
|
* `dwellFor` would silently clamp.
|
||||||
|
*/
|
||||||
|
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||||
|
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||||
|
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||||
|
for (const p of PACE_LEVELS) {
|
||||||
|
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||||
|
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||||
|
}
|
||||||
|
// Strictly increasing, so stepping the control always changes the speed.
|
||||||
|
for (let i = 1; i < PACE_LEVELS.length; i++) {
|
||||||
|
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
|
||||||
|
}
|
||||||
|
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
|
||||||
|
// the ladder is a full minute, which is the "watch them struggle" case.
|
||||||
|
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
|
||||||
|
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a silent step beats only when the clock turns over — TODO #18', () => {
|
||||||
|
/**
|
||||||
|
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
|
||||||
|
* phases that moved trains without saying so, killing the very thing #18 asks for. "Anything
|
||||||
|
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
|
||||||
|
* times per intent — which came to a quarter of an hour a game.
|
||||||
|
*/
|
||||||
|
const silent = { cause: 'phase' as const, lines: [] as string[] };
|
||||||
|
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
|
||||||
|
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
|
||||||
|
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
|
||||||
|
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
|
||||||
|
// Narration always earns the dwell of whatever caused it, clock or no clock.
|
||||||
|
assert.equal(
|
||||||
|
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
|
||||||
|
DWELL.switching,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||||
|
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
||||||
|
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
||||||
|
const turn: Intent['type'][] = [
|
||||||
|
'localOps.choose',
|
||||||
|
...Array<Intent['type']>(6).fill('switch.move'),
|
||||||
|
'switch.end',
|
||||||
|
];
|
||||||
|
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||||
|
assert.equal(total, DWELL.action + 6 * DWELL.switching);
|
||||||
|
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
|
||||||
|
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
|
||||||
|
/**
|
||||||
|
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on `phoenix.local`:
|
||||||
|
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
|
||||||
|
* was done."* This is the shape that turn actually had — no switching in it at all, because
|
||||||
|
* switching is not legal until there is track down — and under the original values it came to
|
||||||
|
* 750ms for the whole thing.
|
||||||
|
*/
|
||||||
|
const turn: Intent['type'][] = [
|
||||||
|
'localOps.choose',
|
||||||
|
'draw.fromHomeOffice',
|
||||||
|
'card.play',
|
||||||
|
'draw.end',
|
||||||
|
'localOps.choose',
|
||||||
|
'freightAgent.stockOutbound',
|
||||||
|
];
|
||||||
|
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||||
|
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
|
||||||
|
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,8 +59,11 @@ describe('what each game type is', () => {
|
|||||||
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
|
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
|
||||||
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
||||||
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
||||||
// At one player the two rules are the same rule.
|
// At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the
|
||||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice');
|
// start is another seat's, which cannot happen. Solitaire said `anyOffice` until 2026-08-30:
|
||||||
|
// true, and it read wrong, since a lone player has no "any player" to be contrasted with. The
|
||||||
|
// label changed and the behaviour did not.
|
||||||
|
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'ownOffice');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves every optional rule off, in every type', () => {
|
it('leaves every optional rule off, in every type', () => {
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* THE SEATLESS PUBLIC DELTA — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||||
|
*
|
||||||
|
* The property that matters is RECONSTRUCTION: a receiver that started from one full frame and
|
||||||
|
* merged every delta since must hold exactly what a fresh `publicSnapshot()` would give it. A delta
|
||||||
|
* scheme that is merely smaller is worthless if the two sides drift, and the drift would show up as
|
||||||
|
* a board that is subtly wrong rather than as an error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { pump } from '../src/engine/advance.ts';
|
||||||
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
|
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||||
|
import { applyIntent } from '../src/engine/apply.ts';
|
||||||
|
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
||||||
|
import type { PublicFrame } from '../src/sim/view.ts';
|
||||||
|
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
||||||
|
|
||||||
|
const config: GameConfig = {
|
||||||
|
mode: 'competitive',
|
||||||
|
days: 5,
|
||||||
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: {
|
||||||
|
reducedVisibility: false,
|
||||||
|
employeeRotation: false,
|
||||||
|
emergencyToolbox: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function newState(seed: number, players = 3, rotation = false): GameState {
|
||||||
|
const s = createGame({
|
||||||
|
id: `delta-${seed}`,
|
||||||
|
seed,
|
||||||
|
config: rotation
|
||||||
|
? { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } }
|
||||||
|
: config,
|
||||||
|
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
|
||||||
|
});
|
||||||
|
pump(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays one legal action, preferring a switch move so districts actually change between frames. */
|
||||||
|
function step(s: GameState, actor: PlayerIndex): boolean {
|
||||||
|
const options = legalActions(s, actor);
|
||||||
|
if (options.length === 0) return false;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
||||||
|
const r = applyIntent(s, actor, chosen);
|
||||||
|
if (!r.ok) return false;
|
||||||
|
pump(s);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('public frame delta', () => {
|
||||||
|
it('reconstructs exactly what a fresh projection produces, over a long chain', () => {
|
||||||
|
for (const seed of [1917398, 4242]) {
|
||||||
|
const s = newState(seed);
|
||||||
|
let sent: PublicFrame | null = null;
|
||||||
|
let held: PublicFrame | null = null;
|
||||||
|
let steps = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
const actor = currentActorOfState(s);
|
||||||
|
if (actor === null) break;
|
||||||
|
if (!step(s, actor)) break;
|
||||||
|
|
||||||
|
const next = publicSnapshot(s);
|
||||||
|
const delta = deltaPublicFrame(sent, next);
|
||||||
|
held = applyPublicDelta(held, delta);
|
||||||
|
sent = next;
|
||||||
|
steps++;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
held,
|
||||||
|
next,
|
||||||
|
`merged frame drifted from a fresh projection at step ${steps} (seed ${seed})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(steps > 20, `only ${steps} steps for seed ${seed} — the chain proved little`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a district board only when that district changed', () => {
|
||||||
|
const s = newState(1917398);
|
||||||
|
const first = publicSnapshot(s);
|
||||||
|
// Nothing has moved, so a delta against an identical frame must null every board.
|
||||||
|
const idle = deltaPublicFrame(first, publicSnapshot(s));
|
||||||
|
assert.equal(idle.division, null, 'the Division was unchanged and must not be resent');
|
||||||
|
assert.equal(idle.districts.length, 0, 'an unchanged district must be omitted, not sent as nulls');
|
||||||
|
assert.deepEqual(idle.table, {}, 'an unchanged table must send no fields at all');
|
||||||
|
|
||||||
|
// Now move one player. Only that seat's board may be sent — this is the whole point of keying
|
||||||
|
// the delta by seat rather than comparing `districts` as one array.
|
||||||
|
let moved: PublicIndexed | null = null;
|
||||||
|
for (let i = 0; i < 200 && moved === null; i++) {
|
||||||
|
const actor = currentActorOfState(s);
|
||||||
|
if (actor === null) break;
|
||||||
|
const before = publicSnapshot(s);
|
||||||
|
if (!step(s, actor)) break;
|
||||||
|
const after = publicSnapshot(s);
|
||||||
|
const changed = after.districts.filter(
|
||||||
|
(d) => JSON.stringify(d.cells) !== JSON.stringify(before.districts.find((b) => b.seat === d.seat)?.cells),
|
||||||
|
);
|
||||||
|
if (changed.length === 1) moved = { seat: changed[0]!.seat, before, after };
|
||||||
|
}
|
||||||
|
assert.ok(moved !== null, 'no single-district change occurred, so this test proved nothing');
|
||||||
|
|
||||||
|
const delta = deltaPublicFrame(moved.before, moved.after);
|
||||||
|
assert.equal(delta.districts.length, 1, 'only the district that changed may be sent');
|
||||||
|
assert.equal(delta.districts[0]!.seat, moved.seat);
|
||||||
|
assert.notEqual(delta.districts[0]!.cells, null, 'the district that changed must carry its board');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a step that changes one field sends one field — the reason this is a partial', () => {
|
||||||
|
/**
|
||||||
|
* MEASURED, not assumed. The first version spread the whole frame and nulled only the boards, so
|
||||||
|
* a step whose sole change was whose turn it is still shipped all 35 top-level properties. Once
|
||||||
|
* TODO #18 gave automatic phases their own steps, most steps became exactly that, and a full game
|
||||||
|
* cost 19.4 MB of which 16.7 MB was those. This is the guard against that returning.
|
||||||
|
*/
|
||||||
|
const s = newState(1917398);
|
||||||
|
const before = publicSnapshot(s);
|
||||||
|
const full = JSON.stringify(deltaPublicFrame(null, before)).length;
|
||||||
|
|
||||||
|
// Hand the turn on without touching a board, which is what an automatic phase mostly does.
|
||||||
|
const after = { ...before, actor: ((before.actor ?? 0) + 1) as PlayerIndex };
|
||||||
|
const delta = deltaPublicFrame(before, after);
|
||||||
|
assert.deepEqual(Object.keys(delta.table), ['actor'], 'only the field that changed may be sent');
|
||||||
|
assert.equal(delta.districts.length, 0);
|
||||||
|
assert.equal(delta.division, null);
|
||||||
|
|
||||||
|
const size = JSON.stringify(delta).length;
|
||||||
|
assert.ok(size < 120, `a one-field delta serialised to ${size} bytes`);
|
||||||
|
assert.ok(size * 100 < full, `a one-field delta (${size}B) is not much smaller than a full frame (${full}B)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always carries seat, player and name, so Employee Rotation cannot be missed', () => {
|
||||||
|
// Rotation moves players between districts, so the seat→player pairing is itself news. Those
|
||||||
|
// fields are small and are never nulled; the boards they label are what the delta saves.
|
||||||
|
const s = newState(777, 3, true);
|
||||||
|
const a = publicSnapshot(s);
|
||||||
|
// A full frame carries every district, each labelled — that is what a receiver matches on later.
|
||||||
|
const full = deltaPublicFrame(null, a);
|
||||||
|
assert.equal(full.districts.length, a.districts.length);
|
||||||
|
for (const d of full.districts) {
|
||||||
|
assert.equal(typeof d.seat, 'number');
|
||||||
|
assert.equal(typeof d.player, 'number');
|
||||||
|
assert.ok(typeof d.name === 'string' && d.name.length > 0, 'every district must stay labelled');
|
||||||
|
}
|
||||||
|
// And a district sent at all always carries its labels, even when only its board moved: rotation
|
||||||
|
// makes the seat→player pairing news in its own right.
|
||||||
|
const rotated = { ...a, districts: a.districts.map((d, i) => (i === 0 ? { ...d, player: ((d.player + 1) % 3) as PlayerIndex } : d)) };
|
||||||
|
const delta = deltaPublicFrame(a, rotated);
|
||||||
|
assert.equal(delta.districts.length, 1, 'a relabelled district must be sent even with no board change');
|
||||||
|
assert.equal(typeof delta.districts[0]!.player, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a first frame is sent whole', () => {
|
||||||
|
const s = newState(4242);
|
||||||
|
const full = deltaPublicFrame(null, publicSnapshot(s));
|
||||||
|
assert.notEqual(full.division, null);
|
||||||
|
for (const d of full.districts) {
|
||||||
|
assert.notEqual(d.cells, null, `seat ${d.seat} must be sent in full on a first frame`);
|
||||||
|
assert.notEqual(d.facilities, null);
|
||||||
|
}
|
||||||
|
// And it merges with no previous frame at all.
|
||||||
|
assert.deepEqual(applyPublicDelta(null, full), publicSnapshot(s));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to merge an "unchanged" board it has nothing to merge onto', () => {
|
||||||
|
// A sender whose bookkeeping has drifted would otherwise hand a player a blank district.
|
||||||
|
const s = newState(4242);
|
||||||
|
const a = publicSnapshot(s);
|
||||||
|
const unchanged = deltaPublicFrame(a, publicSnapshot(s));
|
||||||
|
assert.throws(() => applyPublicDelta(null, unchanged), /no previous frame to merge onto/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
|
||||||
+413
-1
@@ -17,7 +17,10 @@ import { pump } from '../src/engine/advance.ts';
|
|||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { cardName, publicSnapshot, snapshot } from '../src/sim/view.ts';
|
||||||
|
import { newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||||
|
import { createSession } from '../src/server/session.ts';
|
||||||
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
@@ -139,3 +142,412 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE OTHER HALF OF §7, AND THE HALF THAT WAS NEVER LOOKED AT.
|
||||||
|
*
|
||||||
|
* Every test above serializes a `Frame`, and every one of them passes `[]` for the narration log —
|
||||||
|
* so the entire shared log has sat outside the redaction net since the net was built. It is not a
|
||||||
|
* hypothetical hole: `game.log` is ONE list, and `linesSince(seat)` (`server/session.ts`) slices it
|
||||||
|
* with no per-seat filter at all, so every line written into it reaches every player.
|
||||||
|
*
|
||||||
|
* Two things were being written into it that should never have left the seat that caused them, both
|
||||||
|
* found while planning the public common board (Gitea#20 step 1) and both live in multiplayer today,
|
||||||
|
* with or without that display:
|
||||||
|
*
|
||||||
|
* 1. the SEED, announced in the opening line of every multiplayer game — which hands every player
|
||||||
|
* the whole future of the deal;
|
||||||
|
* 2. the NAME OF A CARD DRAWN BLIND from the Home Office deck.
|
||||||
|
*
|
||||||
|
* SOLITAIRE IS DELIBERATELY LEFT ALONE in both cases. There is nobody to leak to at a one-seat
|
||||||
|
* table, the seed in the log is what a bug report quotes, and a solo player's own history naming
|
||||||
|
* the card they drew is the record, not a leak. The rule is "do not tell the OTHER seats", not
|
||||||
|
* "write less down" — so both checks below assert the solitaire text is still there.
|
||||||
|
*/
|
||||||
|
describe('redaction — the shared narration log never carries a seat\'s secrets', () => {
|
||||||
|
const names = ['Ann', 'Bob', 'Cy'];
|
||||||
|
|
||||||
|
it('never announces the seed to the table (Gitea#20 step 1)', () => {
|
||||||
|
const g = newMultiplayerGame(550943578, config, names);
|
||||||
|
const log = g.log.map((l) => l.text).join('\n');
|
||||||
|
assert.ok(
|
||||||
|
!/550943578/.test(log),
|
||||||
|
`the seed was announced to every seat:\n${log}`,
|
||||||
|
);
|
||||||
|
// The opening line must still say what the game IS — the leak is the number, not the line.
|
||||||
|
assert.match(log, /Game Begins/);
|
||||||
|
assert.match(log, /3 players/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still tells a solitaire player their own seed — there is nobody to leak it to', () => {
|
||||||
|
const g = newGame(550943578);
|
||||||
|
const log = g.log.map((l) => l.text).join('\n');
|
||||||
|
assert.match(log, /550943578/, 'a solo game stopped recording the seed its bug reports quote');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never names a card drawn blind from the Home Office deck (Gitea#20 step 1)', () => {
|
||||||
|
const g = newMultiplayerGame(4242, config, names);
|
||||||
|
|
||||||
|
// Drive to the first Home Office draw any seat makes, and note what it actually drew.
|
||||||
|
let drawn: string | null = null;
|
||||||
|
for (let i = 0; i < 400 && drawn === null; i++) {
|
||||||
|
const actor = g.state.clock.currentActor;
|
||||||
|
if (actor === null) break;
|
||||||
|
const before = g.log.length;
|
||||||
|
if (!submit(g, { type: 'localOps.choose', option: 'draw' }, actor as PlayerIndex)) continue;
|
||||||
|
if (!submit(g, { type: 'draw.fromHomeOffice' }, actor as PlayerIndex)) continue;
|
||||||
|
drawn = g.justDrawn;
|
||||||
|
void before;
|
||||||
|
}
|
||||||
|
assert.ok(drawn, 'no seat ever drew from the Home Office deck');
|
||||||
|
|
||||||
|
const name = cardName(g.state, drawn!);
|
||||||
|
const log = g.log.map((l) => l.text).join('\n');
|
||||||
|
assert.ok(
|
||||||
|
!log.includes(name),
|
||||||
|
`a blind draw named "${name}" to the whole table:\n${log.split('\n').slice(-6).join('\n')}`,
|
||||||
|
);
|
||||||
|
// The draw itself is public — everyone saw a hand go to the deck. Only WHICH card is not.
|
||||||
|
assert.match(log, /Home Office/i);
|
||||||
|
|
||||||
|
// And the drawing seat still learns what it got: `justDrawn` is the owner-only channel, and
|
||||||
|
// `session.ts` sends it to that seat alone.
|
||||||
|
assert.equal(g.justDrawn, drawn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #91 — THE SYSTEMATIC NET, not two strings.
|
||||||
|
*
|
||||||
|
* v0.7.9.2 closed the seed and the blind draw. Both were found by reading a plan, not by a test, and
|
||||||
|
* that is the point: a redaction suite made of the leaks somebody happened to notice proves nothing
|
||||||
|
* about the next one. This is the pass the common-board plan asks for (Gitea#20 step 1 § Tests) —
|
||||||
|
* serialise EVERYTHING a seat or a spectator receives and search it for everything that must not be
|
||||||
|
* in it, across every game state where the shape of the answer changes.
|
||||||
|
*
|
||||||
|
* **What is searched for**, per the plan: every opponent hand card id AND its display name, the
|
||||||
|
* objective, `justDrawn` for the wrong seat, seed values and seed narration, and private decision
|
||||||
|
* and menu data. Display names matter as much as ids — "Red Flags" in a log leaks exactly what
|
||||||
|
* `c118` would, and only the id would have been caught before.
|
||||||
|
*
|
||||||
|
* **Where it is searched**: a player's `Frame`, the `PublicFrame` a spectator gets, the incremental
|
||||||
|
* narration `Push.lines` carries, and a reconnect push — which is a full Frame rather than a delta
|
||||||
|
* and is therefore its own opportunity to leak.
|
||||||
|
*
|
||||||
|
* **And the acceptance bar is not this file.** The plan is explicit that passing redaction tests
|
||||||
|
* alone is insufficient and that every public property needs an allow-list review; the last test
|
||||||
|
* here is that allow-list, so adding a field to the public projection fails until somebody has said
|
||||||
|
* out loud that it is public.
|
||||||
|
*/
|
||||||
|
describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||||
|
const names = ['Ann', 'Bob', 'Cy'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything one seat can see, split into the two halves the checks below treat differently.
|
||||||
|
*
|
||||||
|
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
|
||||||
|
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
|
||||||
|
*
|
||||||
|
* Steps are folded in here rather than given a test of their own so every case below covers them:
|
||||||
|
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
|
||||||
|
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
|
||||||
|
* narration half of a step and does not need to be searched twice.
|
||||||
|
*/
|
||||||
|
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
|
||||||
|
structural: string;
|
||||||
|
history: string;
|
||||||
|
narration: string[];
|
||||||
|
} => ({
|
||||||
|
/**
|
||||||
|
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
|
||||||
|
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
|
||||||
|
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
|
||||||
|
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
|
||||||
|
* `narration`; this is a de-duplication, not a relaxation.
|
||||||
|
*/
|
||||||
|
structural:
|
||||||
|
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
|
||||||
|
'\n' + JSON.stringify(publicSnapshot(g.state)),
|
||||||
|
/**
|
||||||
|
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
|
||||||
|
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
|
||||||
|
* lines do.
|
||||||
|
*
|
||||||
|
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
|
||||||
|
* the bottom of this file pins property by property; that is what guarantees a step frame is
|
||||||
|
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
|
||||||
|
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
|
||||||
|
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
|
||||||
|
* one and no public field carries an opponent's, so finding one anywhere is still proof.
|
||||||
|
*/
|
||||||
|
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
|
||||||
|
narration: g.log.map((l) => l.text),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
|
||||||
|
*
|
||||||
|
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
|
||||||
|
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
|
||||||
|
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
|
||||||
|
* takes the card back into her hand. The name-based check below would otherwise read that historical
|
||||||
|
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
|
||||||
|
* seed 1917398.
|
||||||
|
*
|
||||||
|
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
|
||||||
|
* over them, because those are precise: an id is unique, so finding one is proof, and narration
|
||||||
|
* never renders a raw id.
|
||||||
|
*
|
||||||
|
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
|
||||||
|
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
|
||||||
|
* matches nothing here.
|
||||||
|
*/
|
||||||
|
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
|
||||||
|
* render as. Ids alone were what the original tests looked for, and an id is the precise
|
||||||
|
* instrument — it is unique, so finding one is proof.
|
||||||
|
*
|
||||||
|
* **A NAME IS ONLY EVIDENCE WHEN IT IS DISTINCTIVE, and most are not.** Card names are types, not
|
||||||
|
* identities: "right-hand curve" names a dozen cards, and one of them is legitimately drawn on the
|
||||||
|
* board as a cell label the moment anybody lays track. Searching for a name that also exists in
|
||||||
|
* public is a test that fails on correct code, which is worse than no test — so a name counts only
|
||||||
|
* when EVERY card bearing it is in that one opponent's hand. Then, and only then, seeing it says
|
||||||
|
* something about what they are holding.
|
||||||
|
*
|
||||||
|
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
|
||||||
|
* was in the log.
|
||||||
|
*/
|
||||||
|
const secretsOfOthers = (
|
||||||
|
g: ReturnType<typeof newMultiplayerGame>,
|
||||||
|
seat: PlayerIndex,
|
||||||
|
): { what: string; value: string; precise: boolean }[] => {
|
||||||
|
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
|
||||||
|
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
|
||||||
|
// `namesAFaceUpPile`.
|
||||||
|
const out: { what: string; value: string; precise: boolean }[] = [];
|
||||||
|
// How many cards in the whole game carry each name, and how many of those are in a given hand.
|
||||||
|
const totalByName = new Map<string, number>();
|
||||||
|
for (const id of g.state.cards.keys()) {
|
||||||
|
const n = cardName(g.state, id);
|
||||||
|
totalByName.set(n, (totalByName.get(n) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const p of g.state.players) {
|
||||||
|
if (p.index === seat) continue;
|
||||||
|
const hand = g.state.decks.hands.get(p.index) ?? [];
|
||||||
|
const heldByName = new Map<string, number>();
|
||||||
|
for (const id of hand) {
|
||||||
|
const n = cardName(g.state, id);
|
||||||
|
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const id of hand) {
|
||||||
|
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
|
||||||
|
const name = cardName(g.state, id);
|
||||||
|
if (totalByName.get(name) === heldByName.get(name)) {
|
||||||
|
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs the whole net over one state, and says which state failed if it does. */
|
||||||
|
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
||||||
|
for (const seat of g.state.players.map((p) => p.index)) {
|
||||||
|
const { structural, history, narration } = everythingSeatSees(g, seat);
|
||||||
|
const everything = structural + '\n' + history + '\n' + narration.join('\n');
|
||||||
|
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
|
||||||
|
// is entitled to name a card on. Ids are precise and are searched everywhere.
|
||||||
|
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
|
||||||
|
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
|
||||||
|
assert.ok(
|
||||||
|
!(precise ? everything : forNames).includes(value),
|
||||||
|
`${where}: seat ${seat} can see ${what} ("${value}")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The seed is the whole future of the deal and must not reach a seat by any route.
|
||||||
|
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||||
|
}
|
||||||
|
// And the spectator board, which has no seat and is therefore entitled to nothing private.
|
||||||
|
const pub = JSON.stringify(publicSnapshot(g.state));
|
||||||
|
for (const p of g.state.players) {
|
||||||
|
for (const id of g.state.decks.hands.get(p.index) ?? []) {
|
||||||
|
assert.ok(!pub.includes(id), `${where}: the public board carries ${p.name}'s card ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(!pub.includes(String(g.seed)), `${where}: the public board carries the seed`);
|
||||||
|
for (const k of ['hand', 'objective', 'justDrawn', 'decision', 'moves', 'blocked', 'viewer']) {
|
||||||
|
assert.ok(!(k in (JSON.parse(pub) as Record<string, unknown>)), `${where}: the public board has a "${k}" field`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Plays `n` legal moves, so a state is a real position rather than a constructed one. */
|
||||||
|
const play = (g: ReturnType<typeof newMultiplayerGame>, n: number): void => {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = g.state.clock.currentActor;
|
||||||
|
if (a === null) break;
|
||||||
|
const opts = legalActions(g.state, a);
|
||||||
|
if (!opts.length) break;
|
||||||
|
if (!submit(g, opts[i % opts.length]!, a)) break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('a newly created multiplayer game', () => {
|
||||||
|
audit(newMultiplayerGame(4242, config, names), 'fresh game');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after a blind Home Office draw', () => {
|
||||||
|
const g = newMultiplayerGame(4242, config, names);
|
||||||
|
let drew = false;
|
||||||
|
for (let i = 0; i < 200 && !drew; i++) {
|
||||||
|
const a = g.state.clock.currentActor;
|
||||||
|
if (a === null) break;
|
||||||
|
if (!submit(g, { type: 'localOps.choose', option: 'draw' }, a)) continue;
|
||||||
|
drew = submit(g, { type: 'draw.fromHomeOffice' }, a);
|
||||||
|
}
|
||||||
|
assert.ok(drew, 'no seat drew from the Home Office deck');
|
||||||
|
audit(g, 'after a blind draw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
|
||||||
|
/**
|
||||||
|
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
|
||||||
|
* every case above is audited against — which is worth nothing if that array is empty in
|
||||||
|
* practice. So: play a real game, and assert both that steps accumulated and that the audited
|
||||||
|
* string contains them.
|
||||||
|
*/
|
||||||
|
const g = newMultiplayerGame(1917398, config, names);
|
||||||
|
play(g, 120);
|
||||||
|
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
|
||||||
|
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
|
||||||
|
assert.ok(
|
||||||
|
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
|
||||||
|
'the audited string does not actually contain the step frames',
|
||||||
|
);
|
||||||
|
// And a step's own narration is a slice of the log, so the log half covers it.
|
||||||
|
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
|
||||||
|
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
|
||||||
|
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
|
||||||
|
audit(g, 'a played game with presentation steps');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mid-game, with real hands and a built board', () => {
|
||||||
|
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
|
||||||
|
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
|
||||||
|
// not one. Nine digits collide with nothing, which is what makes a substring match evidence.
|
||||||
|
const g = newMultiplayerGame(613884219, config, names);
|
||||||
|
play(g, 300);
|
||||||
|
audit(g, 'mid-game');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with a decision pending, and with the Superintendent acting', () => {
|
||||||
|
const g = newMultiplayerGame(550943578, config, names);
|
||||||
|
let sawDecision = false;
|
||||||
|
for (let i = 0; i < 800; i++) {
|
||||||
|
if (g.state.clock.pendingDecision !== null) {
|
||||||
|
sawDecision = true;
|
||||||
|
audit(g, `pending decision (${g.state.clock.pendingDecision.kind})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const a = g.state.clock.currentActor;
|
||||||
|
if (a === null) break;
|
||||||
|
const opts = legalActions(g.state, a);
|
||||||
|
if (!opts.length || !submit(g, opts[0]!, a)) break;
|
||||||
|
}
|
||||||
|
// A seed that never raises one is not a failure of redaction; say so rather than passing mutely.
|
||||||
|
if (!sawDecision) assert.ok(true, 'no decision arose on this seed — nothing to audit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with Employee Rotation on, before and after ownership moves', () => {
|
||||||
|
// The case where seat and player index come apart. A projection that confused them would hand
|
||||||
|
// one player another's district, which is a leak the other tests cannot see.
|
||||||
|
const rotating = { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } };
|
||||||
|
const g = newMultiplayerGame(729315046, rotating, names);
|
||||||
|
audit(g, 'employee rotation, before');
|
||||||
|
const seatingBefore = [...g.state.seating];
|
||||||
|
play(g, 400);
|
||||||
|
audit(g, 'employee rotation, after');
|
||||||
|
// If the seating never moved this test proved less than it looks — say which happened.
|
||||||
|
const moved = seatingBefore.some((p, i) => g.state.seating[i] !== p);
|
||||||
|
assert.ok(moved || g.state.status !== 'active', 'rotation never moved anybody and the game did not end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a game played out to the end, or as far as it goes', () => {
|
||||||
|
const g = newMultiplayerGame(613884219, config, names);
|
||||||
|
play(g, 6000);
|
||||||
|
// Says which it actually got, rather than claiming a finished game it may not have reached.
|
||||||
|
audit(g, `played out (status ${g.state.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reconnect push, which is a full Frame rather than a delta', () => {
|
||||||
|
const session = createSession(550943578, config, names);
|
||||||
|
for (const seat of [0, 1, 2] as PlayerIndex[]) {
|
||||||
|
const push = session.connect(seat);
|
||||||
|
const seen = JSON.stringify(push);
|
||||||
|
const state = session.exportSave();
|
||||||
|
assert.ok(!seen.includes(String(state.seed)), `the reconnect push for seat ${seat} carries the seed`);
|
||||||
|
for (const p of [0, 1, 2] as PlayerIndex[]) {
|
||||||
|
if (p === seat) continue;
|
||||||
|
// `connect` returns that seat's own Frame; another seat's hand must not be in it.
|
||||||
|
assert.ok(
|
||||||
|
!/"hand":\[[^\]]/.test(JSON.stringify((push.frame as unknown as Record<string, unknown>)['players'] ?? '')),
|
||||||
|
`the reconnect push for seat ${seat} carries a hand inside players[]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ALLOW-LIST, and the plan's actual acceptance bar.
|
||||||
|
*
|
||||||
|
* Every property of the public projection, written down and reviewed as public. This does not
|
||||||
|
* check the CONTENT of anything — the tests above do that — it checks that nobody has added a
|
||||||
|
* field without saying out loud that a spectator may see it. That is the check that would have
|
||||||
|
* caught both v0.7.9.2 leaks, because both were fields nobody had ever asked the question about.
|
||||||
|
*
|
||||||
|
* When this fails, the fix is not to add the key here. It is to decide whether the field is
|
||||||
|
* public, and only then to add it.
|
||||||
|
*/
|
||||||
|
it('every public property is on the allow-list, and nothing else is', () => {
|
||||||
|
const PUBLIC: readonly string[] = [
|
||||||
|
// The clock and the phase — what a spectator's board is FOR.
|
||||||
|
'day', 'stage', 'clock', 'phase', 'phaseKey', 'actor', 'superintendent',
|
||||||
|
// Deck sizes and face-up piles. A Department pile is face up; the Home Office deck is a count.
|
||||||
|
'deck', 'departments', 'departmentsWhat', 'departmentDepth', 'salvage',
|
||||||
|
// Rolling stock in the yards, by type — visible on the table.
|
||||||
|
'yards',
|
||||||
|
// The timetable is public: it is what everyone is playing against.
|
||||||
|
'timetable', 'timetableWhat',
|
||||||
|
// The rules the game was dealt under, and the score.
|
||||||
|
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
|
||||||
|
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
|
||||||
|
// What the Day that just ended finished on. Public for the same reason the running counts are:
|
||||||
|
// a collision happens on the Mainline in front of everybody.
|
||||||
|
'collisionsPrevDay',
|
||||||
|
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
|
||||||
|
// Names, seats, revenue and HAND SIZE — never hand contents.
|
||||||
|
'players',
|
||||||
|
// The opening rolls decided seating and the Superintendent in the open.
|
||||||
|
'openingRolls',
|
||||||
|
// Where every train is standing.
|
||||||
|
'trains',
|
||||||
|
// The Crew Tray pool and the trains queued for one (#98). §7 scarcity is played out in the
|
||||||
|
// open: the trays are objects in the middle of the table, and an Extra is played face up, so
|
||||||
|
// who is waiting for a crew is not a secret. Counts and train numbers only — never a hand.
|
||||||
|
'crewTrays', 'queued',
|
||||||
|
// The board itself.
|
||||||
|
'division', 'districts',
|
||||||
|
];
|
||||||
|
const g = newMultiplayerGame(4242, config, names);
|
||||||
|
const actual = Object.keys(publicSnapshot(g.state)).sort();
|
||||||
|
const allowed = [...PUBLIC].sort();
|
||||||
|
assert.deepEqual(
|
||||||
|
actual,
|
||||||
|
allowed,
|
||||||
|
'the public projection gained or lost a property — decide whether it is public before listing it',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+163
-12
@@ -6,6 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { pump } from '../src/engine/advance.ts';
|
import { pump } from '../src/engine/advance.ts';
|
||||||
@@ -13,8 +16,8 @@ import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiv
|
|||||||
import type { GameEvent } from '../src/engine/events.ts';
|
import type { GameEvent } from '../src/engine/events.ts';
|
||||||
import { areaOf } from '../src/engine/apply.ts';
|
import { areaOf } from '../src/engine/apply.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import { coordKey } from '../src/engine/state.ts';
|
import { coordKey, turnOf } from '../src/engine/state.ts';
|
||||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, GridCoord } from '../src/engine/state.ts';
|
||||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||||
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'phaseBegan', phase: 'mainline' },
|
{ type: 'phaseBegan', phase: 'mainline' },
|
||||||
{ type: 'actorChanged', player: 0 },
|
{ type: 'actorChanged', player: 0 },
|
||||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||||
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||||
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||||
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||||
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
||||||
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
|
|||||||
|
|
||||||
describe('narration', () => {
|
describe('narration', () => {
|
||||||
it('covers every event type the engine can emit', () => {
|
it('covers every event type the engine can emit', () => {
|
||||||
// Guards against a new event type slipping in unnarrated.
|
/**
|
||||||
const covered = new Set(SAMPLES.map((e) => e.type));
|
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
|
||||||
const declared = new Set<string>();
|
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
|
||||||
for (const e of SAMPLES) declared.add(e.type);
|
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
|
||||||
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
|
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
|
||||||
assert.equal(declared.size, 30);
|
* a second reader.
|
||||||
|
*
|
||||||
|
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
|
||||||
|
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
|
||||||
|
* stale, which is how this got here.
|
||||||
|
*/
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const declared = new Set(
|
||||||
|
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
|
||||||
|
.map((m) => m[1]!),
|
||||||
|
);
|
||||||
|
const narrated = new Set(
|
||||||
|
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
|
||||||
|
.map((m) => m[1]!),
|
||||||
|
);
|
||||||
|
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
|
||||||
|
|
||||||
|
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
|
||||||
|
// through to a placeholder, in front of a player. This is the check the old version promised.
|
||||||
|
const unnarrated = [...declared].filter((t) => !narrated.has(t));
|
||||||
|
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
|
||||||
|
|
||||||
|
const covered = new Set<string>(SAMPLES.map((e) => e.type));
|
||||||
|
const unknown = [...covered].filter((t) => !declared.has(t));
|
||||||
|
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||||
|
*
|
||||||
|
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
||||||
|
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
|
||||||
|
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
|
||||||
|
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
|
||||||
|
* thing it could not detect was the thing its comment promised.
|
||||||
|
*
|
||||||
|
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
|
||||||
|
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
|
||||||
|
* cannot join the unsampled set silently.
|
||||||
|
*/
|
||||||
|
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
|
||||||
|
assert.equal(
|
||||||
|
unsampled.length,
|
||||||
|
25,
|
||||||
|
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives every event a specific, non-empty sentence', () => {
|
it('gives every event a specific, non-empty sentence', () => {
|
||||||
@@ -138,6 +185,89 @@ describe('impediments', () => {
|
|||||||
s.freeTrays = [];
|
s.freeTrays = [];
|
||||||
assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
|
assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A crew standing on `at`, working the given train, with one empty tank car on the drawbar. */
|
||||||
|
const express = (s: GameState, trainNumber: number, at: GridCoord): string => {
|
||||||
|
const id = s.freeTrays.pop()!;
|
||||||
|
s.trays.set(id, {
|
||||||
|
id, trainNumber, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'tank', loaded: false, origin: 0 }],
|
||||||
|
direction: 'east', position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GITEA#21 — THE GAME REFUSED, AND THE PANEL EXPLAINED SOMETHING ELSE.
|
||||||
|
*
|
||||||
|
* "I wanted to drop two empty tank cars so that the freight agents and men at work could load
|
||||||
|
* them later. I dropped the first tank car, but that was all I was allowed to do. Checked
|
||||||
|
* 'Blocked — why nothing is moving' and saw: refinery 1,0 — green box empty — nothing to load
|
||||||
|
* (needs a Freight Agent action)."
|
||||||
|
*
|
||||||
|
* Replayed from the attached save (seed 550943578, 181 intents): the crew was Train 3, and the
|
||||||
|
* engine's answer was `FREIGHT_WORKED_HERE`. Train 3 is the Express, and the Express prints "May
|
||||||
|
* drop or pick up one freight car at every location" — so THE REFUSAL WAS CORRECT and the rule
|
||||||
|
* is not what is wrong here. It resets next turn, and the Express may work a car at the next
|
||||||
|
* square this turn; that is what makes it an Express rather than a one-car-a-Stage train.
|
||||||
|
*
|
||||||
|
* What was wrong is that nothing said so. The panel whose entire job is "why is nothing moving?"
|
||||||
|
* listed the refinery's green box — a true statement about the FACILITY, and nothing to do with
|
||||||
|
* why the drop was refused — so the player was sent to fix a Freight Agent action that would not
|
||||||
|
* have helped. The rule was on the train card's own tooltip, which is not where somebody looks
|
||||||
|
* when a button they expected is missing.
|
||||||
|
*
|
||||||
|
* The panel is where a refusal gets explained, so the budget belongs in it.
|
||||||
|
*/
|
||||||
|
it('says when the Express has spent its one freight car on this square (Gitea#21)', () => {
|
||||||
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
const at = area.officeCoord;
|
||||||
|
|
||||||
|
// A crew standing at the Office working Train 3 — the Express — with a tank car still on it.
|
||||||
|
const trayId = express(s, 3, at);
|
||||||
|
|
||||||
|
s.clock.phase = 'localOps';
|
||||||
|
const turn = turnOf(s, 0);
|
||||||
|
turn.option = 'switch';
|
||||||
|
|
||||||
|
// Nothing to say before it has worked anything here.
|
||||||
|
assert.ok(
|
||||||
|
!impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)),
|
||||||
|
'the budget was reported spent before the train had worked a car at all',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now it has set one car out here — exactly the state the save is in at intent 181.
|
||||||
|
turn.freightWorked[`${trayId}@${coordKey(at)}`] = 1;
|
||||||
|
|
||||||
|
const row = impediments(s, 0).find((b) => /FREIGHT CAR PER LOCATION/i.test(b.why));
|
||||||
|
assert.ok(
|
||||||
|
row,
|
||||||
|
`nothing explained the refusal:\n${JSON.stringify(impediments(s, 0), null, 2)}`,
|
||||||
|
);
|
||||||
|
// It must name the train, or a player with three crews out cannot tell which one it means.
|
||||||
|
assert.match(row!.where, /Train 3/);
|
||||||
|
// And it must say the limit lifts, or it reads as "this train can never work here again".
|
||||||
|
assert.match(row!.why, /turn/i);
|
||||||
|
// Amber: this is the printed rule doing its job, not a fault.
|
||||||
|
assert.equal(row!.severity, 'waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves every other train alone — the rule is printed on 3 and 4 only (Gitea#21)', () => {
|
||||||
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
const at = area.officeCoord;
|
||||||
|
// Train 5 is The Sparrow, which prints no per-location freight limit.
|
||||||
|
const trayId = express(s, 5, at);
|
||||||
|
s.clock.phase = 'localOps';
|
||||||
|
turnOf(s, 0).option = 'switch';
|
||||||
|
turnOf(s, 0).freightWorked[`${trayId}@${coordKey(at)}`] = 1;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
!impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)),
|
||||||
|
'a train with no such rule was told it had spent a budget it does not have',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,6 +344,24 @@ describe('a blocked platform says why (Gitea#2)', () => {
|
|||||||
assert.ok(f.inboundBox.length === 0, 'the red slots were free — the shortage is the only cause');
|
assert.ok(f.inboundBox.length === 0, 'the red slots were free — the shortage is the only cause');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says why the game ended in words, not as a raw enum (TODO #34)', () => {
|
||||||
|
/**
|
||||||
|
* The heading read `loss — revenueFloor` — the exact defect Gitea#16 was filed about on the
|
||||||
|
* playable page, still alive here a release after that was fixed, because nothing
|
||||||
|
* player-facing pointed at the developer replay. It shares `reasonSentence` with the results
|
||||||
|
* screen now, so the two cannot explain one ending in two ways.
|
||||||
|
*/
|
||||||
|
const rec = record(1234, 'standard');
|
||||||
|
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
|
||||||
|
assert.ok(!rec.outcome.includes(raw), `the summary still prints the raw reason "${raw}"`);
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(rec.outcome, /<[^>]+>/, 'markup leaked into a heading and a console line');
|
||||||
|
assert.match(rec.outcome, /Revenue/, 'the summary says nothing about how the game went');
|
||||||
|
// And the sentence is the shared one, with this game's own numbers in it.
|
||||||
|
assert.match(rec.outcome, /closed short|last on the timetable|declared unsafe/,
|
||||||
|
'the ending is not explained in the words the results screen uses');
|
||||||
|
});
|
||||||
|
|
||||||
it('says nothing about a platform that is working fine', () => {
|
it('says nothing about a platform that is working fine', () => {
|
||||||
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
||||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
@@ -257,7 +405,10 @@ describe('replay recording', () => {
|
|||||||
const last = rec.frames[rec.frames.length - 1]!;
|
const last = rec.frames[rec.frames.length - 1]!;
|
||||||
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
|
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
|
||||||
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
|
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
|
||||||
assert.match(rec.outcome, new RegExp(stats.result));
|
// The summary says "won"/"lost" rather than the engine's `win`/`loss` (TODO #34 — it is a
|
||||||
|
// sentence for a reader now, not an enum). Mapped here so this still checks the two AGREE,
|
||||||
|
// which is what the test is for, rather than checking they are spelled the same.
|
||||||
|
assert.match(rec.outcome, new RegExp(stats.result === 'win' ? 'won' : 'lost'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('narrates every frame', () => {
|
it('narrates every frame', () => {
|
||||||
|
|||||||
@@ -531,3 +531,79 @@ describe('§3.3 extended play across the server (Gitea#11)', () => {
|
|||||||
assert.deepEqual(b.official, a.official, 'the official result did not survive the replay');
|
assert.deepEqual(b.official, a.official, 'the official result did not survive the replay');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NARRATION HAS ONE PATH, AND A RECONNECT HAS TO GET ALL OF IT (#97, Gitea#20 step 1).
|
||||||
|
*
|
||||||
|
* The common-board plan asks for one thing here: "stop passing the full game log into `frameFor()`;
|
||||||
|
* continue sending sanitized incremental narration through `Push.lines`." Doing only the first half
|
||||||
|
* would have deleted a real behaviour, so this pins the pair.
|
||||||
|
*
|
||||||
|
* WHAT WAS ACTUALLY WRONG. `Frame.lines` carried the WHOLE log on every push, and nothing read it:
|
||||||
|
* `RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone and its `lines()`
|
||||||
|
* returns that accumulator. So the log was serialised into every frame for every seat, grew all
|
||||||
|
* game, and was thrown away on arrival — while `linesSince` sent the same text again, correctly,
|
||||||
|
* beside it.
|
||||||
|
*
|
||||||
|
* And the duplicate was masking a bug rather than merely wasting bandwidth. `connect()` clears
|
||||||
|
* `lastFrame` but did NOT clear `sentLines`, so a reconnecting seat was told "nothing new since your
|
||||||
|
* last push" — while the browser it was answering had just reloaded and started from an EMPTY
|
||||||
|
* accumulator. The history panel came back blank after a refresh, mid-game, with the server holding
|
||||||
|
* the whole log and shipping it in the one field nobody reads.
|
||||||
|
*
|
||||||
|
* So the two halves are one change: a (re)connect resets the seat's watermark and `Push.lines` on a
|
||||||
|
* connect IS the history, which is what lets the frame stop carrying a second copy.
|
||||||
|
*/
|
||||||
|
describe('narration reaches a seat exactly once, by one path (#97)', () => {
|
||||||
|
/**
|
||||||
|
* A session with narration already in the log and NO connect yet, so a first connect is a real
|
||||||
|
* "catch me up" rather than a no-op. Connecting inside this helper is what made the first draft of
|
||||||
|
* the reconnect test pass vacuously: both sides of the comparison were the empty array.
|
||||||
|
*/
|
||||||
|
const played = (): GameSession => createSession(550943578, config, ['Alice', 'Bob']);
|
||||||
|
|
||||||
|
it('a FIRST connect carries the narration so far in Push.lines', () => {
|
||||||
|
const session = createSession(550943578, config, ['Alice', 'Bob']);
|
||||||
|
const push = session.connect(0 as PlayerIndex);
|
||||||
|
assert.ok(push.lines.length > 0, 'a first connect was given no narration at all');
|
||||||
|
assert.ok(
|
||||||
|
push.lines.some((l) => /players|competitive/i.test(l.text)),
|
||||||
|
'the opening lines are not in what a first connect received',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a RECONNECT is given the whole log again, because the browser it answers has none', () => {
|
||||||
|
const session = played();
|
||||||
|
const first = session.connect(0 as PlayerIndex);
|
||||||
|
const again = session.connect(0 as PlayerIndex);
|
||||||
|
// NON-EMPTY first: two empty arrays are deepEqual, and asserting only that is how this test
|
||||||
|
// passed against the broken code on its first draft.
|
||||||
|
assert.ok(first.lines.length > 0, 'the premise is gone: there was no narration to be given');
|
||||||
|
assert.deepEqual(
|
||||||
|
again.lines,
|
||||||
|
first.lines,
|
||||||
|
'a reconnecting seat was told nothing was new, and its history panel would come back empty',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Frame does NOT carry a second copy of the log', () => {
|
||||||
|
const session = played();
|
||||||
|
const push = session.connect(0 as PlayerIndex);
|
||||||
|
assert.deepEqual(
|
||||||
|
(push.frame as unknown as { lines: unknown[] }).lines,
|
||||||
|
[],
|
||||||
|
'the whole narration log is still being serialised into every Frame, where nothing reads it',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ordinary push after a connect carries only what is NEW', () => {
|
||||||
|
const session = played();
|
||||||
|
const opening = session.connect(0 as PlayerIndex);
|
||||||
|
assert.ok(opening.lines.length > 0);
|
||||||
|
// A second connect for the OTHER seat must not re-send seat 0 anything.
|
||||||
|
const other = session.connect(1 as PlayerIndex);
|
||||||
|
assert.ok(other.lines.length > 0, 'the other seat got no history of its own');
|
||||||
|
const third = session.connect(0 as PlayerIndex);
|
||||||
|
assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts';
|
import { actionGroups, currentActor, handPlayable, newGame, submit, toSave, view } from '../src/web/game.ts';
|
||||||
import { createLocalSession } from '../src/web/session.ts';
|
import { createLocalSession } from '../src/web/session.ts';
|
||||||
import { seatLabel } from '../src/sim/view.ts';
|
import { seatLabel } from '../src/sim/view.ts';
|
||||||
|
|
||||||
@@ -81,7 +81,6 @@ describe('a local session plays the same game as the calls it replaced', () => {
|
|||||||
assert.equal(session.seat(), 0);
|
assert.equal(session.seat(), 0);
|
||||||
assert.equal(session.actor(), currentActor(session.game));
|
assert.equal(session.actor(), currentActor(session.game));
|
||||||
assert.deepEqual(session.handPlayable(), handPlayable(session.game));
|
assert.deepEqual(session.handPlayable(), handPlayable(session.game));
|
||||||
assert.equal(session.overHandLimit(), overHandLimit(session.game));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 5-6.
|
||||||
|
*
|
||||||
|
* Driven against REAL steps from a real game rather than hand-built fixtures, because the properties
|
||||||
|
* that matter are about what actual play produces: a bot's whole switching turn arriving in one
|
||||||
|
* burst, and a backlog that is mostly bookkeeping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
|
import type { GameConfig } from '../src/engine/state.ts';
|
||||||
|
import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||||
|
import { publicSnapshot } from '../src/sim/view.ts';
|
||||||
|
import { takeSteps } from '../src/sim/display-step.ts';
|
||||||
|
import type { DisplayStep } from '../src/sim/display-step.ts';
|
||||||
|
import { createStepQueue } from '../src/web/step-queue.ts';
|
||||||
|
import { DWELL } from '../src/sim/pacing.ts';
|
||||||
|
|
||||||
|
const config: GameConfig = {
|
||||||
|
mode: 'competitive',
|
||||||
|
days: 5,
|
||||||
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: {
|
||||||
|
reducedVisibility: false,
|
||||||
|
employeeRotation: false,
|
||||||
|
emergencyToolbox: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a real game and returns its steps, preferring switch moves so a burst actually occurs.
|
||||||
|
*
|
||||||
|
* 400 moves, not 120: switching is not legal until there is track laid and a train in the district,
|
||||||
|
* and on this seed the first `switch.move` is at move 144. A shorter run produces a queue with no
|
||||||
|
* switching in it at all, which would make the pacing assertions here vacuous.
|
||||||
|
*/
|
||||||
|
function realSteps(seed: number, moves: number): { steps: DisplayStep[]; final: ReturnType<typeof publicSnapshot> } {
|
||||||
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
takeSteps(game.display);
|
||||||
|
const steps: DisplayStep[] = [];
|
||||||
|
for (let i = 0; i < moves; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
steps.push(...takeSteps(game.display));
|
||||||
|
}
|
||||||
|
return { steps, final: publicSnapshot(game.state) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The baseline a queue starts from, matching what a connect push carries. */
|
||||||
|
function baseline(seed: number): ReturnType<typeof publicSnapshot> {
|
||||||
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
return publicSnapshot(game.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the step queue', () => {
|
||||||
|
it('shows the whole burst in order and lands on the real board', () => {
|
||||||
|
const { steps, final } = realSteps(1917398, 400);
|
||||||
|
assert.ok(steps.length > 30, `only ${steps.length} steps — this proved little`);
|
||||||
|
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(steps);
|
||||||
|
|
||||||
|
// Run a clock forward until it settles, in 50ms ticks like a render loop would.
|
||||||
|
let now = 0;
|
||||||
|
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||||
|
q.advance(now);
|
||||||
|
now += 50;
|
||||||
|
}
|
||||||
|
assert.equal(q.busy(), false, 'the queue never drained');
|
||||||
|
assert.deepEqual(q.current(), final, 'the animated board did not land on the real one');
|
||||||
|
assert.equal(q.showing()?.seq, steps[steps.length - 1]!.seq, 'the caption is not on the last step');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a burst of switching takes real time, and bookkeeping takes none', () => {
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
|
||||||
|
// Only the bookkeeping: it must all collapse into a single advance.
|
||||||
|
// `.end` only: `localOps.choose` became an announcement worth watching after the first real play.
|
||||||
|
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end'));
|
||||||
|
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
|
||||||
|
q.push(bookkeeping);
|
||||||
|
q.advance(0);
|
||||||
|
q.advance(0);
|
||||||
|
assert.equal(q.busy(), false, `${bookkeeping.length} bookkeeping steps should cost no time at all`);
|
||||||
|
|
||||||
|
// And switching: each one must hold the screen.
|
||||||
|
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
|
||||||
|
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
|
||||||
|
const q2 = createStepQueue();
|
||||||
|
q2.reset(baseline(1917398));
|
||||||
|
q2.push(switching.slice(0, 6));
|
||||||
|
q2.advance(0);
|
||||||
|
assert.equal(q2.behind(), 5, 'the first is shown at once; five are still to watch');
|
||||||
|
q2.advance(DWELL.switching - 1);
|
||||||
|
assert.equal(q2.behind(), 5, 'a switching move must not be replaced early');
|
||||||
|
q2.advance(DWELL.switching);
|
||||||
|
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only what will be watched, so the countdown is steady', () => {
|
||||||
|
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
|
||||||
|
// that collapses the instant it starts.
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(steps);
|
||||||
|
const behind = q.behind();
|
||||||
|
assert.ok(behind > 0 && behind < steps.length, `behind ${behind} of ${steps.length} queued`);
|
||||||
|
|
||||||
|
q.advance(0);
|
||||||
|
let ticks = 0;
|
||||||
|
let previous = q.behind();
|
||||||
|
let now = 0;
|
||||||
|
while (q.busy() && ticks++ < 20_000) {
|
||||||
|
now += 50;
|
||||||
|
q.advance(now);
|
||||||
|
const nowBehind = q.behind();
|
||||||
|
assert.ok(nowBehind <= previous, 'the counter must never go up while draining');
|
||||||
|
previous = nowBehind;
|
||||||
|
}
|
||||||
|
assert.equal(q.behind(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skip jumps to the real board without losing a single state on the way', () => {
|
||||||
|
const { steps, final } = realSteps(1917398, 400);
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(steps);
|
||||||
|
q.advance(0);
|
||||||
|
|
||||||
|
assert.equal(q.skip(), true, 'there was a backlog to skip');
|
||||||
|
assert.equal(q.busy(), false);
|
||||||
|
assert.equal(q.behind(), 0);
|
||||||
|
// Skip applies every delta rather than jumping the chain, so the board is exact.
|
||||||
|
assert.deepEqual(q.current(), final, 'skipping produced a board the game was never in');
|
||||||
|
assert.equal(q.skip(), false, 'skipping an empty queue changes nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pace 0 turns animation off entirely — TODO #18', () => {
|
||||||
|
const { steps, final } = realSteps(1917398, 400);
|
||||||
|
const q = createStepQueue(() => 0);
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(steps);
|
||||||
|
// One advance at a single instant must consume everything: nothing dwells at all.
|
||||||
|
q.advance(0);
|
||||||
|
q.advance(0);
|
||||||
|
assert.equal(q.busy(), false, 'with animation off, nothing may be left waiting');
|
||||||
|
assert.equal(q.behind(), 0, 'nothing is "behind" when nothing is being animated');
|
||||||
|
assert.deepEqual(q.current(), final);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pace scales the wait without changing the order', () => {
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end').slice(0, 3);
|
||||||
|
assert.equal(switching.length, 3);
|
||||||
|
|
||||||
|
const half = createStepQueue(() => 0.5);
|
||||||
|
half.reset(baseline(1917398));
|
||||||
|
half.push(switching);
|
||||||
|
half.advance(0);
|
||||||
|
half.advance(DWELL.switching / 2);
|
||||||
|
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => {
|
||||||
|
/**
|
||||||
|
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
||||||
|
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
|
||||||
|
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
|
||||||
|
* play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
|
||||||
|
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
|
||||||
|
*
|
||||||
|
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||||
|
* for as long as their move is being shown.
|
||||||
|
*/
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1);
|
||||||
|
assert.equal(one.length, 1);
|
||||||
|
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(one);
|
||||||
|
|
||||||
|
q.advance(0);
|
||||||
|
assert.equal(q.behind(), 0, 'nothing is queued behind it');
|
||||||
|
assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle');
|
||||||
|
|
||||||
|
q.advance(DWELL.switching - 1);
|
||||||
|
assert.equal(q.busy(), true, 'still inside its dwell');
|
||||||
|
|
||||||
|
q.advance(DWELL.switching);
|
||||||
|
assert.equal(q.busy(), false, 'and idle only once its moment has passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not spend time replaying the viewer's own moves", () => {
|
||||||
|
// A seated player's own board is drawn from their authoritative Frame, so they have already seen
|
||||||
|
// their own click. Holding it delays the thing they wanted to watch — a bot's turn.
|
||||||
|
const { steps } = realSteps(1917398, 400);
|
||||||
|
const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3);
|
||||||
|
assert.equal(mine.length, 3, 'need three of seat 0\'s own moves');
|
||||||
|
|
||||||
|
const asSeat0 = createStepQueue(() => 1, () => 0);
|
||||||
|
asSeat0.reset(baseline(1917398));
|
||||||
|
asSeat0.push(mine);
|
||||||
|
// Twice at the same instant: the first call shows the head of the burst, the second collapses the
|
||||||
|
// zero-dwell run behind it. In the page that is two animation frames, ~16ms apart.
|
||||||
|
asSeat0.advance(0);
|
||||||
|
asSeat0.advance(0);
|
||||||
|
assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all");
|
||||||
|
assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for');
|
||||||
|
|
||||||
|
// The same steps seen by somebody else are worth watching.
|
||||||
|
const asSpectator = createStepQueue(() => 1, () => 1);
|
||||||
|
asSpectator.reset(baseline(1917398));
|
||||||
|
asSpectator.push(mine);
|
||||||
|
asSpectator.advance(0);
|
||||||
|
assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing");
|
||||||
|
assert.equal(asSpectator.behind(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
|
||||||
|
/**
|
||||||
|
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
|
||||||
|
* Merging them onto the new one would draw a board that never existed — and `applyPublicDelta`
|
||||||
|
* would throw the moment a "null means unchanged" field had nothing to merge onto.
|
||||||
|
*/
|
||||||
|
const { steps, final } = realSteps(1917398, 400);
|
||||||
|
const q = createStepQueue();
|
||||||
|
q.reset(baseline(1917398));
|
||||||
|
q.push(steps.slice(0, 10));
|
||||||
|
q.advance(0);
|
||||||
|
assert.ok(q.busy());
|
||||||
|
|
||||||
|
q.reset(final);
|
||||||
|
assert.equal(q.busy(), false, 'a reset must empty the queue');
|
||||||
|
assert.equal(q.behind(), 0);
|
||||||
|
assert.deepEqual(q.current(), final);
|
||||||
|
// And the caption survives: a reconnect should not blank the "what just happened" line.
|
||||||
|
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing before a reset has arrived', () => {
|
||||||
|
const q = createStepQueue();
|
||||||
|
assert.equal(q.current(), null);
|
||||||
|
assert.equal(q.advance(0), false);
|
||||||
|
assert.equal(q.behind(), 0);
|
||||||
|
assert.equal(q.showing(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
|
||||||
|
*
|
||||||
|
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
|
||||||
|
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
|
||||||
|
*
|
||||||
|
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
|
||||||
|
* and a line that does not say who acted is useless on a screen built to answer "what did they
|
||||||
|
* just do?".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { applyIntent } from '../src/engine/apply.ts';
|
||||||
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
|
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||||
|
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||||
|
import { currentActor } from '../src/web/game.ts';
|
||||||
|
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
||||||
|
import { publicSnapshot } from '../src/sim/view.ts';
|
||||||
|
import type { PublicFrame } from '../src/sim/view.ts';
|
||||||
|
import { takeSteps } from '../src/sim/display-step.ts';
|
||||||
|
import { createSession } from '../src/server/session.ts';
|
||||||
|
import { kindOf } from '../src/sim/pacing.ts';
|
||||||
|
|
||||||
|
const config: GameConfig = {
|
||||||
|
mode: 'competitive',
|
||||||
|
days: 5,
|
||||||
|
minCombinedRevenue: 0,
|
||||||
|
maxCollisionsPerDay: 0,
|
||||||
|
maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: {
|
||||||
|
reducedVisibility: false,
|
||||||
|
employeeRotation: false,
|
||||||
|
emergencyToolbox: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
|
||||||
|
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
|
||||||
|
* event itself carries `player`, and these four were the only events in their class that did not
|
||||||
|
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
|
||||||
|
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
|
||||||
|
* bracket around anonymous contents:
|
||||||
|
*
|
||||||
|
* Player Alice chose to switch ← attributed
|
||||||
|
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
||||||
|
* Player Alice finished Local Operations ← attributed
|
||||||
|
*
|
||||||
|
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
|
||||||
|
*/
|
||||||
|
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
||||||
|
|
||||||
|
/** How each of those four reads in the log, so the assertions can find them by text. */
|
||||||
|
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
||||||
|
|
||||||
|
describe('switching is attributed — TODO #13', () => {
|
||||||
|
it('every switching event carries the player who acted', () => {
|
||||||
|
/**
|
||||||
|
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
|
||||||
|
* switching is clustered rather than spread: two of the three published replays contain no
|
||||||
|
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
|
||||||
|
* this. The counter below then guards against the test passing vacuously.
|
||||||
|
*/
|
||||||
|
let seen = 0;
|
||||||
|
for (const seed of [1917398, 191056, 4242]) {
|
||||||
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < 800; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
||||||
|
|
||||||
|
// Read the events this intent produces before applying it for real, so the assertion sees
|
||||||
|
// exactly what `record()` will be handed.
|
||||||
|
const preview = applyIntent(structuredClone(game.state), actor, chosen);
|
||||||
|
if (preview.ok) {
|
||||||
|
for (const e of preview.events) {
|
||||||
|
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
|
||||||
|
assert.ok(
|
||||||
|
'player' in e,
|
||||||
|
`${e.type} carries no player, so the log cannot say whose crew it was`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(e as { player: PlayerIndex }).player,
|
||||||
|
actor,
|
||||||
|
`${e.type} names the wrong player`,
|
||||||
|
);
|
||||||
|
seen++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!submit(game, chosen)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads as a player action in the log, not as anonymous plain text', () => {
|
||||||
|
let lines = 0;
|
||||||
|
for (const seed of [1917398, 4242]) {
|
||||||
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < 800; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of game.log) {
|
||||||
|
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
|
||||||
|
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
|
||||||
|
// "Player Alice CREW moved …". Both were reworded to compose.
|
||||||
|
assert.doesNotMatch(
|
||||||
|
line.text,
|
||||||
|
/^CREW moved|^SMALL YARD —/,
|
||||||
|
`an unattributed switching line survived: ${line.text}`,
|
||||||
|
);
|
||||||
|
if (SWITCHING_LINE.test(line.text)) {
|
||||||
|
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
||||||
|
lines++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the display-step collector — TODO #13', () => {
|
||||||
|
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
|
||||||
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
let accepted = 0;
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
accepted++;
|
||||||
|
}
|
||||||
|
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
|
||||||
|
|
||||||
|
const steps = takeSteps(game.display);
|
||||||
|
/**
|
||||||
|
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
|
||||||
|
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
|
||||||
|
* one step, which is the invariant that matters.
|
||||||
|
*/
|
||||||
|
const byIntent = steps.filter((s) => s.cause !== 'phase');
|
||||||
|
const byPhase = steps.filter((s) => s.cause === 'phase');
|
||||||
|
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
|
||||||
|
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
|
||||||
|
steps.forEach((s, i) => {
|
||||||
|
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
|
||||||
|
assert.equal(s.protocolVersion, 1);
|
||||||
|
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
|
||||||
|
// A phase is nobody's move; an intent is always somebody's.
|
||||||
|
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
|
||||||
|
assert.equal(s.seat === null, s.cause === 'phase');
|
||||||
|
});
|
||||||
|
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rejected intent produces no step', () => {
|
||||||
|
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
takeSteps(game.display);
|
||||||
|
// Somebody else's turn: refused before the engine is touched, so nothing to present.
|
||||||
|
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
|
||||||
|
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
|
||||||
|
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the step deltas reconstruct the public board exactly', () => {
|
||||||
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
let held: PublicFrame | null = null;
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
|
||||||
|
}
|
||||||
|
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
|
||||||
|
*
|
||||||
|
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
||||||
|
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
|
||||||
|
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
|
||||||
|
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
|
||||||
|
*/
|
||||||
|
it('replaying a save emits no steps at all', () => {
|
||||||
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
if (!submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
assert.ok(game.history.length > 20, 'need a real history to replay');
|
||||||
|
|
||||||
|
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
|
||||||
|
assert.equal(
|
||||||
|
rebuilt.game.display.steps.length,
|
||||||
|
0,
|
||||||
|
'a replay re-emitted the whole game as display steps',
|
||||||
|
);
|
||||||
|
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solitaire collects the same way multiplayer does', () => {
|
||||||
|
// The standing design direction: solitaire is a special case of multiplayer, not a second
|
||||||
|
// implementation. Both go through one `submit()`, so this needs no separate code path — and
|
||||||
|
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
|
||||||
|
const game = newGame(4242);
|
||||||
|
let accepted = 0;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
if (!submit(game, options[0]!)) break;
|
||||||
|
accepted++;
|
||||||
|
}
|
||||||
|
assert.ok(accepted > 10, 'the solitaire game did not get going');
|
||||||
|
const collected = takeSteps(game.display);
|
||||||
|
assert.equal(
|
||||||
|
collected.filter((s) => s.cause !== 'phase').length,
|
||||||
|
accepted,
|
||||||
|
'solitaire must collect a step per intent too',
|
||||||
|
);
|
||||||
|
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
|
||||||
|
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('steps reach a seated player — TODO #13', () => {
|
||||||
|
it('never replays the opening bot turns at the first client to connect', () => {
|
||||||
|
/**
|
||||||
|
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
|
||||||
|
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
|
||||||
|
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
|
||||||
|
* so replaying them onto it would draw positions the game had already left.
|
||||||
|
*
|
||||||
|
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
|
||||||
|
*/
|
||||||
|
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
||||||
|
const push = session.connect(0 as PlayerIndex);
|
||||||
|
assert.ok(push.publicReset, 'a connecting client needs a baseline');
|
||||||
|
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
|
||||||
|
|
||||||
|
// And the first real broadcast must carry only what THIS move produced — nothing older.
|
||||||
|
const option = push.menu?.options[0];
|
||||||
|
assert.ok(option, 'seat 0 should have something to do');
|
||||||
|
const r = session.intent(0 as PlayerIndex, 1, option);
|
||||||
|
assert.ok(r.accepted);
|
||||||
|
const steps = [...r.pushes.values()][0]?.steps ?? [];
|
||||||
|
assert.ok(steps.length > 0, 'the move produced no steps');
|
||||||
|
/**
|
||||||
|
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
|
||||||
|
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
|
||||||
|
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
|
||||||
|
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
|
||||||
|
* zero-based.
|
||||||
|
*/
|
||||||
|
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
|
||||||
|
assert.equal(steps[0]!.cause, option.type);
|
||||||
|
steps.forEach((st, i) => {
|
||||||
|
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
|
||||||
|
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
||||||
|
|
||||||
|
const connected = session.connect(0 as PlayerIndex);
|
||||||
|
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
|
||||||
|
|
||||||
|
let seen = 0;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const menu = session.connect(0 as PlayerIndex).menu;
|
||||||
|
const option = menu?.options[0];
|
||||||
|
if (!option) break;
|
||||||
|
const r = session.intent(0 as PlayerIndex, i, option);
|
||||||
|
if (!r.accepted) break;
|
||||||
|
const pushes = [...r.pushes.values()];
|
||||||
|
if (pushes.length === 0) continue;
|
||||||
|
const first = pushes[0]!.steps ?? [];
|
||||||
|
if (first.length === 0) continue;
|
||||||
|
seen += first.length;
|
||||||
|
for (const p of pushes) {
|
||||||
|
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
||||||
|
});
|
||||||
|
});
|
||||||
+746
-85
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user