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:
+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