More bug fixes. Show the timetable, and stop the arrival message contradicting itself. Fixed division point in labels. And highlighted discard as targets.
This commit is contained in:
@@ -10,6 +10,70 @@ The target is 20 Revenue over 5 Days.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### A second playtest pass
|
||||||
|
|
||||||
|
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
|
||||||
|
`expedite: true` — Q3 departs an expedited train in the SAME Stage it arrives. So it correctly
|
||||||
|
arrived and highballed in one Mainline Phase and correctly did not wait at the Depot. But
|
||||||
|
`trainArrived` narrated *"it will highball again next Mainline Phase, so any work must happen now"*
|
||||||
|
to every arrival, which for an expedited train is exactly backwards, and the log then contradicted
|
||||||
|
itself two lines later. The event carries `expedited` now and the message says which case it is.
|
||||||
|
|
||||||
|
**`TX14 (2)` was Stages remaining to cross that Mainline card**, not the car count — a bare figure
|
||||||
|
beside a train's name, read as the consist twice by the same player because that is the obvious
|
||||||
|
guess. Now `TX14 · 2⧗`.
|
||||||
|
|
||||||
|
**"stock a coach at (0, 0)"** reads as putting a car on the track, and was reported as exactly that
|
||||||
|
confusion: no train at the Depot, so how is a coach being stocked there? It is a load taken from the
|
||||||
|
Division Yard into the green Loading box, waiting for a train that can carry it — for a passenger
|
||||||
|
facility, people on the platform. It now says so.
|
||||||
|
|
||||||
|
**"trains enter" / "trains leave"** claimed the Division ran one way. Odd trains run west and even
|
||||||
|
run east, so both Division Points are a way on AND a way off; the buffer stops only mean it is a line
|
||||||
|
rather than a loop. Both ends now read `in and out`.
|
||||||
|
|
||||||
|
**The discard targets were "a very tiny highlight".** Choosing a card to PLAY lights big ghost squares
|
||||||
|
on the board; choosing one to discard lit a 2px outline on a small tile in a panel you might not be
|
||||||
|
looking at. The piles now grow, brighten, say DISCARD HERE, pulse once, and dim the Salvage Yard
|
||||||
|
beside them so the three that are choices read as a choice.
|
||||||
|
|
||||||
|
### The timetable, and watching the die land
|
||||||
|
|
||||||
|
Playing a train card rolls 1D12 for the Stage it departs, and the card simply left the hand — the
|
||||||
|
answer arrived as one line in the history panel, among many. The twelve slots have been in the Frame
|
||||||
|
all along, and only the standalone replay ever drew them.
|
||||||
|
|
||||||
|
The play page now has a **Timetable panel**: twelve Stages across, the train due out at each, the
|
||||||
|
current Stage lit, Stages already gone dimmed. Playing a train card **flashes the slot the die just
|
||||||
|
filled**, sounds a die-and-chime cue, and says it in words above the actions — *"Train 4 is scheduled
|
||||||
|
to depart at Stage 11 — see the Timetable"*. The flash marks the moment rather than the state: it is
|
||||||
|
gone by the next render, which is what makes it read as "that just happened".
|
||||||
|
|
||||||
|
No acknowledge-click. It would stop the game to say something the highlighted timetable says better,
|
||||||
|
and would be clicked through by the third time.
|
||||||
|
|
||||||
|
### The load pipeline is drawn the way freight actually flows
|
||||||
|
|
||||||
|
Reported after unloading at a warehouse: *"it went W, A, M, and then to the red box at the far right."*
|
||||||
|
|
||||||
|
**The engine was right.** §9.3 is explicit — *"The first Laborer replaces the load with an empty car
|
||||||
|
of that type on the industry's track and places the load on WORK. Additional Laborers move it to AT
|
||||||
|
then MEN. The last one places the load on a red Unloading box."* An unload ends in red, and it did.
|
||||||
|
|
||||||
|
**The drawing was wrong.** The pipeline was laid out `green → MEN|AT|WORK → red`, left to right, with
|
||||||
|
both arrows pointing right. But loading runs Green → MEN → AT → WORK → **car**, and unloading runs the
|
||||||
|
other way entirely: **car** → WORK → AT → MEN → red. Green and red therefore both belong beside MEN,
|
||||||
|
and the car belongs beside WORK — so putting red at the far right put it exactly where the car is,
|
||||||
|
and an unload appeared to run backwards across the whole row and land on the end it had just left.
|
||||||
|
|
||||||
|
Now green and red both sit before the sign with their arrows pointing opposite ways, and **only the
|
||||||
|
boxes an industry actually uses are drawn**: a Grocer's Warehouse has no green box and a Mine Tipple
|
||||||
|
no red one, where before every industry showed both and half of them were dead squares.
|
||||||
|
|
||||||
|
The Facilities panel had the same left-to-right assumption in its row labels. `green` and `red` are
|
||||||
|
now `waiting to load` and `cleared inbound`, each with the direction its loads travel spelled out,
|
||||||
|
and a row an industry cannot use is omitted rather than shown empty.
|
||||||
|
|
||||||
### A playtest report, worked through
|
### A playtest report, worked through
|
||||||
|
|
||||||
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
|
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
|
||||||
|
|||||||
@@ -756,6 +756,7 @@ function arriveAtOffice(
|
|||||||
trainNumber: tray.trainNumber ?? 0,
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
consist: tray.consist.map((c) => ({ ...c })),
|
consist: tray.consist.map((c) => ({ ...c })),
|
||||||
office: officeProfile(area.tier).name,
|
office: officeProfile(area.tier).name,
|
||||||
|
expedited: isExpedited(tray),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
|
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
|
||||||
|
|||||||
@@ -84,7 +84,12 @@ export type GameEvent =
|
|||||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
||||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
|
/**
|
||||||
|
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
|
||||||
|
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
|
||||||
|
* every other train stands until the next Mainline Phase and can be switched in between.
|
||||||
|
*/
|
||||||
|
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||||
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
||||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||||
|
|||||||
+39
-22
@@ -132,7 +132,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
trains: n.trains.flat(),
|
trains: n.trains.flat(),
|
||||||
cap: n.capacity,
|
cap: n.capacity,
|
||||||
tip: dp
|
tip: dp
|
||||||
? 'A Division Point — trains enter and leave the Division here, and queue without limit'
|
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
|
||||||
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
|
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
|
||||||
owner: null,
|
owner: 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.
|
||||||
@@ -286,12 +286,16 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
if (first) {
|
if (first) {
|
||||||
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
||||||
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
||||||
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">trains enter</text>`;
|
// BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
|
||||||
|
// Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said
|
||||||
|
// the Division ran one way, which it does not — the point of the buffer stops is only that it is
|
||||||
|
// a LINE and not a loop.
|
||||||
|
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">west end · in and out</text>`;
|
||||||
}
|
}
|
||||||
if (last && last !== first) {
|
if (last && last !== first) {
|
||||||
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
||||||
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
||||||
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">trains leave</text>`;
|
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">east end · in and out</text>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
out += '</svg>';
|
out += '</svg>';
|
||||||
@@ -443,12 +447,19 @@ export function officeSvg(
|
|||||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE LOAD PIPELINE, on the card itself.
|
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
|
||||||
*
|
*
|
||||||
* A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of
|
* §9.3 runs loading Green → MEN → AT → WORK → the spotted car, and unloading the other way
|
||||||
* freight. It was drawn only in the side panel, so advancing a load changed nothing on the card
|
* entirely: car → WORK → AT → MEN → red. So green and red BOTH belong beside MEN, and the car
|
||||||
* the player was looking at. The printed cards put these squares under the track for exactly
|
* belongs beside WORK.
|
||||||
* this reason.
|
*
|
||||||
|
* This drew green, then MEN|AT|WORK, then red — putting the red box at the far right, which is
|
||||||
|
* exactly where the car is. An unload therefore appeared to run backwards across the whole row
|
||||||
|
* and then land on the end it had just come from. Reported after unloading at a warehouse:
|
||||||
|
* "it went W, A, M, and then to the red box at the far right".
|
||||||
|
*
|
||||||
|
* Only the boxes an industry actually uses are drawn, and the arrows point the way its freight
|
||||||
|
* really moves.
|
||||||
*/
|
*/
|
||||||
const f = cell.facility;
|
const f = cell.facility;
|
||||||
if (f) {
|
if (f) {
|
||||||
@@ -459,25 +470,31 @@ export function officeSvg(
|
|||||||
|
|
||||||
let bx = 7;
|
let bx = 7;
|
||||||
const by = H - 42;
|
const by = H - 42;
|
||||||
// Green: what is waiting to be worked.
|
// Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
|
||||||
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
|
// point opposite ways. An industry that only receives has no green boxes at all.
|
||||||
out += box(bx, by, i < f.green.length, 'bs-green');
|
if (f.allowsOut) {
|
||||||
bx += cellW + 2;
|
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
|
||||||
|
out += box(bx, by, i < f.green.length, 'bs-green');
|
||||||
|
bx += cellW + 2;
|
||||||
|
}
|
||||||
|
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||||
|
bx += 9;
|
||||||
}
|
}
|
||||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
if (f.allowsIn) {
|
||||||
bx += 8;
|
for (let i = 0; i < Math.max(1, f.redCap); i++) {
|
||||||
// MEN | AT | WORK — a load sits in one of these while it is being worked.
|
out += box(bx, by, i < f.red.length, 'bs-red');
|
||||||
|
bx += cellW + 2;
|
||||||
|
}
|
||||||
|
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
|
||||||
|
bx += 9;
|
||||||
|
}
|
||||||
|
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
|
||||||
|
// unloading. The spotted car sits past WORK, on the siding drawn below.
|
||||||
for (let i = 0; i < f.maw.length; i++) {
|
for (let i = 0; i < f.maw.length; i++) {
|
||||||
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
||||||
bx += cellW + 2;
|
bx += cellW + 2;
|
||||||
}
|
}
|
||||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||||||
bx += 8;
|
|
||||||
// Red: what has come off an arriving car and is waiting to be cleared.
|
|
||||||
for (let i = 0; i < Math.max(1, f.redCap); i++) {
|
|
||||||
out += box(bx, by, i < f.red.length, 'bs-red');
|
|
||||||
bx += cellW + 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standing room, drawn as the printed squares: filled means occupied.
|
// Standing room, drawn as the printed squares: filled means occupied.
|
||||||
|
|||||||
+14
-1
@@ -239,9 +239,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
|
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
|
||||||
};
|
};
|
||||||
case 'trainArrived':
|
case 'trainArrived':
|
||||||
|
/**
|
||||||
|
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND.
|
||||||
|
*
|
||||||
|
* This said "it will highball again next Mainline Phase, so any work must happen now" to every
|
||||||
|
* arrival. For an expedited train that is exactly backwards — Q3 departs it in the SAME Stage
|
||||||
|
* it arrived, so there is no turn in which to work it — and the log then contradicted itself
|
||||||
|
* two lines later when the train left. Reported after Train 4, the Express, did precisely that.
|
||||||
|
*/
|
||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
|
text: e.expedited
|
||||||
|
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — and EXPEDITES, so it leaves again this same Mainline Phase; there is no turn in which to work it`
|
||||||
|
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands until the next Mainline Phase, so any work must happen before then`,
|
||||||
};
|
};
|
||||||
case 'trainDiverted':
|
case 'trainDiverted':
|
||||||
return {
|
return {
|
||||||
@@ -551,6 +561,9 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
|
|||||||
// board with only a line of history to say where it went is the thing that most needs a noise.
|
// board with only a line of history to say where it went is the thing that most needs a noise.
|
||||||
if (e.type === 'carsCoupled') out.push('couple');
|
if (e.type === 'carsCoupled') out.push('couple');
|
||||||
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
|
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
|
||||||
|
// The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed
|
||||||
|
// silently, so the card was gone and the answer to "when does it run?" was a line of history.
|
||||||
|
if (e.type === 'trainScheduled') out.push('schedule');
|
||||||
if (e.type === 'stageBegan') {
|
if (e.type === 'stageBegan') {
|
||||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||||
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
||||||
|
|||||||
+34
-3
@@ -109,6 +109,16 @@ export type FacilityView = {
|
|||||||
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
||||||
*/
|
*/
|
||||||
base: { out: number; in: number; laborers: number };
|
base: { out: number; in: number; laborers: number };
|
||||||
|
/**
|
||||||
|
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
|
||||||
|
*
|
||||||
|
* §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way: car → WORK → AT →
|
||||||
|
* MEN → red. So green and red both sit beside MEN, and the car sits beside WORK. Drawing red at
|
||||||
|
* the far right — where the car is — made an unload look like it ran backwards across the whole
|
||||||
|
* row and then landed on the wrong end.
|
||||||
|
*/
|
||||||
|
allowsOut: boolean;
|
||||||
|
allowsIn: boolean;
|
||||||
/** The Modifier cards standing beside it, by name. */
|
/** The Modifier cards standing beside it, by name. */
|
||||||
modifiers: string[];
|
modifiers: string[];
|
||||||
/**
|
/**
|
||||||
@@ -322,6 +332,8 @@ function facilityView(
|
|||||||
porters: `${portersLeft(f)}/${f.porters}`,
|
porters: `${portersLeft(f)}/${f.porters}`,
|
||||||
canFinish: canFinishHere(f),
|
canFinish: canFinishHere(f),
|
||||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||||
|
allowsOut: f.allows.outbound,
|
||||||
|
allowsIn: f.allows.inbound,
|
||||||
base: baseOf(card),
|
base: baseOf(card),
|
||||||
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
||||||
};
|
};
|
||||||
@@ -468,8 +480,20 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
}
|
}
|
||||||
case 'switch.sortConsist':
|
case 'switch.sortConsist':
|
||||||
return `re-order consist [${i.order.join(',')}]`;
|
return `re-order consist [${i.order.join(',')}]`;
|
||||||
case 'freightAgent.stockOutbound':
|
case 'freightAgent.stockOutbound': {
|
||||||
return `stock a ${i.carType} at ${at(i.at)}`;
|
/**
|
||||||
|
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
|
||||||
|
* that confusion: no train at the Depot, so how is a coach being stocked there? It is not a
|
||||||
|
* car on the track — it is a load taken from the Division Yard into the green Loading box,
|
||||||
|
* waiting for a train that can carry it. For a passenger facility that load is passengers on
|
||||||
|
* the platform.
|
||||||
|
*/
|
||||||
|
const f = areaOf(s, 0).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
|
||||||
|
const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
|
||||||
|
return i.carType === 'coach' && f?.kind === 'passenger'
|
||||||
|
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach`
|
||||||
|
: `bring a ${i.carType} load ${where} at ${at(i.at)} — it waits there for a car to be spotted`;
|
||||||
|
}
|
||||||
case 'freightAgent.unjam':
|
case 'freightAgent.unjam':
|
||||||
return `unjam ${i.from} at ${at(i.at)}`;
|
return `unjam ${i.from} at ${at(i.at)}`;
|
||||||
case 'freightAgent.clearInbound':
|
case 'freightAgent.clearInbound':
|
||||||
@@ -683,7 +707,14 @@ export function snapshot(
|
|||||||
const chip = trainChip(s, t.tray);
|
const chip = trainChip(s, t.tray);
|
||||||
return {
|
return {
|
||||||
...chip,
|
...chip,
|
||||||
label: `${chip.label} (${t.stagesRemaining})`,
|
/**
|
||||||
|
* SAY WHAT THE NUMBER IS.
|
||||||
|
*
|
||||||
|
* This read "TX14 (2)", a bare figure next to a train's name — and it was read as the
|
||||||
|
* car count twice, by the same player, because that is the obvious guess. It is Stages
|
||||||
|
* left to cross this Mainline card. Naming the unit costs three characters.
|
||||||
|
*/
|
||||||
|
label: `${chip.label} · ${t.stagesRemaining}⧗`,
|
||||||
region: place(t),
|
region: place(t),
|
||||||
direction: t.direction,
|
direction: t.direction,
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-1
@@ -91,6 +91,14 @@ export type Game = {
|
|||||||
* would be guesswork from a rendered frame.
|
* would be guesswork from a rendered frame.
|
||||||
*/
|
*/
|
||||||
cues: string[];
|
cues: string[];
|
||||||
|
/**
|
||||||
|
* The timetable slot the last batch of events filled, or null.
|
||||||
|
*
|
||||||
|
* Playing a train card rolls 1D12 for a Stage and the answer landed nowhere the player could see.
|
||||||
|
* Carrying the slot lets the timetable flash the one that just changed — the roll becomes
|
||||||
|
* something you watch land rather than something you are told about afterwards.
|
||||||
|
*/
|
||||||
|
scheduled: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -125,7 +133,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: [] };
|
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null };
|
||||||
// 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' });
|
||||||
@@ -603,6 +611,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
|
|
||||||
}
|
}
|
||||||
game.cues.push(...cuesFor(events));
|
game.cues.push(...cuesFor(events));
|
||||||
|
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
|
||||||
|
// schedule more than one train, and the most recent is the one the eye should be sent to.
|
||||||
|
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
|
||||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-4
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||||
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, yardHtml } from './panels.ts';
|
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import type { Game } from './game.ts';
|
import type { Game } from './game.ts';
|
||||||
@@ -293,6 +293,9 @@ function render(): void {
|
|||||||
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
|
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (discarding) {
|
if (discarding) {
|
||||||
|
// Dim the piles that are not targets, so the three that are read as a choice being offered
|
||||||
|
// rather than as four cards that happen to be there.
|
||||||
|
$('depts').classList.add('aiming');
|
||||||
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
|
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
|
||||||
const node = el as HTMLElement;
|
const node = el as HTMLElement;
|
||||||
const slot = Number(node.dataset['dept']);
|
const slot = Number(node.dataset['dept']);
|
||||||
@@ -309,6 +312,10 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
$('depts').classList.remove('aiming');
|
||||||
|
}
|
||||||
|
|
||||||
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||||
if (menu.makeUp) {
|
if (menu.makeUp) {
|
||||||
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
||||||
@@ -326,6 +333,16 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE TIMETABLE, with the slot the die just filled flashed.
|
||||||
|
*
|
||||||
|
* `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state —
|
||||||
|
* it is gone by the next render, which is what makes it read as "that just happened".
|
||||||
|
*/
|
||||||
|
const justSet = game.scheduled;
|
||||||
|
game.scheduled = null;
|
||||||
|
$('timetable').innerHTML = timetableHtml(f, justSet);
|
||||||
|
|
||||||
$('blocked').innerHTML = blockedHtml(f);
|
$('blocked').innerHTML = blockedHtml(f);
|
||||||
|
|
||||||
// -- log
|
// -- log
|
||||||
@@ -354,7 +371,7 @@ function render(): void {
|
|||||||
lastPhase = f.phaseKey;
|
lastPhase = f.phaseKey;
|
||||||
|
|
||||||
renderDistrict(f);
|
renderDistrict(f);
|
||||||
renderActions(menu, f);
|
renderActions(menu, f, justSet);
|
||||||
|
|
||||||
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
||||||
// not then play a backlog of everything that happened while it was off.
|
// not then play a backlog of everything that happened while it was off.
|
||||||
@@ -413,7 +430,11 @@ function renderDistrict(f: ReturnType<typeof view>): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof view>): void {
|
function renderActions(
|
||||||
|
menu: ReturnType<typeof actionMenu>,
|
||||||
|
f: ReturnType<typeof view>,
|
||||||
|
justSet: number | null,
|
||||||
|
): void {
|
||||||
const el = $('actions');
|
const el = $('actions');
|
||||||
|
|
||||||
if (game.state.status !== 'active') {
|
if (game.state.status !== 'active') {
|
||||||
@@ -474,6 +495,19 @@ function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof
|
|||||||
* the count belongs beside the buttons. It was reported only in the history panel, which is the
|
* the count belongs beside the buttons. It was reported only in the history panel, which is the
|
||||||
* one place a player is not looking while switching.
|
* one place a player is not looking while switching.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* WHAT THE DIE DID, said where the player is looking.
|
||||||
|
*
|
||||||
|
* Playing a train card rolls 1D12 for its departure Stage, and the card simply left the hand — the
|
||||||
|
* answer arrived only in the history panel, one line among many. The timetable now shows it and
|
||||||
|
* flashes the slot; this says it in words at the same moment.
|
||||||
|
*/
|
||||||
|
const scheduledNote =
|
||||||
|
justSet !== null && f.timetable[justSet] != null
|
||||||
|
? `<div class="scheduled" data-tip="§7 — the Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
|
||||||
|
`Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const movesNote =
|
const movesNote =
|
||||||
f.movesLeft !== null
|
f.movesLeft !== null
|
||||||
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
|
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
|
||||||
@@ -483,7 +517,7 @@ function renderActions(menu: ReturnType<typeof actionMenu>, f: ReturnType<typeof
|
|||||||
// A heading over the buttons, so the panel says what it is before it says what is in it. The
|
// A heading over the buttons, so the panel says what it is before it says what is in it. The
|
||||||
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
|
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
|
||||||
// no worker able to act, the only thing offered is "End my Cargo phase".
|
// no worker able to act, the only thing offered is "End my Cargo phase".
|
||||||
let html = `<h3 class="actions-hd">Actions</h3>${movesNote}`;
|
let html = `<h3 class="actions-hd">Actions</h3>${scheduledNote}${movesNote}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHAT IS LEFT IN THE ACTION LIST.
|
* WHAT IS LEFT IN THE ACTION LIST.
|
||||||
|
|||||||
+86
-11
@@ -97,6 +97,43 @@ export function yardHtml(rows: { type: string; loaded: number; empty: number }[]
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE TIMETABLE: twelve Stages of a Day, and which train is due out at each.
|
||||||
|
*
|
||||||
|
* Playing a train card rolls 1D12 for a slot, and the roll landed nowhere a player could see — the
|
||||||
|
* card was gone and the answer to "so when does it run?" was one line of history. The Frame has
|
||||||
|
* carried the twelve slots all along and only the standalone replay ever drew them.
|
||||||
|
*
|
||||||
|
* `justSet` is the slot filled by the roll being reported this frame, so it can be flashed. It marks
|
||||||
|
* the moment rather than the state: the highlight is gone by the next render, which is what makes it
|
||||||
|
* read as "that just happened" rather than "this is special".
|
||||||
|
*/
|
||||||
|
export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||||
|
const slots = f.timetable
|
||||||
|
.map((t, k) => {
|
||||||
|
const stage = k + 1;
|
||||||
|
const cls = [
|
||||||
|
t !== null ? 'due' : '',
|
||||||
|
stage === f.stage ? 'now' : '',
|
||||||
|
stage < f.stage ? 'past' : '',
|
||||||
|
k === justSet ? 'fresh' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const tip =
|
||||||
|
t !== null
|
||||||
|
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.`
|
||||||
|
: `Stage ${stage} — no train due out.`;
|
||||||
|
return (
|
||||||
|
`<div class="tt-slot ${cls}" data-tip="${esc(tip)}" tabindex="0">` +
|
||||||
|
`<span class="tt-s">${stage}</span>` +
|
||||||
|
`<span class="tt-t">${t !== null ? `T${t}` : '·'}</span></div>`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<div class="tt">${slots}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function blockedHtml(f: Frame): string {
|
export function blockedHtml(f: Frame): string {
|
||||||
return f.blocked.length === 0
|
return f.blocked.length === 0
|
||||||
? '<li class="dim">nothing blocked</li>'
|
? '<li class="dim">nothing blocked</li>'
|
||||||
@@ -147,18 +184,28 @@ export function facilitiesHtml(f: Frame): string {
|
|||||||
*/
|
*/
|
||||||
`<div class="caps">` +
|
`<div class="caps">` +
|
||||||
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
|
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
|
||||||
stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') +
|
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
|
||||||
stat('in', String(x.redCap), x.base.in, 'Red boxes: loads that have come off an arriving car and are waiting to be cleared.') +
|
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
|
||||||
(x.modifiers.length
|
(x.modifiers.length
|
||||||
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
|
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
|
||||||
: '') +
|
: '') +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
/**
|
||||||
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
|
* IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car,
|
||||||
|
* and unloads the other way: car → WORK → AT → MEN → red. Labelling the rows by colour
|
||||||
|
* alone left a player to work out which direction their industry ran; naming the step
|
||||||
|
* says it outright, and an industry that only receives has no green row to puzzle over.
|
||||||
|
*/
|
||||||
|
(x.allowsOut
|
||||||
|
? `<div class="boxes"><span class="dim" data-tip="Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.">waiting to load</span>${boxes(x.green, x.greenCap)}</div>`
|
||||||
|
: '') +
|
||||||
|
`<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
|
||||||
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
|
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
|
(x.allowsIn
|
||||||
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
? `<div class="boxes"><span class="dim" data-tip="Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.">cleared inbound</span>${boxes(x.red, x.redCap)}</div>`
|
||||||
|
: '') +
|
||||||
|
`<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
||||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
||||||
x.jammed
|
x.jammed
|
||||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
||||||
@@ -196,14 +243,42 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
|||||||
.stock .ld{color:#8fd6a0}
|
.stock .ld{color:#8fd6a0}
|
||||||
.stock .mt{color:#9fb6d8}
|
.stock .mt{color:#9fb6d8}
|
||||||
.stock.none{opacity:.4}
|
.stock.none{opacity:.4}
|
||||||
/* A pile or a yard chip that is currently a target for the thing you picked. Blue, matching the
|
/* A PILE THAT IS WAITING TO BE PICKED, drawn like the board's ghost squares because it is the same
|
||||||
legal-square outline on the board: the same question is being asked, so it looks the same. */
|
question: you have chosen a card, now choose where it goes. It was a 2px outline on a small tile
|
||||||
.handcard.target{border-color:#5aa9e6;background:#233246;cursor:pointer;
|
in a panel the player might not be looking at, and was reported as "a very tiny highlight". Now it
|
||||||
box-shadow:0 0 0 2px rgba(90,169,230,.20)}
|
grows, brightens, says DISCARD HERE outright, and pulses once so the eye finds it. */
|
||||||
.handcard.target:hover{background:#2b3d55}
|
.handcard.target{border:2px dashed #5aa9e6;background:#233246;cursor:pointer;color:#e6e9ee;
|
||||||
|
box-shadow:0 0 0 4px rgba(90,169,230,.22);transform:translateY(-2px);
|
||||||
|
animation:pick 1.1s ease-out 2}
|
||||||
|
.handcard.target:hover{background:#31485f;border-style:solid;box-shadow:0 0 0 6px rgba(90,169,230,.30)}
|
||||||
|
.handcard.target::before{content:"DISCARD HERE";display:block;font-size:9px;letter-spacing:.09em;
|
||||||
|
color:#8fc4ee;font-weight:700;margin-bottom:2px}
|
||||||
|
@keyframes pick{0%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}
|
||||||
|
50%{box-shadow:0 0 0 9px rgba(90,169,230,.10)}
|
||||||
|
100%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.handcard.target{animation:none}}
|
||||||
|
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
|
||||||
|
are stand out from the Salvage Yard beside them. */
|
||||||
|
.cardrow.aiming .handcard:not(.target){opacity:.4}
|
||||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
||||||
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
||||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
||||||
|
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
|
||||||
|
dimmed, and a slot the die has just filled flashes once. */
|
||||||
|
.tt{display:flex;gap:3px;flex-wrap:wrap}
|
||||||
|
.tt-slot{flex:1 1 0;min-width:26px;text-align:center;border:1px solid #2c333d;border-radius:4px;
|
||||||
|
padding:2px 0;background:#1a1f26;font-size:10px;cursor:help;line-height:1.25}
|
||||||
|
.tt-slot .tt-s{display:block;color:#5f6b7a;font-size:9px}
|
||||||
|
.tt-slot .tt-t{display:block;color:#5f6b7a;font-variant-numeric:tabular-nums}
|
||||||
|
.tt-slot.due{background:#222c38;border-color:#4d6fa8}
|
||||||
|
.tt-slot.due .tt-t{color:#cfe0f5;font-weight:700}
|
||||||
|
.tt-slot.past{opacity:.45}
|
||||||
|
.tt-slot.now{border-color:#b98cf0;box-shadow:0 0 0 2px rgba(150,110,230,.20)}
|
||||||
|
.tt-slot.now .tt-s{color:#b98cf0;font-weight:700}
|
||||||
|
.tt-slot.fresh{animation:ttflash 1.5s ease-out 1}
|
||||||
|
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
|
||||||
|
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
|
||||||
ul.blocked{margin:0;padding-left:18px}
|
ul.blocked{margin:0;padding-left:18px}
|
||||||
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
|
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
|
||||||
.fac{border-top:1px solid #2c333d;padding:7px 0}
|
.fac{border-top:1px solid #2c333d;padding:7px 0}
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ button.cardact.discard{color:#d6b48a}
|
|||||||
box-shadow:0 6px 22px rgba(0,0,0,.5)}
|
box-shadow:0 6px 22px rgba(0,0,0,.5)}
|
||||||
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||||
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
|
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
|
||||||
|
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
|
||||||
|
thing that just happened — and it disappears with the next render, as a moment should. */
|
||||||
|
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||||||
|
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||||||
.makeup-note{font-size:11px;margin:0 0 5px}
|
.makeup-note{font-size:11px;margin:0 0 5px}
|
||||||
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
|
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
|
||||||
buttons underneath it; struck through in red once it is gone. */
|
buttons underneath it; struck through in red once it is gone. */
|
||||||
@@ -187,6 +191,9 @@ ul.blocked li{padding:2px 0}
|
|||||||
<div id="clsyard" class="yard"></div>
|
<div id="clsyard" class="yard"></div>
|
||||||
<div class="dim" id="yardnote"></div>
|
<div class="dim" id="yardnote"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
|
||||||
|
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export function playCue(cue: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cue === 'schedule') {
|
||||||
|
// A die landing on the despatcher's desk, then the slot taking it: a short clatter and a chime.
|
||||||
|
for (const at of [t, t + 0.05, t + 0.09]) tone(at, 200 + Math.random() * 90, 0.05, 0.07, 'square');
|
||||||
|
tone(t + 0.16, 880, 0.3, 0.09, 'sine');
|
||||||
|
tone(t + 0.16, 1320, 0.28, 0.05, 'sine');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cue === 'day') {
|
if (cue === 'day') {
|
||||||
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
||||||
for (const at of [t, t + 0.22]) {
|
for (const at of [t, t + 0.22]) {
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ describe('the replay behaves like the game it is replaying', () => {
|
|||||||
// board and only history said where it went.
|
// board and only history said where it went.
|
||||||
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
|
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
|
||||||
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
|
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
|
||||||
|
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
|
||||||
|
|
||||||
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
||||||
// accounting the live game is held to. The switching cues are counted separately: they follow
|
// accounting the live game is held to. The switching cues are counted separately: they follow
|
||||||
|
|||||||
+71
-7
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
|
import { timetableHtml } from '../src/web/panels.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||||
@@ -343,18 +344,54 @@ describe('the board shows freight work happening', () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn');
|
// A Mine Tipple only ships OUT, so it has no red Unloading box at all — only the boxes an
|
||||||
|
// industry actually uses are drawn now.
|
||||||
|
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw'], 'the waiting load is not drawn');
|
||||||
|
|
||||||
game.state.clock.phase = 'loadUnload';
|
game.state.clock.phase = 'loadUnload';
|
||||||
game.state.clock.currentActor = 0;
|
game.state.clock.currentActor = 0;
|
||||||
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
|
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
|
||||||
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible');
|
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw'], 'starting a load is invisible');
|
||||||
|
|
||||||
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
|
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
|
||||||
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible');
|
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw'], 'advancing a load is invisible');
|
||||||
|
|
||||||
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
|
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
|
||||||
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK');
|
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL'], 'the load did not reach WORK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the pipeline the way the freight actually flows', () => {
|
||||||
|
// REPORTED after unloading at a warehouse: "it went W, A, M, and then to the red box at the far
|
||||||
|
// right." §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way entirely:
|
||||||
|
// car → WORK → AT → MEN → red. So green and red BOTH sit beside MEN and the car sits beside
|
||||||
|
// WORK — but red was drawn at the far right, which is exactly where the car is, so an unload
|
||||||
|
// looked like it ran backwards across the row and landed on the end it came from.
|
||||||
|
const game = newGame(5);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const fac = (kind: string, out: boolean, into: boolean) => ({
|
||||||
|
geometry: { kind: 'facility', facility: kind },
|
||||||
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||||
|
facility: {
|
||||||
|
kind: 'freight', subtype: kind,
|
||||||
|
allows: { outbound: out, inbound: into },
|
||||||
|
outboundBox: [], inboundBox: [], capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
|
||||||
|
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
|
||||||
|
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
area.grid.set('-1,0', fac('grocersWarehouse', false, true) as never);
|
||||||
|
area.grid.set('-1,1', fac('mineTipple', true, false) as never);
|
||||||
|
|
||||||
|
const cells = view(game).cells;
|
||||||
|
const order = (row: number, col: number): string[] => {
|
||||||
|
const cell = cells.find((c) => c.row === row && c.col === col)!;
|
||||||
|
return [...officeSvg([cell], 0).matchAll(/class="bs-wb (bs-\w+)/g)].map((m) => m[1]!.replace('bs-', ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Receives only: the red box sits BEFORE the sign, which is the end an unload arrives at.
|
||||||
|
assert.deepEqual(order(-1, 0), ['red', 'maw', 'maw', 'maw'], 'the red box is not beside MEN');
|
||||||
|
// Ships out only: green before the sign, and no red box at all.
|
||||||
|
assert.deepEqual(order(-1, 1), ['green', 'maw', 'maw', 'maw'], 'the green box is not beside MEN');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names where the work has got to, in the tooltip', () => {
|
it('names where the work has got to, in the tooltip', () => {
|
||||||
@@ -1465,6 +1502,32 @@ describe('the static build', () => {
|
|||||||
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
|
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows where a played train card landed on the timetable', () => {
|
||||||
|
// REPORTED: playing a train card rolls 1D12 for its departure Stage and the card simply left the
|
||||||
|
// hand — the answer arrived only as one line in the history panel. The twelve slots have been in
|
||||||
|
// the Frame all along and only the standalone replay ever drew them.
|
||||||
|
const game = newGame(430);
|
||||||
|
let landed: number | null = null;
|
||||||
|
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
if (game.scheduled !== null) {
|
||||||
|
landed = game.scheduled;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(landed !== null, 'no train was ever scheduled');
|
||||||
|
const f = view(game);
|
||||||
|
assert.ok(f.timetable[landed!] !== null, 'the slot the roll reported is empty');
|
||||||
|
// The roll gets a sound, because it is the one die a player rolls and it landed silently.
|
||||||
|
assert.ok(game.cues.includes('schedule'), 'scheduling a train made no sound');
|
||||||
|
// And the panel flashes the slot that changed, marking the moment rather than the state.
|
||||||
|
const html = timetableHtml(f, landed);
|
||||||
|
assert.match(html, /tt-slot[^"]*fresh/, 'the new slot is not flagged');
|
||||||
|
assert.equal((timetableHtml(f, null).match(/fresh/g) ?? []).length, 0, 'the flash must not persist');
|
||||||
|
assert.match(html, new RegExp(`T${f.timetable[landed!]}`), 'the train is not named in its slot');
|
||||||
|
});
|
||||||
|
|
||||||
it('draws the side panels from one place, on both screens', () => {
|
it('draws the side panels from one place, on both screens', () => {
|
||||||
// A replay is the game being WATCHED rather than played, so it should look like the game. The
|
// A replay is the game being WATCHED rather than played, so it should look like the game. The
|
||||||
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
|
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
|
||||||
@@ -1632,12 +1695,13 @@ describe('the Division map shows the whole route', () => {
|
|||||||
|
|
||||||
it('draws the Division as a line with two ends, never as a loop', () => {
|
it('draws the Division as a line with two ends, never as a loop', () => {
|
||||||
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
||||||
// does not — trains enter at one Division Point and leave at the other.
|
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
|
||||||
|
// and even run east, so each Division Point is a way on AND a way off.
|
||||||
for (const players of [1, 2, 3, 4]) {
|
for (const players of [1, 2, 3, 4]) {
|
||||||
const svg = divisionFor(players);
|
const svg = divisionFor(players);
|
||||||
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
|
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
|
||||||
assert.match(svg, /trains enter/, `${players}p does not label where trains enter`);
|
assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
|
||||||
assert.match(svg, /trains leave/, `${players}p does not label where trains leave`);
|
assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user