v0.8.0.13 — the board on screen is the board you can act on
Nine reports from the Day 1-2 playtest of v0.8.0.12. One moved a car, one was a rule working correctly with nothing on screen to say so, and the rest are things the table could not see. The real bug: the Division Yard chips stayed lit and clickable while the board was catching up. `renderActions` puts the action list away while the queue is behind — a move offered against a position that has already moved on is a move made blind — but the make-up wiring sat outside that guard. A chip was clicked during a bot's make-up, a coach left the yard, and the train ended up with three cars: a real intent submitted against a board several moves stale. The chips now follow the queue like every other control, and the yard COUNTS are drawn from the shown board rather than the live game — they were the one panel still reporting a future the player had not been shown. The Office Area picker had a button per opponent and none for yourself, so the one player who could not reach their own district was the player waiting on everybody else. Your own seat is in the row now, and the row is ordered by SEAT, west to east as the Division map draws it, rather than by join order — sorted from the Frame's own `seat` on every render, so it rotates with Employee Rotation instead of having to be told. §5's handover of the Fedora rode on `actorChanged`, which is turn bookkeeping and which `record()` drops as noise, so the one moment it carried that a player needed went past in silence. It is its own event now, narrated and announced. The phase keeps its name: the Supervisor Shift refreshes every Laborer and Porter EVERY Stage and the Fedora moves only every third. A collision now names whose Office it was and who paid the 5 Revenue, which rode in a separate `revenueChanged`; a Mainline collision is phrased differently because §10 makes it the Superintendent's. Passengers, reported as a bug and ruled not one after replaying the save: the Depot's capacity and modifiers were fine, and §6.3 stocking wants a LOADED coach out of the Division Yard, which held none while six sat in Classification. The shortage stays — running out is part of the game, the same ruling Gitea#2 got — but the blocked panel says so now instead of the action being silently absent. Smaller: "working left" is "working eastward" in the make-up panel and the New Train tip, because the map runs west to east and the table does not; the history panel keeps 90 lines instead of 60 in the same 230px box. `git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time: `events.ts` declares `superintendentChanged` and `advance.ts` emits it. Both are additive — `check()`, `legal.ts` and every predicate are untouched, and events are derived by replaying a save rather than stored — so no once-legal move became illegal and games in progress resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
This commit is contained in:
co-authored by
Claude Opus 5
parent
7c9ef8797d
commit
ad277fb994
@@ -19,6 +19,90 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.8.0.13 — 2026-09-16
|
||||||
|
|
||||||
|
Nine reports from the Day 1–2 playtest of v0.8.0.12. One was a real bug that cost a car, one was a
|
||||||
|
rule working correctly with nothing on screen to say so, and the rest are things the table could not
|
||||||
|
see.
|
||||||
|
|
||||||
|
### The Division Yard no longer takes a click while your board is behind
|
||||||
|
|
||||||
|
The worst of the batch, because it moved a car. `renderActions` puts the action list away while the
|
||||||
|
queue is catching up — a move offered against a position that has already moved on is a move made
|
||||||
|
blind — but the make-up wiring sat OUTSIDE that guard, so the yard chips stayed lit and clickable.
|
||||||
|
Reported exactly as it happens: a bot was adding the last car, the board lagged, a chip was clicked,
|
||||||
|
a coach left the Division Yard and the train still ended up with three cars. The click had submitted
|
||||||
|
a real intent against a board that was several moves stale.
|
||||||
|
|
||||||
|
Both halves are fixed. The chips are wired only when the queue is idle, and the yard COUNTS are now
|
||||||
|
drawn from the board on screen rather than the live game — they were the one panel still reporting a
|
||||||
|
future the player had not been shown, which is what "the yards may not be in sync with the turns
|
||||||
|
behind" was describing.
|
||||||
|
|
||||||
|
### Every seat has a button, in map order
|
||||||
|
|
||||||
|
The Office Area picker had a button per opponent and none for yourself, so the one player who could
|
||||||
|
not reach their own district was the player waiting on everybody else — watching the board follow
|
||||||
|
whoever was acting, with no way back. Your own seat is now in the row, and `watchedDistrict` returns
|
||||||
|
your board for it rather than falling through to the actor, which is the same bug seen from the other
|
||||||
|
side.
|
||||||
|
|
||||||
|
The buttons are ordered by SEAT — west to east, exactly as the Division map draws it — instead of by
|
||||||
|
player index, which is the order people joined. Seat is not player index and must not be assumed to
|
||||||
|
be: under Employee Rotation the seating moves, and because the row is sorted from the Frame's own
|
||||||
|
`seat` on every render, the buttons rotate with the players rather than having to be told.
|
||||||
|
|
||||||
|
### The Fedora passing is said out loud
|
||||||
|
|
||||||
|
§5 moves the Superintendent at the end of Stages 3, 6, 9 and 12, and the log never mentioned it. The
|
||||||
|
rotation was riding on `actorChanged` — turn bookkeeping, fired every time the cursor moves, which
|
||||||
|
`record()` drops as noise — so the one moment that event carried something a player needed went past
|
||||||
|
in silence, with only "Supervisor Shift" in the history to hint at it.
|
||||||
|
|
||||||
|
It is its own event now (`superintendentChanged`), narrated in the log and announced on screen the
|
||||||
|
way a completed run already is. The phase keeps its name: the Supervisor Shift refreshes every
|
||||||
|
Laborer and Porter EVERY Stage, and the Fedora moves only every third — naming the phase after the
|
||||||
|
rarer event would mislead about the common one.
|
||||||
|
|
||||||
|
### A collision says whose Office it was and who paid for it
|
||||||
|
|
||||||
|
The line named the wreck and the reason and stopped there: the 5-point penalty rides in a separate
|
||||||
|
`revenueChanged`, so a player had to add two log entries together to learn who had just lost five
|
||||||
|
Revenue, and "COLLISION at the Office" never said whose. The faulting seat is in the event — and for
|
||||||
|
anything inside a district that seat IS the district's owner — so the line now reads "COLLISION at
|
||||||
|
Tom's Office … Tom loses 5 Revenue — it happened in their district." A Mainline collision is phrased
|
||||||
|
differently because §10 makes it the Superintendent's, which is a different kind of fault.
|
||||||
|
|
||||||
|
### Passengers: the rule stands, the silence goes
|
||||||
|
|
||||||
|
Reported as a bug and it is not one, which took a replay of the save to establish. The Depot in
|
||||||
|
question had a Restaurant and a Hotel beside it and `cap{out:3}` — capacity was never the problem,
|
||||||
|
and the modifiers grant exactly what they print. What stopped a second passenger was §6.3: stocking
|
||||||
|
takes a LOADED car of the facility's type out of the Division Yard, and there was not a loaded coach
|
||||||
|
in it. Six were sitting in the Classification Yard, which §2.2 returns only when the Division Yard
|
||||||
|
runs bare, and it was holding sixty-odd cars.
|
||||||
|
|
||||||
|
Jesse's ruling is the same one Gitea#2 got: the shortage stays, because running out is part of the
|
||||||
|
game. So the blocked panel now says it — room for N more passengers, no loaded coach in the Division
|
||||||
|
Yard, and how many are waiting in Classification — instead of the action simply being absent from the
|
||||||
|
menu with no reason given.
|
||||||
|
|
||||||
|
### Smaller
|
||||||
|
|
||||||
|
"Working left" is now "working eastward" in the make-up panel and the New Train tip. It was always the
|
||||||
|
same rule — `playerLeftOf` is increasing seat index — but "left" describes a table nobody is looking
|
||||||
|
at, while the map runs west to east, so at a real three-player game the second car went to the player
|
||||||
|
sitting EAST and the text read as wrong. The history panel keeps 90 lines instead of 60, in the same
|
||||||
|
230px box: more to scroll back through, no more screen taken, and the newest line stays where the eye
|
||||||
|
already is.
|
||||||
|
|
||||||
|
### Note for the packaging repo
|
||||||
|
|
||||||
|
`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time: `events.ts` declares
|
||||||
|
`superintendentChanged` and `advance.ts` emits it. Both are additive — `check()`, `legal.ts` and
|
||||||
|
every predicate are untouched, and events are derived by replaying a save rather than stored — so no
|
||||||
|
once-legal move became illegal and games in progress resume.
|
||||||
|
|
||||||
## 0.8.0.12 — 2026-09-16
|
## 0.8.0.12 — 2026-09-16
|
||||||
|
|
||||||
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
|
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0.12",
|
"version": "0.8.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
@@ -1656,6 +1656,13 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||||
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
||||||
|
/**
|
||||||
|
* SAID OUT LOUD, as well as recorded. `actorChanged` is turn bookkeeping and the log discards it,
|
||||||
|
* so this — the one time in three Stages that it means the Fedora moved — had no line anywhere
|
||||||
|
* (playtest, 2026-09-16). Emitted alongside rather than instead: `actorChanged` still carries the
|
||||||
|
* cursor, and anything reading it keeps working.
|
||||||
|
*/
|
||||||
|
events.push({ type: 'superintendentChanged', player: s.clock.superintendent, stage: s.clock.stage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export type GameEvent =
|
|||||||
| { type: 'stageBegan'; day: number; stage: number }
|
| { type: 'stageBegan'; day: number; stage: number }
|
||||||
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||||
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||||
|
/**
|
||||||
|
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
|
||||||
|
*
|
||||||
|
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
|
||||||
|
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
|
||||||
|
* moment it carried that a player actually needed to see went past in silence. Reported from the
|
||||||
|
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
|
||||||
|
*/
|
||||||
|
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
|
||||||
| { type: 'phaseBegan'; phase: string }
|
| { type: 'phaseBegan'; phase: string }
|
||||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||||
// -- local operations
|
// -- local operations
|
||||||
|
|||||||
+66
-3
@@ -127,6 +127,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
||||||
.join(' → ')}`,
|
.join(' → ')}`,
|
||||||
};
|
};
|
||||||
|
case 'superintendentChanged':
|
||||||
|
/**
|
||||||
|
* The Fedora is the only thing in the game that changes hands on a clock rather than because
|
||||||
|
* somebody did something, so it is the one handover nobody at the table watches happen.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
tone: 'clock',
|
||||||
|
text:
|
||||||
|
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
|
||||||
|
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
|
||||||
|
`questions, and every round that goes round the table now starts with them.`,
|
||||||
|
};
|
||||||
case 'phaseBegan':
|
case 'phaseBegan':
|
||||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||||
@@ -435,12 +447,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
const wrecked = e.trains
|
const wrecked = e.trains
|
||||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||||
.join(' and ');
|
.join(' and ');
|
||||||
|
/**
|
||||||
|
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
|
||||||
|
* loss… we need to know which player received the penalty and why").
|
||||||
|
*
|
||||||
|
* `player` is the seat at fault, and for everything that happens inside a district that is the
|
||||||
|
* district's owner — so it names the place as well as the payer. A Mainline collision is the
|
||||||
|
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
|
||||||
|
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
|
||||||
|
* never mentioned them; a player should not have to add two log entries together.
|
||||||
|
*/
|
||||||
|
const who = ctx.playerName?.(e.player) ?? null;
|
||||||
|
const mainline = e.where === 'the Mainline';
|
||||||
|
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
|
||||||
|
const cost =
|
||||||
|
who === null
|
||||||
|
? ' 5 Revenue is lost.'
|
||||||
|
: mainline
|
||||||
|
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
|
||||||
|
: ` ${who} loses 5 Revenue — it happened in their district.`;
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
text:
|
text:
|
||||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
|
||||||
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
|
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
|
||||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
`returns to its slot and runs again next Day; an Extra is gone for good.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +691,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
* that actually refused rather than a second guess at it.
|
* that actually refused rather than a second guess at it.
|
||||||
*/
|
*/
|
||||||
if (f.kind === 'passenger') {
|
if (f.kind === 'passenger') {
|
||||||
|
/**
|
||||||
|
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* "He would like to have two passengers waiting in his depot… but the only option he had was
|
||||||
|
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
|
||||||
|
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
|
||||||
|
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
|
||||||
|
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
|
||||||
|
*
|
||||||
|
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
|
||||||
|
* out is part of the game. What must not stay is the silence — an action with no legal target
|
||||||
|
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
|
||||||
|
* rules or the game is broken.
|
||||||
|
*/
|
||||||
|
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
|
||||||
|
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||||
|
if (loadedCoaches === 0) {
|
||||||
|
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||||
|
out.push({
|
||||||
|
where: `${name} ${key}`,
|
||||||
|
why:
|
||||||
|
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
|
||||||
|
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
|
||||||
|
`coach in the Division Yard for the Freight Agent to bring over` +
|
||||||
|
(waiting > 0
|
||||||
|
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
|
||||||
|
'back only when the Division Yard is bare'
|
||||||
|
: ''),
|
||||||
|
severity: 'waiting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (portersLeft(f) > 0) {
|
if (portersLeft(f) > 0) {
|
||||||
const coord = uncoordKey(key);
|
const coord = uncoordKey(key);
|
||||||
// Passengers standing on the platform with nothing carrying them away.
|
// Passengers standing on the platform with nothing carrying them away.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
|||||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||||
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||||
// a locomotive being made up
|
// a locomotive being made up
|
||||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1337,6 +1337,18 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* It is the one thing in the game that changes hands on the clock rather than because somebody
|
||||||
|
* did something, so nobody is watching for it — and it decides who rules on clearances and who
|
||||||
|
* every round starts with. A line in the history is where you find it afterwards; this is what
|
||||||
|
* tells the table as it happens, the same treatment a completed run already gets.
|
||||||
|
*/
|
||||||
|
if (e.type === 'superintendentChanged') {
|
||||||
|
const name = game.state.players[e.player]?.name ?? 'the next player';
|
||||||
|
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 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);
|
||||||
|
|||||||
+75
-20
@@ -337,9 +337,17 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
|
|||||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||||
*/
|
*/
|
||||||
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
|
/**
|
||||||
if (peekPlayer !== null && peekPlayer !== f.viewer) {
|
* A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`)
|
||||||
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
|
* — INCLUDING A LOOK AT YOUR OWN BOARD, which is why this returns rather than falling through.
|
||||||
|
*
|
||||||
|
* Falling through sent "show me mine" to the actor logic below, so the one player who could not
|
||||||
|
* reach their own Office Area was the player waiting on everybody else (playtest, 2026-09-16: Tom,
|
||||||
|
* hanging about while the board followed Jesse). Null IS your own district: it is what the caller
|
||||||
|
* draws from `f.cells` when nobody else is being watched.
|
||||||
|
*/
|
||||||
|
if (peekPlayer !== null) {
|
||||||
|
return peekPlayer === f.viewer ? null : (pub.districts.find((d) => d.player === peekPlayer) ?? null);
|
||||||
}
|
}
|
||||||
let player: PlayerIndex | null = f.actor;
|
let player: PlayerIndex | null = f.actor;
|
||||||
if (stepQueue.busy()) {
|
if (stepQueue.busy()) {
|
||||||
@@ -1749,8 +1757,17 @@ function render(): void {
|
|||||||
$('depts').classList.remove('aiming');
|
$('depts').classList.remove('aiming');
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
/**
|
||||||
if (menu.makeUp) {
|
* -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||||
|
*
|
||||||
|
* NOT WHILE THE BOARD IS BEHIND (playtest, 2026-09-16). `renderActions` puts the action list away
|
||||||
|
* while the queue is catching up — a move offered against a position that has already moved on is
|
||||||
|
* a move made blind — but this wiring sat outside that guard, so the yard chips stayed lit and
|
||||||
|
* clickable. Jesse clicked one during a bot's make-up, a coach left the yard, and the train ended
|
||||||
|
* up with three cars: he had submitted a real intent against a board he could not see. The chips
|
||||||
|
* follow the same rule as every other control now.
|
||||||
|
*/
|
||||||
|
if (menu.makeUp && !stepQueue.busy()) {
|
||||||
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
||||||
const node = el as HTMLElement;
|
const node = el as HTMLElement;
|
||||||
const car = menu.makeUp!.cars.find(
|
const car = menu.makeUp!.cars.find(
|
||||||
@@ -1791,7 +1808,16 @@ function render(): void {
|
|||||||
*/
|
*/
|
||||||
const heldBack = stepQueue.pendingLines();
|
const heldBack = stepQueue.pendingLines();
|
||||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
||||||
const shownLines = allLines.slice(-60);
|
/**
|
||||||
|
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
|
||||||
|
*
|
||||||
|
* The panel scrolls already, so a longer tail costs no screen and lets a player scroll further
|
||||||
|
* back through a Stage they were not watching. It is capped at all only because the list is
|
||||||
|
* rebuilt on every render; the log itself is uncapped in memory, so the number is a display
|
||||||
|
* choice rather than a limit. The BOX stays 230px on purpose — growing it would push the newest
|
||||||
|
* line, the one being read, further from where the eye already is.
|
||||||
|
*/
|
||||||
|
const shownLines = allLines.slice(-90);
|
||||||
/**
|
/**
|
||||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||||
@@ -1914,16 +1940,26 @@ function renderUndo(): void {
|
|||||||
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||||
*/
|
*/
|
||||||
function renderYards(f: Frame): void {
|
function renderYards(f: Frame): void {
|
||||||
$('divyard').innerHTML = yardHtml(f.yards.division);
|
/**
|
||||||
$('clsyard').innerHTML = yardHtml(f.yards.classification);
|
* THE YARDS BELONG TO THE BOARD ON SCREEN, NOT TO THE GAME (playtest, 2026-09-16).
|
||||||
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
*
|
||||||
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
* They were drawn from the live Frame while everything around them was held back, so a player
|
||||||
|
* five moves behind read yard counts from a future they had not been shown — "the yards may not
|
||||||
|
* be in sync with the turns behind", and they were not. Same rule as the turn chart: while the
|
||||||
|
* queue is behind, this is the shown board's yards; at rest the two are the same object.
|
||||||
|
*/
|
||||||
|
const pub = stepQueue.current();
|
||||||
|
const yards = pub && stepQueue.busy() ? pub.yards : f.yards;
|
||||||
|
$('divyard').innerHTML = yardHtml(yards.division);
|
||||||
|
$('clsyard').innerHTML = yardHtml(yards.classification);
|
||||||
|
$('divtot').textContent = `${yards.divisionTotal} cars`;
|
||||||
|
$('clstot').textContent = `${yards.classificationTotal} cars`;
|
||||||
|
|
||||||
// The one thing worth calling out: the yard about to turn over.
|
// The one thing worth calling out: the yard about to turn over.
|
||||||
const bare = f.yards.divisionTotal === 0;
|
const bare = yards.divisionTotal === 0;
|
||||||
$('divyard').classList.toggle('bare', bare);
|
$('divyard').classList.toggle('bare', bare);
|
||||||
$('yardnote').textContent = bare
|
$('yardnote').textContent = bare
|
||||||
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
|
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
|
||||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1996,16 +2032,36 @@ function renderDistrict(f: Frame): void {
|
|||||||
* from another person, and so the one that must never be pasted into markup.
|
* from another person, and so the one that must never be pasted into markup.
|
||||||
*/
|
*/
|
||||||
const peek = $('districtpeek');
|
const peek = $('districtpeek');
|
||||||
const others = f.players.filter((p) => p.index !== f.viewer);
|
/**
|
||||||
|
* EVERY SEAT, YOURS INCLUDED, IN MAP ORDER (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* Two faults, both reported from one game. There was no button for your OWN district, so a player
|
||||||
|
* waiting on everybody else could look at any board except the one they were playing — and the
|
||||||
|
* buttons came out in player order, which is the order people joined, not the order they sit.
|
||||||
|
*
|
||||||
|
* Sorted by SEAT, which is west-to-east along the Division exactly as the map draws it, so the row
|
||||||
|
* reads left to right the way the railroad does. Seat is not player index and must not be assumed
|
||||||
|
* to be: under Employee Rotation the seating moves, and because this sorts the Frame's own `seat`
|
||||||
|
* on every render, the buttons rotate with the players rather than having to be told.
|
||||||
|
*/
|
||||||
|
const seats = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||||
peek.innerHTML = '';
|
peek.innerHTML = '';
|
||||||
peek.hidden = others.length === 0;
|
peek.hidden = seats.length < 2;
|
||||||
for (const p of others) {
|
const watchedNow = watchedDistrict(f);
|
||||||
|
for (const p of seats) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.type = 'button';
|
b.type = 'button';
|
||||||
b.className = 'ghost';
|
b.className = 'ghost';
|
||||||
|
// NAMES GO IN AS TEXT, NEVER MARKUP: a display name is whatever somebody typed in the lobby.
|
||||||
b.textContent = p.name;
|
b.textContent = p.name;
|
||||||
b.title =
|
const isYou = p.index === f.viewer;
|
||||||
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
// Which board is up right now — yours when nothing is being watched, otherwise the watched one.
|
||||||
|
const showing = watchedNow === null ? f.viewer : watchedNow.player;
|
||||||
|
// `.seg button[aria-pressed="true"]` already lights the current one — no extra class to style.
|
||||||
|
b.setAttribute('aria-pressed', String(p.index === showing));
|
||||||
|
b.title = isYou
|
||||||
|
? 'Back to your own Office Area.'
|
||||||
|
: `Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
||||||
`redraws — press Pause first if you want to study it.`;
|
`redraws — press Pause first if you want to study it.`;
|
||||||
b.onclick = () => {
|
b.onclick = () => {
|
||||||
peekPlayer = p.index;
|
peekPlayer = p.index;
|
||||||
@@ -2407,9 +2463,8 @@ function renderActions(
|
|||||||
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
||||||
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
||||||
*/
|
*/
|
||||||
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
|
`<b>Each player adds one car at a time</b>, starting from the Superintendent and working ` +
|
||||||
`starting from the Superintendent and working left, coming round again until the train is ` +
|
`eastward, repeating until the train is full or the Division Yard holds nothing it can take.`
|
||||||
`full or the Division Yard holds nothing it can take.`
|
|
||||||
: menu.makeUp.pass !== null
|
: menu.makeUp.pass !== null
|
||||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||||
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ const KNOWN_UNREDUCED = [
|
|||||||
// the pattern every entry on this list follows.
|
// the pattern every entry on this list follows.
|
||||||
'seatsRotated',
|
'seatsRotated',
|
||||||
'stageBegan',
|
'stageBegan',
|
||||||
|
/**
|
||||||
|
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
|
||||||
|
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
|
||||||
|
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||||
|
*/
|
||||||
|
'superintendentChanged',
|
||||||
'trainArrived',
|
'trainArrived',
|
||||||
'trainCompleted',
|
'trainCompleted',
|
||||||
'trainDiverted',
|
'trainDiverted',
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'stageBegan', day: 1, stage: 7 },
|
{ type: 'stageBegan', day: 1, stage: 7 },
|
||||||
{ type: 'phaseBegan', phase: 'mainline' },
|
{ type: 'phaseBegan', phase: 'mainline' },
|
||||||
{ type: 'actorChanged', player: 0 },
|
{ type: 'actorChanged', player: 0 },
|
||||||
|
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
|
||||||
|
// the table every third Stage, so its text is worth exercising.
|
||||||
|
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
||||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||||
|
|||||||
@@ -310,6 +310,33 @@ describe('steps reach a seated player — TODO #13', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
|
||||||
|
it('names the new Superintendent in the history at the Stage it happens', async () => {
|
||||||
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||||
|
const { legalActions } = await import('../src/engine/legal.ts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
|
||||||
|
* moment that event meant something never reached a player. Driven far enough to cross a shift
|
||||||
|
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
|
||||||
|
* that a real game produces the line.
|
||||||
|
*/
|
||||||
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
|
||||||
|
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
|
||||||
|
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
||||||
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
||||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||||
|
|||||||
+48
-3
@@ -1522,6 +1522,15 @@ describe('the static build', () => {
|
|||||||
attrs,
|
attrs,
|
||||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught this factory above, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
@@ -1779,6 +1788,15 @@ describe('the static build', () => {
|
|||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal() {
|
showModal() {
|
||||||
@@ -1894,6 +1912,15 @@ describe('the static build', () => {
|
|||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
showModal() {},
|
showModal() {},
|
||||||
close() {},
|
close() {},
|
||||||
@@ -2680,10 +2707,15 @@ describe('the static build', () => {
|
|||||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||||
/**
|
/**
|
||||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
|
||||||
* with the Superintendent and working left" — but nothing on the board said who the
|
* with the Superintendent and working EASTWARD — but nothing on the board said who the
|
||||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||||
|
*
|
||||||
|
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
|
||||||
|
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
|
||||||
|
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
|
||||||
|
* car went to the player sitting to the EAST. The word changed; the order did not.
|
||||||
*/
|
*/
|
||||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||||
@@ -2697,7 +2729,7 @@ describe('the static build', () => {
|
|||||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/starting with the Superintendent and working left/,
|
/starting with the Superintendent and working eastward/,
|
||||||
'the New Train pill does not say whose turn the make-up round starts on',
|
'the New Train pill does not say whose turn the make-up round starts on',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -4170,6 +4202,16 @@ describe('the lobby screen', () => {
|
|||||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||||
querySelectorAll: (sel: string) => matching(sel),
|
querySelectorAll: (sel: string) => matching(sel),
|
||||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
|
||||||
|
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel threw on every render, which took out 21 tests across three suites
|
||||||
|
* — and the thing it was hiding was a control nothing had ever exercised.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
};
|
};
|
||||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||||
return node;
|
return node;
|
||||||
@@ -4567,6 +4609,9 @@ describe('the solitaire setup screen', () => {
|
|||||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||||
checked: false, disabled: false, hidden: false, className: '',
|
checked: false, disabled: false, hidden: false, className: '',
|
||||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||||
|
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
|
||||||
|
// node that cannot be appended to throws on every render — see the note in the other factory.
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: (type: string, fn: () => void) =>
|
addEventListener: (type: string, fn: () => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal: () => void ((node as { open: boolean }).open = true),
|
showModal: () => void ((node as { open: boolean }).open = true),
|
||||||
|
|||||||
Reference in New Issue
Block a user