v0.7.9.1 — the engine was right twice; the screen was not
Gitea#22: the Division map drew every westbound train in the wrong half of its Mainline card. `regionOfTransit` counts from the end a train entered, which is what the collision rules ask; the map wanted "which printed box, left to right" and used the same number, so an eastbound train came out right by luck and a westbound one came out mirrored. It cost a collision — Train 3 was cleared to follow T5 and ran into TX17, which the picture had drawn ahead of T5 rather than behind it. One mirror in `view.ts`, at the boundary the map is drawn from; the collision rules are untouched. Gitea#21: a second tank car would not come off at a refinery, and "Blocked — why nothing is moving" answered by describing the refinery's green box. The real answer was Train 3's printed rule — the Express works one freight car per location — so the refusal was correct and the panel sent the player to spend a Freight Agent action that could not have helped. No rule changed. The panel now names the budget, asking the reducer's own predicate so its words cannot drift from the rule. Both were replayed from the saves attached to the issues and verified in the exact position each report names. The map fix is proved by mutation: reverting the mirror fails two tests, and making the renderer ignore the region fails a third. Every existing region test ran eastbound, where the mirror is the identity, which is why the bug survived them. 891 tests pass, up from 884. Closes #21 Closes #22 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
This commit is contained in:
co-authored by
Claude Opus 5
parent
6058f6c17e
commit
7ade60e21f
+16
-1
@@ -607,7 +607,8 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
||||
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.
|
||||
@@ -657,6 +658,20 @@ function switchingRefusal(tray: CrewTray): RejectionCode | 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).
|
||||
*
|
||||
|
||||
@@ -452,6 +452,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
|
||||
* 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 cars = t.cars ?? [];
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||
@@ -465,7 +473,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')}${
|
||||
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
|
||||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||||
t.what ? `\n\n${esc(t.what)}` : ''
|
||||
|
||||
+37
-1
@@ -18,7 +18,7 @@
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
* 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);
|
||||
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
|
||||
for (const [id, tray] of s.trays) {
|
||||
|
||||
+26
-5
@@ -48,7 +48,7 @@ import {
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { actingPlayer, carsOn, 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 { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
import type { Impediment } from './narrate.ts';
|
||||
@@ -1259,10 +1259,31 @@ export function snapshot(
|
||||
* one per Stage — and the entry point is what the rules actually move. There is nothing left
|
||||
* 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
|
||||
// collision rule cannot disagree about who is where.
|
||||
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining);
|
||||
/**
|
||||
* AND WHICH WAY IT CAME IN (Gitea#22). `regionOfTransit` counts from the end the train
|
||||
* ENTERED — everything still to run is region 0 — and both directions share that one index
|
||||
* 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 {
|
||||
kind: 'ml',
|
||||
label: name,
|
||||
|
||||
Reference in New Issue
Block a user