v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist
This commit is contained in:
+44
-6
@@ -42,8 +42,15 @@ import {
|
||||
trainName,
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_HOUSE_RULES,
|
||||
HAND_LIMIT,
|
||||
LEGACY_HOUSE_RULES,
|
||||
houseRules,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
|
||||
@@ -59,8 +66,16 @@ export const SOLO_CONFIG: GameConfig = {
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
// Spelt out rather than left to fall through, so a save written by the page always names the rules
|
||||
// it was played under — see `Save.rules`.
|
||||
houseRules: DEFAULT_HOUSE_RULES,
|
||||
};
|
||||
|
||||
/** The same config with the New Game dialog's answers in it. */
|
||||
export function configWith(rules: HouseRuleOverrides): GameConfig {
|
||||
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
|
||||
}
|
||||
|
||||
/** A group of legal actions of one kind, ready to put on screen. */
|
||||
export type ActionGroup = {
|
||||
kind: string;
|
||||
@@ -708,10 +723,31 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
// Saving — seed plus intents, replayed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Save = { seed: number; history: Intent[] };
|
||||
/**
|
||||
* A save is a seed, the house rules it was dealt under, and the intents submitted.
|
||||
*
|
||||
* `rules` is optional because saves written before the New Game dialog existed do not have it, and
|
||||
* those replay under `LEGACY_HOUSE_RULES` — see `configFor`. Everything written from now on carries
|
||||
* its rules, so a save can never again be silently re-dealt by a change of default.
|
||||
*/
|
||||
export type Save = { seed: number; history: Intent[]; rules?: HouseRuleOverrides };
|
||||
|
||||
export function toSave(game: Game): Save {
|
||||
return { seed: game.seed, history: game.history };
|
||||
const rules = game.state.config.houseRules;
|
||||
return rules ? { seed: game.seed, history: game.history, rules } : { seed: game.seed, history: game.history };
|
||||
}
|
||||
|
||||
/**
|
||||
* THE CONFIG A SAVE MUST BE REPLAYED UNDER, which is not necessarily today's default.
|
||||
*
|
||||
* A save is a seed and a list of intents: replay it under different rules and it is a different
|
||||
* game, and the symptom is not an error but a replay that quietly stops early. `TODO.md` records
|
||||
* that happening twice unnoticed, once 42 intents into 360. So a save that names its rules gets
|
||||
* exactly those, and a save that names none is from before the dialog and gets the rules that were
|
||||
* in force then — never the current defaults.
|
||||
*/
|
||||
function configFor(save: Save, config: GameConfig): GameConfig {
|
||||
return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -736,7 +772,9 @@ export function toSave(game: Game): Save {
|
||||
*/
|
||||
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
|
||||
if (game.history.length === 0) return null;
|
||||
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
|
||||
// `toSave` first, so the rules this game was dealt under come with it. Rebuilding the save by hand
|
||||
// here dropped them, and undo re-dealt the game under the defaults instead of its own settings.
|
||||
return fromSave({ ...toSave(game), history: game.history.slice(0, -1) }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -747,7 +785,7 @@ export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null
|
||||
* stops the replay rather than being forced — better a short game than a corrupt one.
|
||||
*/
|
||||
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const game = newGame(save.seed, config);
|
||||
const game = newGame(save.seed, configFor(save, config));
|
||||
for (const intent of save.history) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
|
||||
+124
-22
@@ -12,7 +12,8 @@ import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
|
||||
import { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
|
||||
import type { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||
import type { LocalSession } from './session.ts';
|
||||
import { createLocalSession } from './session.ts';
|
||||
|
||||
@@ -112,14 +113,73 @@ function renderTurnChart(f: Frame): void {
|
||||
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings this game was dealt under, beside the seed, because the seed alone does not name it.
|
||||
*
|
||||
* Abbreviated to fit a header that must not wrap — `3 cards · 1/1/0` — with the whole of it in the
|
||||
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
|
||||
* thing at 1 Revenue per transit and another at 5.
|
||||
*/
|
||||
function renderHouseRules(rules: HouseRules): void {
|
||||
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
|
||||
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
|
||||
const el = $('houserules');
|
||||
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
|
||||
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
|
||||
el.title =
|
||||
`Opening hand: ${handWords.toLowerCase()}.\n` +
|
||||
`Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` +
|
||||
`Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` +
|
||||
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED.
|
||||
*
|
||||
* A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different
|
||||
* railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a
|
||||
* different economy again. The link has to carry all of it or "same link, same deal" stops being
|
||||
* true — and the New Game dialog navigates by URL, so this is also how its answers reach `start()`.
|
||||
*
|
||||
* Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at
|
||||
* today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`).
|
||||
*/
|
||||
const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const;
|
||||
|
||||
function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
||||
const rules: HouseRuleOverrides = {};
|
||||
const hand = params.get('hand');
|
||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||
|
||||
const revenue: Partial<RevenueRules> = {};
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
||||
const raw = params.get(param);
|
||||
// `houseRules()` clamps and rounds, so anything hand-edited into the URL lands in range rather
|
||||
// than dealing a game at 900 Revenue a coach.
|
||||
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
|
||||
}
|
||||
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rulesToUrl(rules: HouseRules, seed: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (seed !== '') params.set('seed', seed);
|
||||
params.set('hand', rules.startingHand);
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
||||
return `?${params}`;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requested = params.get('seed');
|
||||
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
session = createLocalSession(seed);
|
||||
session = createLocalSession(seed, rulesFromUrl(params));
|
||||
|
||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
|
||||
const saved = load();
|
||||
if (saved && requested === null) session.restore(saved);
|
||||
|
||||
@@ -185,6 +245,7 @@ function render(): void {
|
||||
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
obj.className = 'pace';
|
||||
$('seed').textContent = String(session.seed());
|
||||
renderHouseRules(f.houseRules);
|
||||
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division);
|
||||
@@ -828,35 +889,76 @@ if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
|
||||
* replay download is right beside it.
|
||||
*
|
||||
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
|
||||
* it would deal the same game again and look like the button had done nothing.
|
||||
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
|
||||
* deal the same game again and look like the button had done nothing.
|
||||
*/
|
||||
const newBtn = document.getElementById('newgame');
|
||||
if (newBtn) {
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
|
||||
/**
|
||||
* ASK FOR ALL THREE, rather than documenting URL parameters in the title bar.
|
||||
*
|
||||
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
|
||||
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
|
||||
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
|
||||
*
|
||||
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
|
||||
* compare against the first is the common case, and re-entering four settings each time is how a
|
||||
* comparison silently stops comparing.
|
||||
*/
|
||||
newBtn.onclick = () => {
|
||||
const f = session.view();
|
||||
const day = f.day;
|
||||
const started = f.status === 'active' && (day > 1 || f.stage > 1);
|
||||
if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
/**
|
||||
* ASK FOR THE SEED, rather than documenting a URL parameter in the title bar.
|
||||
*
|
||||
* The same deal can be replayed, shared or compared by seed, which is worth offering — it was
|
||||
* offered as the note "add ?seed=1234 for a set deal", which spent width on the one line that
|
||||
* must not wrap to explain a thing the button could simply ask. Blank means random.
|
||||
*/
|
||||
const asked = prompt('Seed for the new game — leave blank for a random one:', '');
|
||||
if (asked === null) return; // cancelled
|
||||
clearSave();
|
||||
const wanted = asked.trim();
|
||||
if (wanted === '') {
|
||||
// No `?seed=`, so `start()` rolls one. Reload rather than re-render, to clear any seed in the URL.
|
||||
if (location.search === '') location.reload();
|
||||
else location.search = '';
|
||||
return;
|
||||
|
||||
const current = session.view().houseRules;
|
||||
field<HTMLInputElement>('ng-seed').value = '';
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
|
||||
input.checked = input.value === current.startingHand;
|
||||
}
|
||||
location.search = `?seed=${encodeURIComponent(wanted)}`;
|
||||
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
||||
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
||||
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
|
||||
dlg.showModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
|
||||
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
|
||||
*
|
||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
||||
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
|
||||
*/
|
||||
dlg.addEventListener('close', () => {
|
||||
if (dlg.returnValue !== 'deal') return;
|
||||
|
||||
const asked = field<HTMLInputElement>('ng-seed').value.trim();
|
||||
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable
|
||||
// both mean "surprise me", which is what leaving the box alone plainly asks for.
|
||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
||||
const picked = dlg.querySelector<HTMLInputElement>('input[name="ng-hand"]:checked')?.value;
|
||||
const rules = houseRules({
|
||||
houseRules: {
|
||||
...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}),
|
||||
revenue: {
|
||||
passengerPerCoach: Number(field<HTMLInputElement>('ng-passenger').value),
|
||||
freightPerLoad: Number(field<HTMLInputElement>('ng-freight').value),
|
||||
trainPerTransit: Number(field<HTMLInputElement>('ng-transit').value),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
clearSave();
|
||||
const next = rulesToUrl(rules, seed);
|
||||
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
|
||||
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
|
||||
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||
if (next === location.search) location.reload();
|
||||
else location.search = next;
|
||||
});
|
||||
}
|
||||
|
||||
const soundBtn = document.getElementById('sound');
|
||||
|
||||
+79
-1
@@ -40,6 +40,32 @@ header button:disabled:hover{border-color:#2c333d}
|
||||
.pace.good{background:rgba(40,140,60,.32)}
|
||||
.pace.behind{background:rgba(190,120,40,.28)}
|
||||
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
||||
/* NEW GAME DIALOG. Native <dialog>, so Esc closes it and focus is trapped without any of that
|
||||
being written here. Everything below is only colour and spacing: the browser's own white-on-white
|
||||
default is unreadable against this page. */
|
||||
dialog{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:9px;
|
||||
padding:16px 18px;max-width:520px;width:calc(100% - 32px);font:inherit;max-height:86vh;overflow:auto}
|
||||
dialog::backdrop{background:rgba(0,0,0,.62)}
|
||||
dialog h3{margin-top:15px}
|
||||
/* The rationale under each heading, not beside each control — the reasons are sentences, and a
|
||||
sentence squeezed into a label column wraps into noise. */
|
||||
.ng-note{color:var(--dim);font-size:11.5px;margin:0 0 8px;line-height:1.45}
|
||||
dialog input[type=text],dialog input[type=number]{background:#0f1318;color:var(--fg);
|
||||
border:1px solid var(--line);border-radius:5px;padding:4px 7px;font:inherit;font-size:13px}
|
||||
dialog input[type=text]{width:100%}
|
||||
dialog input[type=number]{width:64px;text-align:right}
|
||||
dialog input:focus{outline:none;border-color:#4d6fa8}
|
||||
/* Whole rows, so the click target is the sentence and not the 13px circle beside it. */
|
||||
.ng-radio{display:flex;gap:9px;align-items:flex-start;padding:6px 7px;border-radius:5px;cursor:pointer}
|
||||
.ng-radio:hover{background:#20262e}
|
||||
.ng-radio input{margin-top:3px;flex:0 0 auto}
|
||||
.ng-radio .dim{font-size:11.5px}
|
||||
.ng-num{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 7px}
|
||||
.ng-buttons{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0}
|
||||
.ng-buttons button{background:#2a3038;color:inherit;border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||||
.ng-buttons button:hover{border-color:#4d6fa8}
|
||||
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
||||
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;
|
||||
@@ -176,10 +202,15 @@ ul.blocked li{padding:2px 0}
|
||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
|
||||
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
|
||||
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
|
||||
to keep the header on one line; the tooltip spells it out. -->
|
||||
<span class="dim" id="houserules" title="">—</span>
|
||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Deal a fresh game. You will be asked for a seed — leave it blank for a random one. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
@@ -248,6 +279,53 @@ ul.blocked li{padding:2px 0}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ===================================================================
|
||||
NEW GAME — the seed, the opening hand, and what the three economies pay.
|
||||
|
||||
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
|
||||
you are about to play had no way in at all: the opening hand had been changed twice with no
|
||||
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
|
||||
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
|
||||
at different settings — which needs a dialog, not a rebuild.
|
||||
|
||||
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
|
||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
||||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
||||
==================================================================== -->
|
||||
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
||||
<form method="dialog" id="newgameform">
|
||||
<h2 class="big" id="ng-title">New game</h2>
|
||||
|
||||
<h3>Seed</h3>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||||
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom" checked>
|
||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom">
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and again when it is detrained; a load pays when it is made up and again when it is broken. Zero switches an economy off so the others can be read.</p>
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="./web/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-3
@@ -17,10 +17,12 @@
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { HouseRuleOverrides } from '../engine/content.ts';
|
||||
import type { Game, Menu, Save } from './game.ts';
|
||||
import {
|
||||
actionMenu,
|
||||
configWith,
|
||||
currentActor,
|
||||
fromSave,
|
||||
handPlayable,
|
||||
@@ -112,9 +114,15 @@ export type LocalSession = Session & {
|
||||
*
|
||||
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
|
||||
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
|
||||
*
|
||||
* It takes `rules` rather than a whole `GameConfig` because DEALING is the only thing on the far
|
||||
* side of this that the page is allowed to decide. A config carries the mode, the victory condition
|
||||
* and the optional rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build
|
||||
* meant importing the engine's own defaults into the page, which is the boundary `session.test.ts`
|
||||
* guards. The seed and the house rules are the two things a player picks when they press New game.
|
||||
*/
|
||||
export function createLocalSession(seed: number, config?: GameConfig): LocalSession {
|
||||
let game: Game = config ? newGame(seed, config) : newGame(seed);
|
||||
export function createLocalSession(seed: number, rules?: HouseRuleOverrides): LocalSession {
|
||||
let game: Game = rules ? newGame(seed, configWith(rules)) : newGame(seed);
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
|
||||
Reference in New Issue
Block a user