v0.4.5 - fix bug with not being able to drop cars
This commit is contained in:
@@ -21,6 +21,56 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.4.5 — 2026-08-14
|
||||||
|
|
||||||
|
### A train can back out of a curve again
|
||||||
|
|
||||||
|
**Reported one commit after 0.4.4 shipped:** "seems like I can't drop a car at all. I'm trying to
|
||||||
|
switch to get an empty car to an industry, but I can't drop any cars on the siding first."
|
||||||
|
|
||||||
|
Setting out a cut needs no Move and is refused almost nowhere — but you may only set out where the
|
||||||
|
train **is**, and the Office square is barred outright (§A.4). So "I cannot drop" is nearly always
|
||||||
|
"I cannot get there", and getting there means leaving the Running Track through a turnout and a
|
||||||
|
curve. 0.4.4 fixed the forward half of exactly that and left the reverse half wrong:
|
||||||
|
|
||||||
|
- **Forward** exits by `facing` — fixed in 0.4.4 to read the card's far end rather than assuming
|
||||||
|
`opposite(entry)`.
|
||||||
|
- **Reverse** still exited by `opposite(facing)`, which is the other end of a **straight** and of
|
||||||
|
nothing else. A crew facing north on a north-west curve backs out through **west**; `opposite('n')`
|
||||||
|
is a south port the card does not have, and `exploreMoves` returns nothing at all from a port the
|
||||||
|
card lacks.
|
||||||
|
|
||||||
|
So 0.4.4 moved the problem rather than solving it. Before it, a crew that rounded a curve could only
|
||||||
|
back out; after it, a crew could only carry on. Either way a siding entered one way could not be left
|
||||||
|
the other, and a siding that had to be *backed* into could not be entered at all.
|
||||||
|
|
||||||
|
`reversePort` now asks the card for its other end, the same way `farPort` does going forward. A train
|
||||||
|
always stands on a two-port card — §A.1 forbids finishing a Move on a turnout — so there is exactly
|
||||||
|
one other end to find.
|
||||||
|
|
||||||
|
**How much of the board this was hiding.** Over 25 bot games, at 1,937 points where a crew could have
|
||||||
|
been switching, comparing what the board highlights now against what it highlighted before:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| squares offered, before → now | 7,461 → 10,040 (**+35%**) |
|
||||||
|
| positions hiding at least one legal square | 694 (**35.8%**) |
|
||||||
|
| positions with **no legal move at all** | 300 (**15.5%**) |
|
||||||
|
|
||||||
|
Both figures understate it: the "before" reckoning was run with occupancy ignored, so it was allowed
|
||||||
|
squares that another train was actually sitting on. A crew counted as stuck was stuck even on the
|
||||||
|
generous reading.
|
||||||
|
|
||||||
|
**Tests.** The failing case is pinned three ways — backing off a curve, carrying on round one, and
|
||||||
|
the whole errand from the report: take a cut off the Running Track into a siding, set it out, and
|
||||||
|
come back for the industry. That last one fails on 0.4.4 with *"the crew is stranded on the siding —
|
||||||
|
it cannot return to the Running Track"*, which is the report in one line.
|
||||||
|
|
||||||
|
`sim.test.ts` needed a wider sample rather than a lower bar: crews now have real switching to do, so
|
||||||
|
the bot lays fewer track pieces per game and five seeds no longer produced the 40 placements the
|
||||||
|
dead-end **rate** is measured over. Eight seeds now, same stride; the rate itself came out at 0.147
|
||||||
|
against a bar of 0.25.
|
||||||
|
|
||||||
## 0.4.4 — 2026-08-14
|
## 0.4.4 — 2026-08-14
|
||||||
|
|
||||||
Out of a play session: engines that pointed north, a card whose name collided with five other things,
|
Out of a play session: engines that pointed north, a card whose name collided with five other things,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.4.4",
|
"version": "0.4.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+22
-6
@@ -138,6 +138,23 @@ function farPort(card: TrackCard | undefined, entry: Port): Port {
|
|||||||
return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry);
|
return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE PORT A TRAIN BACKS OUT BY — the other end of the card it is standing on.
|
||||||
|
*
|
||||||
|
* Not `opposite(facing)`. A train stands on a two-port card (never a turnout: §A.1 forbids
|
||||||
|
* finishing a Move on one), and backing up means leaving by whichever of those two ports it is not
|
||||||
|
* facing. On a straight those coincide; on a curve they never do, because a curve joins ADJACENT
|
||||||
|
* edges — a crew facing north on a north-west curve backs out through WEST, and `opposite('n')` is
|
||||||
|
* a south port the card does not have.
|
||||||
|
*
|
||||||
|
* The consequence of getting this wrong is total: `exploreMoves` returns nothing at all from a port
|
||||||
|
* the card lacks, so the crew simply cannot back up. It could round a curve and never come off it,
|
||||||
|
* which is enough to make a siding unreachable and setting out a cut impossible.
|
||||||
|
*/
|
||||||
|
function reversePort(s: GameState, player: PlayerIndex, from: GridCoord, facing: Port): Port {
|
||||||
|
return farPort(areaOf(s, player).grid.get(coordKey(from)), facing);
|
||||||
|
}
|
||||||
|
|
||||||
/** A tray's facing, expressed as the port it would leave by going forward. */
|
/** A tray's facing, expressed as the port it would leave by going forward. */
|
||||||
function facingPort(s: GameState, trayId: TrayId): Port {
|
function facingPort(s: GameState, trayId: TrayId): Port {
|
||||||
const tray = s.trays.get(trayId);
|
const tray = s.trays.get(trayId);
|
||||||
@@ -1118,11 +1135,7 @@ function destinationsFor(
|
|||||||
) {
|
) {
|
||||||
const tray = s.trays.get(trayId)!;
|
const tray = s.trays.get(trayId)!;
|
||||||
const facing = facingPort(s, trayId);
|
const facing = facingPort(s, trayId);
|
||||||
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
|
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
|
||||||
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
|
|
||||||
// have — and could never back out of a district spur. Combined with a facing that was itself
|
|
||||||
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
|
|
||||||
const exit: Port = reverse ? opposite(facing) : facing;
|
|
||||||
return reachableDestinations(
|
return reachableDestinations(
|
||||||
{
|
{
|
||||||
area: areaOf(s, player),
|
area: areaOf(s, player),
|
||||||
@@ -1161,7 +1174,10 @@ export function movesFor(
|
|||||||
};
|
};
|
||||||
const facing = facingPort(s, trayId);
|
const facing = facingPort(s, trayId);
|
||||||
const forward = exploreMoves(ctx, from, facing);
|
const forward = exploreMoves(ctx, from, facing);
|
||||||
const back = exploreMoves(ctx, from, opposite(facing));
|
// The card's other end, not the compass opposite — see `reversePort`. This is what the board
|
||||||
|
// highlights, so getting it wrong hides half the crew's legal moves rather than merely refusing
|
||||||
|
// one: the squares behind the train never light up at all.
|
||||||
|
const back = exploreMoves(ctx, from, reversePort(s, player, from, facing));
|
||||||
|
|
||||||
const to = new Map<string, GridCoord>();
|
const to = new Map<string, GridCoord>();
|
||||||
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
|
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
|
||||||
|
|||||||
@@ -1402,6 +1402,44 @@ describe('a train that rounds a curve points where the curve took it', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can still back out of the curve it just rounded', () => {
|
||||||
|
/**
|
||||||
|
* THE OTHER HALF OF THE SAME MISTAKE, and the one that bites hardest.
|
||||||
|
*
|
||||||
|
* Forward exits by `facing`; reverse used to exit by `opposite(facing)` — which, again, is the
|
||||||
|
* other end of a STRAIGHT and of nothing else. A crew standing on a north-west curve facing
|
||||||
|
* north reverses out through WEST, the way it came; `opposite('n')` is south, a port the curve
|
||||||
|
* does not have, so backing up found nothing.
|
||||||
|
*
|
||||||
|
* Fixing only the forward case moved the problem rather than solving it: before, a crew that
|
||||||
|
* rounded a curve could only back out; after, it could only carry on. A crew must be able to do
|
||||||
|
* both, which is what makes a siding reachable and therefore what makes setting out a cut
|
||||||
|
* possible at all.
|
||||||
|
*/
|
||||||
|
const s = game();
|
||||||
|
addCard(s, at(0, 0), straight());
|
||||||
|
addCard(s, at(0, 1), curve('nw')); // joins north and west
|
||||||
|
addCard(s, at(1, 1), curve('se')); // the spur carries on north
|
||||||
|
const id = placeTray(s, at(0, 0), []);
|
||||||
|
s.trays.get(id)!.facing = 'e';
|
||||||
|
s.clock.phase = 'localOps';
|
||||||
|
s.clock.currentActor = 0;
|
||||||
|
turnOf(s, 0).option = 'switch';
|
||||||
|
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
|
||||||
|
const { to } = movesFor(s, 0, id);
|
||||||
|
assert.ok(
|
||||||
|
to.some((c) => c.row === 0 && c.col === 0),
|
||||||
|
'the crew cannot back off the curve onto the track it came from',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
to.some((c) => c.row === 1 && c.col === 1),
|
||||||
|
'the crew cannot carry on round the curve either',
|
||||||
|
);
|
||||||
|
// And backing up really does go there, rather than merely being offered.
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||||||
|
});
|
||||||
|
|
||||||
it('turns the DRAWN direction when the curve really does turn the engine round', () => {
|
it('turns the DRAWN direction when the curve really does turn the engine round', () => {
|
||||||
// South down a spur into a curve that turns west. The engine genuinely now points west, and the
|
// South down a spur into a curve that turns west. The engine genuinely now points west, and the
|
||||||
// board has to say so — this is the mirrored consist that was reported.
|
// board has to say so — this is the mirrored consist that was reported.
|
||||||
@@ -1419,3 +1457,73 @@ describe('a train that rounds a curve points where the curve took it', () => {
|
|||||||
assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on');
|
assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('the switching job a player actually does: put a car in a siding, take an empty to an industry', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED from play: "I'm trying to switch to get an empty car to an industry, but I can't drop
|
||||||
|
* any cars on the siding first."
|
||||||
|
*
|
||||||
|
* Setting out a cut needs no Move and is refused almost nowhere — but you can only set out where
|
||||||
|
* the train IS, and the Office square itself is barred (§A.4). So "I cannot drop" is nearly always
|
||||||
|
* "I cannot GET there", and getting there means leaving the Running Track through a turnout and a
|
||||||
|
* curve. That is exactly where `opposite(facing)` was wrong in both directions.
|
||||||
|
*
|
||||||
|
* This walks the whole errand rather than any one rule, because each individual rule passed its
|
||||||
|
* own test while the errand was impossible.
|
||||||
|
*/
|
||||||
|
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
||||||
|
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||||
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
|
});
|
||||||
|
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
|
||||||
|
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
|
||||||
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a cut off the Running Track into a siding and sets it out', () => {
|
||||||
|
const s = game();
|
||||||
|
// Running Track east from the Office, with a turnout diverging north onto a short siding.
|
||||||
|
addCard(s, at(0, 0), straight());
|
||||||
|
addCard(s, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' }));
|
||||||
|
addCard(s, at(0, 2), straight());
|
||||||
|
addCard(s, at(1, 1), curve('se')); // up off the turnout, then east along the siding
|
||||||
|
addCard(s, at(1, 2), straight());
|
||||||
|
|
||||||
|
const id = placeTray(s, at(0, 0), [
|
||||||
|
{ type: 'boxcar', loaded: false },
|
||||||
|
{ type: 'tank', loaded: false },
|
||||||
|
] as never);
|
||||||
|
s.trays.get(id)!.facing = 'e';
|
||||||
|
s.clock.phase = 'localOps';
|
||||||
|
s.clock.currentActor = 0;
|
||||||
|
turnOf(s, 0).option = 'switch';
|
||||||
|
|
||||||
|
// Out of the Office and up onto the siding. A turnout is not somewhere a train may stop, so the
|
||||||
|
// first square it can finish on is the curve beyond it.
|
||||||
|
const reachable = movesFor(s, 0, id).to;
|
||||||
|
assert.ok(
|
||||||
|
reachable.some((c) => c.row === 1 && c.col === 1),
|
||||||
|
'the siding is not reachable from the Running Track at all',
|
||||||
|
);
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 1), reverse: false }).ok);
|
||||||
|
|
||||||
|
// Set the cut out. This is the step that was reported as impossible.
|
||||||
|
const drop = applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 });
|
||||||
|
assert.ok(drop.ok, `setting out on the siding was refused: ${drop.ok ? '' : drop.code}`);
|
||||||
|
assert.deepEqual(
|
||||||
|
areaOf(s, 0).grid.get(coordKey(at(1, 1)))!.standing.map((c) => c.type),
|
||||||
|
['tank'],
|
||||||
|
'the car did not end up on the siding',
|
||||||
|
);
|
||||||
|
assert.equal(s.trays.get(id)!.consist.length, 1, 'the train still has the car it set out');
|
||||||
|
|
||||||
|
// And it can get back off the siding again, carrying the empty on toward an industry.
|
||||||
|
const after = movesFor(s, 0, id).to;
|
||||||
|
assert.ok(
|
||||||
|
after.some((c) => c.row === 0 && c.col === 0) || after.some((c) => c.row === 0 && c.col === 2),
|
||||||
|
'the crew is stranded on the siding — it cannot return to the Running Track',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+10
-2
@@ -945,6 +945,8 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
|||||||
assert.ok(lays.length > 3, `the bot laid only ${lays.length} pieces`);
|
assert.ok(lays.length > 3, `the bot laid only ${lays.length} pieces`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SEEDS = [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433];
|
||||||
|
|
||||||
it('rarely lays rail that butts a card which cannot accept it', () => {
|
it('rarely lays rail that butts a card which cannot accept it', () => {
|
||||||
// A port facing an EMPTY square is a promise — something may be built there later. A port
|
// A port facing an EMPTY square is a promise — something may be built there later. A port
|
||||||
// butting an OCCUPIED square whose card has no matching port is not: that square is taken, so
|
// butting an OCCUPIED square whose card has no matching port is not: that square is taken, so
|
||||||
@@ -956,7 +958,13 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
|||||||
// placements measured better and cut these from 28% of pieces to 7%.
|
// placements measured better and cut these from 28% of pieces to 7%.
|
||||||
let laid = 0;
|
let laid = 0;
|
||||||
let dead = 0;
|
let dead = 0;
|
||||||
for (const seed of [1000, 8919, 16838, 24757, 32676]) {
|
/**
|
||||||
|
* EIGHT GAMES, NOT FIVE. Five stopped yielding a big enough sample once crews could back out of
|
||||||
|
* a curve: the bot has more switching worth doing, so it spends fewer turns laying track, and
|
||||||
|
* the count fell to 34 against a floor of 40. Seeds added on the same stride rather than the
|
||||||
|
* floor lowered — the floor is what makes the RATE below mean anything.
|
||||||
|
*/
|
||||||
|
for (const seed of SEEDS) {
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `dp-${seed}`, seed,
|
id: `dp-${seed}`, seed,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, length: 'standard' },
|
||||||
@@ -995,7 +1003,7 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
|||||||
};
|
};
|
||||||
playGame(s, spy as never, pump);
|
playGame(s, spy as never, pump);
|
||||||
}
|
}
|
||||||
assert.ok(laid > 40, `only ${laid} pieces laid across five games`);
|
assert.ok(laid > 40, `only ${laid} pieces laid across ${SEEDS.length} games`);
|
||||||
const rate = dead / laid;
|
const rate = dead / laid;
|
||||||
/**
|
/**
|
||||||
* RAISED FROM 0.15 TO 0.25 WHEN THE OPENING DEAL CHANGED, and that is a regression, not a
|
* RAISED FROM 0.15 TO 0.25 WHEN THE OPENING DEAL CHANGED, and that is a regression, not a
|
||||||
|
|||||||
Reference in New Issue
Block a user