The screen does what you tell it — three Display items, and a fourth declined
Reviewed with Jesse out of TODO.md's Display section. Stays in the unshipped v0.7.9. #17 (hiding the Division map) was DECLINED, and the reason is that its premise had already died. Gitea#18 replaced the wrapped layout with a single row, and the reason to fold the map away was that it GREW — a horseshoe of three or a square of four pushed the board off the screen. One row is boardH = PAD * 2 + CH + 30: 150px, fixed, at every seat count. That is not worth a control, three states and a persisted preference. It was a sixth member of the drawing pass that got closed with Gitea#18 and stayed open only because it reads as a control question rather than a drawing one — recorded in TODO.md as an explicit decision, with the design that had already been worked out kept, and with the one thing that would justify reopening it: the map growing again. #16 THE OFFICE AREA'S AUTO-HIDE COULD NOT REACH EVERY STATE. One button cycling auto -> pinned -> auto, where the pin was `open ? 'closed' : 'open'` and `open` is what auto is doing AT THAT MOMENT. So the pin a press offered depended on the phase, and going from always-show to always-hide meant clicking back to auto, waiting for the phase to turn over, and clicking again. Three controls now, one per mode. The labels still say what pressing DOES, which was an earlier deliberate fix; what the cycle could not do was report the state it was in, and aria-pressed carries that now. They are addressed by id rather than queried off the container, and that is testability rather than style: the stub DOM the web suite runs against only models markup the page WROTE, so a child query finds nothing and the control would have shipped green and unexercised. The test presses always-show to always-hide directly — the transition the cycle could not make. #23 THE HISTORY READS NEWEST FIRST. Jesse: "the top line is the most recent and the further down you go, the older the entry." The phase headings now trail the lines they announce, ruled acceptable rather than overlooked: "stage changes will be beneath (prior to / older than) the following events. That is OK." Reading down is reading backwards. Grouping by phase and reversing the groups was offered and declined as more machinery than the complaint needs. replays.ts keeps its oldest-first log deliberately — it is paired with a frame stepper, where newest-first would fight the stepping. The slice(-60) cap is untouched and stays open. #28 THE SETTINGS MOVED INTO A CARD. The top line carried six things and now carries four: Revenue, the objective, the collision counts and the game code. The rest is a This Game card at the foot of the right-hand column, folded by default. Nothing new travels for it — configFromFrame already existed and main.ts already called it three times, so rulesListHtml(configFromFrame(f), ...) needed no refactor, and the card draws from the same renderer as the lobby's join preview so the two cannot drift. THE COLLISION COUNTS ARE NEW ON THE BOARD, NOT MOVED. The Frame has carried collisionsToday and collisionsTotal since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran invisibly — the second time this release that the Frame had the answer and the view never asked (see #43's actingPlayer). They stay on the top line while the limits go in the card: a limit is agreed to once, "2 of 3 today" changes how you play the next Stage. One stub gap closed to get here: none of the five element factories in test/web.test.ts had setAttribute, so the first render threw and any control reporting state through ARIA was untestable. WHAT IS NOT VERIFIED: the layout. There is no browser on this box, so nothing has confirmed the segmented control, the card or the reversed panel look right on screen. The logic is tested; the appearance is not, and wants the next play session. 881 tests pass, fourteen new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
This commit is contained in:
co-authored by
Claude Opus 5
parent
31b942cc38
commit
d267f89a82
+88
-1
@@ -254,6 +254,93 @@ and the line reads "resumed" when it is.
|
|||||||
Both halves are pinned, including that a brand-new game does *not* claim to be a resume — an
|
Both halves are pinned, including that a brand-new game does *not* claim to be a resume — an
|
||||||
announcement that fires either way says nothing.
|
announcement that fires either way says nothing.
|
||||||
|
|
||||||
|
### The screen does what you tell it — three items off `TODO.md`
|
||||||
|
|
||||||
|
Reviewed with Jesse 2026-08-30 out of the Display section. A fourth, #17 (hiding the Division map),
|
||||||
|
was **declined** in the same pass: its premise died with Gitea#18 and nobody had connected the two.
|
||||||
|
The map used to grow a row at a time and was worth folding away at three or four seats; a single row
|
||||||
|
is `boardH = PAD * 2 + CH + 30` — 150px, fixed, at every seat count — and that is not worth a
|
||||||
|
control, three states and a persisted preference.
|
||||||
|
|
||||||
|
#### The Office Area's auto-hide could not reach every state (#16)
|
||||||
|
|
||||||
|
One button cycling `auto -> pinned -> auto`, where the pin it reached was
|
||||||
|
`open ? 'closed' : 'open'` — and `open` is what auto is doing **at that moment**,
|
||||||
|
`FOCUS_PHASES.has(f.phaseKey)`. So which pin a press offered depended on the phase: "always hidden"
|
||||||
|
during Local Operations and Cargo, "always showing" everywhere else. Getting from one pin to the
|
||||||
|
other meant clicking back to auto, waiting for the phase to turn over, and clicking again. That is
|
||||||
|
why it never read as a setting — it was not one.
|
||||||
|
|
||||||
|
Three controls now, one per mode, and every mode is one press from every other. The labels still say
|
||||||
|
what pressing **does** rather than what the panel is doing, which was an earlier deliberate fix; what
|
||||||
|
the cycle could not do was report the state it was in, and `aria-pressed` on the lit button carries
|
||||||
|
that instead of the label.
|
||||||
|
|
||||||
|
**Addressed by id (`#dm-auto`/`#dm-open`/`#dm-closed`) rather than by querying the container's
|
||||||
|
children**, and that is a testability decision rather than a style one. The page never writes this
|
||||||
|
markup, so a child query finds nothing in the stubbed DOM the web suite runs against — the control
|
||||||
|
would have shipped green and completely unexercised. The test that now pins it presses
|
||||||
|
always-show → always-hide directly, which is precisely the transition the cycle could not make.
|
||||||
|
|
||||||
|
#### The history reads newest first (#23)
|
||||||
|
|
||||||
|
Jesse: "it should be reversed so the top line is the most recent and the further down you go, the
|
||||||
|
older the entry." The panel ran oldest-first and scrolled itself to the bottom, so the thing that had
|
||||||
|
just happened was the one line you had to go and find.
|
||||||
|
|
||||||
|
**The phase headings now trail their lines, and that is accepted rather than overlooked.** A
|
||||||
|
`t-phase` line reads forwards — it introduces what follows it — so reversing puts each one below the
|
||||||
|
events it announced. Jesse ruled on it directly: "stage changes will be beneath (prior to / older
|
||||||
|
than) the following events. That is OK." Reading down the panel is reading backwards in time, and a
|
||||||
|
heading under its own lines is what backwards looks like. Grouping by phase and reversing the groups
|
||||||
|
was the alternative and was declined as more machinery than the complaint needs. The
|
||||||
|
"— the game began —" marker moves by the same logic: it is the oldest thing on screen, so it goes
|
||||||
|
last.
|
||||||
|
|
||||||
|
`replays.ts` keeps its own oldest-first log deliberately. It is paired with a frame stepper, where
|
||||||
|
"what just happened" is the step you have this moment clicked, so newest-first would fight the
|
||||||
|
stepping rather than help it.
|
||||||
|
|
||||||
|
#### The settings moved into a card, and the collision counts came out of hiding (#28)
|
||||||
|
|
||||||
|
Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card like
|
||||||
|
Facilities, timetable or blocked… we can give complete information about all the game options and not
|
||||||
|
take up valuable real estate at the top of the screen." And on when it is read: "To go, 'Oh wait, what
|
||||||
|
did we set that to?'"
|
||||||
|
|
||||||
|
The top line carried six things and now carries four: **Revenue, the objective, the collision counts,
|
||||||
|
and the game code**. The seed or seat, the game type and the abbreviated house rules moved into a
|
||||||
|
**This Game** card at the foot of the right-hand column, folded by default and persisted with the
|
||||||
|
other display preferences.
|
||||||
|
|
||||||
|
**Nothing new travels for it.** `configFromFrame` already turns the Frame's copy of the config back
|
||||||
|
into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating screen
|
||||||
|
already draw — so what a player agreed to before the deal and what they read mid-game come from one
|
||||||
|
implementation and cannot drift. The card adds only the half `rulesListHtml` has no notion of: which
|
||||||
|
seed or seat this is, and what the game is called.
|
||||||
|
|
||||||
|
**The collision counts are new on the board, not merely moved.** The Frame has carried
|
||||||
|
`collisionsToday` and `collisionsTotal` since v0.7.0 and nothing drew them, so the one victory
|
||||||
|
condition that ends a game EARLY ran invisibly — which v0.7.9 made reachable in solitaire too, and
|
||||||
|
that is what made them worth having. They stay on the top line while the limits go in the card,
|
||||||
|
because the two are different kinds of thing: a limit is a setting agreed to once, "2 of 3 today" is
|
||||||
|
a number that changes how you play the next Stage. A limit of `0` is off, and an off half is left out
|
||||||
|
rather than shown as "1 of 0"; with both off the chip is empty and collapses.
|
||||||
|
|
||||||
|
In solitaire the game-code span is empty, so its tooltip — which still carries the type, the blurb and
|
||||||
|
the victory conditions — is unreachable. Not a hole: the card's summary line is on screen whether the
|
||||||
|
card is open or shut and opens with the type, "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1".
|
||||||
|
|
||||||
|
**One gap in the test stubs was closed to get here.** None of the five element factories in the web
|
||||||
|
suite had `setAttribute`, so the first render threw `b.setAttribute is not a function` — meaning any
|
||||||
|
control that reports its state through ARIA could not be tested at all. They carry an attribute bag
|
||||||
|
now, and the segmented control's test reads `aria-pressed` through it.
|
||||||
|
|
||||||
|
**What is verified and what is not.** The logic is covered by three new tests and the two that used to
|
||||||
|
read `#houserules`/`#gametype` were rewritten to open the card rather than deleted. **The layout is
|
||||||
|
not verified** — there is no browser on this box, so nothing has confirmed the segmented control, the
|
||||||
|
card, or the reversed panel actually look right on screen. That wants a play session.
|
||||||
|
|
||||||
### Three wording and layout fixes
|
### Three wording and layout fixes
|
||||||
|
|
||||||
- **The collision entries** on all three screens now read "The game ends immediately and results in
|
- **The collision entries** on all three screens now read "The game ends immediately and results in
|
||||||
@@ -268,7 +355,7 @@ announcement that fires either way says nothing.
|
|||||||
cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue
|
cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue
|
||||||
Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal".
|
Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal".
|
||||||
|
|
||||||
878 tests pass, eleven of them new; one existing test asserted the opposite of the collision ruling
|
881 tests pass, fourteen of them new; one existing test asserted the opposite of the collision ruling
|
||||||
above and says so where it was reversed.
|
above and says so where it was reversed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ Display were still open `- [ ]` although Next already recorded them as supersede
|
|||||||
shipped in v0.7.9. **An item closed in one place and left open in another is worse than no entry at
|
shipped in v0.7.9. **An item closed in one place and left open in another is worse than no entry at
|
||||||
all**, because it reads as live work. So: Next is now grouped by what each item is WAITING ON, the
|
all**, because it reads as live work. So: Next is now grouped by what each item is WAITING ON, the
|
||||||
closed entries moved to a "Settled" block at the end of it, and the five dead Division-map drawing
|
closed entries moved to a "Settled" block at the end of it, and the five dead Division-map drawing
|
||||||
items collapsed into one that keeps only the two ideas which outlived them. 65 open items became 57.
|
items collapsed into one that keeps only the two ideas which outlived them.
|
||||||
|
|
||||||
|
**Gitea#18 turned out to have answered a sixth item nobody had connected to it** — #17, hiding the
|
||||||
|
Division map — found by reviewing the queue with Jesse rather than by reading the file. Worth the
|
||||||
|
general lesson: **when one change supersedes a batch of items, the batch is rarely the whole list.**
|
||||||
|
#17 survived because it reads as a control question while the other five read as drawing questions,
|
||||||
|
so it was not in the group that got closed with them. 65 open items became 56, and 53 once #16, #23
|
||||||
|
and #28 shipped the same day.
|
||||||
|
|
||||||
Earlier passes, kept: **Display** was split out of Other on 2026-08-22, when a session at the board
|
Earlier passes, kept: **Display** was split out of Other on 2026-08-22, when a session at the board
|
||||||
produced seven items about the screen rather than the rules. Reorganized 2026-08-20 from a flat list.
|
produced seven items about the screen rather than the rules. Reorganized 2026-08-20 from a flat list.
|
||||||
@@ -93,25 +100,39 @@ ever meeting them.
|
|||||||
|
|
||||||
### The screen — v0.8.0 candidates
|
### The screen — v0.8.0 candidates
|
||||||
|
|
||||||
All six were raised by Jesse from play and all six live in **Display** below, where the reasoning is.
|
Raised by Jesse from play; the reasoning for each lives in **Display** below. **Three shipped
|
||||||
Listed here in rough increasing cost. Two of them entangle with Gitea#20; see the note under each.
|
2026-08-30 in v0.7.9** — 28, 16 and 23, each with the decision that settled it recorded under it.
|
||||||
|
**One was declined** — 17. **Two remain, and both entangle with Gitea#20** rather than being display
|
||||||
|
work at all; see 15 and 18, which is why v0.8.0 is not finished by this batch.
|
||||||
|
|
||||||
28. **The game's settings belong in a card, not along the top line** — and show ALL of them, not the
|
28. ~~**The game's settings belong in a card, not along the top line.**~~ — done 2026-08-30 in
|
||||||
four that fit. **Cheapest of the six**: nothing new has to be sent (`Frame` gained `mode` and
|
v0.7.9. A **This Game** card at the foot of the right-hand column, folded by default; the top
|
||||||
`optionalRules` in v0.7.0) and the renderer already exists — `rulesListHtml` (`settings-form.ts`)
|
line keeps Revenue, the objective, the collision counts and the game code (Jesse's call). It
|
||||||
draws exactly this list for the join preview and the seating screen.
|
turned out cheaper than this entry said: `configFromFrame` already existed and `main.ts` already
|
||||||
|
called it three times, so `rulesListHtml(configFromFrame(f), …)` needed no refactor at all.
|
||||||
|
**The collision counts are new on the board rather than moved** — the Frame has carried them
|
||||||
|
since v0.7.0 and nothing drew them.
|
||||||
|
|
||||||
16. **Three explicit display options for the Office map** — always hidden, always on, auto-hide. All
|
16. ~~**Three explicit display options for the Office map.**~~ — done 2026-08-30 in v0.7.9. A
|
||||||
three modes already exist (`districtMode`, persisted); only the BUTTON is a cycle, and it cannot
|
segmented control, one button per mode, replacing the cycle. **The buttons are addressed by id
|
||||||
reach every one of them.
|
rather than queried off the container**, because the web suite's stub DOM only models markup the
|
||||||
|
page writes — a child query finds nothing there, and the control would have shipped green and
|
||||||
|
unexercised.
|
||||||
|
|
||||||
17. **The same three options for the Division map**, which today cannot be hidden at all. `auto`
|
17. ~~**The same three options for the Division map.**~~ — **DECLINED 2026-08-30 (Jesse): "we are
|
||||||
there means something different and useful — hide it now, bring it back at the end of the phase —
|
not going to hide the division map anymore."** Its premise died with Gitea#18 and nobody noticed,
|
||||||
so it is a one-shot with an expiry rather than a standing rule, and probably should not share an
|
because it reads as a control question rather than a drawing one and so was not on the list of
|
||||||
implementation with #16.
|
five that were closed with it. Reasoning in Display below.
|
||||||
|
|
||||||
23. **History: newest at the top?** Reversing is nearly free; the cap (`slice(-60)`) and the forward-
|
23. ~~**History: newest at the top.**~~ — done 2026-08-30 in v0.7.9. Flat reversal, `scrollTop = 0`,
|
||||||
reading phase headings are not. Carries the timestamps question from #14. **Weaker if #15 lands.**
|
the start marker last. **The phase headings now trail their lines and Jesse ruled that acceptable**
|
||||||
|
— "stage changes will be beneath (prior to / older than) the following events. That is OK."
|
||||||
|
Grouping by phase and reversing the groups was declined as more machinery than the complaint
|
||||||
|
needs. `replays.ts` deliberately keeps its oldest-first log: it is paired with a frame stepper,
|
||||||
|
where newest-first would fight the stepping.
|
||||||
|
|
||||||
|
**Two things this did NOT do**, both still open: the `slice(-60)` cap still ends scrollback at
|
||||||
|
sixty lines whichever way the panel runs, and the timestamps question from #14 is untouched.
|
||||||
|
|
||||||
15. **INVESTIGATE: a "most recent action" line under the status block.** The text already exists and
|
15. **INVESTIGATE: a "most recent action" line under the status block.** The text already exists and
|
||||||
is already correct — this is placement, not content. **Overlaps #13 and should be decided with
|
is already correct — this is placement, not content. **Overlaps #13 and should be decided with
|
||||||
@@ -1430,8 +1451,22 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
your own last turn ended) answers that better. They may both be right, and one may make the
|
your own last turn ended) answers that better. They may both be right, and one may make the
|
||||||
other pointless; deciding them separately risks building both and needing neither.
|
other pointless; deciding them separately risks building both and needing neither.
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: three explicit display options for the Office map — always hidden, always on,
|
- [x] **~~INVESTIGATE: three explicit display options for the Office map — always hidden, always on,
|
||||||
auto-hide.** Raised by Jesse 2026-08-22.
|
auto-hide.~~** — done 2026-08-30 in v0.7.9 (`Next` #16). A segmented control, one button per
|
||||||
|
mode, `aria-pressed` on the lit one. The investigation below was right about the cause and
|
||||||
|
right about the fix; what it did not foresee is the testing trap.
|
||||||
|
|
||||||
|
**THE BUTTONS ARE ADDRESSED BY ID (`#dm-auto` / `#dm-open` / `#dm-closed`), NOT QUERIED OFF
|
||||||
|
THE CONTAINER — and that is worth knowing before the next control like this.** The web suite
|
||||||
|
runs against a stub DOM whose `querySelectorAll` reads the element's own `innerHTML`, so it
|
||||||
|
only ever sees markup THE PAGE WROTE. This control lives in `play.html`, so a child query
|
||||||
|
returns nothing there, the wiring loop does nothing, and the whole control ships green and
|
||||||
|
completely unexercised. Addressing by id also puts every button under the "asks the page for
|
||||||
|
no element its page lacks" check.
|
||||||
|
|
||||||
|
**A second gap closed on the way:** none of the five element factories in `test/web.test.ts`
|
||||||
|
had `setAttribute`, so the first render threw. Any control reporting its state through ARIA
|
||||||
|
was untestable until they got an attribute bag. Original note below.
|
||||||
|
|
||||||
**All three modes already exist.** `districtMode` is `'auto' | 'open' | 'closed'`, persisted to
|
**All three modes already exist.** `districtMode` is `'auto' | 'open' | 'closed'`, persisted to
|
||||||
`localStorage` with the sound and zoom settings (`main.ts`). Nothing needs adding to the model.
|
`localStorage` with the sound and zoom settings (`main.ts`). Nothing needs adding to the model.
|
||||||
@@ -1448,8 +1483,34 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
("always showing — click for auto-hide") rather than what the panel is currently doing, which
|
("always showing — click for auto-hide") rather than what the panel is currently doing, which
|
||||||
was a deliberate fix and should survive whatever replaces the cycle.
|
was a deliberate fix and should survive whatever replaces the cycle.
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: the same three options for the Division map, where `auto` means something
|
- [x] **~~INVESTIGATE: the same three options for the Division map, where `auto` means something
|
||||||
different.** Raised by Jesse 2026-08-22.
|
different.~~** — **DECLINED 2026-08-30.** Jesse: "we are not going to hide the division map
|
||||||
|
anymore."
|
||||||
|
|
||||||
|
**Gitea#18 answered this one too, and it was missed.** It was raised 2026-08-22, four days
|
||||||
|
before that issue closed, and it is really a sixth member of the drawing pass settled below —
|
||||||
|
it just reads as a control question rather than a drawing one, so it stayed open when the other
|
||||||
|
five were closed. **The reason to fold the map away was that it grew.** A horseshoe of three or
|
||||||
|
a square of four was tall enough to push the board off the screen, which is what made "hide it
|
||||||
|
while I switch" worth a control. A single row is `boardH = PAD * 2 + CH + 30` — **150px, fixed,
|
||||||
|
at every seat count** (`board-svg.ts`) — and a 150px strip is not worth a control, three states
|
||||||
|
and a persisted preference. It also scrolls and zooms rather than reflowing, so it costs the
|
||||||
|
same at one seat as at four.
|
||||||
|
|
||||||
|
**What it would have cost, recorded because the design was worked out before it was declined:**
|
||||||
|
the Division's `auto` is not a third MODE at all. It is two sticky pins plus a transient — "hide
|
||||||
|
for now", expiring when `f.phaseKey` moves — so it needed `divisionMode: 'open' | 'closed'`
|
||||||
|
persisted beside `districtMode`, plus a `hiddenDuringPhase` that clears itself. Deliberately not
|
||||||
|
shared with `districtMode`, whose `auto` is a standing rule keyed on `FOCUS_PHASES`. **And a
|
||||||
|
folded Division needed a summary line written for it** (Jesse's call, 2026-08-30, before the
|
||||||
|
decline): `.folded` on `#district` hides `#grid` and `.districtrule` but keeps
|
||||||
|
`#districtsummary`, so the house idiom is that a folded panel still says something, and the
|
||||||
|
Division has no such line today.
|
||||||
|
|
||||||
|
**If it ever comes back, this is what to check first:** whether the map has started growing
|
||||||
|
again. That, not the control, is the thing that would justify it.
|
||||||
|
|
||||||
|
Original note below.
|
||||||
|
|
||||||
**Today it cannot be hidden at all.** `#division` is a plain `<div>` in an unnamed `<section>`
|
**Today it cannot be hidden at all.** `#division` is a plain `<div>` in an unnamed `<section>`
|
||||||
in `play.html` with no toggle and no fold rule — `#district` has `.folded` styling and a button,
|
in `play.html` with no toggle and no fold rule — `#district` has `.folded` styling and a button,
|
||||||
@@ -1491,7 +1552,28 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
will want it off. Whatever this becomes probably needs a speed control, or to scale with whether
|
will want it off. Whatever this becomes probably needs a speed control, or to scale with whether
|
||||||
anything actually happened in the phase.
|
anything actually happened in the phase.
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: the game's settings belong in a card, not along the top line.** Raised by Jesse
|
- [x] **~~INVESTIGATE: the game's settings belong in a card, not along the top line.~~** — done
|
||||||
|
2026-08-30 in v0.7.9 (`Next` #28). A **This Game** card at the foot of the right-hand column,
|
||||||
|
folded by default and persisted with the other display preferences.
|
||||||
|
|
||||||
|
**The open questions below were answered by Jesse, 2026-08-30.** Which of the six stay on the
|
||||||
|
top line: Revenue, the objective and the game code — *plus the collision counts*, which were
|
||||||
|
not on it at all. Whether the card folds: yes, like `#district`, with a summary line that
|
||||||
|
survives folding. Where the collision counts belong: the top line, because a limit is a setting
|
||||||
|
agreed to once and "2 of 3 today" is a number that changes how you play the next Stage.
|
||||||
|
|
||||||
|
**Cheaper than this entry estimated.** It says the renderer already exists; what it misses is
|
||||||
|
that `configFromFrame` also already exists, is exported, and `main.ts` already called it three
|
||||||
|
times — so `rulesListHtml(configFromFrame(f), f.players.length, f.days)` needed no refactor
|
||||||
|
whatsoever. The card is markup plus one call.
|
||||||
|
|
||||||
|
**The collision counts were the real find.** The Frame has carried `collisionsToday` and
|
||||||
|
`collisionsTotal` since v0.7.0 and NOTHING ON THE BOARD DREW THEM, so the one victory condition
|
||||||
|
that ends a game early ran invisibly — the same shape as #43's `actingPlayer`, and the second
|
||||||
|
time in one release that the Frame had the answer and the view never asked. Original note
|
||||||
|
below.
|
||||||
|
|
||||||
|
**The original note.** Raised by Jesse
|
||||||
2026-08-23, playing the v0.7.0 build: "the game-specific information in the very top line should
|
2026-08-23, playing the v0.7.0 build: "the game-specific information in the very top line should
|
||||||
probably be a card like Facilities, timetable or blocked. Off on the side, we can give complete
|
probably be a card like Facilities, timetable or blocked. Off on the side, we can give complete
|
||||||
information about all the game options and not take up valuable real estate at the top of the
|
information about all the game options and not take up valuable real estate at the top of the
|
||||||
@@ -1574,7 +1656,24 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
must not go. Jesse called it definitively superseded, 2026-08-26.
|
must not go. Jesse called it definitively superseded, 2026-08-26.
|
||||||
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: history newest-at-the-top, and timestamps on it.** Raised by Jesse 2026-08-22.
|
- [x] **~~INVESTIGATE: history newest-at-the-top~~, and timestamps on it.** — the ORDER is done
|
||||||
|
2026-08-30 in v0.7.9 (`Next` #23); **the timestamps half is still open and still #14.**
|
||||||
|
|
||||||
|
Flat reversal, `scrollTop = 0`, the start marker last. Jesse ruled directly on the phase
|
||||||
|
headings, which the note below calls the thing that is not free: they now trail the lines they
|
||||||
|
announce, and "stage changes will be beneath (prior to / older than) the following events.
|
||||||
|
That is OK." Reading down the panel is reading backwards in time. Grouping by phase and
|
||||||
|
reversing the groups was offered and declined as more machinery than the complaint needs.
|
||||||
|
|
||||||
|
**The `slice(-60)` cap is untouched and is the other half of this note** — scrollback still
|
||||||
|
ends at sixty lines whichever way the panel runs. Worth reopening on its own if anyone ever
|
||||||
|
tries to scroll back and cannot.
|
||||||
|
|
||||||
|
`replays.ts` deliberately keeps its oldest-first log: it is paired with a frame stepper, where
|
||||||
|
"what just happened" is the step you have this moment clicked, so newest-first would fight the
|
||||||
|
stepping. Original note below.
|
||||||
|
|
||||||
|
**Raised by Jesse 2026-08-22.**
|
||||||
|
|
||||||
**Timestamps** are item 14 above — the same question, and it should be answered once. Whether
|
**Timestamps** are item 14 above — the same question, and it should be answered once. Whether
|
||||||
the history DISPLAYS a time is downstream of whether one is recorded at all, and of the sidecar
|
the history DISPLAYS a time is downstream of whether one is recorded at all, and of the sidecar
|
||||||
|
|||||||
+165
-38
@@ -36,7 +36,7 @@ import {
|
|||||||
settingsOf,
|
settingsOf,
|
||||||
} from './presets.ts';
|
} from './presets.ts';
|
||||||
import type { GameType, PresetName } from './presets.ts';
|
import type { GameType, PresetName } from './presets.ts';
|
||||||
import { settingsForm } from './settings-form.ts';
|
import { rulesListHtml, settingsForm } from './settings-form.ts';
|
||||||
import type { SettingsForm } from './settings-form.ts';
|
import type { SettingsForm } from './settings-form.ts';
|
||||||
|
|
||||||
const SAVE_KEY = 'station-master.save.v1';
|
const SAVE_KEY = 'station-master.save.v1';
|
||||||
@@ -62,9 +62,14 @@ type Settings = {
|
|||||||
districtMode: 'auto' | 'open' | 'closed';
|
districtMode: 'auto' | 'open' | 'closed';
|
||||||
soundOn: boolean;
|
soundOn: boolean;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
/**
|
||||||
|
* Is the This Game card open? (TODO #28.) Folded by default: it answers "what did we set that
|
||||||
|
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||||
|
*/
|
||||||
|
gameCardOpen: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
|
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
|
||||||
|
|
||||||
function loadSettings(): Settings {
|
function loadSettings(): Settings {
|
||||||
try {
|
try {
|
||||||
@@ -80,6 +85,8 @@ function loadSettings(): Settings {
|
|||||||
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
|
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
|
||||||
? parsed.zoom
|
? parsed.zoom
|
||||||
: DEFAULT_SETTINGS.zoom,
|
: DEFAULT_SETTINGS.zoom,
|
||||||
|
gameCardOpen:
|
||||||
|
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||||
@@ -141,6 +148,7 @@ let pendingAt: string | null = null;
|
|||||||
* save, for exactly that reason.
|
* save, for exactly that reason.
|
||||||
*/
|
*/
|
||||||
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
||||||
|
let gameCardOpen = settings.gameCardOpen;
|
||||||
/**
|
/**
|
||||||
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
|
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
|
||||||
*
|
*
|
||||||
@@ -257,17 +265,88 @@ function renderTurnChart(f: Frame): void {
|
|||||||
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
|
* 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.
|
* thing at 1 Revenue per transit and another at 5.
|
||||||
*/
|
*/
|
||||||
function renderHouseRules(rules: HouseRules): void {
|
function gameCardSummary(f: Frame): string {
|
||||||
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
|
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = f.houseRules.revenue;
|
||||||
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
|
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
|
||||||
const el = $('houserules');
|
const config = configFromFrame(f);
|
||||||
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
|
const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode);
|
||||||
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
|
const floor = f.minCombinedRevenue === 0 ? 'no floor' : `floor ${f.minCombinedRevenue}`;
|
||||||
|
return `${type} · ${f.days} Days · ${floor} · ${short[f.houseRules.startingHand]} · ${pax}/${frt}/${trn}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THIS GAME — every setting it was dealt under, in a card rather than along the top line (TODO #28).
|
||||||
|
*
|
||||||
|
* Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card
|
||||||
|
* like Facilities, timetable or blocked. Off on the side, we can give complete information about all
|
||||||
|
* the game options and not take up valuable real estate at the top of the screen." And on when it is
|
||||||
|
* read: "To go, 'Oh wait, what did we set that to?' They should be able to look that up, but it does
|
||||||
|
* not need to be at the top every moment."
|
||||||
|
*
|
||||||
|
* NOTHING NEW TRAVELS FOR THIS. `configFromFrame` already turns the Frame's copy of the config back
|
||||||
|
* into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating
|
||||||
|
* screen already draw — so what a player agreed to before the deal and what they can read mid-game
|
||||||
|
* come from ONE implementation and cannot drift. The identity block above it is the half
|
||||||
|
* `rulesListHtml` has no notion of: which seed or seat this is, and what the game is called.
|
||||||
|
*
|
||||||
|
* THE SEED IS SOLITAIRE-ONLY, and that is a redaction rule rather than a layout one: it is never
|
||||||
|
* sent to a remote client at all, because it would leak every future shuffle and roll
|
||||||
|
* (`multiplayer.md` §7). `RemoteSession` has no `.seed()` to call. A seated player gets their seat
|
||||||
|
* instead, which is the thing they actually need to know.
|
||||||
|
*/
|
||||||
|
function renderGameCard(f: Frame): void {
|
||||||
|
const sec = $('gamecard');
|
||||||
|
sec.classList.toggle('folded', !gameCardOpen);
|
||||||
|
$('gamecardsummary').textContent = gameCardSummary(f);
|
||||||
|
|
||||||
|
const btn = $('gamecardtoggle');
|
||||||
|
btn.textContent = gameCardOpen ? 'hide' : 'show';
|
||||||
|
btn.onclick = () => {
|
||||||
|
gameCardOpen = !gameCardOpen;
|
||||||
|
saveSettings({ gameCardOpen });
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
if (!gameCardOpen) {
|
||||||
|
// Folded: the body is display:none anyway, and rebuilding it every frame is work nobody sees.
|
||||||
|
$('gamecardbody').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = configFromFrame(f);
|
||||||
|
const players = f.players.length;
|
||||||
|
const type = presetOf(config, players, f.days);
|
||||||
|
const who = isLocal(session)
|
||||||
|
? `<dt>Seed</dt><dd>${esc(String(session.seed()))}</dd>`
|
||||||
|
: `<dt>Seat</dt><dd>${esc(String(seatLabel(session.seat())))}</dd>`;
|
||||||
|
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
||||||
|
$('gamecardbody').innerHTML =
|
||||||
|
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
||||||
|
rulesListHtml(config, players, f.days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
|
||||||
|
*
|
||||||
|
* They stay on the top line while the limits themselves move into the card, because the two are
|
||||||
|
* different kinds of thing: `maxCollisionsPerDay` is a setting you agreed to once, and "2 of 3
|
||||||
|
* today" is a number that changes how you play the next Stage. The Frame has carried both counts
|
||||||
|
* since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran
|
||||||
|
* invisibly — v0.7.9 made it reachable in solitaire too, which is what made this worth having.
|
||||||
|
*
|
||||||
|
* `0` means the limit is off (the engine's convention), and a half that is off is left out rather
|
||||||
|
* than shown as "1 of 0". With both off the chip is empty, and an empty span collapses.
|
||||||
|
*/
|
||||||
|
function renderCollisions(f: Frame): void {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (f.maxCollisionsPerDay > 0) parts.push(`${f.collisionsToday} of ${f.maxCollisionsPerDay} today`);
|
||||||
|
if (f.maxCollisionsTotal > 0) parts.push(`${f.collisionsTotal} of ${f.maxCollisionsTotal} total`);
|
||||||
|
const el = $('collisions');
|
||||||
|
el.textContent = parts.length === 0 ? '' : `collisions ${parts.join(' · ')}`;
|
||||||
el.title =
|
el.title =
|
||||||
`Opening hand: ${handWords.toLowerCase()}.\n` +
|
parts.length === 0
|
||||||
`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` +
|
: 'Reaching either limit ends the game immediately and results in a loss. Both limits are in ' +
|
||||||
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
|
'the This Game card; these are the running counts.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -930,21 +1009,32 @@ function renderPresence(f: Frame): void {
|
|||||||
*/
|
*/
|
||||||
function renderGameIdentity(f: Frame): void {
|
function renderGameIdentity(f: Frame): void {
|
||||||
const codeEl = document.getElementById('gamecode');
|
const codeEl = document.getElementById('gamecode');
|
||||||
if (codeEl) codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
if (!codeEl) return;
|
||||||
|
codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
||||||
const el = document.getElementById('gametype');
|
/**
|
||||||
if (!el) return;
|
* THE CODE KEEPS ITS TOOLTIP, AND THE TOOLTIP KEEPS THE RULES. The game type and the house rules
|
||||||
|
* moved into the This Game card (TODO #28), but the code is the thing a player reads out to say
|
||||||
|
* WHICH game they are in — so it is worth being able to hover it and get the whole answer without
|
||||||
|
* opening the card.
|
||||||
|
*
|
||||||
|
* IN SOLITAIRE THERE IS NO CODE, so the span is empty and this tooltip is unreachable. That is not
|
||||||
|
* a hole: the card's summary line is always on screen whether the card is folded or not, and it
|
||||||
|
* opens with the type — "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1". A lone player has no
|
||||||
|
* game to name to anybody, and the one thing this tooltip adds over that line is the blurb.
|
||||||
|
*/
|
||||||
const config = configFromFrame(f);
|
const config = configFromFrame(f);
|
||||||
const players = f.players.length;
|
const players = f.players.length;
|
||||||
const type = presetOf(config, players, f.days);
|
const type = presetOf(config, players, f.days);
|
||||||
const near = closestPreset(config, players, f.days);
|
const near = closestPreset(config, players, f.days);
|
||||||
el.textContent = gameTypeLabel(type, f.mode);
|
const blurb =
|
||||||
el.title =
|
|
||||||
type === 'custom'
|
type === 'custom'
|
||||||
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
|
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
|
||||||
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
|
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
|
||||||
`from ${preset(near.name).label}.\n\n${rulesSummary(f)}`
|
`from ${preset(near.name).label}.`
|
||||||
: `${preset(type).blurb}\n\n${rulesSummary(f)}`;
|
: preset(type).blurb;
|
||||||
|
codeEl.title =
|
||||||
|
`${gameTypeLabel(type, f.mode)}. ${blurb}\n\n${rulesSummary(f)}\n\n` +
|
||||||
|
'The full settings are in the This Game card, at the foot of the right-hand column.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The victory conditions in force, spelled out for the header's tooltip. */
|
/** The victory conditions in force, spelled out for the header's tooltip. */
|
||||||
@@ -1004,11 +1094,9 @@ function render(): void {
|
|||||||
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
||||||
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
||||||
obj.className = 'pace';
|
obj.className = 'pace';
|
||||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
renderCollisions(f);
|
||||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
|
||||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
|
|
||||||
renderGameIdentity(f);
|
renderGameIdentity(f);
|
||||||
renderHouseRules(f.houseRules);
|
renderGameCard(f);
|
||||||
|
|
||||||
// -- division
|
// -- division
|
||||||
$('division').innerHTML = divisionSvg(f.division, {
|
$('division').innerHTML = divisionSvg(f.division, {
|
||||||
@@ -1288,15 +1376,40 @@ function render(): void {
|
|||||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||||
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
|
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
|
||||||
* top of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
* end of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
||||||
*/
|
*/
|
||||||
const startMarker =
|
const startMarker =
|
||||||
!isLocal(session) && allLines.length === shownLines.length
|
!isLocal(session) && allLines.length === shownLines.length
|
||||||
? '<div class="line t-phase">— the game began —</div>'
|
? '<div class="line t-phase">— the game began —</div>'
|
||||||
: '';
|
: '';
|
||||||
|
/**
|
||||||
|
* NEWEST FIRST (TODO #23). Jesse, 2026-08-30: "it should be reversed so the top line is the most
|
||||||
|
* recent and the further down you go, the older the entry."
|
||||||
|
*
|
||||||
|
* The panel used to run oldest-first and scroll itself to the bottom, so the thing that had just
|
||||||
|
* happened was the one line you had to go and find. A glance at the top is now always the most
|
||||||
|
* recent thing, and `scrollTop = 0` keeps it there as lines arrive rather than chasing the end.
|
||||||
|
*
|
||||||
|
* THE PHASE HEADINGS NOW TRAIL THEIR LINES, and that is accepted rather than overlooked. A
|
||||||
|
* `t-phase` line reads forwards — it introduces what follows it — so reversing puts each one
|
||||||
|
* BELOW the events it announced. Jesse ruled on it directly: "stage changes will be beneath
|
||||||
|
* (prior to / older than) the following events. That is OK." Reading down the panel is reading
|
||||||
|
* backwards in time, and a heading sitting under its own lines is what backwards looks like.
|
||||||
|
* Grouping by phase and reversing the groups was the alternative, and it was declined as more
|
||||||
|
* machinery than the complaint needs.
|
||||||
|
*
|
||||||
|
* The start marker moves with the same logic: it is the OLDEST thing on screen, so it goes last.
|
||||||
|
*
|
||||||
|
* `replays.ts` keeps its own oldest-first log deliberately — it is paired with a frame stepper,
|
||||||
|
* where "what just happened" is the step you have this moment clicked, so newest-first would
|
||||||
|
* fight the stepping rather than help it.
|
||||||
|
*/
|
||||||
log.innerHTML =
|
log.innerHTML =
|
||||||
startMarker + shownLines.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`).join('');
|
shownLines
|
||||||
log.scrollTop = log.scrollHeight;
|
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
|
||||||
|
.reverse()
|
||||||
|
.join('') + startMarker;
|
||||||
|
log.scrollTop = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SAY WHEN THE PHASE TURNS OVER.
|
* SAY WHEN THE PHASE TURNS OVER.
|
||||||
@@ -1408,22 +1521,36 @@ function renderDistrict(f: Frame): void {
|
|||||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||||
|
|
||||||
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
|
/**
|
||||||
// status line and was missed entirely; "always show" is an instruction.
|
* THREE CONTROLS, ONE PER MODE (TODO #16) — not one control that cycles.
|
||||||
const btn = $('districttoggle');
|
*
|
||||||
btn.textContent =
|
* The cycle was `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing
|
||||||
districtMode === 'auto'
|
* AT THAT MOMENT — `FOCUS_PHASES.has(f.phaseKey)`. So which pin a press reached depended on the
|
||||||
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
* phase: during Local Operations or Cargo it offered "always hidden", and in every other phase
|
||||||
: districtMode === 'open'
|
* "always showing". Getting from one pin to the other meant clicking back to auto, waiting for
|
||||||
? 'always showing — click for auto-hide'
|
* the phase to turn over, and clicking again — which is why it never read as a setting.
|
||||||
: 'always hidden — click for auto-hide';
|
*
|
||||||
btn.onclick = () => {
|
* The labels still say what pressing DOES rather than what the panel is doing. That was a
|
||||||
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
* deliberate earlier fix ("auto · folded" read as a status line and was missed entirely) and it
|
||||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
* survives the change; what the CYCLE could not do was be honest about the state it was in, which
|
||||||
|
* is now carried by `aria-pressed` and the lit button instead of by the label.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Addressed by id, one lookup each, rather than by querying the container's children. Everything
|
||||||
|
* else on this page is reached with `$('...')`, and it is what makes the control testable at all:
|
||||||
|
* the page never writes this markup, so a child query finds nothing in a stubbed DOM and the
|
||||||
|
* whole control would ship green and unexercised.
|
||||||
|
*/
|
||||||
|
for (const mode of ['auto', 'open', 'closed'] as const) {
|
||||||
|
const b = $(`dm-${mode}`);
|
||||||
|
b.setAttribute('aria-pressed', String(mode === districtMode));
|
||||||
|
b.onclick = () => {
|
||||||
|
districtMode = mode;
|
||||||
saveSettings({ districtMode });
|
saveSettings({ districtMode });
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The square an action button acts on, as an attribute the board can be matched against.
|
* The square an action button acts on, as an attribute the board can be matched against.
|
||||||
|
|||||||
+45
-10
@@ -215,6 +215,17 @@ button.act{display:inline-block}
|
|||||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||||
|
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
|
||||||
|
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
|
||||||
|
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
|
||||||
|
from "always showing" without waiting for the right phase in between. Three controls, one per
|
||||||
|
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
|
||||||
|
is doing, which was the earlier fix and is worth keeping. */
|
||||||
|
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
|
||||||
|
border:1px solid #4a5361}
|
||||||
|
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
|
||||||
|
.seg button.ghost:first-child{border-left:0}
|
||||||
|
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
|
||||||
#district.folded #grid{display:none}
|
#district.folded #grid{display:none}
|
||||||
#district.folded .districtrule{display:none}
|
#district.folded .districtrule{display:none}
|
||||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||||
@@ -224,6 +235,16 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
|||||||
.districtrule b{color:#cfd6e0}
|
.districtrule b{color:#cfd6e0}
|
||||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
#districtsummary{display:none}
|
#districtsummary{display:none}
|
||||||
|
/* The This Game card folds the same way the district does, and for the same reason: a panel that
|
||||||
|
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
|
||||||
|
#gamecard.folded #gamecardbody{display:none}
|
||||||
|
#gamecard #gamecardsummary{display:none}
|
||||||
|
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
|
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
|
||||||
|
#gamecardbody dt{color:#8b94a3;font-size:11px}
|
||||||
|
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||||
|
#gamecardbody dd.changed{color:#f0b64a}
|
||||||
|
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
/* 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. */
|
so a disabled button must not wear it. */
|
||||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||||
@@ -816,20 +837,23 @@ ul.blocked li{padding:2px 0}
|
|||||||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
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 id="objective" class="pace">—</span>
|
||||||
<span class="dim">seed <span id="seed">—</span></span>
|
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
|
||||||
|
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
|
||||||
|
them, so the one victory condition that can end a game early was invisible while it ran.
|
||||||
|
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
|
||||||
|
counting towards it. The limits themselves live in the This Game card; this is progress. -->
|
||||||
|
<span class="dim" id="collisions" title=""></span>
|
||||||
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
||||||
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
||||||
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
||||||
Empty (and collapsed) in solitaire, where there is no code. -->
|
Empty (and collapsed) in solitaire, where there is no code. -->
|
||||||
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
||||||
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
|
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
|
||||||
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
|
(TODO #28). Jesse: "we can give complete information about all the game options and not take
|
||||||
<span class="dim" id="gametype" title=""></span>
|
up valuable real estate at the top of the screen… it is not something that they're likely to
|
||||||
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
|
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
|
||||||
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
|
the collision counts — plus the game code, which is identity rather than settings: it is how
|
||||||
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
|
you say WHICH game you are in, out loud, without opening anything. -->
|
||||||
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="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>
|
||||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||||
@@ -873,7 +897,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<section id="district">
|
<section id="district">
|
||||||
<h2>Your Office Area
|
<h2>Your Office Area
|
||||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="districtsummary" class="dim"></div>
|
<div id="districtsummary" class="dim"></div>
|
||||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||||
@@ -917,6 +941,17 @@ ul.blocked li{padding:2px 0}
|
|||||||
</section>
|
</section>
|
||||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></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>
|
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||||
|
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
|
||||||
|
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
|
||||||
|
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
|
||||||
|
and the seating screen draw, so what you agreed to in the lobby and what you can read
|
||||||
|
mid-game cannot drift apart. -->
|
||||||
|
<section id="gamecard" class="folded"><h2>This Game
|
||||||
|
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
|
||||||
|
</h2>
|
||||||
|
<div id="gamecardsummary" class="dim"></div>
|
||||||
|
<div id="gamecardbody"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+154
-6
@@ -1462,7 +1462,17 @@ describe('the static build', () => {
|
|||||||
// opens it with `showModal`. Without these the page throws before it draws anything — which
|
// opens it with `showModal`. Without these the page throws before it draws anything — which
|
||||||
// is a page that never starts, exactly what this stub exists to catch.
|
// is a page that never starts, exactly what this stub exists to catch.
|
||||||
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
||||||
|
/**
|
||||||
|
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||||
|
* Office Area's segmented control (TODO #16): without it every render threw
|
||||||
|
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||||
|
* accessible control ships green and unexercised.
|
||||||
|
*/
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
|
attrs,
|
||||||
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
@@ -1707,7 +1717,17 @@ describe('the static build', () => {
|
|||||||
let html = '';
|
let html = '';
|
||||||
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
||||||
const ownClasses = new Set<string>();
|
const ownClasses = new Set<string>();
|
||||||
|
/**
|
||||||
|
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||||
|
* Office Area's segmented control (TODO #16): without it every render threw
|
||||||
|
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||||
|
* accessible control ships green and unexercised.
|
||||||
|
*/
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
|
attrs,
|
||||||
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
@@ -1812,7 +1832,17 @@ describe('the static build', () => {
|
|||||||
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
|
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
|
||||||
const els = new Map<string, Record<string, unknown>>();
|
const els = new Map<string, Record<string, unknown>>();
|
||||||
const make = (): Record<string, unknown> => {
|
const make = (): Record<string, unknown> => {
|
||||||
|
/**
|
||||||
|
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||||
|
* Office Area's segmented control (TODO #16): without it every render threw
|
||||||
|
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||||
|
* accessible control ships green and unexercised.
|
||||||
|
*/
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
|
attrs,
|
||||||
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
@@ -3792,7 +3822,17 @@ describe('the lobby screen', () => {
|
|||||||
const make = (id: string): Record<string, unknown> => {
|
const make = (id: string): Record<string, unknown> => {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
let html = '';
|
let html = '';
|
||||||
|
/**
|
||||||
|
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||||
|
* Office Area's segmented control (TODO #16): without it every render threw
|
||||||
|
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||||
|
* accessible control ships green and unexercised.
|
||||||
|
*/
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
|
attrs,
|
||||||
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
id, value: '', textContent: '', title: '', placeholder: '', className: '',
|
id, value: '', textContent: '', title: '', placeholder: '', className: '',
|
||||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
|
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
|
||||||
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
|
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
|
||||||
@@ -4165,7 +4205,17 @@ describe('the solitaire setup screen', () => {
|
|||||||
const make = (id: string): Record<string, unknown> => {
|
const make = (id: string): Record<string, unknown> => {
|
||||||
const listeners = new Map<string, (() => void)[]>();
|
const listeners = new Map<string, (() => void)[]>();
|
||||||
let html = '';
|
let html = '';
|
||||||
|
/**
|
||||||
|
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||||
|
* Office Area's segmented control (TODO #16): without it every render threw
|
||||||
|
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||||
|
* accessible control ships green and unexercised.
|
||||||
|
*/
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
const node: Record<string, unknown> = {
|
const node: Record<string, unknown> = {
|
||||||
|
attrs,
|
||||||
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
|
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
|
||||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||||
checked: false, disabled: false, hidden: false, className: '',
|
checked: false, disabled: false, hidden: false, className: '',
|
||||||
@@ -4403,18 +4453,116 @@ describe('the solitaire setup screen', () => {
|
|||||||
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deals the game the URL describes, and says so in the header', async () => {
|
it('deals the game the URL describes, and says so in the This Game card', async () => {
|
||||||
// The other half of the round trip: the dialog wrote those parameters, and this is the page
|
// The other half of the round trip: the dialog wrote those parameters, and this is the page
|
||||||
// reading them back. Without this the two halves can drift and each still pass its own test.
|
// reading them back. Without this the two halves can drift and each still pass its own test.
|
||||||
|
//
|
||||||
|
// MOVED OFF THE HEADER 2026-08-30 (TODO #28). It used to read `#houserules`, an abbreviation
|
||||||
|
// along the top line. The card is the whole of it now, so this checks both halves: the summary
|
||||||
|
// line a folded card still shows, and the full list behind it.
|
||||||
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
|
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
|
||||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
|
assert.match(
|
||||||
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
|
String(els.get('gamecardsummary')!['textContent']),
|
||||||
|
/6 cards · 4\/2\/1/,
|
||||||
|
'the folded card does not say what this game was dealt under',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Folded, the body is not built at all — there is no point rendering what display:none hides.
|
||||||
|
assert.equal(String(els.get('gamecardbody')!['innerHTML']), '', 'a folded card built its body anyway');
|
||||||
|
|
||||||
|
(els.get('gamecardtoggle')!['onclick'] as () => void)();
|
||||||
|
const body = String(els.get('gamecardbody')!['innerHTML']);
|
||||||
|
assert.match(body, /Starting hand<\/dt><dd[^>]*>six random/, 'the opened card does not name the opening hand');
|
||||||
|
assert.match(body, /Passenger per coach<\/dt><dd[^>]*>4/, 'the opened card does not carry the revenue rates');
|
||||||
|
assert.match(body, /Seed<\/dt><dd[^>]*>430/, 'the opened card does not say which seed this is');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => {
|
it('reaches every Office Area mode directly, including the one the cycle could not', async () => {
|
||||||
|
/**
|
||||||
|
* TODO #16, AND THIS IS THE DEFECT ITSELF. The control used to be ONE button stepping
|
||||||
|
* `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing at that
|
||||||
|
* moment — `FOCUS_PHASES.has(f.phaseKey)`. So the pin a press reached depended on the phase,
|
||||||
|
* and going from "always showing" to "always hidden" meant clicking back to auto, waiting for
|
||||||
|
* the phase to turn over, and clicking again. Three controls, one per mode, and every mode is
|
||||||
|
* one press away from every other.
|
||||||
|
*/
|
||||||
const { els } = await load('?seed=430');
|
const { els } = await load('?seed=430');
|
||||||
assert.equal(els.get('gametype')!['textContent'], 'Solitaire');
|
const press = (m: string): void => (els.get(`dm-${m}`)!['onclick'] as () => void)();
|
||||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
const pressed = (m: string): string =>
|
||||||
|
String((els.get(`dm-${m}`)!['attrs'] as Record<string, string>)['aria-pressed']);
|
||||||
|
// Which mode is LIT is what this test is about. Whether the panel then folds is `renderDistrict`'s
|
||||||
|
// existing behaviour, covered where the fold rules are — this suite's stub has a no-op classList.
|
||||||
|
|
||||||
|
assert.equal(pressed('auto'), 'true', 'the page did not start on auto');
|
||||||
|
|
||||||
|
press('open');
|
||||||
|
assert.equal(pressed('open'), 'true', 'pressing "always show" did not light it');
|
||||||
|
assert.equal(pressed('auto'), 'false', 'auto stayed lit after pinning the panel open');
|
||||||
|
|
||||||
|
// THE PRESS THE CYCLE COULD NOT MAKE: straight from one pin to the other, in one click, with
|
||||||
|
// no trip through auto and no waiting for a phase.
|
||||||
|
press('closed');
|
||||||
|
assert.equal(pressed('closed'), 'true', 'could not go from always-show to always-hide');
|
||||||
|
assert.equal(pressed('open'), 'false', 'two modes were lit at once');
|
||||||
|
|
||||||
|
press('auto');
|
||||||
|
assert.equal(pressed('auto'), 'true', 'could not get back to auto');
|
||||||
|
assert.equal(pressed('closed'), 'false', 'the previous mode stayed lit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the newest line at the top of the history', async () => {
|
||||||
|
/**
|
||||||
|
* TODO #23. Jesse, 2026-08-30: "it should be reversed so the top line is the most recent and
|
||||||
|
* the further down you go, the older the entry." The panel used to run oldest-first and scroll
|
||||||
|
* itself to the bottom, so what had just happened was the line you had to go and find.
|
||||||
|
*/
|
||||||
|
const { els } = await load('?seed=430&hand=sixRandom');
|
||||||
|
const lines = [...String(els.get('log')!['innerHTML']).matchAll(/<div class="line[^"]*">([^<]*)<\/div>/g)].map(
|
||||||
|
(m) => m[1]!,
|
||||||
|
);
|
||||||
|
assert.ok(lines.length > 1, 'the log was too short to have an order at all');
|
||||||
|
|
||||||
|
// The deal is the OLDEST thing that has happened, so it must now be LAST rather than first.
|
||||||
|
const dealtAt = lines.findIndex((l) => /dealt|begins|opening/i.test(l));
|
||||||
|
assert.notEqual(dealtAt, -1, 'nothing in the log looks like the opening line');
|
||||||
|
assert.equal(
|
||||||
|
dealtAt,
|
||||||
|
lines.length - 1,
|
||||||
|
`the opening line is at ${dealtAt} of ${lines.length} — the log is still oldest-first`,
|
||||||
|
);
|
||||||
|
assert.equal(els.get('log')!['scrollTop'], 0, 'the panel still scrolls itself to the bottom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the collision counts only while a collision limit is switched on', async () => {
|
||||||
|
/**
|
||||||
|
* TODO #28. The limits moved into the This Game card; the running counts stay on the top line,
|
||||||
|
* because a setting agreed to once and a number that changes how you play the next Stage are
|
||||||
|
* different kinds of thing. `0` means the limit is off, and a half that is off is left out
|
||||||
|
* rather than shown as "1 of 0".
|
||||||
|
*/
|
||||||
|
const on = await load('?seed=430&colday=3&coltotal=5');
|
||||||
|
assert.match(String(on.els.get('collisions')!['textContent']), /0 of 3 today · 0 of 5 total/);
|
||||||
|
|
||||||
|
const dayOnly = await load('?seed=430&colday=3&coltotal=0');
|
||||||
|
const text = String(dayOnly.els.get('collisions')!['textContent']);
|
||||||
|
assert.match(text, /0 of 3 today/);
|
||||||
|
assert.doesNotMatch(text, /total/, 'a switched-off limit was counted towards anyway');
|
||||||
|
|
||||||
|
const off = await load('?seed=430&colday=0&coltotal=0');
|
||||||
|
assert.equal(off.els.get('collisions')!['textContent'], '', 'a game that cannot end this way still counted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the game type in the card, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||||
|
const { els } = await load('?seed=430');
|
||||||
|
// The summary line carries it whether the card is open or shut — which is what replaces the
|
||||||
|
// header's `#gametype`, and is why solitaire losing the (empty) game-code tooltip costs nothing.
|
||||||
|
assert.match(String(els.get('gamecardsummary')!['textContent']), /^Solitaire · 5 Days/);
|
||||||
|
|
||||||
|
(els.get('gamecardtoggle')!['onclick'] as () => void)();
|
||||||
|
const body = String(els.get('gamecardbody')!['innerHTML']);
|
||||||
|
assert.match(body, /Type<\/dt><dd[^>]*>Solitaire/, 'the opened card does not name the game type');
|
||||||
|
assert.match(body, /Combined Revenue floor<\/dt><dd[^>]*>15/, 'the card does not carry the victory conditions');
|
||||||
|
assert.match(body, /Days<\/dt><dd[^>]*>5/, 'the card does not say how long the game is');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
|
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user