v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
@@ -13,9 +13,37 @@ Ordered within each section by how much it is currently costing us.
|
||||
and the track mix all need a pass together, and none of them should move until the rules stop
|
||||
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
|
||||
industries tripled (Gap 12), both tuned when the deck held 139 cards and **no track**; it now
|
||||
holds 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure
|
||||
those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
|
||||
holds 235 of which 96 are track, so every draw is diluted by 41% — precisely the pressure
|
||||
those multipliers exist to relieve. The 8 sharp curves have already been taken out on that
|
||||
argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue
|
||||
numbers; they are a functionality signal only.
|
||||
- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional.** Jesse's
|
||||
call, both implemented and measured, both flagged in `rules-v0.2.md`.
|
||||
|
||||
**The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it
|
||||
was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with
|
||||
revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the
|
||||
private-supply era, so the supply question is softened rather than answered. Two things to
|
||||
watch at the table: whether opening with six against a limit of three is a real decision or
|
||||
just bookkeeping, and whether three is the right number of each.
|
||||
|
||||
**One Revenue for every train that clears your section.** Worth exactly **+5.40 a game** —
|
||||
departures run at 5.40 and each pays 1 — taking the bot from 2.7 to 8.0 mean, median 0 → 6.5,
|
||||
wins 2/200 → 14/200. It more than doubles the score on its own and pays for traffic the player
|
||||
does not have to work. **This makes the victory-target question live**: 20 over 5 Days is now
|
||||
reachable largely on traffic, which is either the intent or an argument for raising the target.
|
||||
- [ ] **BOT DRIFT ACROSS THIS RELEASE — three measurements, all for the rebalance pass.** Recorded
|
||||
together so the pattern is visible rather than three relaxed thresholds nobody adds up:
|
||||
- **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to
|
||||
shed on turn one, the bot would rather lay a piece than discard it; a player would discard
|
||||
the ones with nowhere good to go. It also means the district-size gain from the new deal is
|
||||
partly padding rather than useful railroad.
|
||||
- **Interlocking placed, 15/60 → 7/60 games.** Departure Revenue pulls the bot toward other
|
||||
work and it spends its opening on the track it was dealt.
|
||||
- **Aimless shuttling in 3 games of 16** — thirteen are clean, so this is a minority behaviour
|
||||
rather than the every-game waste the detector was written for.
|
||||
Each floor was moved to match what is measured, with the reasoning written into the test. None
|
||||
is a crisis on its own; together they say the bot spends its openings worse than it did.
|
||||
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
|
||||
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
|
||||
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
|
||||
@@ -171,46 +199,134 @@ Ordered within each section by how much it is currently costing us.
|
||||
|
||||
---
|
||||
|
||||
## From playtesting, 2026-08-12
|
||||
|
||||
Jesse played and reported nine things. **All of them are now done** — the entries are kept because
|
||||
each carries the decision behind it, and two of the nine turned out not to be bugs. The measurements
|
||||
and what went wrong on the way are in `CHANGELOG.md`.
|
||||
|
||||
- [x] **LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way.** The
|
||||
engine's `left` turnout is `{stem:'w', through:'e', diverge:'s'}`: a train entering at the
|
||||
points from the west heads east and the diverging route leaves to its **right**. The engine's
|
||||
`left` curve is arc `sw`, which turns an eastbound train **right** as well. One consistent sign
|
||||
error in the hand↔diagonal mapping, and it mislabels every track card a player ever holds.
|
||||
**The artwork is right and does not change** — `board-svg.ts:462` draws rails from
|
||||
`connectionsFor()` geometry alone, so only words are wrong. **Decision: flip the `hand` value on
|
||||
the `TRACK_CARDS` rows AND the two variant tables in the same commit**, so the code keeps
|
||||
speaking left/right like the physical supply and now means it. Keep the **row order** in
|
||||
`TRACK_CARDS` untouched: `setup.ts:95` builds the deck by iterating that array, so flipping only
|
||||
the labels leaves pre-shuffle slot 32 holding a `ne_sw` curve before and after, and
|
||||
`variantsFor(…)[0]` still `'sw'` — same seed, same board, and every published replay still
|
||||
plays. Also: `track.ts:126` arc fallback, `track.ts:191-212` doc block, `view.ts:1041` and
|
||||
`view.ts:1208` diagonal phrases, `bot.ts:837-852` `arcInHand`, seven test files, and the prose
|
||||
plus ~14 `data-tip="Turnout · left"` strings in `docs/design/track-geometry.html` — which has no
|
||||
generator and must be hand-edited. Verify by fingerprinting a fixed seed's board before and
|
||||
after: identical geometry, different words.
|
||||
- [x] **A turnout should be playable as an UPGRADE, on top of a card already down.** On a straight,
|
||||
or on a curve whose arc matches the turnout's diverging leg. Nothing like this exists — the only
|
||||
"upgrade" in the game is the Office tier change, which is explicitly not a card swap
|
||||
(`apply.ts:1290`), and `canPlaceAt` hard-stops at `if (existing && !isMovableSign) return false`
|
||||
(`track.ts:518`). **Decision: cars AND enhancements both block it** — `standing.length > 0` →
|
||||
`UPGRADE_OCCUPIED`, `enhancements.length > 0` → `UPGRADE_ENHANCED`, so an Interlocked straight
|
||||
stays a straight. Express the curve rule on **arcs, not hands**, so it survives the flip above.
|
||||
No extra connection requirement: all four turnout orientations are port supersets of a straight
|
||||
and of any same-arc curve, so an upgrade can never sever an existing join, and the new leg is
|
||||
allowed to dangle — that is what it is for. The lifted card leaves play, which is already how
|
||||
board cards behave (`apply.ts:1249` salvages only cards that were *not* placed). Reuse
|
||||
`card.play` with a placement on an occupied square; `legal.ts:252` must offer those squares for
|
||||
turnouts, and the `attachments` set at `legal.ts:137` is already exactly that list.
|
||||
**The other half of this report needs no work:** a turnout carries the through route
|
||||
(`carriesThroughTrack`), so it is already legal at a Limit, along the Running Track and on
|
||||
Secondary Track. Confirmed, not re-investigated.
|
||||
- [x] **A Depot shows three MEN AT WORK boxes it can never work.** Offices are Passenger Facilities —
|
||||
no freight — but `setup.ts:176` gives every one a three-slot `menAtWork` array, and the three
|
||||
renderers loop it with no guard while the green and red rows beside them *are* guarded
|
||||
(`board-svg.ts:548`, `panels.ts:218`, `replay.ts:522`). **Decision: all three tiers** — Depot,
|
||||
Station and Terminal are all Passenger Facilities and all get `laborers: 0`, so the boxes are
|
||||
inert on every one. **Fix it in the model, not the renderers**, so it cannot reappear in a
|
||||
fourth place: make `menAtWork` nullable and null for passenger facilities, which is what the
|
||||
"Freight only" comment at `state.ts:153` has claimed all along. Freight handling must be neither
|
||||
allowed nor displayed there. Two more artifacts of the same "an Office is a Facility" modelling
|
||||
go with it: `board-svg.ts:562` calls a Depot **"SHIPS + RECEIVES"**, an industry's flow word,
|
||||
and `board-svg.ts:599`'s `Math.max(1, trackCap)` draws it a siding slot although its industry
|
||||
track has length 0. `FacilityView` needs a `kind` field; it has no freight/passenger flag today.
|
||||
- [x] **Two Ice Houses can be built in one district.** Industries already ban duplicates per Office
|
||||
Area — `isLockedOut` (`apply.ts:1774`) covers the Mine Tipple half of the report — but **Ice
|
||||
House is a Modifier, not an industry**, and `checkPlay`'s modifier branch (`apply.ts:662`) has
|
||||
no duplicate check at all. **Decision: extend the ban to modifier kinds, leave enhancements
|
||||
alone.** An Interlocking is a plant at one junction, so a second on another straight is a
|
||||
different installation, and the Telegraph → Telephone → Radio chain is already gated per card.
|
||||
Expect modifiers with `copies > 1` to go partly dead in solitaire, where there is one Office
|
||||
Area — correct, since the spare copies exist for other players' districts.
|
||||
- [x] **A drawn card lands at the far end of the hand with nothing to mark it.** `apply.ts:1219`
|
||||
pushes, the hand renders in state order (`game.ts:355`), and with `flex-wrap` the newest card is
|
||||
exactly where the eye is least likely to be. **Decision: reverse in the display layer, not the
|
||||
engine** — `view.ts:898-899` (reversing `hand` and `handWhat` identically, or they desync)
|
||||
covers the replay viewer and the standalone replay together, and `game.ts:355` covers the play
|
||||
page. Keeping `hand.push` means the bot's option-iteration order does not move, so every revenue
|
||||
figure in this file stays comparable; an engine `unshift` would invalidate the lot. The marker
|
||||
is play-page only — a `Frame` carries card names, not ids, so a replay cannot say which card
|
||||
arrived that step. Follow the `game.scheduled` precedent for a `justDrawn` field, but make the
|
||||
badge **persist** rather than flash: it says *which card is new*, not *something just happened*.
|
||||
A static `::before` badge as `.handcard.target` does it (`panels.ts:262`), no keyframe — the
|
||||
innerHTML rebuild would restart an animation on every render. Clear it in `renderUndo()` and
|
||||
after `fromSave()`, or a fresh page load badges last session's draw.
|
||||
- [x] **The inbound boxes render green in the side panel.** Green is outbound and red is inbound
|
||||
everywhere the colour carries direction — `board-svg.ts:528-545`, `replay.ts:452`, rules §9.1 —
|
||||
except `panels.ts`, whose shared `boxes()` helper (`panels.ts:162`) emits class `f` for every
|
||||
filled box regardless of direction, so the red row at `panels.ts:222` comes out green. The board
|
||||
SVG on the same page draws it correctly, which makes the panel actively contradict the board.
|
||||
Give `boxes()` its class from the caller as `replay.ts` already does, add `.box.r` in
|
||||
`board-svg.ts:820`'s palette, and take the siding off green in both panels and `replay.ts:525`.
|
||||
- [x] **An Interlocking on the board is a bare label, and it does nothing.** The enhancement text is
|
||||
drawn with no tooltip (`board-svg.ts:651`) while the copy already exists as data in
|
||||
`ENHANCEMENT_CARDS` (`content.ts:589`). Done: every card prints its effect, and the one nothing
|
||||
reads says so. **CORRECTED — the first pass had five of the ten statuses wrong.** It claimed
|
||||
only Telegraph/Telephone/Radio were live, because the survey grepped for four helper function
|
||||
names and read "no match" as "no implementation". In fact **seven are live**: those three plus
|
||||
Interlocking (`advance.ts:770`), Yard Office (`advance.ts:750`), Small Yard (`apply.ts:405`) and
|
||||
ABS Signals (`advance.ts:599`, stored on the Mainline node). Facing Point Locks and Water Column
|
||||
are wired but dormant in solitaire; **Overpass alone has no code path at all**. The shipped
|
||||
tooltip briefly told players four working cards did nothing, which is worse than the bare label
|
||||
it replaced — `enhancements.test.ts` had passing tests for all four the whole time.
|
||||
- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so.**
|
||||
Reported as "Ice House added the laborer but not the outbound slot" — **checked, and there is no
|
||||
bug**: Ice House prints +1 *outbound*, a Grocer's Warehouse is `flow: 'inbound'` so
|
||||
`allows.outbound` is false, and the capacity was raised on a direction that can never render or
|
||||
be stocked. The laborer arrived because laborers have no direction gate. **Decision: an
|
||||
industry's printed flow is absolute** — drop the grant on hosts that cannot use it rather than
|
||||
opening the direction up. So `applyModifier` (`apply.ts:1788`) gates each capacity grant on
|
||||
`allows` and grows `industryTrack.length` only by what was actually applied. **Audit all 17
|
||||
profiles for the same trap:** `iceHouse`, `truckDock` and `forklifts` all print `addOut: 1` and
|
||||
list `grocersWarehouse`; `waitingArea`, `restaurant` and `hotel` print `addOut: 1` for
|
||||
`hosts: ['office']`, which includes a Whistle Post. Then say it in both directions — the hand
|
||||
tooltip naming which printed hosts cannot use which half (computable from the profiles, no host
|
||||
on the board needed), and the panel's "prints N, Modifiers add M" line showing a suppressed
|
||||
grant as suppressed instead of quietly omitting it. That delta display already cites the Ice
|
||||
House as the bug that motivated it.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for Jesse
|
||||
|
||||
Blocked on a decision, not on work.
|
||||
|
||||
- [ ] **PROPOSED RULE — deal each player six track cards at the start.** Jesse's proposal, and it
|
||||
aims squarely at the measured problem above: a run-around needs five specific pieces and the
|
||||
bot holds a turnout and a matching curve together on 0.2% of turns. Six pieces in the opening
|
||||
hand is roughly the private supply the prototype had, in a form that does not reintroduce an
|
||||
unbounded one — and it would make the opening district a decision rather than a wait. Questions
|
||||
before it goes in: are the six drawn from the track cards in the deck (thinning it for
|
||||
everyone) or from outside it; do they sit in the hand, against the three-card limit, or in a
|
||||
separate track hand that does not compete; and does the bot's opening change enough to need
|
||||
re-measuring (it will — this is the one change that could move the run-around numbers).
|
||||
- [ ] **PROPOSED RULE — one Revenue point for every train that exits your section.** Jesse's
|
||||
proposal. Worth noting what it would do to the economy as measured: arrivals run at 5.4 a game
|
||||
and completed runs at about the same, so this is roughly **+5 Revenue a game** against a
|
||||
current mean of 2.6 — it would more than triple the score and, unlike freight or passengers, it
|
||||
pays for traffic the player does not have to work. That may be exactly the intent (it rewards
|
||||
keeping the line clear, which is the Superintendent's job) but it changes what the game is
|
||||
about, and it interacts with the victory-target question below: 20 over 5 Days becomes
|
||||
reachable almost on traffic alone. Cheap to measure once decided.
|
||||
|
||||
- [x] ~~**Q13 — rear-end collisions on a Mainline card.**~~ Answered: collide on catching up.
|
||||
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
|
||||
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
|
||||
- [ ] **NINE OF THE TWELVE SPECIAL-TRAIN RULES ARE DECLARED AND READ BY NOTHING.** Found by grepping
|
||||
each flag on `TrainRules` for a reader outside `content.ts`. Only `expedite`, `emptiesOnly` and
|
||||
`freightTypes` are enforced; `stopEarnsPoint` now is too, after a playtest reported the Circus
|
||||
Train standing still for a Stage and earning nothing. Still unbuilt:
|
||||
|
||||
`noSwitching` · `terminalsOnly` · `coachStaysOnStationTrack` · `oneFreightPerLocation` ·
|
||||
`noPassengerWork` · `dropOnly` · `pickUpEmptiesOnly` · `stopThenExpedite` · `copiesNextScheduled`
|
||||
|
||||
These are what make a special train special — a Circus Train that may not switch, a per-diem
|
||||
train that may only pick up empties, a Second Section that copies the train ahead. Until they
|
||||
are enforced, `trainRules()` marks them **NOT YET ENFORCED BY THE ENGINE** in the train's
|
||||
tooltip rather than listing them as if they applied, because telling a player a rule is in
|
||||
force when it is not is worse than saying nothing. Each is small on its own; the question is
|
||||
whether they are worth building before the rebalance, since several of them restrict switching
|
||||
and would move the freight numbers.
|
||||
- [x] **~~Nine of the twelve special-train rules are declared and read by nothing.~~** Done — all
|
||||
nine enforced, and one of them deleted instead. `copiesNextScheduled` was never carried by any
|
||||
train card: a Second Section is a Maneuver with its own working intent, so the flag was an
|
||||
unreachable second description of an existing mechanic. Cost 0.8 revenue and half the wins
|
||||
(8.0 → 7.2, 14/200 → 6/200), which is what enforcing restrictions does.
|
||||
- [ ] **THE LOCAL'S COACH HAS NOWHERE TO STAND, AND THAT IS A §A.4 QUESTION.** Trains 7/8 print
|
||||
"coach must remain on station track if switching", which should mean the coach is set out at
|
||||
the station while the engine works. It cannot be: §A.4 refuses the Office square to every drop
|
||||
("Rolling Stock may not be left there"), so "only at the Office" and "nowhere" are the same
|
||||
rule. It is enforced as "the coach is never set out", which keeps the Local from abandoning it
|
||||
at an industry but loses the drop-and-collect pattern a real local works. **Decision needed:
|
||||
should the Office square — or a station track beside it — accept a parked coach?** That is a
|
||||
change to §A.4, not to the train card, and it would also give the A/D tracks something to do.
|
||||
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
|
||||
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
|
||||
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
|
||||
@@ -270,6 +386,41 @@ target is settled and freight carries its intended share.
|
||||
|
||||
## Not yet built
|
||||
|
||||
- [ ] **INVESTIGATE: how would a player publish a replay so other people can watch it?** Today
|
||||
"Save replay" downloads a JSON file to the player's own machine, and the only way it reaches
|
||||
the site is by sending it to Jesse to drop into `public/replays/` and redeploy. The question is
|
||||
what a self-service version would look like.
|
||||
|
||||
**The constraint.** The site is fully static — `dist/` is uploaded to File Browser and Start9
|
||||
Pages serves the folder — and the replay list is a build-time `manifest.json` because static
|
||||
hosting cannot list a directory. So publishing needs something that accepts a write.
|
||||
|
||||
**The one measurement that matters:** a full 5-Day game is **451–1017 bytes** compressed
|
||||
(brotli), about **600–1150 characters** base64. A save is the seed plus the intents and the
|
||||
engine recomputes the board, so a whole game fits in a URL.
|
||||
|
||||
Four shapes, roughly costed:
|
||||
1. **Share by link, no server (~1–2 hours).** Put the compressed save in the URL fragment
|
||||
(`replays.html#s=…`); "Share replay" copies a link and anyone opening it watches the game.
|
||||
The viewer already parses saves and already has a file-open path, so this is compression, a
|
||||
hash reader and a copy button. The fragment never reaches the host. It is a link rather than
|
||||
a gallery: nobody discovers a game they were not sent.
|
||||
2. **A write endpoint (a day or two, and it is a service).** Accepts a POST, validates the save
|
||||
by replaying it through the engine — `save-replay.ts` already does exactly that check —
|
||||
writes the file and regenerates the manifest. The work is the surround: auth or rate
|
||||
limiting, abuse handling for a public write, CORS, and a deploy story. It also ends "static
|
||||
hosting is all this needs", which has been load-bearing.
|
||||
3. **Browser writes to File Browser directly — rejected.** It needs FB credentials in a static
|
||||
page, so anyone viewing source gets write access to the whole File Browser, and the manifest
|
||||
would need a read-modify-write from the browser that loses a save when two people publish at
|
||||
once.
|
||||
4. **Curated, manual (zero code).** What happens today, and it composes with (1): players send
|
||||
links, Jesse publishes the good ones.
|
||||
|
||||
**The question behind the question is whether a gallery of strangers' games is wanted on a
|
||||
personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the
|
||||
right thing to build first either way.
|
||||
|
||||
- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
|
||||
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
|
||||
placeholder, not the finished sound. Sound therefore defaults to OFF.
|
||||
|
||||
Reference in New Issue
Block a user