v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them
This commit is contained in:
@@ -84,7 +84,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
}[];
|
||||
cap: number | null;
|
||||
tip: string;
|
||||
owner: number | null;
|
||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||
seat: number | null;
|
||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||
regions: number;
|
||||
w: number;
|
||||
@@ -120,7 +121,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
: rc.trains,
|
||||
cap: isOffice ? cap : null,
|
||||
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
|
||||
owner: n.owner ?? null,
|
||||
seat: n.seat ?? null,
|
||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||
// occupies a card outright rather than a part of one.
|
||||
regions: 0,
|
||||
@@ -148,7 +149,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
tip: dp
|
||||
? '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(' · ')}` : ''}`,
|
||||
owner: null,
|
||||
seat: null,
|
||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||
regions: dp ? 1 : (n.regions ?? 0),
|
||||
w: dp ? CW.dp : CW.ml,
|
||||
|
||||
+18
-20
@@ -27,7 +27,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import { coordKey, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
@@ -206,7 +206,7 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
|
||||
: options;
|
||||
const usable = held.length > 0 ? held : options;
|
||||
if (s.turn.option === null) return chooseLocalOption(s, player, usable, tweaks);
|
||||
if (turnOf(s, player).option === null) return chooseLocalOption(s, player, usable, tweaks);
|
||||
return followThrough(s, player, usable, tweaks);
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@ function chooseLocalOption(
|
||||
* NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight.
|
||||
*
|
||||
* There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify`
|
||||
* requires `s.turn.option === 'draw'` and this runs while the option is still null, so
|
||||
* requires `turnOf(s, player).option === 'draw'` and this runs while the option is still null, so
|
||||
* `legalActions` had already filtered it out — the branch could never fire and never had.
|
||||
*
|
||||
* Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game,
|
||||
@@ -402,7 +402,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
|
||||
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||||
* attack — which is why the choice belongs to the discarding player and not to the rules.
|
||||
*/
|
||||
function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
@@ -412,7 +412,7 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
|
||||
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
|
||||
const top = topOfDepartment(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, player, i.toSlot);
|
||||
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
|
||||
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
|
||||
if (score > bestScore) {
|
||||
@@ -424,8 +424,8 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
return takingRank(s, slot) > 0;
|
||||
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
|
||||
return takingRank(s, player, slot) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,12 +435,12 @@ function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
* happened to be scanned first — a coin flip on the card that decides whether the district ever
|
||||
* becomes a Passenger Facility at all.
|
||||
*/
|
||||
function takingRank(s: GameState, slot: number): number {
|
||||
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
|
||||
const id = topOfDepartment(s, slot);
|
||||
if (!id) return 0;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 0;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
|
||||
if (k.kind === 'freightFacility') return 1;
|
||||
return 0;
|
||||
@@ -646,8 +646,7 @@ function runAroundCells(area: OfficeArea): Set<string> {
|
||||
* there is one the crew dare not serve.
|
||||
*/
|
||||
function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
const area = areaOf(s, player);
|
||||
|
||||
const onLoop = runAroundCells(area);
|
||||
const reachable = reachableOffMain(area);
|
||||
@@ -703,8 +702,7 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
}
|
||||
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
const area = areaOf(s, player);
|
||||
|
||||
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
|
||||
|
||||
@@ -1125,17 +1123,17 @@ function followThrough(
|
||||
options: Intent[],
|
||||
tweaks: BotTweaks,
|
||||
): Intent {
|
||||
switch (s.turn.option) {
|
||||
switch (turnOf(s, player).option) {
|
||||
case 'draw': {
|
||||
// Draw before playing — otherwise the hand empties and never refills.
|
||||
//
|
||||
// Prefer a face-up Department card only when it is actually worth having. Taking the visible
|
||||
// card unconditionally meant the bot never once drew blind from the deck across 60 games,
|
||||
// which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
|
||||
if (!s.turn.drawnThisTurn) {
|
||||
if (!turnOf(s, player).drawnThisTurn) {
|
||||
const piles = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
|
||||
);
|
||||
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
|
||||
// both "qualify", and only one of them stops the collisions.
|
||||
@@ -1143,7 +1141,7 @@ function followThrough(
|
||||
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
|
||||
// ranking function is for, and a coin flip on the card that decides whether the district
|
||||
// ever becomes a Passenger Facility is not worth keeping for its own sake.
|
||||
const useful = piles.sort((a, b) => takingRank(s, b.slot) - takingRank(s, a.slot))[0];
|
||||
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
|
||||
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
|
||||
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
||||
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
|
||||
@@ -1235,7 +1233,7 @@ function followThrough(
|
||||
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||
return because(
|
||||
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
|
||||
bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1654,14 +1652,14 @@ function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; c
|
||||
|
||||
function trayOf(s: GameState, player: PlayerIndex) {
|
||||
for (const tray of s.trays.values()) {
|
||||
if (tray.position.at === 'grid' && tray.position.owner === player) return tray;
|
||||
if (tray.position.at === 'grid' && tray.position.seat === player) return tray;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
|
||||
for (const tray of s.trays.values()) {
|
||||
if (tray.position.at === 'grid' && tray.position.owner === player) {
|
||||
if (tray.position.at === 'grid' && tray.position.seat === player) {
|
||||
return tray.position.coord;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -15,9 +15,9 @@
|
||||
* panel cannot drift from the rules.
|
||||
*/
|
||||
|
||||
import { adTrackCount, coordKey } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
|
||||
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -446,10 +446,9 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait
|
||||
* This is the panel that should answer the standing questions: whether facilities jam, whether
|
||||
* trains are held for want of a crew, whether the Office is about to cause a collision.
|
||||
*/
|
||||
export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
|
||||
const out: Impediment[] = [];
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return out;
|
||||
const area = areaOf(s, player);
|
||||
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
@@ -512,9 +511,10 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
|
||||
* squares the movement walk reached and refused, not every square on the board.
|
||||
*/
|
||||
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
|
||||
const turn = turnOf(s, player);
|
||||
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const { blocked } = movesFor(s, player, id);
|
||||
for (const b of blocked) {
|
||||
// A turnout is not an obstruction — a train runs through one all day and simply may not
|
||||
@@ -583,7 +583,7 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
}
|
||||
|
||||
// A full Office means the next arrival is an automatic collision (Gap 2d).
|
||||
const cap = adTrackCount(s, player);
|
||||
const cap = adTrackCount(s, seatOf(s, player));
|
||||
if (area.adOccupancy.length >= cap) {
|
||||
out.push({
|
||||
where: 'Office',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { areaAtSeat } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { BotPolicy } from './bot.ts';
|
||||
import { developerBot, makeDeveloperBot } from './bot.ts';
|
||||
@@ -71,7 +72,7 @@ export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000
|
||||
save: toSave(game),
|
||||
note:
|
||||
`${revenue} Revenue over ${game.state.clock.day - 1} Days · ${trains} train(s) on the timetable · ` +
|
||||
`${game.state.officeAreas.get(0)?.grid.size ?? 0} cards down` +
|
||||
`${areaAtSeat(game.state, 0).grid.size} cards down` +
|
||||
(collisions > 0 ? ` · ${collisions} collision(s)` : ' · no collisions'),
|
||||
};
|
||||
}
|
||||
|
||||
+6
-3
@@ -18,7 +18,7 @@
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameState } from '../engine/state.ts';
|
||||
import { areaOf, facilityCarTypes } from '../engine/apply.ts';
|
||||
import { areaAtSeat, areaOf, facilityCarTypes } from '../engine/apply.ts';
|
||||
import { officeProfile } from '../engine/content.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,7 +121,7 @@ export function makeFunnelProbe(player = 0): {
|
||||
// A buried engine is a per-decision condition: every turn it persists is a turn the train is
|
||||
// stuck, so this counts turns rather than trains.
|
||||
for (const tray of s.trays.values()) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== player) continue;
|
||||
if (tray.engineAt <= 0 || tray.engineAt >= tray.consist.length) continue;
|
||||
funnel.buriedTurns += 1;
|
||||
// Cars may never be set out at the Office (§A.4), which is where this almost always happens.
|
||||
@@ -295,7 +295,10 @@ export function summarize(
|
||||
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
|
||||
const gross = grossFreight + grossPassenger;
|
||||
|
||||
const area = final.officeAreas.get(0);
|
||||
// Seat 0 explicitly: these are SOLITAIRE summaries, where there is exactly one Office Area and
|
||||
// seat 0 is the only player. `areaAtSeat` rather than `officeAreas.get` so the key's meaning is
|
||||
// stated — a multi-player report would have to sum over seats, and would fail loudly here first.
|
||||
const area = areaAtSeat(final, 0);
|
||||
let freightFacilities = 0;
|
||||
if (area) {
|
||||
for (const card of area.grid.values()) {
|
||||
|
||||
+57
-20
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
areaAtSeat,
|
||||
areaOf,
|
||||
destinationsFor,
|
||||
facilityCarType,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { playerAtSeat, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
@@ -250,7 +252,8 @@ export type DivisionView = {
|
||||
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
||||
running?: RunningCardView[];
|
||||
/** Office nodes only: whose district this is. */
|
||||
owner?: number;
|
||||
/** Which SEAT's district this is — a position on the Division, not a player. */
|
||||
seat?: number;
|
||||
/**
|
||||
* Office nodes only: crews working BELOW the Running Track.
|
||||
*
|
||||
@@ -275,6 +278,21 @@ export type Frame = {
|
||||
actor: number | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
/**
|
||||
* The rest of what a client needs so it never has to reach into `GameState`.
|
||||
*
|
||||
* The browser client used to read `game.state` in eleven places for exactly these. That is fine
|
||||
* with the engine in the same process and impossible with a server, where the client holds no
|
||||
* state at all — so they live on the projection instead. See `docs/architecture/multiplayer.md` §5.
|
||||
*/
|
||||
/** Which of §6's three exclusive options the VIEWER has taken this Stage, if any. */
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
/** Every seat's public standing — names and Revenue. "The race is the game" (protocol.md §4). */
|
||||
players: { index: number; name: string; revenue: number; hand: number }[];
|
||||
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
|
||||
handCount: number;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
@@ -548,7 +566,10 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
|
||||
// An intent belongs to whoever is acting, so it is described against THEIR district.
|
||||
const seat: PlayerIndex = s.clock.currentActor ?? 0;
|
||||
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
|
||||
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
|
||||
// exact confusion the seat/player split exists to stop.
|
||||
const actor: PlayerIndex = s.clock.currentActor ?? 0;
|
||||
switch (i.type) {
|
||||
case 'localOps.choose':
|
||||
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
|
||||
@@ -578,7 +599,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
|
||||
*/
|
||||
const over = i.placement
|
||||
? s.officeAreas.get(seat)?.grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
: undefined;
|
||||
const upgrade = over?.geometry.kind === 'track';
|
||||
return (
|
||||
@@ -616,7 +637,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
|
||||
let picks = '';
|
||||
if (here) {
|
||||
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
|
||||
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse)
|
||||
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (dest && dest.couples.length > 0) {
|
||||
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
|
||||
@@ -651,7 +672,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
* waiting for a train that can carry it. For a passenger facility that load is passengers on
|
||||
* the platform.
|
||||
*/
|
||||
const f = areaOf(s, seat).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
|
||||
const f = areaOf(s, actor).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`
|
||||
@@ -786,9 +807,14 @@ export function snapshot(
|
||||
viewer: PlayerIndex = 0,
|
||||
): Frame {
|
||||
const area = areaOf(s, viewer);
|
||||
const viewerSeat = seatOf(s, viewer);
|
||||
const trayAt = new Map<string, string>();
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid') {
|
||||
// KEYED BY COORDINATE, so it must be filtered by seat first. Every district uses the same
|
||||
// (row, col) origin, so without this a crew standing at (0,1) in one player's Office Area is
|
||||
// drawn onto (0,1) of every other player's board — the cells come from `area.grid`, which is
|
||||
// the viewer's, but the train on them came from anybody's.
|
||||
if (tray.position.at === 'grid' && tray.position.seat === viewerSeat) {
|
||||
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
||||
@@ -906,14 +932,14 @@ export function snapshot(
|
||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||
};
|
||||
}
|
||||
const oa = areaOf(s, n.owner);
|
||||
const oa = areaAtSeat(s, n.seat);
|
||||
|
||||
// Where every crew in this district actually is: on a Running Track card, or below it.
|
||||
const onRunning = new Map<string, TrainChip[]>();
|
||||
const below: TrainChip[] = [];
|
||||
for (const [id, tray] of s.trays) {
|
||||
const pos = tray.position;
|
||||
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
|
||||
if (pos.at !== 'grid' || pos.seat !== n.seat) continue;
|
||||
const c = trainChip(s, id);
|
||||
if (pos.coord.row === oa.runningRow) {
|
||||
const k = `${pos.coord.row},${pos.coord.col}`;
|
||||
@@ -954,7 +980,7 @@ export function snapshot(
|
||||
capacity: officeProfile(oa.tier).adTracks,
|
||||
modifiers: [],
|
||||
gradeUp: null,
|
||||
owner: n.owner,
|
||||
seat: n.seat,
|
||||
running,
|
||||
switching: below,
|
||||
};
|
||||
@@ -968,7 +994,7 @@ export function snapshot(
|
||||
phaseKey: s.clock.phase,
|
||||
actor: s.clock.currentActor,
|
||||
superintendent: s.clock.superintendent,
|
||||
revenue: s.players[0]?.revenue ?? 0,
|
||||
revenue: s.players[viewer]?.revenue ?? 0,
|
||||
lines,
|
||||
where,
|
||||
whereFrom,
|
||||
@@ -1011,11 +1037,21 @@ export function snapshot(
|
||||
timetable: [...s.timetable],
|
||||
decision,
|
||||
wasted,
|
||||
objective: objectiveOf(s),
|
||||
option: turnOf(s, viewer).option,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
players: s.players.map((p) => ({
|
||||
index: p.index,
|
||||
name: p.name,
|
||||
revenue: p.revenue,
|
||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||||
})),
|
||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||
objective: objectiveOf(s, viewer),
|
||||
runningRow: area.runningRow,
|
||||
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
|
||||
moves: switchingMoves(s, 0),
|
||||
blocked: impediments(s, 0),
|
||||
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
|
||||
moves: switchingMoves(s, viewer),
|
||||
blocked: impediments(s, viewer),
|
||||
trains: [...s.trays.values()].map((t) => ({
|
||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
where:
|
||||
@@ -1410,10 +1446,10 @@ const SIMPLE_CARDS = [
|
||||
...ACTION_CARDS,
|
||||
];
|
||||
|
||||
/** The goal, and whether the current score is keeping up with the clock. */
|
||||
function objectiveOf(s: GameState): Frame['objective'] {
|
||||
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
|
||||
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
|
||||
const profile = lengthProfile(s.config.length);
|
||||
const revenue = s.players[0]?.revenue ?? 0;
|
||||
const revenue = s.players[viewer]?.revenue ?? 0;
|
||||
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
|
||||
const elapsed = profile.days - daysLeft + 1;
|
||||
// Straight-line pace: by the end of Day N you want N/days of the target.
|
||||
@@ -1505,10 +1541,11 @@ function countStock(
|
||||
* question the page does not yet ask.
|
||||
*/
|
||||
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
|
||||
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
|
||||
if (s.turn.movesRemaining < 1) return null;
|
||||
const turn = turnOf(s, player);
|
||||
if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return null;
|
||||
if (turn.movesRemaining < 1) return null;
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const { to, blocked } = movesFor(s, player, id);
|
||||
return { from: tray.position.coord, to, blocked };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user