Tally sheets let any player score at any moment (+1, -1 or a custom amount)
with a log of entries that can be taken back. Entries are stored as one-player
rows so totals, undo and sharing work unchanged.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A guest was timing out with nothing ever shown on the host. Without a
device to look at, this makes the next test say where it stops, and fixes
what was found on the way.
- The host's Sharing panel shows links received and join requests read, which
tells "nobody reached this phone" from "reached it and not understood".
- The guest says when its link is up and it is waiting for an answer.
- A request waiting shows on the host's top bar ("Sharing . 1 waiting"), and
the Sharing panel steps aside while one is up so two dialogs never stack.
- Join is a top-bar action on a sheet already under way; it was only offered
on an empty one.
- LanTransport: a host being cleaned up no longer unregisters the
advertisement or clears the address of a newer host started in its place.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
A host can share its score sheet and others nearby can join it, found the
same way games are, or by typed address over a hotspot.
- A joining guest is not let in on its own say-so. The host is prompted with
the guest's name and answers Allow or Deny. A device already let in that
drops out (locked phone) is let straight back; the host can remove anyone.
- The host's sheet is authoritative. What travels is what was done (SheetOp:
add round, rename, move column, ...), not whole sheets, so changes made at
the same moment all land. A guest's own changes show at once and are laid
over the host's sheet until confirmed (SheetReplica).
- Sync asks the host for the sheet as it stands, and runs on its own when
the app returns to the front, as it does for a game table.
- Hello carries a purpose and hosts advertise one, so a game join screen does
not list score sheets and a game guest typing a sheet's address is told so.
- The Scorekeeper screen now expresses every edit as a SheetOp, so one grid
serves a local sheet, a hosted one and a joined one.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
Zeros are now stored so a typed 0 shows as 0 while a blank cell stays blank.
Tapping a score in an earlier round opens that round for correction with the
cursor in the tapped cell, instead of needing the row number first.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A phone locked when its turn came round could not play once it was
unlocked. The game was never wrong — the host still had the position and
the seat was still the player's — but the only route to it was gone, and
nothing rebuilt it. The board came back with the state it had before the
turn arrived, and every tap on it went nowhere.
A locked phone puts its Wi-Fi radio into power save, and a link carrying
nothing between one turn and the next is exactly what that drops. Four
things were missing, and all four are needed:
The radio is held awake for as long as there is a game on, which stops
most of these drops happening at all. FULL_HIGH_PERF rather than the
LOW_LATENCY its deprecation notice points at: that one only applies with
the screen on and the app in front, which is the case that worked.
A link with nothing on it is pinged, and answered. TCP does not report a
link that died this way — the read never returns and the next write goes
into a socket that leads nowhere — so without traffic of its own the app
could not tell a player who was thinking from one who was gone. Both ends
have to speak it or a quiet link would be dropped as dead, hence the
protocol bump; the lobby already turns a mismatch away with "update both
devices".
A write that fails now reports the link as broken instead of closing it as
though this end had meant to. That silence was the reason a dead board
answered nothing and said nothing about why.
And a guest that loses its link dials the host again — which is still
listening, on the same port, for the whole match — and is put back in the
seat it never actually left, by the device id it is already known by. The
table then resyncs, which is what fills the screen back in. Coming back
to the front asks for the same thing, since a client draws nothing but
what it was sent and anything pushed while the link was down was pushed
at nothing.
Sends are serialised while I am here: a message is a line, a write is
three calls, and the heartbeat writes on its own schedule.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GarwvAPvWHELNspro4foFY
Binding the join socket to the Wi-Fi network covers a phone that has
joined a hotspot. It does nothing for the phone that is sharing one:
that phone has no Wi-Fi *client* network, so there was nothing to bind
to and the code gave up — leaving the socket on the default network,
which is mobile data, and a connection to a guest going out over
cellular to die.
ConnectivityManager does not offer the tethering interface as a Network,
so there is no network to name. Binding this end of the socket to one of
our own addresses on the guest's subnet picks the route instead, which
is the only lever available. The subnet test assumes a /24, which every
Android hotspot hands out, and getting it wrong costs a bind that would
not have helped anyway.
This is the direction that was left broken: the owner of the hotspot
trying to join a game hosted by one of its guests.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
Two phones on one phone's hotspot could not find or reach each other.
Nothing was wrong with the Wi-Fi. There were three faults, and the first
one is the whole bug.
A hotspot has no internet behind it, and Android takes that literally:
the Wi-Fi network fails its connectivity check, stays unvalidated, and
mobile data remains the *default* network. Every socket opened without
naming a network is therefore routed out over cellular — where
192.168.43.1 means nothing. The join socket is now bound to the Wi-Fi
network before it connects, and the search for that network deliberately
does not ask whether it has internet, because asking is what breaks it.
Second, the manifest has asked for CHANGE_WIFI_MULTICAST_STATE since the
beginning, with a comment saying discovery needs it, and nothing ever
took the lock. Without it the radio drops multicast frames not addressed
to this device before the system sees them, and mDNS is multicast. Both
advertising and discovering now hold it.
Third, and not fixable: a phone sharing its own hotspot does not
reliably advertise over the tethering interface, whatever the other end
does. So the host now shows where it is and the joiner can type it in.
That path is offered always rather than kept back for when discovery has
failed, because discovery here does not fail loudly — it just never
finds anything, and a screen saying only "no games found" leaves nowhere
to go. Hosts take a known port when they can get one, so what gets typed
is an address and not an address and five more digits.
The lobby scrolls instead of filling. Its lazy lists took the whole
screen for a handful of rows and pushed the Start button off the bottom;
they would have done the same to the address box.
Parsing what somebody types off another phone's screen is the one pure
piece of this and is tested, including that what a host displays is
exactly what the other end accepts. The rest needs two phones and a
hotspot, which CI does not have.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
A trick slid off towards its winner and stopped existing. Where it went
was the only record that it had been won at all, and by the third trick
of a hand there was nothing on the table to say who was sitting on what
— which on a real table is the most-glanced-at thing in the game, and
the whole reason cards are gathered rather than binned.
Each player now has a face-down pile in front of them: one card back per
trick, because a trick is squared into a single bundle and put down, and
from above that is what you see. The piles sit beside each player's own
card, one place clockwise along their edge, which is both where a player
would actually put them and the one spot on each edge nothing else was
using. Wizard seats three to six so its piles go half a seat clockwise
round the same ring the cards are played on.
The count follows the cards, not the rules. A trick is counted the
instant its last card is played, which is a beat before the gather and
two before the cards arrive; reading the state directly grew the
winner's pile while the trick was still lying in the middle of the
table, and the cards then slid over and landed on a pile that had
already counted them. rememberArrivedTricks holds the count back until
the sweep has actually run.
The sweep changes to suit its new destination. A gathered card is
squared down towards the size of the bundle it is joining, and stays
whole until the last stretch instead of fading the whole way over: it
now vanishes at the same moment the pile grows by one, and that swap is
only invisible if there was something there to swap.
Kaiser needed a per-seat trick count, which it had never had — it scores
in points, so no rule wanted one. It is defaulted for the wire and
carries a test, because a field no rule depends on is a field that can
quietly stop being maintained. Hearts already keeps the cards themselves
and counts them by the deal.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
President drew only the set on top. Everything played before it was in
the state and simply not on the screen, so beating a pair looked like
the previous pair being taken away and replaced rather than covered.
The pile is the point of the game — what is under the top set is the
history of the round — and it was invisible.
The whole pile is now drawn in one loop keyed by position in it. The top
set is laid out square and side by side, because that is the set that
has to be beaten and the only one that has to be readable; everything
under it is pushed out towards the rim of the ground the top set covers,
so a corner shows. Buried dead centre is buried, and then drawing it
achieves nothing.
Keying by position is what makes the transition work. A superseded set
is the same composable it always was, so when its target moves from a
row slot to the rim it animates from wherever it actually is — no
bookkeeping about where it used to sit, and no card vanishing from the
row to reappear under the heap a frame later. The throw stays relative
to wherever a card belongs, so the two compose: a card lands in its
slot, and is shoved aside from it later without the landing having to
know.
The heap scatter is also now its own pair of bounds, looser than the
table's: eight degrees and fifteen per cent of a card width against four
and a half and five. This was not cosmetic. At the tight bounds a
discard pile of five cards was five cards hidden to within a twentieth
of a card width of each other, which reads as one card — Crazy 8s and
Golf were technically stacking and visibly not. A card keeps whichever
scatter it was given for as long as it lies there, since a card that
shuffled sideways the moment something landed on it would be a card
moving on its own.
Cribbage's series is the same shape of thing and was already keeping its
cards, so it gains the landing to match; its hand gains the tilt the
other hands have.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
Cards appeared and disappeared. A card was drawn at its resting place
the frame it was played, and a finished trick slid off in a straight
line at a constant speed with every card starting and stopping
together. Nothing about that reads as a hand putting a card down.
What a real card does is arrive from the side of the person who threw
it, carry past the spot it was aimed at and rock back, and finish a
little crooked and a little off the mark. That is the whole model. There
are no collisions and no ragdolls, because the difference between cards
on a table and cards thrown down a stairwell is entirely the size of the
numbers: the tilt is capped at four and a half degrees and the drift at
five per cent of a card width, and both are smaller than they want to
be.
The flight is one underdamped spring on a single 0-to-1 value, with
position, angle and height above the table all read off it. The
overshoot is the spring passing 1 and returning, so the card carries and
settles as one motion rather than as three animations that happen to
start together. Driving them apart was tried and looked like it.
The scatter is hashed out of the card's identity rather than drawn from
a generator. A generator would give a card a new angle on every
recomposition, which twitches; hashing the list position instead would
make a card jump whenever another landed beside it. Hashed this way a
card lies the same way for as long as it lies there, and no two cards in
a deck lie the same way as each other. The pure half of that — the
hash, the bounds, the sweep stagger — is unit tested, since it is not
something a screenshot would show.
The sweep now staggers and accelerates: cards break away over the first
third of it rather than in lockstep, each carrying out the same turn it
carried in. Piles are drawn as the last few cards of the heap and stocks
as a pack with depth, so a card played onto a discard pile lands on
something. Every card gains the shadow of a card lying on felt.
Applied to Euchre, Kaiser, Wizard and Hearts at the trick, and to
Crazy 8s, President and Golf at the pile; hands get a tilt small enough
that nobody sees it and large enough that a row of cards stops looking
printed. Motion follows the existing animation-speed setting through a
composition local, so the slider still governs all of it.
Series bumped to 1.3 for the dev channel.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
Holding dice works whatever order they are tapped in, and the hold survives
the throw. Verified on dev by ci.yml runs 118 and 121 and published as
v1.2.123-dev from this tree.
The selection survives a throw and clears on a turn boundary, keyed on the
round as well as the seat so a solo game resets too. Verified by ci.yml
run 121.
Picking the same three dice again after every throw is work the screen can do.
A kept die does not move and does not change, so the hold a player just made is
still the hold they want, and clearing it was throwing away a decision they had
already expressed.
The selection has to go when the turn does, though. A turn opens with the dice
back in the cup and its first throw must keep nothing -- the rules refuse
anything else -- so a selection carried over from the last turn would be
rejected and Throw would look broken in exactly the way it did before.
Keyed on the round as well as the seat, because a solo game never changes seat:
turn stays at zero and only the round moves on.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Holding dice did nothing unless they were tapped left to right: a tap-ordered
list was not among the ascending ones legalMoves offered, and the controller
drops what it cannot find there. Verified by ci.yml run 118.
Holding dice did nothing unless they happened to be tapped left to right.
The screen appends each die as it is tapped, so holding the third and then the
first produced [2, 0]. legalMoves only ever generated ascending subsets, and
RollDice is a data class over a List, so [2, 0] was a different move from
[0, 2] and simply was not among the legal ones. MatchController drops a move it
cannot find there, silently -- hence a highlighted die, a Throw that did
nothing, and no error to go on.
A keep is a set of positions. Holding the third die and then the first is the
same act as holding the first and then the third, and a set says so: the two
are now equal, and the order they were built up in cannot matter. That kills
the whole class of bug rather than sorting at the one call site that happened
to show it.
Positions rather than faces still stands -- two fives are two different dice.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Listing a game in each menu it belonged to made every section a partial
duplicate of another: Card Games repeated all four trick-taking games, and
repeated all four patiences again. A menu that echoes itself is harder to scan
than a longer one that does not.
GameDescriptor keeps its set, because it is still true that Klondike is a card
game and Euchre is a trick-taking one, and the blurbs and the sub-menu heading
both lean on that. What changed is where a game is listed: home takes the first
of its menus in GameMenu declaration order, and that order is the priority --
solitaire, then trick-taking, then the rest of the card games, then board
games. The enum is reordered to say so, which puts the rule in one place
instead of in a comparator somewhere else.
Sequence goes back to being a board game only. It is played with two packs, so
it was listed as both, and under this priority "both" would have quietly filed
it under Card Games -- away from where anyone who owns the box would look. The
set is the wrong tool for a game that is only arguably two things.
Nothing in the screens changed: inMenu now answers with homes, and the section
screens were already asking it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
The games moved a level down when the menus became a tree, so the top screen
now holds three sections and two buttons. Spread across an adaptive grid that
read as a page half empty rather than as a menu.
One column, held in the middle, capped at 340dp so a tablet gets a menu rather
than a row of very wide buttons. Join and Scorekeeper sit below the sections and
run the same width, so the column stays a column all the way down rather than
turning into a wrapped row at the top.
The grid is still what the section screens use, where there are enough games for
it to earn its place.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Solitaire, Card Games and Board Games, with Trick-taking under Card Games,
and a game listed in every menu it belongs to rather than just one.
Verified by ci.yml run 105.
Three sections on the main screen -- Solitaire, Card Games, Board Games -- and
Trick-taking sitting under Card Games rather than beside it, because it is a
kind of card game and not a rival to one.
GameDescriptor carries a set of menus rather than one category. A patience is
solitaire and a card game; Euchre is a card game and a trick-taking one; peg
solitaire is solitaire and a board game. Each is listed in every place it
belongs, which is the point: a game reachable one way and not the other is a
game somebody cannot find.
GameMenu reads its parent from a `when` rather than taking it as a constructor
argument. An enum entry cannot refer to another entry while the class is still
initialising, and the lookup afterwards costs nothing.
Counting a section counts a set rather than adding lists together, since a game
in both Card Games and Trick-taking is one game and not two. hasAnything asks
about a menu and its children together, so a section is only hidden when there
is nothing under it anywhere -- the same rule that used to keep a heading from
being stranded over an empty category.
The dev-build "coming soon" behaviour is unchanged and still tested. What could
not survive the change is the old test that counted games across categories and
expected the total to equal the catalogue: with a game in several menus that
sum is meaningly larger. It is now a union, which is what it was really
checking -- that no game is stranded in no menu at all.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Three more patiences, all playable, and the Klondike table laid out the way
a real one is.
Verified by ci.yml runs 100 and 101, green across build, tests and lint.
Seven games now, and the three new ones are tappable.
The furniture they share moves into CardTable: a place a card sits, an empty
place that can still be tapped into, and the two dialogs for leaving. Klondike
keeps its own copies rather than being rewritten to use them -- it was written
first, its constants are tuned to seven columns, and its private helpers cannot
collide with these.
FreeCell puts the cells on the left and the homes on the right, which is what
makes the cells read as somewhere to put things rather than somewhere cards come
from. Spider shows the two numbers the game actually turns on above the columns:
runs gone away, and rows still in the stock waiting to bury everything.
Pyramid is drawn as it is dealt, each row offset half a card so every card sits
in the notch between the two below it. The shape is the rules -- a card is only
in play once both the cards resting on it have gone -- so seeing it at a glance
is most of playing it. Tapping a king takes it alone; tapping anything else
holds it and lights up only what would make thirteen with it.
All three restart by rebuilding the controller, as Klondike and peg solitaire
do, because a blocked deal is genuinely finished and advanceIdle never runs.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
The test asserted that a card parked in a free cell can always be played again.
It cannot. A cell holds a card nothing on the tableau will take and that is not
yet ready for a foundation, and it stays there -- that is the whole danger of
the cells rather than a fault in them, and the reason four of them is a game
worth playing.
What is worth pinning is what parking actually does: the card leaves the column
and is in the cell afterwards. The one card a cell can never strand is an ace,
since a foundation will always have it, so that gets a test of its own.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Three changes, all about where things are and how much room they take.
Columns now overlap by a third rather than sitting one under another. A column
of thirteen took more vertical space than a phone has; overlapped, it fits, and
a third is the most that can be hidden while every card still shows its rank and
suit. Absolute offsets rather than negative spacing, because the column has to
work out its own height anyway to reserve the right room.
The cards turned off the stock fan sideways by the same third. Drawing three and
showing one hid two cards the player is entitled to see -- they are face up on
the table. Only the last is playable, which the rules already enforce; the other
two are there to be read.
The stock and the cards turned from it move to the bottom left, under the hand
that turns them, and the foundations keep the top on their own. That is where
both live on a real table, and it puts the pile you tap most within reach of a
thumb rather than at the far corner of the screen.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Three more patiences, and they are three different games rather than three
settings of one. FreeCell builds down in alternating colours with everything
face up; Spider builds down in rank but only moves runs of one suit; Pyramid
builds nothing at all and takes cards away in pairs that make thirteen.
FreeCell's supermove is arithmetic rather than a rule. A run is really moved one
card at a time through the cells and the spaces, so what can travel is what
those could have held: one per free cell plus the card itself, doubled for every
empty column usable as a staging pile. Moving into an empty column cannot also
stage through it, which is the fiddly case and is tested on its own.
Spider separates landing from travelling, which is the whole difficulty of the
four-suit game: a card lands on anything a rank higher, but only a same-suit run
moves as a unit. The pack is a hundred and four cards whatever the suit count --
eight copies of one suit, or two of each of four -- so the deal keeps its shape.
Pyramid blanks the places it takes cards from rather than compacting them. A
place is what tells the two cards above it that they are free, so an index that
shifted would uncover the wrong cards.
All three read the ace-low ordering from game.solitaire rather than defining it,
which is the point of having put it there.
The AI is deliberately dim -- it takes the first move on offer. A greedy
patience player that looked clever would be worse, because it would be trusted
as a hint, and a hint that walks a run back and forth is more annoying than no
hint at all. Klondike has a real one because its move ordering is obvious;
these three do not, and pretending otherwise would be the wrong kind of helpful.
Ids only for now. Catalogue entries land with the screens.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
A dice game and the first card solitaire, both playable. The ace-low
convention patience needs is now shared in game.solitaire rather than
defined per game.
Verified by ci.yml runs 90, 92, 93 and 94, all green.
Klondike had its own patienceOrder, acceptsOnFoundation and buildsDown. FreeCell
builds the same way, Spider builds in suit, and Pyramid needs an ace worth one
to pair to thirteen -- four separate definitions of "the ace is low" would be
four chances to disagree, and the one that already went wrong cost a CI round
trip to find.
game.solitaire.Patience now holds the ordering and the build rules, and
Klondike reads them from there. buildsDown stays as a name in the Klondike
package because that is what this game builds with; it just no longer defines
what it means.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Two engines that had been sitting behind ids now have screens, so they are
tappable and playable.
The Yahtzee card shows what the dice on the table would score in every box
still open. That comparison is the whole of the decision, and doing the
arithmetic by eye is not what anybody wants from a phone. A turn is two choices
and the screen shows them together: which dice to keep before throwing again,
and which box to write in when the throwing stops.
Klondike plays tap-to-take then tap-to-put rather than by dragging. A drag
across seven columns of overlapping cards on a phone is mostly a test of aim.
Tapping a face-up card takes it and everything resting on it, so a run travels
the way it would on a table. Which taps do anything is read off the legal moves
the rules offer, so the screen never has to know why a move is allowed.
Klondike deals again by rebuilding the controller, as peg solitaire does: a
blocked deal is genuinely finished, so driveIdleSeats has already stopped and
advanceIdle would never run.
Yahtzee seats one to six, so it needed a case in seatCountFor. Without one the
seat count falls through to the catalogue minimum, which is one now that solo
play exists -- a two-handed game would have been dealt a single seat and the
rules would have refused the table.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Rank.order puts the ace at fourteen, which is right for the games this deck
was built for -- in Euchre, Kaiser and the rest it is the highest card there
is. Patience counts it the other way: foundations run ace upwards and columns
run down to an ace.
Using the raw order meant a two could never follow an ace home and an ace could
never be laid on a two, so no foundation could be built past its first card.
Nothing about the deal or the moves would have looked wrong; the game would
simply never have gone out.
patienceOrder is local to this game rather than a change to Rank, because ace
high is correct everywhere else in the app.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Seven columns, four foundations and a stock, played by one person against the
deal. Three axes of variation, which is what most people mean by house rules
for patience: one card off the stock or three, how often the waste may be
turned back over, and whether an empty column will take anything or only a
king. A single pass through the pack is redealLimit = 0.
A spot is a kind and an index rather than a class per kind. Every place a card
can be in this game is one of four things identified by at most one number, so
a pair says everything a hierarchy would and crosses the wire as two plain
fields.
A tableau pile keeps its face-down and face-up parts separate, because that
split is the game: a column is only worth what has been turned up, and what is
buried is the thing being played for. Stripping the turned-up run off a column
turns the next card, which is where most of a deal is won.
Shifting a whole column into an empty one is not offered as a move at all. It
achieves nothing and would let a game walk back and forth for ever -- which
matters here more than usual, because isFinished is "won, or nothing left to
try", and a game that can always shuffle pointlessly is never blocked.
Turning a packet over reverses it, so drawing three leaves the deepest of the
three showing, and a redeal puts the top of the waste at the bottom of the new
stock. Both are tested, because getting either backwards is invisible until
somebody counts the cards.
The AI orders its preferences rather than scoring finely: home always, then
turning a card up, then emptying a column, and aimless shuffling between
columns below drawing, so it is only reached when there is nothing else. That
ordering is what keeps it from walking a run between two columns for ever.
Not in GameCatalog yet -- id now, entry with the screen.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Five dice, thirteen boxes, three throws a turn. The first dice game in the
app -- backgammon rolls them, but as a race rather than for what the faces
themselves are worth.
The throw is derived from the state rather than from a generator held on the
side. applyMove has to return the same thing every time it is given the same
state and move, because the host and every client run it separately, and a
throw that differed between them would split the game in two. Seeding on the
round, the seat, the rolls used and what was kept gives dice that are
unpredictable to play against and identical to replay.
RollDice carries positions rather than faces. Two fives are two different dice,
and keeping one while rerolling the other is something a set of faces cannot
say.
A card is thirteen slots in printed order rather than a map, so an unfilled box
crosses the wire as a plain null instead of going missing. A full house is
strictly three and two, so five of a kind does not pay for it -- that is what is
printed on the card, and the joker variations that say otherwise are not played
here. The second-Yahtzee hundred is only earned over a fifty: a zero written in
that box earlier forfeits it.
The AI does not try each way of keeping and look at what came back. Since the
throw is a pure function of the state and the dice held, that would be reading
the roll before making it -- winning by seeing the future rather than by playing
well. It decides from what is already on the table: a run if one is nearly
there, otherwise the face there is most of.
Not in GameCatalog yet, for the same reason as the others: the id exists so the
rules compile, and the entry lands with the screen.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Two new games and the first single-player one. GameRules needed no change
for a one-seat table; CatalogTest's floor of two players is the only
invariant that moved.
Verified by ci.yml run 86 on 480f0ba: build, unit tests and lint all green.
Both engines had been sitting behind ids with no screen. This gives them one
each and puts them in the catalogue.
Peg solitaire is the first game here played alone, and almost nothing had to
move for it. seatCountFor already fell through to minPlayers, so one seat came
free; CatalogTest's floor of two players is the only invariant that had to go,
and it went to one. The setup screen grows a solo path instead: a one-seat game
has nobody to play against and no seat anyone could join, so it offers "Play"
and not "Host a game for others to join", and the menu says "On your own"
rather than "1 players".
Playing again cannot go through advanceIdle. driveIdleSeats returns the moment
rules.isFinished is true, before it ever reaches the confirmation branch, and a
peg board with no jumps left genuinely is finished -- unlike a Wizard round,
which is only between deals. Rather than bend isFinished into meaning something
else, the screen rebuilds the controller behind a generation counter. The board
is deterministic, so the retry is the same puzzle rather than a new one.
One board layout serves both geometries. Each hole is placed in hole-widths
rather than pixels and a triangle offsets its rows by half a hole, which is the
same staggering that gives a triangular hole six neighbours instead of four.
The Hearts screen reads the phase because two different things happen at it:
choosing three cards to give away, which is a multiple selection nothing else
in the app does, and then playing one card at a time like every other trick
screen here. Passing is capped at three by the picker rather than by the error
the rules would otherwise raise.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC