Say who is playing, what you can press, and where you are in the Stage
Mostly presentation, plus one rules fix found on the way.
This commit is contained in:
+24
-6
@@ -604,9 +604,9 @@ function checkPlay(
|
||||
case 'modifier': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby
|
||||
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'enhancement': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
@@ -1423,15 +1423,33 @@ function buildFreightFacility(kind: FreightKind): Facility {
|
||||
* Facility found instead. Modelling the per-Stage choice needs an extra decision point in the
|
||||
* Load/Unload phase and is not worth it until the mechanic has been played.
|
||||
*/
|
||||
function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null {
|
||||
/**
|
||||
* The neighbouring Facility a Modifier would attach to — and, when a Modifier is named, only one it
|
||||
* is actually ALLOWED to attach to.
|
||||
*
|
||||
* Every Modifier prints its host: a Waiting Area, a Restaurant and a Hotel go beside a Passenger
|
||||
* Facility, Forklifts beside a Freight House or Packing Sheds, and so on. That was not checked. Any
|
||||
* square touching ANY facility was offered — so a Waiting Area was legal beside a Mine Tipple and
|
||||
* `applyModifier` then handed its extra Porter to whichever facility the scan reached first, which
|
||||
* could be a different one again. The player saw three legal spots for a card that has one.
|
||||
*/
|
||||
function adjacentFacilityCoord(
|
||||
area: OfficeArea,
|
||||
coord: GridCoord,
|
||||
modifier?: ModifierKind,
|
||||
): GridCoord | null {
|
||||
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
|
||||
if (coord.row > area.runningRow) return null;
|
||||
const hosts = modifier ? modifierProfile(modifier).hosts : null;
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
const c = { row: coord.row + dr, col: coord.col + dc };
|
||||
if (c.row > area.runningRow) continue;
|
||||
if (area.grid.get(coordKey(c))?.facility) return c;
|
||||
const f = area.grid.get(coordKey(c))?.facility;
|
||||
if (!f) continue;
|
||||
if (hosts && !hosts.includes(f.subtype)) continue;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -1583,7 +1601,7 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
|
||||
|
||||
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
|
||||
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
|
||||
const target = adjacentFacilityCoord(area, coord);
|
||||
const target = adjacentFacilityCoord(area, coord, modifier);
|
||||
if (!target) return;
|
||||
const f = area.grid.get(coordKey(target))?.facility;
|
||||
if (!f) return;
|
||||
|
||||
+6
-4
@@ -59,9 +59,9 @@ export function phaseLabel(phase: string): string {
|
||||
case 'mainline':
|
||||
return 'Mainline';
|
||||
case 'loadUnload':
|
||||
return 'Load / Unload';
|
||||
return 'Cargo';
|
||||
case 'shiftChange':
|
||||
return 'Shift Change';
|
||||
return 'Supervisor Shift';
|
||||
default:
|
||||
return phase;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function phaseLabel(phase: string): string {
|
||||
export type Narration = {
|
||||
text: string;
|
||||
/** Colours the line in the viewer. */
|
||||
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
|
||||
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet' | 'phase';
|
||||
/** Board cell to highlight, if the event happened somewhere. */
|
||||
where?: GridCoord;
|
||||
};
|
||||
@@ -104,7 +104,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
case 'stageBegan':
|
||||
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
||||
case 'phaseBegan':
|
||||
return { tone: 'quiet', text: `${phaseLabel(e.phase)}` };
|
||||
// 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.
|
||||
return { tone: 'phase', text: `▸ ${phaseLabel(e.phase)} phase` };
|
||||
case 'actorChanged':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
|
||||
+6
-3
@@ -111,6 +111,8 @@ export type Frame = {
|
||||
stage: number;
|
||||
clock: string;
|
||||
phase: string;
|
||||
/** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */
|
||||
phaseKey: string;
|
||||
actor: number | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
@@ -332,11 +334,11 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
case 'draw.fromHomeOffice':
|
||||
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
||||
case 'draw.end':
|
||||
return 'done drawing — end my turn';
|
||||
return 'End my turn';
|
||||
case 'switch.end':
|
||||
return 'done switching — end my turn';
|
||||
return 'End my switching turn';
|
||||
case 'loadUnload.end':
|
||||
return 'done working — end the Load/Unload phase';
|
||||
return 'End my Cargo phase';
|
||||
case 'newTrain.passCar':
|
||||
return 'add no more cars to this train';
|
||||
case 'newTrain.secondSection':
|
||||
@@ -459,6 +461,7 @@ export function snapshot(
|
||||
stage: s.clock.stage,
|
||||
clock: clockTime(s.clock.stage),
|
||||
phase: phaseLabel(s.clock.phase),
|
||||
phaseKey: s.clock.phase,
|
||||
actor: s.clock.currentActor,
|
||||
superintendent: s.clock.superintendent,
|
||||
revenue: s.players[0]?.revenue ?? 0,
|
||||
|
||||
+44
-7
@@ -70,6 +70,15 @@ export type Game = {
|
||||
history: Intent[];
|
||||
/** Narrated lines, newest last. */
|
||||
log: { text: string; tone: string }[];
|
||||
/**
|
||||
* Set when a card has been drawn this turn and not yet played or discarded.
|
||||
*
|
||||
* §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible,
|
||||
* as a disabled button rather than a rejection after the click. UI state deliberately — putting
|
||||
* it in the engine would change what the bot may do, and with it every balance figure measured
|
||||
* against the current rules.
|
||||
*/
|
||||
mustPlayCard: boolean;
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
@@ -77,8 +86,14 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||||
{ prefix: 'switch.', title: 'Switching' },
|
||||
{ prefix: 'draw.', title: 'Draw' },
|
||||
{ prefix: 'card.', title: 'Cards' },
|
||||
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
||||
// three are different acts. Taking a face-up Department card is not the same decision as
|
||||
// gambling on the Home Office deck, and neither is ending the turn.
|
||||
{ prefix: 'draw.fromHomeOffice', title: 'Draw a card from the Home Office deck' },
|
||||
{ prefix: 'draw.fromDepartment', title: 'Take a Department card (face up)' },
|
||||
{ prefix: 'card.play', title: 'Play a card from my hand' },
|
||||
{ prefix: 'card.discard', title: 'Discard a card from my hand' },
|
||||
{ prefix: 'draw.end', title: 'Finish' },
|
||||
{ prefix: 'track.lay', title: 'Lay track from your supply' },
|
||||
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
|
||||
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
|
||||
@@ -91,9 +106,19 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||
{ prefix: 'redFlag.', title: 'Red flag' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The solitaire seat still has a NAME, because the history reads "Player Solitaire chose…" and a
|
||||
* log that says "You" cannot be read back by anyone else — a save is meant to be sent around.
|
||||
*/
|
||||
export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
|
||||
const game: Game = { state, seed, history: [], log: [] };
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
@@ -261,7 +286,14 @@ export function submit(game: Game, intent: Intent): boolean {
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
record(game, result.events);
|
||||
if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') {
|
||||
game.mustPlayCard = true;
|
||||
} else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') {
|
||||
// Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all,
|
||||
// so nothing can be left outstanding from them.
|
||||
game.mustPlayCard = false;
|
||||
}
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
return true;
|
||||
}
|
||||
@@ -290,7 +322,8 @@ export function view(game: Game): Frame {
|
||||
return snapshot(game.state, [], null);
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[]): void {
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||
for (const e of events) {
|
||||
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
||||
if (e.type === 'actorChanged') continue;
|
||||
@@ -298,7 +331,11 @@ function record(game: Game, events: GameEvent[]): void {
|
||||
cardName: (id) => cardName(game.state, id),
|
||||
trainName: (id) => trainName(game.state, id),
|
||||
});
|
||||
game.log.push({ text: n.text, tone: n.tone });
|
||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
const mine = who !== null && 'player' in e;
|
||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
}
|
||||
// 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);
|
||||
|
||||
+12
-9
@@ -33,7 +33,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
<body>
|
||||
<main>
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">Timetable-and-train-order railroading, 1840–1950.</p>
|
||||
<p class="tag">TimeTable and Train Order (TT&TO) railroading, 1840–1950.</p>
|
||||
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
@@ -42,24 +42,27 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</svg>
|
||||
|
||||
<p class="blurb">
|
||||
You run a division: a Running Track between your Limits, a district of industries hanging beneath
|
||||
it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
|
||||
loads, and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
As Station Master in your line-side office, you manage the town's railroad connection. You are
|
||||
responsible for all track between your Limits, a district of industries hanging beneath it, and
|
||||
trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
|
||||
and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
|
||||
<div class="doors">
|
||||
<a class="door" href="./play.html">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Five Days, twelve Stages each, and a target of 20 Revenue. Your game saves in this browser,
|
||||
so you can close the tab and come back to it.</p>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
without clearing your cache, your game is preserved and you can continue automatically.
|
||||
During the game you can also explicitly save your progress for later replay.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./replays.html">
|
||||
<h2>Replays</h2>
|
||||
<p>Watch a finished game back, Stage by Stage. Open one saved on this site, or a save file
|
||||
somebody has sent you.</p>
|
||||
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
|
||||
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
|
||||
<span class="go">Browse replays →</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -68,7 +71,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>runs entirely in your browser — nothing is sent anywhere</span>
|
||||
<span>runs entirely in your browser — no server code required</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
|
||||
+94
-7
@@ -38,6 +38,76 @@ const esc = (s: string): string =>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
|
||||
*
|
||||
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
|
||||
* player could not see what had already happened this Stage or what was coming. The printed game
|
||||
* has a turn chart for exactly this reason.
|
||||
*
|
||||
* Each phase carries a tooltip describing what actually happens in it — the first three are the
|
||||
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
|
||||
* sheet does not spell them out.
|
||||
*/
|
||||
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
||||
{
|
||||
key: 'localOps',
|
||||
label: 'Local Operations',
|
||||
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
|
||||
// a turnout: the choice of three roads
|
||||
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
|
||||
},
|
||||
{
|
||||
key: 'newTrain',
|
||||
label: 'New Train',
|
||||
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
|
||||
// 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"/>',
|
||||
},
|
||||
{
|
||||
key: 'mainline',
|
||||
label: 'Mainline',
|
||||
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
|
||||
// open road
|
||||
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
|
||||
},
|
||||
{
|
||||
key: 'loadUnload',
|
||||
label: 'Cargo',
|
||||
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
|
||||
// a loaded box on a flat
|
||||
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
|
||||
},
|
||||
{
|
||||
key: 'shiftChange',
|
||||
label: 'Supervisor Shift',
|
||||
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
|
||||
// a pocket watch
|
||||
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
|
||||
},
|
||||
];
|
||||
|
||||
function renderTurnChart(f: ReturnType<typeof view>): void {
|
||||
$('tc-day').textContent = `Day ${f.day}`;
|
||||
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
|
||||
$('tc-clock').textContent = f.clock;
|
||||
$('tc-phase').textContent = f.phase;
|
||||
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
|
||||
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
|
||||
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
|
||||
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
|
||||
|
||||
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
|
||||
$('tc-phases').innerHTML = PHASES.map((p, i) => {
|
||||
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
|
||||
return (
|
||||
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
|
||||
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
|
||||
`<span>${esc(p.label)}</span></li>`
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requested = params.get('seed');
|
||||
@@ -66,8 +136,7 @@ function render(): void {
|
||||
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
||||
}
|
||||
|
||||
$('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
||||
$('phase').textContent = f.phase;
|
||||
renderTurnChart(f);
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
const obj = $('objective');
|
||||
obj.textContent = f.objective.note;
|
||||
@@ -150,16 +219,19 @@ function render(): void {
|
||||
|
||||
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
||||
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
|
||||
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
|
||||
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
|
||||
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
||||
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
||||
`<b>${esc(name)}</b>` +
|
||||
(playable === false ? ' <span class="tag">not yet</span>' : '') +
|
||||
`</div>`;
|
||||
`<b>${esc(name)}</b></div>`;
|
||||
|
||||
const canPlay = handPlayable(game);
|
||||
$('hand').innerHTML = f.hand.length
|
||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
// Reference, not choices: the Department cards are three FACE-UP CARDS, not three decks — there
|
||||
// is nothing underneath them. Drawn like the hand so they read as cards, dashed and unlit so they
|
||||
// do not read as clickable.
|
||||
$('depts').innerHTML = f.departments.length
|
||||
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
||||
: '<span class="dim">none</span>';
|
||||
@@ -243,11 +315,26 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
||||
);
|
||||
};
|
||||
|
||||
let html = menu.direct
|
||||
// A heading over the buttons, so the panel says what it is before it says what is in it. The
|
||||
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
|
||||
// no worker able to act, the only thing offered is "End my Cargo phase".
|
||||
let html = '<h3 class="actions-hd">Actions</h3>';
|
||||
|
||||
html += menu.direct
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||
g.actions.map((a) => actionButton(a.label, a.index)).join('') +
|
||||
g.actions
|
||||
.map((a) => {
|
||||
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
||||
// the INTENT, not the label: matching button text would break the moment the wording
|
||||
// changed, and would have caught `switch.end` too.
|
||||
const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard;
|
||||
return blocked
|
||||
? `<button class="act blocked" disabled data-tip="You have drawn a card. Play it or discard it to a Department slot before ending your turn — §6.2 holds you to three cards.">${esc(a.label)} — play or discard first</button>`
|
||||
: actionButton(a.label, a.index);
|
||||
})
|
||||
.join('') +
|
||||
`</div>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
+82
-14
@@ -5,13 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master — play</title>
|
||||
<style>
|
||||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
|
||||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250;--now:#b98cf0}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
h1{font-size:17px;margin:0}
|
||||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
|
||||
margin:0 0 6px;font-weight:600}
|
||||
h2.big{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
||||
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
|
||||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||
.dim{color:var(--dim)}
|
||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
@@ -25,12 +27,21 @@ header button:hover{border-color:#4d6fa8}
|
||||
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
|
||||
.pace.good{background:rgba(40,140,60,.32)}
|
||||
.pace.behind{background:rgba(190,120,40,.28)}
|
||||
.handcard{border-top:1px solid var(--line);padding:4px 0}
|
||||
.handcard:first-child{border-top:0}
|
||||
.handcard{padding:3px 0}
|
||||
.handcard:focus{outline:1px solid #4d6fa8;border-radius:3px}
|
||||
.handcard.unplayable{opacity:.55}
|
||||
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
|
||||
/* CARDS YOU HOLD vs CARDS YOU READ.
|
||||
Both are drawn as cards, because they are cards. What separates them is the frame: a card in
|
||||
Your Move is clickable and carries the action border; a reference card is flat, unlit and
|
||||
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
|
||||
second list, so nothing looked like a card at all. */
|
||||
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
|
||||
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
|
||||
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
@@ -78,10 +89,24 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||||
used nowhere else, so "this is a move" is answered before the label is read. */
|
||||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
|
||||
color:#f2e6cf;font-weight:600}
|
||||
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
|
||||
#actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px}
|
||||
button.act{display:inline-block}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
so a disabled button must not wear it. */
|
||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||
color:#7d8794;box-shadow:none;cursor:not-allowed;font-weight:400}
|
||||
#actions button.blocked:hover{background:#232830;border-color:#4a5361;box-shadow:none}
|
||||
h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
||||
margin:0 0 7px;font-weight:700}
|
||||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||
.over.win{background:rgba(40,140,60,.35)}
|
||||
.over.loss{background:rgba(160,60,60,.3)}
|
||||
@@ -95,17 +120,51 @@ button.act{display:inline-block}
|
||||
#log{max-height:230px;overflow:auto;font-size:12px}
|
||||
.line{padding:1px 0}
|
||||
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
||||
/* A phase marker is a HEADING inside the log, and takes the same violet the turn chart uses for
|
||||
"where you are" — the two are saying the same thing in different places. `act` is what the
|
||||
player did, so it stays the brightest thing in the column. */
|
||||
.t-phase{color:var(--now);font-weight:700;margin-top:4px;letter-spacing:.02em}
|
||||
.t-act{color:#e6e9ee}
|
||||
.t-start{color:#cfe0f5;font-weight:700;font-size:13px;border-bottom:1px solid var(--line);
|
||||
padding-bottom:3px;margin-bottom:3px}
|
||||
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
|
||||
ul.blocked li{padding:2px 0}
|
||||
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
|
||||
/* turn chart */
|
||||
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
|
||||
background:#161b22;border-bottom:1px solid var(--line);padding:7px 14px}
|
||||
.tc-when{display:flex;align-items:baseline;gap:10px}
|
||||
.tc-when b{font-size:15px;color:#cfe0f5}
|
||||
.tc-now{font-size:12px;color:var(--dim)}
|
||||
.tc-now b{color:var(--now);font-size:13px}
|
||||
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
|
||||
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
|
||||
glances at most often, so it gets its own chip rather than hiding in the phase text. */
|
||||
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
|
||||
.tc-who b{color:var(--now);background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
|
||||
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:14px;
|
||||
padding:3px 10px 3px 7px;font-size:11px;color:var(--dim);background:#1a1f26;cursor:help}
|
||||
.tc-phase svg{display:block;flex:0 0 auto}
|
||||
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
|
||||
.tc-phase .icf{fill:#5f6b7a;stroke:none}
|
||||
.tc-phase.done{opacity:.55}
|
||||
/* VIOLET, not the amber used for actions.
|
||||
The palette already spends amber on "you can click this", blue on "this is a legal square or a
|
||||
link", green on good and red on bad. The turn chart is none of those — it reports WHERE YOU ARE,
|
||||
and a player should never read it as something to press. Violet is the one hue not already
|
||||
carrying a meaning here, so it cannot be confused with any of them. */
|
||||
.tc-phase.now{border-color:var(--now);background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
|
||||
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
|
||||
.tc-phase.now .ic{stroke:var(--now)}
|
||||
.tc-phase.now .icf{fill:var(--now)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||
<span id="clock">—</span>
|
||||
<span>phase: <b id="phase">—</b></span>
|
||||
<span>Revenue <b id="revenue">0</b></span>
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
@@ -115,6 +174,15 @@ ul.blocked li{padding:2px 0}
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
|
||||
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
|
||||
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
|
||||
<div id="turnchart">
|
||||
<div class="tc-when"><b id="tc-day">Day —</b><span id="tc-stage">Stage —</span><span class="dim" id="tc-clock">—</span></div>
|
||||
<div class="tc-now">phase <b id="tc-phase">—</b></div>
|
||||
<div class="tc-who">waiting on <b id="tc-who">—</b></div>
|
||||
<ol class="tc-phases" id="tc-phases"></ol>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||
@@ -123,12 +191,12 @@ ul.blocked li{padding:2px 0}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section><h2>Your move</h2><div id="actions"></div></section>
|
||||
<section><h2>Cards</h2>
|
||||
<div><span class="dim">hand:</span> <span id="hand">—</span></div>
|
||||
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
|
||||
<section><h2 class="big">Your Move</h2><div id="actions"></div></section>
|
||||
<section><h2 class="big">Cards Available</h2>
|
||||
<h3>Cards in My Hand</h3><div id="hand" class="cardrow">—</div>
|
||||
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
|
||||
</section>
|
||||
<section><h2>Your track supply</h2><div id="supply"></div></section>
|
||||
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user