Implement nose coupling so run-arounds work, and stop coupling wiping the district
This commit is contained in:
+37
-1
@@ -10,7 +10,43 @@ The target is 20 Revenue over 5 Days.
|
||||
|
||||
## Unreleased
|
||||
|
||||
Nothing yet.
|
||||
### Teaching the bot to use a siding
|
||||
|
||||
**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on
|
||||
the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so
|
||||
which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met
|
||||
running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which
|
||||
car is next off the tail:
|
||||
|
||||
running forward -> reefer, boxcar, hopper next off: hopper
|
||||
backing up -> boxcar, hopper, reefer next off: reefer
|
||||
|
||||
The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop
|
||||
can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which
|
||||
the shuttling regression correctly failed.
|
||||
|
||||
**A serious bug found on the way.** The `carsCoupled` reducer cleared **every card in the Office
|
||||
Area**, not the ones the crew ran over — its own comment said "every card along the path" while the
|
||||
code iterated the whole grid. One coupling anywhere deleted every standing car and every industry
|
||||
track in the district, so loads worked over several Stages vanished when a crew picked up an
|
||||
unrelated boxcar somewhere else. The event now names the cards it lifted from.
|
||||
|
||||
**A test replaced rather than relaxed.** "Under 60 Moves a game" started failing. That threshold was
|
||||
calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around
|
||||
is *supposed* to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test
|
||||
now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made
|
||||
to wander.
|
||||
|
||||
| | before sidings | now |
|
||||
| --- | --- | --- |
|
||||
| revenue | 3.9 | 3.2 |
|
||||
| freight | 0.9 | 1.0 |
|
||||
| drops per game | 3.3 | **11.1** |
|
||||
| couplings per game | ~0.4 | **7.9** |
|
||||
|
||||
Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per
|
||||
productive act, which is what running a loop costs — but that work is not yet converting into
|
||||
Revenue. Where it goes next is an open question rather than a known fix.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,15 +24,24 @@ single browser.
|
||||
|
||||
```
|
||||
station-master/
|
||||
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
|
||||
├── TODO.md ← open questions, provisional numbers, things to come back to
|
||||
├── docs/
|
||||
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
|
||||
│ └── architecture/ ← how it is built, and what the pieces are
|
||||
│ ├── architecture/ ← how it is built, and what the pieces are
|
||||
│ └── design/ ← board layout studies and rendering samples
|
||||
├── public/replays/ ← saved games published to the site's replay directory
|
||||
├── scripts/ ← build and deploy the static site
|
||||
├── src/
|
||||
│ └── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||||
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||||
│ ├── sim/ ← bot, harness, replay, board rendering
|
||||
│ └── web/ ← the playable site: splash, game, replay viewer
|
||||
└── test/
|
||||
```
|
||||
|
||||
Start with [`docs/design.md`](docs/design.md) — it indexes everything.
|
||||
Start with [`docs/design.md`](docs/design.md) — it indexes everything. Commit messages stay high
|
||||
level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements, and
|
||||
[`TODO.md`](TODO.md) is what we have decided not to forget.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# To do
|
||||
|
||||
Things worth coming back to. Anything noted here should either get done or get an explicit decision
|
||||
not to — the point is that nothing quietly evaporates.
|
||||
|
||||
Ordered within each section by how much it is currently costing us.
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] **Find out why switching work does not become Revenue.** The crew now drops 11 cars a game and
|
||||
couples 8 (was 3.3 and ~0.4), at about 4 Moves per productive act — real railroading. Revenue
|
||||
did not move (3.9 → 3.2). Either the freight it is now shuffling is not the freight the
|
||||
industries want, or the loads finish too late in the Day to be worked, or the Revenue is going
|
||||
somewhere and being lost again. Measure the freight chain end to end before changing anything.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for Jesse
|
||||
|
||||
Blocked on a decision, not on work.
|
||||
|
||||
- [ ] **Q13 — rear-end collisions on a Mainline card.** §10 says a Mainline collision is the
|
||||
Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders —
|
||||
but §8.3's trigger list does not include one, and **none is implemented**. Granting clearance is
|
||||
currently free: verified, both trains survive, no penalty. Three candidates: collide on entry
|
||||
(clearance becomes a gamble), collide on catching up (rewards judging the gap), or accept that
|
||||
clearance is safe and ABS Signals is worth less than it reads. The buttons currently describe
|
||||
only what the engine does, so nothing promises a consequence that cannot happen.
|
||||
- [ ] **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`
|
||||
is synchronous and has no decision point, so it is currently rolled from the seed. Should become
|
||||
a real choice when setup gains an interactive phase.
|
||||
|
||||
---
|
||||
|
||||
## Balance, provisional
|
||||
|
||||
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
||||
target is settled and freight carries its intended share.
|
||||
|
||||
- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance
|
||||
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
|
||||
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
|
||||
capacity at the Whistle Post itself.
|
||||
- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio; freight still only
|
||||
13–18% of gross revenue.
|
||||
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
|
||||
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
|
||||
changes.
|
||||
- [ ] **The victory target itself** (20 over 5 Days). Still only 1 win in 100. Worth revisiting once
|
||||
the bot exploits sidings, since that is a known unclaimed gain.
|
||||
|
||||
---
|
||||
|
||||
## Not yet built
|
||||
|
||||
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
|
||||
an opponent. Rejected with `NOT_IMPLEMENTED`.
|
||||
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
|
||||
server, no turn submission, and no per-player view.
|
||||
|
||||
---
|
||||
|
||||
## Smaller things
|
||||
|
||||
- [ ] **Carry `links` forward in replay frames.** Static per card and never changes once laid, but
|
||||
re-sent whenever anything on the board changes. The replay is 3.4 MB, mostly board state.
|
||||
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
|
||||
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
|
||||
frame — but the number itself deserves a reason.
|
||||
- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular
|
||||
close up.
|
||||
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
|
||||
scrolling. Legible, not compact.
|
||||
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
|
||||
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
|
||||
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
||||
failing loudly, which is the safe direction but says little about what changed.
|
||||
|
||||
---
|
||||
|
||||
## Done, kept for the reasoning
|
||||
|
||||
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
|
||||
decides which car is droppable; the bot runs around rather than setting out, when the drop can
|
||||
follow. Switching activity transformed, revenue unchanged.
|
||||
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
|
||||
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
|
||||
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
|
||||
card when §6.2 was written; a 26-piece supply has no hand to bound it.
|
||||
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
|
||||
sets orientation", so it is a property of the placed card, not a compass constant.
|
||||
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
|
||||
instead.
|
||||
+40
-5
@@ -689,7 +689,26 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples });
|
||||
// Name the cards the cars came from. The reducer used to clear every card in the district
|
||||
// instead, so one coupling anywhere deleted every standing car and every industry track in
|
||||
// the Office Area — loads worked over several Stages vanished when a crew picked up a single
|
||||
// boxcar somewhere else entirely.
|
||||
const lifted = [
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
from: lifted,
|
||||
toNose: !i.reverse,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -992,10 +1011,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'carsCoupled': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
tray.consist.push(...e.stock);
|
||||
// Every card along the path is cleared; the event carries what was taken.
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.standing.length > 0) card.standing = [];
|
||||
if (e.toNose) tray.consist.unshift(...e.stock);
|
||||
else tray.consist.push(...e.stock);
|
||||
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
|
||||
// never went near.
|
||||
for (const coord of e.from) {
|
||||
const card = area.grid.get(coordKey(coord));
|
||||
if (!card) continue;
|
||||
card.standing = [];
|
||||
if (card.facility) card.facility.industryTrack.cars = [];
|
||||
}
|
||||
break;
|
||||
@@ -1485,6 +1508,18 @@ export function checkEnhancementPlacement(
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */
|
||||
function emptyCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
|
||||
+13
-1
@@ -22,7 +22,19 @@ export type GameEvent =
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
/** The cards the cars were lifted from — the ones the crew actually ran over. */
|
||||
from: GridCoord[];
|
||||
/**
|
||||
* Coupled onto the ENGINE'S NOSE rather than behind the train (§A.3). True when the crew was
|
||||
* running forward: the engine meets the cars head-on and takes them on the front.
|
||||
*/
|
||||
toNose: boolean;
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
|
||||
+63
-1
@@ -19,7 +19,7 @@
|
||||
* complete at all.
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -442,6 +442,40 @@ function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolea
|
||||
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
|
||||
}
|
||||
|
||||
/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */
|
||||
function reachableFrom(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: string,
|
||||
from: GridCoord,
|
||||
): GridCoord[] {
|
||||
return [
|
||||
...destinationsFor(s, player, trayId, from, false),
|
||||
...destinationsFor(s, player, trayId, from, true),
|
||||
].map((d) => d.coord);
|
||||
}
|
||||
|
||||
/**
|
||||
* The consist this move would leave, coupling included.
|
||||
*
|
||||
* Asks the engine's own reachability for what the crew would pick up, so the prediction cannot
|
||||
* disagree with what actually happens when the move is submitted.
|
||||
*/
|
||||
function consistAfterMove(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
move: Extract<Intent, { type: 'switch.move' }>,
|
||||
): RollingStock[] | null {
|
||||
const tray = s.trays.get(move.trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return null;
|
||||
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
if (!dest) return null;
|
||||
// Forward means the engine leads and meets the cars head-on (§A.3).
|
||||
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
|
||||
}
|
||||
|
||||
/** Is this player's crew sitting somewhere it can never depart from? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
@@ -629,6 +663,34 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
if (drop) return because('standing on a facility that wants the back car — spot it', drop);
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running
|
||||
* FORWARD land in front of the train and cars met while BACKING land behind — which decides
|
||||
* which car is next off the tail.
|
||||
*
|
||||
* That is what a siding is for. Rather than setting a useless car down and coming back for
|
||||
* it, the crew can approach from the side that leaves the car it actually wants droppable.
|
||||
* Both directions are offered on every move; this picks the one that ends with useful work
|
||||
* at the tail.
|
||||
*/
|
||||
const runAround = options.find((i) => {
|
||||
if (i.type !== 'switch.move') return false;
|
||||
const after = consistAfterMove(s, player, i);
|
||||
if (!after || after.length === 0) return false;
|
||||
const tail = after[after.length - 1]!;
|
||||
// The approach must change the answer: a useless car at the tail now, a wanted one after.
|
||||
if (facilitiesWanting(s, player, endCar).length > 0) return false;
|
||||
const wants = facilitiesWanting(s, player, tail);
|
||||
if (wants.length === 0) return false;
|
||||
// AND the drop has to be able to follow. Without this the crew ran the loop for its own
|
||||
// sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed.
|
||||
return wants.some((w) => w.row === i.to.row && w.col === i.to.col) ||
|
||||
reachableFrom(s, player, i.trayId, i.to).some((c) =>
|
||||
wants.some((w) => w.row === c.row && w.col === c.col),
|
||||
);
|
||||
});
|
||||
if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround);
|
||||
|
||||
// SET OUT — and do it BEFORE travelling.
|
||||
//
|
||||
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
|
||||
|
||||
+3
-1
@@ -132,7 +132,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
`: ${carsLabel(e.stock)}`,
|
||||
};
|
||||
case 'consistSorted':
|
||||
return {
|
||||
|
||||
+6
-3
@@ -341,9 +341,12 @@ function clearSave(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const tipStyle = document.createElement('style');
|
||||
tipStyle.textContent = TOOLTIP_CSS;
|
||||
document.head.appendChild(tipStyle);
|
||||
// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is
|
||||
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
|
||||
// background, so the cards are drawn correctly and are simply invisible.
|
||||
const pageStyle = document.createElement('style');
|
||||
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
|
||||
document.head.appendChild(pageStyle);
|
||||
installTooltips();
|
||||
|
||||
const saveBtn = document.getElementById('savefile');
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', 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 }] },
|
||||
{ type: 'carsCoupled', 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: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||
|
||||
+19
-6
@@ -276,15 +276,28 @@ describe('switching accomplishes something (regression)', () => {
|
||||
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
|
||||
});
|
||||
|
||||
it('does not spend its whole Local Operations budget on motion', () => {
|
||||
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
|
||||
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
|
||||
// the crew is not burning all six Moves every Stage — the shuttling bug.
|
||||
it('does not shuttle aimlessly — Moves have to buy something', () => {
|
||||
// Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds
|
||||
// and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop,
|
||||
// and comes back with a different car droppable. Measured before sidings existed the crew
|
||||
// coupled ~0.4 cars a game; it now couples ~8 and drops ~11.
|
||||
//
|
||||
// So test the thing the old threshold was a proxy for: motion must convert into work. A crew
|
||||
// that shuttles for its own sake shows a high ratio here, however few or many Moves it makes.
|
||||
const report = simulate({
|
||||
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
|
||||
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
|
||||
const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
|
||||
report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length;
|
||||
|
||||
const moves = per((g) => g.actions.moves);
|
||||
const work = per((g) => g.actions.drops + g.actions.couples);
|
||||
assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`);
|
||||
assert.ok(
|
||||
moves / work < 8,
|
||||
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
|
||||
`(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type {
|
||||
GameState,
|
||||
GridCoord,
|
||||
OfficeArea,
|
||||
RollingStock,
|
||||
@@ -14,6 +15,8 @@ import type {
|
||||
TurnoutOrientation,
|
||||
} from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
||||
import {
|
||||
allReachable,
|
||||
@@ -95,6 +98,27 @@ const lockedFacility = (): TrackCard => ({
|
||||
|
||||
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
|
||||
function straightCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */
|
||||
function gameWith(area: OfficeArea): GameState {
|
||||
const s = createGame({
|
||||
id: 'g', seed: 5,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['p'],
|
||||
});
|
||||
s.officeAreas.set(0, area);
|
||||
return s;
|
||||
}
|
||||
|
||||
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
|
||||
const grid = new Map<string, TrackCard>();
|
||||
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
|
||||
@@ -503,3 +527,43 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coupling lifts only the cars the crew ran over', () => {
|
||||
it('leaves cars standing elsewhere in the district alone', () => {
|
||||
// The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar
|
||||
// deleted every car standing anywhere — including loads worked over several Stages onto an
|
||||
// industry track the crew never went near.
|
||||
const grid: Record<string, TrackCard> = {
|
||||
'0,-1': straightCard(),
|
||||
'0,0': straightCard(),
|
||||
'0,1': straightCard(),
|
||||
'0,2': straightCard(),
|
||||
};
|
||||
grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path
|
||||
grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it
|
||||
|
||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||
const s = gameWith(area);
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
s.turn.option = 'switch';
|
||||
s.turn.movesRemaining = 6;
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
|
||||
assert.ok(r.ok, 'the move should be legal');
|
||||
assert.deepEqual(
|
||||
s.trays.get('crew')!.consist.map((c: RollingStock) => c.type),
|
||||
['boxcar'],
|
||||
'the crew should have picked up the car it ran over',
|
||||
);
|
||||
assert.equal(
|
||||
areaOf(s, 0).grid.get('0,2')!.standing.length,
|
||||
1,
|
||||
'a car standing off the path was deleted by an unrelated coupling',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-1
@@ -585,6 +585,7 @@ describe('the static build', () => {
|
||||
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
|
||||
// nothing about whether the page draws them.
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const injected: Record<string, unknown>[] = [];
|
||||
const make = (): Record<string, unknown> => {
|
||||
let html = '';
|
||||
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
|
||||
@@ -663,7 +664,10 @@ describe('the static build', () => {
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: () => {} },
|
||||
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
|
||||
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
|
||||
// broken, and invisible to a stub that ignores CSS.
|
||||
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
|
||||
};
|
||||
g['location'] = { search: '?seed=555' };
|
||||
const store = new Map<string, string>();
|
||||
@@ -702,6 +706,12 @@ describe('the static build', () => {
|
||||
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
||||
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
||||
|
||||
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
||||
// looks empty even though the markup is perfect.
|
||||
const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n');
|
||||
assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible');
|
||||
assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles');
|
||||
|
||||
const html = String(grid['innerHTML']);
|
||||
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
|
||||
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
|
||||
|
||||
Reference in New Issue
Block a user