user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.
This commit is contained in:
+178
-6
@@ -33,6 +33,7 @@ import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import {
|
||||
cardDescription,
|
||||
cardName,
|
||||
describeIntent,
|
||||
geometryLabel,
|
||||
@@ -40,10 +41,10 @@ import {
|
||||
trainName,
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
|
||||
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { areaOf } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
@@ -208,15 +209,78 @@ export type Placeable = {
|
||||
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
||||
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
||||
*/
|
||||
spots: { label: string; index: number; coord: { row: number; col: number } }[];
|
||||
spots: {
|
||||
label: string;
|
||||
index: number;
|
||||
coord: { row: number; col: number };
|
||||
/**
|
||||
* The rails this placement would put on the card, as `connectionsFor` codes.
|
||||
*
|
||||
* So the UI can DRAW the piece as it would land rather than only describing it. Words alone are
|
||||
* not enough for a turnout: "allows traffic from the east to travel west or turn to the south"
|
||||
* is exact and still leaves a player working out which way the leg points on the board. Taken
|
||||
* from the engine's own connections, so a preview cannot promise a shape the placement will not
|
||||
* produce.
|
||||
*/
|
||||
links: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A CARD IN HAND, AND WHAT MAY BE DONE WITH IT.
|
||||
*
|
||||
* The hand is the action surface. A card used to appear in two panels under two different models:
|
||||
* as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and
|
||||
* as one flat button per Department under "Discard a card from my hand". One card, two mental
|
||||
* models, and the discard block was a cross-product — four cards x three Departments was twelve
|
||||
* buttons and about 290px of the action list, repeating the same three choices four times.
|
||||
*
|
||||
* Both verbs now hang off the card itself, and both use the pattern the board placement already
|
||||
* had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is
|
||||
* already on screen with its top card and depth — exactly what you need to choose between them.
|
||||
*/
|
||||
export type HandAction = {
|
||||
cardId: string;
|
||||
name: string;
|
||||
what: string;
|
||||
/** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */
|
||||
playNow: number | null;
|
||||
/** Playable onto the board: the `subjectKey` of the matching `placeable` item. */
|
||||
placeKey: string | null;
|
||||
/** How many squares it may go on, for the count on the button. */
|
||||
spots: number;
|
||||
/** The option index for discarding onto each Department pile, or null where that is illegal. */
|
||||
discard: (number | null)[];
|
||||
/**
|
||||
* Every distinct shape the card could be laid as, for the hover preview.
|
||||
*
|
||||
* Derived from the CARD, not from its legal placements: what a piece looks like does not depend
|
||||
* on whether there is currently a square for it, and a player picking through a hand needs to see
|
||||
* the shape most when there is nowhere obvious to put it. Reading it off the placements meant a
|
||||
* curve with no legal square showed no preview at all.
|
||||
*/
|
||||
shapes: string[][];
|
||||
};
|
||||
|
||||
/** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */
|
||||
export type MakeUpAction = { carType: string; loaded: boolean; index: number };
|
||||
|
||||
export type Menu = {
|
||||
options: Intent[];
|
||||
/** Actions with no further choice to make. */
|
||||
direct: ActionGroup[];
|
||||
/** Actions needing a location, grouped under their card or track piece. */
|
||||
placeable: { title: string; items: Placeable[] }[];
|
||||
/** One entry per card in hand, with the verbs available to it. */
|
||||
hand: HandAction[];
|
||||
/**
|
||||
* Making up a train: the cars that may be added, and the option to add none.
|
||||
*
|
||||
* Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard
|
||||
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
||||
* these key each chip to the option that adds it.
|
||||
*/
|
||||
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
||||
};
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
@@ -241,7 +305,7 @@ export function actionMenu(game: Game): Menu {
|
||||
spots: [],
|
||||
};
|
||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
|
||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
|
||||
}
|
||||
bucket.set(key.subjectKey, entry);
|
||||
placeableByTitle.set(g.title, bucket);
|
||||
@@ -253,16 +317,100 @@ export function actionMenu(game: Game): Menu {
|
||||
title,
|
||||
items: [...m.values()],
|
||||
}));
|
||||
return { options, direct, placeable };
|
||||
|
||||
/**
|
||||
* THE HAND, AND WHAT EACH CARD CAN DO.
|
||||
*
|
||||
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
|
||||
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
|
||||
* of objects the player is looking at, not a list to sort.
|
||||
*/
|
||||
const handIds = game.state.decks.hands.get(0) ?? [];
|
||||
const hand: HandAction[] = handIds.map((cardId) => {
|
||||
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
|
||||
let playNow: number | null = null;
|
||||
const discard: (number | null)[] = [null, null, null];
|
||||
options.forEach((i, index) => {
|
||||
if (i.type === 'card.play' && i.cardId === cardId && i.placement === undefined) playNow = index;
|
||||
if (i.type === 'card.discard' && i.cardId === cardId) discard[i.toSlot] = index;
|
||||
});
|
||||
const kind = game.state.cards.get(cardId)?.kind;
|
||||
const shapes =
|
||||
kind?.kind === 'track'
|
||||
? variantsFor(kind.geometry, kind.hand).map((v) =>
|
||||
connectionsFor({
|
||||
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never).map(([a, b]) => `${a}${b}`),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
cardId,
|
||||
name: cardName(game.state, cardId),
|
||||
what: cardDescription(game.state, cardId),
|
||||
playNow,
|
||||
placeKey: place ? place.subjectKey : null,
|
||||
spots: place ? place.spots.length : 0,
|
||||
discard,
|
||||
shapes: [...new Map(shapes.map((l) => [l.join('|'), l])).values()],
|
||||
};
|
||||
});
|
||||
|
||||
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
|
||||
// car is where the click belongs.
|
||||
const makeUpCars: MakeUpAction[] = [];
|
||||
let pass: number | null = null;
|
||||
options.forEach((i, index) => {
|
||||
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
||||
if (i.type === 'newTrain.passCar') pass = index;
|
||||
});
|
||||
const makeUp =
|
||||
makeUpCars.length > 0 || pass !== null
|
||||
? {
|
||||
title:
|
||||
consistTitle(
|
||||
game,
|
||||
options,
|
||||
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
|
||||
) ?? 'Making up the train',
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
}
|
||||
: null;
|
||||
|
||||
return { options, direct, placeable, hand, makeUp };
|
||||
}
|
||||
|
||||
/** Split an intent into "what" and "where", or null if it needs no location. */
|
||||
function subjectOf(
|
||||
game: Game,
|
||||
i: Intent,
|
||||
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
|
||||
): {
|
||||
subjectKey: string;
|
||||
subject: string;
|
||||
spot: string;
|
||||
coord: { row: number; col: number };
|
||||
links: string[];
|
||||
} | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
if (i.type === 'card.play' && i.placement) {
|
||||
/**
|
||||
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
|
||||
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
|
||||
* "(0, 1)" — which is the wrong half of the board entirely.
|
||||
*/
|
||||
const k = game.state.cards.get(i.cardId)?.kind;
|
||||
if (k?.kind === 'enhancement' && i.placement.row === -1) {
|
||||
const node = game.state.division.nodes[i.placement.col];
|
||||
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
|
||||
return {
|
||||
subjectKey: `card:${i.cardId}`,
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `on the ${where}, out on the Mainline`,
|
||||
coord: i.placement,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
|
||||
// the decision, so they ride on the spot rather than being left for the player to work out.
|
||||
const kind = game.state.cards.get(i.cardId)?.kind;
|
||||
@@ -276,11 +424,35 @@ function subjectOf(
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `${at(i.placement)}${track}`,
|
||||
coord: i.placement,
|
||||
links: placementLinks(game, i.cardId, i.variant),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rails a card would lay, in `connectionsFor` codes — the same source the board draws from.
|
||||
*
|
||||
* Built by asking the engine what the card becomes, not by re-deriving it: a preview that worked out
|
||||
* the shape for itself would eventually show a different piece from the one the placement lays, which
|
||||
* is precisely the confusion it exists to remove.
|
||||
*/
|
||||
function placementLinks(game: Game, cardId: string, variant: number | undefined): string[] {
|
||||
const kind = game.state.cards.get(cardId)?.kind;
|
||||
if (!kind) return [];
|
||||
if (kind.kind === 'track') {
|
||||
const v = variantsFor(kind.geometry, kind.hand)[variant ?? 0];
|
||||
if (!v) return [];
|
||||
return connectionsFor({
|
||||
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never).map(([a, b]) => `${a}${b}`);
|
||||
}
|
||||
// Everything else that goes on the grid is a plain through track, or is not track at all.
|
||||
if (kind.kind === 'freightFacility' || kind.kind === 'office') return ['ew'];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
|
||||
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
|
||||
|
||||
Reference in New Issue
Block a user