v0.4.8 — the Limits bound the whole district, the nine spots really are nine, and an action points at its square
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQAJ4dND7enLj54eLyiF2C
This commit is contained in:
@@ -77,6 +77,7 @@ import {
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
variantsFor,
|
||||
withinLimits,
|
||||
} from './track.ts';
|
||||
|
||||
export type ApplyResult =
|
||||
@@ -1105,6 +1106,9 @@ function checkPlay(
|
||||
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
|
||||
return 'BREAKS_RUNNING_TRACK';
|
||||
}
|
||||
// Same reasoning: a siding reaching past your own sign JOINS perfectly well, and is refused
|
||||
// because it leaves your territory (§2.1). `canPlaceAt` enforces it too — this only names it.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
@@ -1123,6 +1127,10 @@ function checkPlay(
|
||||
* main line.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// A Facility CARRIES TRACK — "placing a Facility places track" (§11.2) — so it is bounded by
|
||||
// the Limits exactly as a siding is. An industry outside them is how a district used to leave
|
||||
// its own territory.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
|
||||
// alongside the consumer of the same commodity.
|
||||
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
|
||||
@@ -1133,6 +1141,18 @@ function checkPlay(
|
||||
case 'modifier': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
/**
|
||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
||||
* halves.
|
||||
*
|
||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
|
||||
@@ -143,6 +143,11 @@ export type RejectionCode =
|
||||
| 'ON_RUNNING_TRACK'
|
||||
/** A card with no east-west road would dead-end the Running Track it was laid in. */
|
||||
| 'BREAKS_RUNNING_TRACK'
|
||||
/**
|
||||
* §2.1 — track, and the Facilities that carry it, stay inside your own Limits at every row. A
|
||||
* Modifier is not track and is exempt (§9's nine spots), which is why this is not shared with it.
|
||||
*/
|
||||
| 'OUTSIDE_LIMITS'
|
||||
/**
|
||||
* A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg
|
||||
* — but only those, and only while the square is idle. See `checkTurnoutUpgrade`.
|
||||
|
||||
@@ -313,5 +313,35 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* THE NINE-SPOT MODIFIER NEIGHBOURHOOD (§9), which the four-square walk above cannot reach.
|
||||
*
|
||||
* A Modifier goes "adjacent to a Facility, on any of the nine nearby spots" — diagonals included —
|
||||
* and `check` has always accepted all eight. It was the CANDIDATES that were orthogonal-only, so a
|
||||
* diagonal square with no orthogonal neighbour was legal and never offered: reported by Jesse as
|
||||
* being unable to place a Modifier to the south-east of his industry.
|
||||
*
|
||||
* Only around a Facility, and only in a second pass. Track has to JOIN, and a diagonal shares no
|
||||
* edge, so a Modifier is the only card these squares can ever take — generating diagonals around
|
||||
* every card instead costs 54% more simulation time for candidates `check` then rejects. Appending
|
||||
* rather than interleaving leaves the existing order untouched, which matters because the bot
|
||||
* breaks ties by first-best: measured over 200 seeded games, 0 played differently.
|
||||
*/
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.facility) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const c of [
|
||||
{ row: row! + 1, col: col! + 1 },
|
||||
{ row: row! + 1, col: col! - 1 },
|
||||
{ row: row! - 1, col: col! + 1 },
|
||||
{ row: row! - 1, col: col! - 1 },
|
||||
]) {
|
||||
const k = `${c.row},${c.col}`;
|
||||
if (seen.has(k) || area.grid.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+31
-8
@@ -559,6 +559,31 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
||||
* a TURNOUT laid on the Running Track — the Office no longer carries a stub of its own, because no
|
||||
* office or industry card on the printed sheet does.
|
||||
*/
|
||||
/**
|
||||
* §2.1 — IS THIS SQUARE INSIDE THE DISTRICT AT ALL?
|
||||
*
|
||||
* The Limits sign denotes "the limit of your control area", and §3 defines Secondary Track as "all
|
||||
* tracks IN YOUR LIMITS that are not the Running Track" — so the bound belongs to the district, not
|
||||
* to the one row the sign happens to stand in. This used to guard the Running Track alone, on the
|
||||
* reasoning that the rows above and below were unbounded; the consequence was a siding snaking east
|
||||
* past your own sign, taking industries with it, and a player switching cars on track outside the
|
||||
* territory §8.1 and §10 reason about. Reported by Jesse: sidings must not be built outside the
|
||||
* Limits.
|
||||
*
|
||||
* INCLUSIVE OF THE SIGN'S OWN COLUMN. The sign stands ON the boundary rather than beyond it, and the
|
||||
* opening district is signs at ±1 around the Office — so the strict reading would leave exactly one
|
||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||
* neighbourhood, are usable from the first Stage.
|
||||
*
|
||||
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
|
||||
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
|
||||
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
|
||||
* grows onto.
|
||||
*/
|
||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||
}
|
||||
|
||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||
const existing = cardAt(area, coord);
|
||||
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
|
||||
@@ -575,14 +600,12 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
|
||||
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
|
||||
// side of the sign and then planted a SECOND sign further out, leaving the board reading
|
||||
// limits · Whistle Post · limits · straight · limits
|
||||
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
|
||||
if (coord.row === area.runningRow) {
|
||||
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
|
||||
// And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main
|
||||
// stops dead at it — the bot did exactly this, turning the west end of its own Running Track
|
||||
// into a stub and cutting the Office off from the Limits.
|
||||
if (!carriesThroughTrack(card)) return false;
|
||||
}
|
||||
// with a Limits card stranded mid-track.
|
||||
if (!withinLimits(area, coord)) return false;
|
||||
// And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main
|
||||
// stops dead at it — the bot did exactly this, turning the west end of its own Running Track
|
||||
// into a stub and cutting the Office off from the Limits.
|
||||
if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
|
||||
|
||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||
for (const p of ports) {
|
||||
|
||||
+44
-1
@@ -383,6 +383,16 @@ export function officeSvg(
|
||||
* offered without a word. Reported as the Depot lighting up for no visible reason.
|
||||
*/
|
||||
legal: { row: number; col: number; label: string }[] = [],
|
||||
/**
|
||||
* The columns the Limits signs stand in, drawn as the district's east and west edges.
|
||||
*
|
||||
* Omitted by the previews and by the replay viewers' single-card renders, which have no district
|
||||
* to bound. Where it is given, the boundary is drawn down the WHOLE canvas: track may not be laid
|
||||
* outside these columns at any row (§2.1), and two signs sitting on one row could not say that —
|
||||
* a player looking at open ground past a sign had no way to know nothing of his could ever go
|
||||
* there until the square failed to light up.
|
||||
*/
|
||||
limits: { west: number; east: number } | null = null,
|
||||
): string {
|
||||
const W = 166;
|
||||
const H = 96;
|
||||
@@ -447,7 +457,15 @@ export function officeSvg(
|
||||
return { x: W / 2, y: H };
|
||||
};
|
||||
|
||||
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
|
||||
/**
|
||||
* ROOM FOR THE BOUNDARY. The Limits edge is drawn just outside the sign's own column, and the west
|
||||
* sign is usually the leftmost card on the canvas — so at x = −1.5 the line lands outside a viewBox
|
||||
* starting at 0 and is simply not there. The margin is added only when there is a boundary to draw.
|
||||
*/
|
||||
const margin = limits ? 3 : 0;
|
||||
let out =
|
||||
`<svg class="bs" viewBox="${-margin} 0 ${width + margin * 2} ${height}" ` +
|
||||
`preserveAspectRatio="xMinYMin meet">`;
|
||||
for (const cell of cells) {
|
||||
const x = px(cell.col);
|
||||
const y = py(cell.row);
|
||||
@@ -804,6 +822,22 @@ export function officeSvg(
|
||||
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE LIMITS, AS THE EDGE OF THE BUILDABLE DISTRICT.
|
||||
*
|
||||
* Drawn just OUTSIDE the sign's own column, because the sign stands on the boundary rather than
|
||||
* beyond it: a siding may run under the sign, and the line has to agree with the rule or it
|
||||
* teaches the wrong one. It moves outward on its own as the Running Track is extended, which is
|
||||
* the reward for extending made visible.
|
||||
*/
|
||||
if (limits) {
|
||||
const edge = (x: number, side: string): string =>
|
||||
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
|
||||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
|
||||
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
|
||||
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
|
||||
}
|
||||
|
||||
// Mark the Running Track so the spine of the district is unmistakable.
|
||||
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||||
out += '</svg>';
|
||||
@@ -914,6 +948,10 @@ export const BOARD_CSS = `
|
||||
text.bs-mod{fill:#c8a04a}
|
||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||
/* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it
|
||||
reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */
|
||||
.bs-limitline{stroke:#5f6b7a;stroke-width:1.5;stroke-dasharray:3 5;opacity:.7}
|
||||
.bs-limitlab{fill:#5f6b7a;font:600 8px ui-monospace,monospace;letter-spacing:.12em}
|
||||
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
|
||||
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
|
||||
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
|
||||
@@ -924,6 +962,11 @@ text.bs-mod{fill:#c8a04a}
|
||||
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
|
||||
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
|
||||
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
||||
/* THE SQUARE THE ACTION UNDER THE CURSOR MEANS. White, and brighter than anything else the board
|
||||
marks, because it answers a question being asked RIGHT NOW rather than describing a standing
|
||||
state — and it has to win when it lands on a square that is already legal, focused or blocked. */
|
||||
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
|
||||
g[data-cell].bs-point .bs-card{fill:#2b3b52}
|
||||
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
|
||||
card reads the same way an action button does. bs-from is where it is standing now. */
|
||||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
||||
|
||||
+1
-1
@@ -499,7 +499,7 @@ function render() {
|
||||
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
||||
|
||||
/* Auto-hide: the district is worth its space during the phases that change it, and folded
|
||||
otherwise. A summary stays, because a panel that vanishes reads as broken. */
|
||||
|
||||
@@ -356,6 +356,15 @@ export type Frame = {
|
||||
cells: CellView[];
|
||||
/** Which grid row is the Running Track — the spine the district hangs beneath. */
|
||||
runningRow: number;
|
||||
/**
|
||||
* The columns the Limits signs stand in — the district's east and west edges, at EVERY row.
|
||||
*
|
||||
* Track may not be laid outside them (§2.1), so a player looking at open ground beyond a sign is
|
||||
* looking at ground no card of his will ever go on. Two signs on one row could not say that; the
|
||||
* board draws the boundary down the whole district instead. Carried on the Frame rather than read
|
||||
* off the area for the usual reason: a remote client holds no `GameState`.
|
||||
*/
|
||||
limits: { west: number; east: number };
|
||||
/**
|
||||
* Moves left in this Local Operations turn, or null outside a switching turn.
|
||||
*
|
||||
@@ -1201,6 +1210,7 @@ export function snapshot(
|
||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||
objective: objectiveOf(s, viewer),
|
||||
runningRow: area.runningRow,
|
||||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||||
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
|
||||
moves: switchingMoves(s, viewer),
|
||||
blocked: impediments(s, viewer),
|
||||
|
||||
+49
-4
@@ -87,9 +87,36 @@ export type ActionGroup = {
|
||||
* from the browser, which needs the whole `GameState`. A remote client has no state, so the menu
|
||||
* carries it. See `docs/architecture/multiplayer.md` §5.
|
||||
*/
|
||||
actions: { index: number; label: string; tip?: string }[];
|
||||
/**
|
||||
* `coord` is the square the action HAPPENS ON, when it happens on one.
|
||||
*
|
||||
* Carried as data rather than left inside the label. Half these buttons are near-identical
|
||||
* sentences distinguished only by a coordinate — "(1,3)" against "(-1,3)" — and picking the wrong
|
||||
* one is recoverable in solitaire, where Undo is a click, and a disaster in a multiplayer game
|
||||
* where it is not. The page hovers the matching square on the board instead of asking the player
|
||||
* to read the row and column off the button. Reported by Jesse.
|
||||
*
|
||||
* Resolved HERE for the same reason `tip` is: a remote client holds no `GameState` and cannot look
|
||||
* up where a tray is standing. See `docs/architecture/multiplayer.md` §5.
|
||||
*/
|
||||
actions: { index: number; label: string; tip?: string; coord?: { row: number; col: number } }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The square an intent acts on, or null when it acts on none.
|
||||
*
|
||||
* Only squares the intent NAMES. A card played out on the Mainline (`node`) is not a district
|
||||
* coordinate and must not be treated as one — that confusion is exactly why `placement` and `node`
|
||||
* are separate fields on the intent (see `intents.ts`) — and an action like ending a turn has no
|
||||
* square at all.
|
||||
*/
|
||||
export function coordOf(i: Intent): { row: number; col: number } | null {
|
||||
if ('at' in i) return i.at;
|
||||
if ('to' in i) return i.to;
|
||||
if (i.type === 'card.play' && i.placement) return i.placement;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Game = {
|
||||
state: GameState;
|
||||
seed: number;
|
||||
@@ -212,7 +239,13 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
* to survive every edit to a line nobody would think to check. One stray byte and every crew's
|
||||
* moves silently collapsed back into a single group. The tray is data; it travels as data.
|
||||
*/
|
||||
type Entry = { index: number; label: string; tip?: string; trayId?: string };
|
||||
type Entry = {
|
||||
index: number;
|
||||
label: string;
|
||||
tip?: string;
|
||||
trayId?: string;
|
||||
coord?: { row: number; col: number };
|
||||
};
|
||||
const byKind = new Map<string, Entry[]>();
|
||||
options.forEach((intent, index) => {
|
||||
const label = describeIntent(game.state, intent);
|
||||
@@ -227,14 +260,26 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
* on the crew as well makes the de-duplication per crew, which is what it always meant.
|
||||
*/
|
||||
if (!list.some((a) => a.label === label && a.trayId === trayId)) {
|
||||
list.push({ index, label, ...(tip ? { tip } : {}), ...(trayId ? { trayId } : {}) });
|
||||
const coord = coordOf(intent);
|
||||
list.push({
|
||||
index,
|
||||
label,
|
||||
...(tip ? { tip } : {}),
|
||||
...(trayId ? { trayId } : {}),
|
||||
...(coord ? { coord } : {}),
|
||||
});
|
||||
}
|
||||
byKind.set(intent.type, list);
|
||||
});
|
||||
|
||||
/** Groups take the plain shape; the crew was only ever needed to split them. */
|
||||
const plain = (entries: Entry[]): ActionGroup['actions'] =>
|
||||
entries.map(({ index, label, tip }) => ({ index, label, ...(tip ? { tip } : {}) }));
|
||||
entries.map(({ index, label, tip, coord }) => ({
|
||||
index,
|
||||
label,
|
||||
...(tip ? { tip } : {}),
|
||||
...(coord ? { coord } : {}),
|
||||
}));
|
||||
|
||||
const groups: ActionGroup[] = [];
|
||||
const used = new Set<string>();
|
||||
|
||||
+57
-5
@@ -301,7 +301,7 @@ function render(): void {
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits);
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
@@ -650,6 +650,51 @@ function renderDistrict(f: Frame): void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The square an action button acts on, as an attribute the board can be matched against.
|
||||
*
|
||||
* `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card
|
||||
* that goes to the timetable — so those buttons carry nothing and highlight nothing.
|
||||
*/
|
||||
function cellRef(coord: { row: number; col: number } | null | undefined): string {
|
||||
return coord ? ` data-square="${coord.row},${coord.col}"` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* POINT AT THE SQUARE THE BUTTON MEANS.
|
||||
*
|
||||
* The action list is a column of sentences that differ by a coordinate — "(1,3)" against "(-1,3)" —
|
||||
* and the board is right beside it saying nothing about which one is which. Hovering a button now
|
||||
* lights its square up, so the check happens with the eye rather than by reading two numbers off a
|
||||
* button and finding them on a map. Reported by Jesse: recoverable in solitaire, where Undo is a
|
||||
* click; a disaster in multiplayer, where it is not.
|
||||
*
|
||||
* ON FOCUS AS WELL AS HOVER, so tabbing through the list works the same way as pointing at it — the
|
||||
* keyboard route is not a lesser one.
|
||||
*
|
||||
* BOTH KINDS OF SQUARE. A played card is a `data-cell`; a square being placed ONTO is an empty
|
||||
* `data-ghost` target, which is precisely the case where the player is choosing between coordinates.
|
||||
* Matching only one of the two would leave the most mistake-prone moment unhelped.
|
||||
*/
|
||||
function wirePointing(node: HTMLElement, key: string): void {
|
||||
const grid = $('grid');
|
||||
const marks = (): Element[] =>
|
||||
[
|
||||
grid.querySelector(`g[data-cell="${key}"]`),
|
||||
grid.querySelector(`g[data-ghost="${key}"]`),
|
||||
].filter((g): g is Element => g !== null);
|
||||
const on = (): void => {
|
||||
for (const g of marks()) g.classList.add('bs-point');
|
||||
};
|
||||
const off = (): void => {
|
||||
for (const g of marks()) g.classList.remove('bs-point');
|
||||
};
|
||||
node.onmouseenter = on;
|
||||
node.onmouseleave = off;
|
||||
node.onfocus = on;
|
||||
node.onblur = off;
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
menu: Menu,
|
||||
f: Frame,
|
||||
@@ -694,14 +739,19 @@ function renderActions(
|
||||
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
|
||||
* which had neither a name for the card it meant nor a word about what it would do.
|
||||
*/
|
||||
const actionButton = (a: { index: number; label: string; tip?: string }): string => {
|
||||
const actionButton = (a: {
|
||||
index: number;
|
||||
label: string;
|
||||
tip?: string;
|
||||
coord?: { row: number; col: number };
|
||||
}): string => {
|
||||
const { label, index } = a;
|
||||
const cut = label.indexOf(' — ');
|
||||
const head = cut > 0 ? label.slice(0, cut) : label;
|
||||
// The menu resolved the card's description server-side, so the page never needs the state.
|
||||
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
|
||||
return (
|
||||
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
||||
`<button class="act" data-i="${index}"${cellRef(a.coord)}${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -748,7 +798,7 @@ function renderActions(
|
||||
f.moves
|
||||
.map(
|
||||
(m) =>
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}" ` +
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
|
||||
)
|
||||
@@ -847,7 +897,7 @@ function renderActions(
|
||||
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
|
||||
: '';
|
||||
return (
|
||||
`<button class="act" data-i="${sp.index}"${fig}` +
|
||||
`<button class="act" data-i="${sp.index}"${fig}${cellRef(sp.coord)}` +
|
||||
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
|
||||
);
|
||||
})
|
||||
@@ -882,6 +932,8 @@ function renderActions(
|
||||
render();
|
||||
}
|
||||
: () => apply(Number(node.dataset['i']));
|
||||
const square = node.dataset['square'];
|
||||
if (square) wirePointing(node, square);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ function show(i: number): void {
|
||||
$('vpos').textContent = `${at} / ${steps.length - 1}`;
|
||||
($('vscrub') as HTMLInputElement).value = String(at);
|
||||
$('vdivision').innerHTML = divisionSvg(f.division);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow, [], [], f.limits);
|
||||
|
||||
// Auto-hide: the district is worth its space during the phases that change it.
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
|
||||
Reference in New Issue
Block a user