Both workflows gain a deploy-pages job that stages their channel into one
Pages site, carrying the other channel over from the live site. The in-app
updater now reads /prod|/dev/latest.json from plg.wallacenet.ca instead of
the GitHub API. GitHub releases are still published for one transition
deployment so existing installs can fetch the new updater.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
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
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 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
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
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
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
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
The tee game from a restaurant table, and four larger boards it generalises to.
Two grid shapes, and the only difference between them is how many directions a
peg can jump: a triangle staggers its rows so a hole has six neighbours, a
square board has four. The jump, the goal and the dead end are the same either
way, which is why one legalJumps serves all five.
Hole counts are checked rather than asserted from memory -- 15, 21, 33 for the
English cross, 37 for the shallower French cut, 41 for the diamond -- and a
test walks every board from its opening to a standstill, which is what would
catch a geometry that produced a board nobody can move on.
PegJump carries only its ends. A jump is always two steps in one direction, so
the hole in between is the only one it can be, and storing it as well would be
a second copy of the same fact with the freedom to disagree.
This is the first one-seat game in the app. GameRules took it without changes:
one seat, always on the clock until the board runs out of jumps, and viewFor is
the identity because there is nobody to hide anything from. The catalogue entry
and the seat-count change it needs come with the screen.
The AI never runs during an ordinary game, since the only seat belongs to the
person playing. It is here because an empty seat has to be playable by
something, and because keeping the pegs together is what a "suggest a jump"
button would want.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
HeartsRules names GameCatalog.HEARTS as its gameId, so the constant has to
exist even though the game is not in the catalogue yet. The id alone puts
nothing in the menu -- `all` is what the menu is built from -- so this compiles
the engine without offering a game that has no screen behind it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
The trick-taking games already here all ask a seat to win tricks it named.
Hearts inverts that: every heart costs a point, the queen of spades costs
thirteen, and the hand is played to take none of them.
Four restrictions decide what may be played, and they lift in that order: the
two of clubs opens the round, a seat holding the led suit must follow it, no
point card may be discarded on the first trick, and hearts cannot be led until
one has been played. Each of the last three lifts when a seat holds nothing
else, which is why playableCards checks them in sequence rather than as one
condition.
Passing is simultaneous at the table but the engine hands out one seat at a
time, so currentSeat returns the lowest seat that has not chosen and the swap
runs once the last one has. viewFor strips the selections as well as the hands:
a pass a client could read in advance is not a pass.
Taking all twenty-six is the one case where a round does not sum to twenty-six,
so scoreRound works from the cards taken rather than a running total -- a count
cannot tell an unbroken moon from thirteen hearts and a queen picked up
elsewhere.
The AI plays to avoid points rather than to shoot: it sheds the queen and her
guards early, ducks under whoever is winning, and takes a trick as cheaply as
it can when it cannot get out of the way.
Not in GameCatalog yet. The engine is complete and tested, but tapping a
catalogue entry with no screen behind it would crash, so it is added with the
screen rather than before it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
The number after the series is the CI run number and climbs on its own, so
this is the only part of a version that is set by hand. Dev builds published
from dev now tag v1.2.<run>-dev.
The example in the comment above tracked the old value; a stale copy of the
number is the exact thing that comment warns about, so it moves too.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
Register a PreToolUse hook on Bash that rewrites each command through rtk,
so `git status` runs as `rtk git status` and the output is compressed on the
way back. The rewrite is transparent; commands are still written normally.
Measured on this repo, a full `git diff HEAD~3` drops from 521 KB to 28 KB,
a recursive grep over app/src/main from 117 KB to 19 KB, and `git log -20`
from 20 KB to 5.3 KB. Small outputs grow marginally because rtk adds a
header, which at those sizes is not worth avoiding.
The container starts empty, so a SessionStart hook installs the binary first.
It pins RTK_VERSION because the installer's default is to resolve the latest
tag through api.github.com, which the proxy answers 403 for every repository
but this one. Outside the sandbox the hook installs nothing and only prints
the command to run, and every failure path exits clean: with rtk absent the
rewrite no-ops and the only cost is verbose output.
Verified that rtk leaves what must not change alone -- `rtk read` returns
GameSetupScreen.kt byte-for-byte, commit messages keep their quoting, and
already-rewritten commands are not wrapped twice.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
The switch has been in Settings since the updater landed and has never
been read by anything: there was no launch-time check to turn on or off.
It is now wired to one.
It speaks only when there is a build to install. An app that is already
current says nothing, and neither does one that could not reach GitHub —
opening the app is not asking a question, and a phone with no signal
should not be told off for it once a launch. The button in Settings *is*
a question and still answers every time, up to date and failures alike.
That rule is `launchOffer`, next to the channel rule and tested with it.
Two things it is careful about:
The setting is read straight from the store rather than from the state
the screens are drawn with. That state begins as the defaults — where
checking is on — and is replaced a moment later by what is stored, so a
check driven off it would have fired on the default before ever learning
it had been switched off.
The prompt waits for the splash to clear before appearing, while the
check itself runs behind it. The look costs no time and cannot land on
top of the tartan.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
The updater took the channel from a setting, so a production install
could be pointed at dev builds and vice versa. That setting was the only
thing standing between a phone and the wrong line of builds, and it was
a chip you could tap by accident.
The channel is now a property of the running APK and nothing else. The
app reads the -dev suffix in its own versionName, asks that channel and
no other, and then checks again at the point of decision — a release
belonging to the other channel is refused whatever came back from the
fetch. There is no preference, no key in the DataStore, and no way to
cross over.
Crossing over was never an upgrade in the first place. The two channels
are counted by different workflows and their numbers are unrelated: dev
is on 69 while production is on 40, so "newer" means nothing across the
divide. They are separate apps under separate applicationIds, so having
both is a matter of installing both, and each then keeps itself current.
The rule is now a pure function with no Android in it —
update/UpdatePolicy.kt — so it is unit-tested on the JVM like the rules
engines are, rather than being buried in a class that needs an Activity.
UpdatePolicyTest holds the wall: a dev install is refused a production
build and a production install a dev one, at every version number
including the ones where the two sequences overlap.
The settings screen states which build is running and where its updates
come from. It no longer offers a switch, because there is no longer one
to offer.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
Four-handed is the only table where the crib in front of somebody else
is still yours, and the lay-away is where that has to be read. Two AI
seats facing each other take more than half the games against two
random ones, which a side giving away half its own cribs would not.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
The pause between hands never ended. driveIdleSeats asks
awaitsConfirmation about the state, and the state does not change while
the players are reading it — so confirmAdvance restarted the loop, the
loop stopped on the same scoreboard, and the next hand was never dealt.
The screens hid their own dialog on the way out, so the table looked
released and then simply sat there.
The release is now something spendable rather than a question re-asked
about an unchanged state: confirmAdvance grants one, and the loop
consumes it to walk past the hold exactly once.
This is not new with cribbage — Golf's card, Wizard's bids against
tricks and the Crazy 8s round score all stop on the same hold and all
end there. Cribbage's show is what made it impossible to miss, because
a game of cribbage is nothing but hands one after another.
Covered by MatchControllerScoreboardTest, which plays a whole game of
one-move hands and insists every one of them stops to be read and then
goes on.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
The whole game: the crib, the cut, the play and the show, pegging on a
board drawn with both pegs so the last score is visible as well as the
total.
Three tables rather than one. Two play head to head with six cards and
lay two away; three play singly with five and lay one, with a fourth
card dealt straight into the crib because three hands cannot fill it;
four play in partnerships pegging on one score, where your partner's
crib is your crib. Every table keeps four cards and every crib holds
four, which is what lets one engine serve all three.
The rule that shapes the code is that a game ends on the point that
reaches the target, mid-count if that is where it falls. Every award
goes through one function that stops the game there and refuses
everything after it, so a non-dealer who counts out wins before the
dealer's hand and crib are counted at all — the show reports those as
never counted rather than adding them on.
A go is called by the engine rather than tapped. The count only rises
within a series, so a hand that cannot play now cannot play later in
it, and there is nothing to decide; the log says who went past.
The opponent works out its lay-away rather than guessing: every way of
keeping four is scored against all forty-six cards that could still be
cut, and what goes to the crib is valued separately and added or
subtracted depending on whose crib it is. It takes 114 games out of 120
against random legal play, from either seat.
teamForSeat now takes the seat count, because cribbage is only a
partnership game at four — reading seat % 2 at a table of three would
have put the first and third players on the same side.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
The series a build is named for was written out three times — in the
build file and in both workflows' tag lines — so bumping it meant
changing all three and a release could be tagged one thing and built as
another. It now lives in gradle.properties as plgVersionSeries, which
the build reads when it stamps the APK and both workflows read when they
name the tag.
Set to 1.1.
versionCode is deliberately left as the CI run number alone, which is
what makes the bump safe: the run number keeps climbing across a change
of series, so 1.1 never looks like a downgrade to Android and an OTA
update is not interrupted. The series is a name for people; the run
number is what the machines compare. The updater already read only the
trailing number, and its comments now say so rather than naming 1.0.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
A generated code takes away half the game. Whether to repeat a colour,
whether to leave one out, whether to do the thing this opponent will not
expect — those are real decisions, and no random number generator makes
them.
So a game now opens in a SETTING phase: each player chooses the code
their opponent will have to break, one after the other, and the guessing
starts once both are down. They choose in turn rather than at once
because the engine only ever has one seat on the clock, and it costs
nothing — neither can see the other's, so going second is not an
advantage.
The same move carries both: while the codes are being set it is your
code, and after that it is a guess at theirs. Which one it means comes
from the phase rather than from a flag on the move, because the phase is
the thing that actually knows — a flag could disagree with it.
An empty code used to mean one thing and now means two — not chosen yet,
or chosen and hidden from the device this copy was sent to — so the
state carries a flag per player. Whether the other player is ready is
not a secret, and a screen has to be able to say "waiting for them"
rather than sit blank.
The computer sets a code at random and does not try to be clever about
it: a code chosen to beat a particular solver has a pattern in it, and a
pattern is the one thing a code should not have. That single choice is
the only random in the app drawn from a source nobody else can see —
every other one is seeded from the position so a host and a client
agree, but a code seeded from anything on the wire could be worked out
by the player who is meant to be guessing it.
A code set on a guest's device reaches the host as a move and stops
there: moves are intents sent to the host and are never relayed to the
other player, so the only copy that reaches an opponent is redacted.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
The four games that were listed but not written. Nothing in the
catalogue is marked "coming soon" any more.
Checkers is English draughts on the thirty-two dark squares, numbered
the way the game is written down. Captures are compulsory and a multiple
jump is one move object, so a player cannot stop halfway through one —
true by construction rather than by a rule that has to be enforced
somewhere. The move generator matches the published perft counts at one
to four ply (7, 49, 302, 1469), which fold in compulsory capture,
crowning and the men's forward-only rule all at once.
Backgammon's hard rule is not how a checker moves but that a player must
use as much of the roll as they can, and that cannot be checked one move
at a time: a move can be illegal precisely because playing it strands
the other die. So the generator works backwards from whole turns —
memoised depth-first over the remaining dice — and offers only the first
steps of the longest plays. Recomputing it after each checker is what
makes the rule true by construction. Every die comes from the table's
seed and a count of rolls made, which is in the state rather than beside
it so a game is reproducible from what crosses the wire. There is no
doubling cube; the rules document says so plainly.
Mastermind is played as a duel — both players are set a code and both
are breaking one — because the pencil-and-paper game gives the codemaker
nothing to do. It ends at the foot of a round, never mid-round, or going
first would be worth half a guess. It is the one game here with anything
to hide, so viewFor does real work: the host sends each device only its
own code, and a device that was never given a code refuses to score a
guess against it rather than quietly answering "nothing".
Pirates and Bulgars is Sackson's theming of Asalto, and the rules
document is explicit about which parts are attested and which had to be
chosen — the book could not be obtained. Two hunters who take by
jumping against twenty-four who cannot take at all, on a thirty-three
point cross whose drawn lines come from the same adjacency table the
moves come from, so the board and the rules cannot disagree.
130 unit tests across the four engines, all run locally as well as in
CI: game/ has no Android in it, which is what makes that possible.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
Nine pieces each on twenty-four points: place them, then move them, and
every line of three takes an enemy piece off the board. Two pieces left,
or no legal move, and the game is over. See docs/RULES-morris.md.
The board is numbered ring * 8 + index, so its geometry falls out
arithmetically rather than being typed in as a table of twenty-four
rows: neighbours around a square are the index either side, the ladders
between squares run through the odd indices alone, and a mill is three
consecutive points centred on an odd one. A table would say the same
thing and could be wrong in one row.
A turn is one move object — the piece placed or stepped, and the enemy
piece taken when that closes a mill. Generating the removal as part of
the move rather than as a turn of its own means there is no moment where
a player is halfway through a turn, so a device that drops off the
network between closing a mill and choosing what to take cannot leave
the table stuck, and the search counts one ply for what is one decision.
Three rules about mills are easy to get backwards and all three are
tested: the piece that arrives is what closes a mill, so stepping out
takes nothing and stepping back in pays again; a slide along the line
being completed is not a mill, because the third piece it needs is the
one that just moved; and a piece in a mill is spared unless every enemy
piece is in one, without which a player could never be reduced and the
game would not end.
The AI is negamax with alpha-beta and iterative deepening over the same
rules engine, with a node budget so the placing phase does not stall a
phone. It has no quiescence tail and does not need one: there are no
exchanges in Morris. Its evaluation knows that being down to three is
dangerous even with flying on — without that term the search reads three
pieces going anywhere as freedom and starts steering towards losing
pieces to get there, which is a real game it would throw away.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
The same sheet, drawn as the paper it replaces: a column per player with
their name at the head, a row per round running down the page, and the
running total ruled off at the foot.
The bottom row is always the one being filled in. Finishing it writes it
down and opens a fresh empty row above the totals, so the place to type
is always in the same spot. The head and the totals sit outside the
scrolling rounds and share one horizontal scroll state with them, which
is what keeps a column and the name over it from ever coming apart —
every row is built by the same composable and is therefore exactly the
same width, which is what lets them share it.
Reordering moves to where a spreadsheet puts it: drag a name sideways
and the whole column travels with it. Tapping a name renames it or
removes that player, tapping a round number down the left edge opens
that row for correction, and a + column at the right adds a player. The
mode toggle and the round dialog are gone with them.
Points still go in as typed text until the row is finished, so a
half-typed minus is not a number yet. The ± button flips the last number
typed: not every keyboard offers a minus on its number pad, and taking
points away has to work on all of them.
adjustResize is now stated in the manifest rather than left to the
system to resolve. This screen types into the bottom row with the totals
and the finish button below it, and they have to stay above the keyboard
rather than being panned off the screen.
The model is untouched: ScoreSheet and its tests did not need to change
for any of this, which is the point of it having no Android in it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
A pencil, reachable from the main menu: say how many are playing, name
them, and write down what each scored every round. The tally sits at the
foot of the screen because that is the one number anybody looks up.
Points are recorded against a player's id rather than their position,
which is what lets the players be dragged into a different order without
their scores moving with them. Ids are never reused, so somebody sitting
down where somebody else got up starts at nought instead of inheriting a
stranger's score. Removing a player does take their points with them: a
score nobody can see is a score nobody can correct.
The drag leaves the sheet alone until the finger lifts. Rows move on
screen as the drag passes them, but the positions the gesture is working
from cannot shift underneath it, and the handlers read the sheet through
rememberUpdatedState so a drag cannot be applied to a stale copy and
throw away a name typed since.
Rounds are not written in ink — tapping one reopens it for correction or
strikes it out — and the sheet is saved as it is edited, so a phone call
does not lose the game. It lives in its own DataStore rather than with
the settings: a game in progress is not a preference.
score/ScoreSheet.kt has no Android in it, for the same reason game/ does
not, and every rule above is unit-tested on the JVM.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
Opening this pull request through the API produced no workflow run, so
the previous version of this file was wrong about the API route being
the fix. The suppression covers everything the sandbox does — pushes,
pull request opens, and merges — because it follows the integration, not
the account the UI attributes the action to.
workflow_dispatch is the only mechanism that works, because it creates a
run directly rather than relying on an event.
Record the loop that follows from that, including the part worth having:
ci.yml gates only its staging and publishing steps on refs/heads/dev, so
dispatching it on a feature branch runs build, tests and lint while
publishing nothing. That is a real pre-merge gate with no YAML changes.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169
A git push from a Claude session is made with a bot token, and GitHub
creates no workflow run for it — the branch moves and CI stays silent.
Today that put a change on dev and then main having only ever been built
after the fact, and both branches needed a manual workflow_dispatch to
produce anything.
Record the working rule that avoids it: push the feature branch, then
open and merge the pull request through the GitHub API, whose calls are
attributed to the repo owner and do trigger workflows. ci.yml already
listens on pull_request for main and dev, so the PR is what builds the
change before it lands.
Also note what a merge actually does here — dev publishes a dev-channel
prerelease, main publishes a production release — and that Gradle cannot
run in the sandbox, so CI is the only verification available.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169
A dev build now installs under its own applicationId, org.prolibertate.games.dev,
so it is a separate app to Android and sits beside the production copy instead
of replacing it. Each channel still updates itself normally, because an update
within a channel keeps the same package.
Three things move together off one isDevBuild flag, because a dev package
wearing the production name and colours is exactly the mix-up that having both
installed is meant to prevent:
- applicationId gets a .dev suffix;
- the launcher label becomes "Pro Libertate Games DEV";
- the cloth becomes Wallace Hunting, the same threadcount with green where
the clan sett has red.
The name and the colours reach the resources through resValue, so app_name,
tartan_field, splash_background and ic_launcher_background are generated by the
build rather than declared in res/values. The icon vectors reference
@color/tartan_field instead of a literal, which is how one set of vectors serves
both colourways without a second copy of the paths. BuildConfig.DEV_BUILD
carries the same flag into Kotlin, where AppSett picks the Compose sett for the
splash, and WallaceHuntingSett is derived from the same threadcount builder as
WallaceSett so the two cannot drift.
versionName now tracks isDevBuild rather than the CI stamp alone, so an
unstamped local build cannot claim a channel its applicationId contradicts.
Separate packages also mean a channel switch can no longer be refused as a
downgrade; the settings copy and the README said it could, and now say what
actually happens.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JDuAayWXLRoUNTsfE79Mg1
Multiplayer is now Wi-Fi (LAN) only. The BluetoothTransport, its RFCOMM
plumbing, the BLUETOOTH/BLUETOOTH_ADMIN/BLUETOOTH_CONNECT permissions and
the runtime consent prompt are all gone; the transport abstraction stays,
so LanTransport is simply the only implementation of it now.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169
Multiplayer now runs over Wi-Fi (LAN) only.
- Delete BluetoothTransport and drop it from LobbyController's transport
list, leaving LanTransport as the sole transport.
- Drop TransportKind.BLUETOOTH and the now-unused RFCOMM_UUID constant.
- Remove the BLUETOOTH/BLUETOOTH_ADMIN/BLUETOOTH_CONNECT permissions and
the android.hardware.bluetooth feature from the manifest, along with
the runtime permission request in MainActivity and the
requestBluetoothPermissions hook on AppEnv.
- Update lobby copy so an empty transport list and an empty host list
talk about Wi-Fi rather than pairing, and refresh the comments and
README sections that described two transports.
The transport abstraction itself is kept: everything after the handshake
was already transport independent, so adding another one later needs no
changes above LanTransport.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169
Two things reach production together.
Ta Yü is a new board game: 1x3 river tiles laid out from the centre of an
18x18 board, two or four players, scored by multiplying the exits you reach on
one edge by the exits on the opposite one. The game is out of print and its
rulebook could not be obtained, so docs/RULES-tayu.md separates what is
attested from what was reconstructed, and names the three things to check
against a real copy.
The other is the networking fix already on dev: a guest saw nothing until the
next state push, and with more than one guest every device decided it was
sitting in the same seat. Both are worth having in a production build more
than Ta Yü is, since they affect every networked game rather than one new one.
Verified as the merged tree rather than as two separate branches: CI run 26
built, tested and linted e17272b clean, and the published dev APK was opened
to confirm it carries both.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
The dev prerelease steps were gated on the event as well as the branch, so a
manual run from the Actions tab built, tested and linted dev and then skipped
the publish. That matters because a push does not always fire the workflow:
anything pushed by an automation token is suppressed by GitHub's own recursion
guard, which leaves the dev channel stuck on an older build with no way to move
it from the Actions tab.
Gated on the branch alone now, which is how release.yml has always gated main —
the inconsistency was between the two pipelines, not a deliberate rule. Nothing
about the safety property changes: publishing still only happens after the same
job has built, tested and linted clean, and a pull request is still excluded
without being named, because a PR build runs against refs/pull/N/merge and
never refs/heads/dev.
PLG_CHANNEL moves to the same condition, which is the part that would bite if
it were missed. It decides whether the APK is stamped with its run number, and
the publish steps decide whether that APK ships. Leave them out of step and the
dev channel gets an APK claiming versionCode 1 — an "update" the installed app
can never see as newer than what it already has.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
The Ta Yü merge landed on dev without a CI run behind it, so the dev channel
still offers the build before it. Nothing in the tree needs to change for a
new one: versionCode and versionName are derived from GITHUB_RUN_NUMBER and
PLG_CHANNEL at build time, so the version iterates on its own and there is no
constant to bump — this commit exists to give ci.yml a push to fire on, which
is the only event the publish steps are gated on.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
Rivers laid out from the centre of an 18x18 board on 1x3 tiles, two or four
players, against a computer opponent held to measured numbers.
Clean merge: the Ta Yü work touches only its own package, its screen, and the
three wiring points every game goes through, so it does not overlap the guest
resync work already on dev. PlayerSlot.deviceId arrived there in the meantime
but the GameRules contract itself is unchanged, so the new engine needed no
adjustment for it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
Rivers laid out from the centre of an 18x18 board on 1x3 tiles, each with
three mouths that must line up with whatever they touch. One side runs water
north and south, the other east and west, and a side's score is one edge
multiplied by the other — so reaching only one of your two edges is worth
nothing, which is the whole tension of the game.
Two things fell out of the geometry rather than needing to be tabulated. The
tile set is generated: three mouths chosen from the eight places one can sit
on a 1x3 tile gives 56 arrangements, turning a tile end for end pairs them
off with none fixed, so there are exactly 28 distinct tiles and the box's 84
is three of each. And because every tile joins the network by a mouth while
all three of its own mouths interconnect, the board is always one connected
river system — so scoring never traces connectivity, and a mouth at the edge
is an exit by construction. Both properties are asserted over whole games.
The placement check is one comparison doing two jobs: where a new tile abuts
an old one, both sides must agree. That refuses a river dead-ending against
stone and, read the other way, refuses a blank flank that would wall off a
river already down.
18x18 comes out under 20dp a cell on a phone, too fine to tap, so laying a
tile is a tap to line it up and a button to commit. Tapping the same cell
again cycles the other legal ways the tile could cover it, which is also how
it gets turned — a rotate control would have landed on illegal positions. A
tap on a cell the tile cannot cover is ignored rather than treated as a
deselection, so a fingertip one cell wide of the mark does not throw away a
placement.
Two AI settings, not three. A blocking-heavy variant was written and dropped
because it played the full setting dead even over 30 games; shipping it as a
step up would have been a claim that could not be backed. What ships is held
to measured numbers: full strength won 20 of 20 against random placement from
either axis, gentle beats random about three games in four and loses to full
strength about four in five, and random against random comes out even, which
is what makes the rest of those numbers mean anything.
The reconstruction's uncertainties stay called out in the rules doc rather
than being smoothed over now that the code exists: the 18x18 board reading,
the marked exits placed at 3, 8 and 13 by choice, and the three-player flood
variant left out because nothing found says how the flood scores.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
Ta Yü has been out of print since the 2009 Goliath reissue, so the rulebook
had to be reconstructed from secondary sources — and this machine's network
policy blocks everything but search summaries, so BGG's rules files were out
of reach too.
The tile set fell out of the geometry rather than needing a component list.
A 1x3 tile has eight places a river mouth can sit; three mouths chosen from
eight gives 56 arrangements, and turning a tile end for end pairs them off
with no arrangement fixed, leaving exactly 28 distinct tiles. The reissue
has 84 tiles and the 1999 original had 112 — 3x28 and 4x28. Twelve of the 28
touch three flanks of the rectangle, which is what the ringed centre stud on
the reissued tiles marks. Three independent numbers agreeing is why the
geometry is worth trusting.
What could not be pinned down is called out in the doc rather than smoothed
over: the board is taken as 18x18 because that is the reading under which
"the four centre indentations" is true, the marked exits are placed at 3, 8
and 13 by choice, and the three-player flood variant is left out entirely
because nothing found says how the flood scores, and inventing that would be
shipping a different game. Same posture as the Sequence board layout.
The catalogue entry is added with available = false, so it shows as coming
soon on dev builds and stays out of production until the engine lands.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
A guest saw nothing until the next state push, which on most tables means
until its own turn came round: no board, no cards, no scoreboard. The
cause is a gap in who is listening. A guest's link is opened in the lobby
and read there; the table only subscribes to it once the host's start
message has been through a state change, a recomposition and a
LaunchedEffect. The host publishes the opening position immediately, into
a link whose table has not attached yet, and incoming is a SharedFlow with
replay = 0 — so that first push went to the lobby's collector and nowhere
else. Not a rare race but the normal order of events, which is why it
looked like a rule rather than a bug.
The guest now asks. A new Resync message is sent from inside
onSubscription, so the request cannot leave before the collector is
attached and the reply cannot arrive before there is anything to receive
it. The host answers one peer with that seat's redacted view. A request
that arrives before the deal needs no answer, since a peer that has asked
is listening and the opening publish will reach it.
Second reason a guest could not see its hand, on any table with more than
one guest: guests worked out their own seat by matching PlayerSlot.peerId,
which is the address the host's transport invented for the incoming link —
an IP on Wi-Fi, a MAC over Bluetooth. A guest has never seen that value,
so no guest ever matched it and every one fell through to "the first seat
held by somebody remote". With one guest that is right by luck; with two,
both claimed the same seat and the other drew a hand belonging to a seat
it was not sitting in, which redacted is no hand at all.
The host now records the id a guest introduced itself by, as
PlayerSlot.deviceId, kept separate from peerId because the two answer
different questions: peerId is how the host recognises a link, deviceId is
how a guest recognises itself. The lookup moved out of LobbyScreen into
seatForDevice, where it can be tested, and a dropped player stops claiming
its old seat.
Neither change needs a protocol bump. Resync is ignored by a host that
does not know it, deviceId is an unknown key to a guest that does not read
it, and the old fallback is kept for exactly that case.
Tests reproduce the real start-up order — host publishes, then the guest
subscribes a frame later — rather than the order that happens to work, and
cover a board, a dealt hand, and a guest that is on the clock at the deal.
Both regressions were confirmed to fail before the fix: the guest had no
state at all, and two guests both came back as seat 1.
216 JVM tests pass.
Leaving a multiplayer game deadlocked the main thread in
BufferedReader.close(), which wants the lock readLine() holds for the
whole of a blocking read — so a game of chess could not be ended at all.
Chess also gains an End game action, the other device is told when a
player leaves, and leaving no longer lands back in a live lobby.
Also carries the fixed-name dev APK change from dev.
Leaving a networked game deadlocked the main thread in
BufferedReader.close(), so a multiplayer chess game could not be ended.
Also adds an End game action, tells the other device when a player
leaves, and stops a leave landing back in a live lobby.
GitHub's /releases/latest/download/<name> redirect needs a file name
that stays put, and the release asset is named after its version. Each
production release now carries the same build twice: the versioned copy,
and a plain ProLibertateGames.apk, which makes this link permanent:
.../releases/latest/download/ProLibertateGames.apk
Only the production channel gets one. /releases/latest skips
prereleases, which is exactly what stops a production install being
handed a dev build, so there is no equivalent dev link to offer.
The updater now picks its asset by name rather than taking the first
.apk it sees. Both copies are byte-identical, so nothing would have
broken either way, but relying on upload order for which file gets
installed is not something to leave lying around.
Leaving a multiplayer game froze the app for good. Closing a link closed
the BufferedReader first, and BufferedReader.close() wants the very lock
readLine() holds for the whole of a blocking read — a read that only
closing the socket underneath it can ever end, which is the step the
close never reached. Both callers run on the thread drawing the screen
(AppRoot.pop and PlayScreen's onDispose), so the main thread waited on
itself and the app stopped responding. runCatching was no help: nothing
was thrown, it simply never came back.
StreamConnection.close() now closes only the raw streams, which is what
actually ends the read; the reader coroutine then closes its own wrapper
on its own thread, where it owns the lock. Leaving takes single-digit
milliseconds instead of never returning.
It bit chess because chess is the one game here worth leaving in the
middle of. A card hand is over in minutes; a game of chess between two
people who have had enough of it has no natural end, and there was
nothing to end it with, so:
- Chess has an End game action, with a confirmation, so a table can be
walked away from without hunting for a way out.
- A link that drops on its own now reports itself on the same channel
as everything else, so the device still at the table is told the
other player has left rather than waiting on a move that is not
coming. Bye was in the protocol and never sent; nothing needed to
change on the wire for this.
- Leaving a table no longer lands back in the lobby that started it. A
lobby starts advertising the moment it is drawn, so "leave the game"
used to put the player straight into another one.
Two crashes of the same shape on the same path, both in coroutines
launched in the screen's own scope where nothing catches anything:
- The host's AI applied a move chosen before it took the lock, checking
only that the seat had not changed. applyMove throws on a move that
is no longer legal. Harmless while an AI answered instantly, which
stopped being true when the chess search moved off-thread and started
taking seconds. It is now checked against the position as it stands,
and a stale choice is made again rather than forced.
- A table the rules reject, or a state a client cannot decode, took the
app down. Both are now reported.
Covered by MatchControllerNetworkTest, which plays a game over a real
socket pair and leaves it from either end. The leaving test runs on a
daemon thread and asserts it returns, so a regression fails the build
rather than hanging it — verified by reinstating the old close(), which
hangs the suite until it is killed.
Four more games, taking the app from four playable to eight. All four
are listed in a production build, so a release now offers Euchre,
Kaiser, President, Golf, Wizard, Crazy 8s, Sequence and Chess.
Also carries two changes the chess search forced: the AI now thinks off
the caller's thread rather than on the screen's scope, and offline setup
can seat the local player somewhere other than seat 0 — which is what
lets you play Black.
Mastermind, Backgammon and Checkers remain dev-only placeholders.
The full game: castling with every condition, en passant including the
case where taking would expose your own king, promotion to any of the
four, pins, checkmate, stalemate, insufficient material, the fifty-move
rule and threefold repetition.
Move generation is checked against the published perft counts rather
than against hand-written cases — the opening array to depth four
(197,281 positions) plus four standard test positions chosen precisely
because they break generators. Those counts are exact, so matching them
leaves very little room for a rule to be subtly wrong.
The opponent is a negamax search with alpha-beta, iterative deepening
and a capture-only quiescence tail, at three strengths. The quiescence
tail is the part that stops it hanging pieces: a fixed-depth search that
stops mid-exchange scores the position as though the recapture never
comes.
Two supporting changes:
The AI now thinks off the caller's thread. A card AI answers instantly,
but a chess search runs for a second or more, and the match controller's
scope belongs to the screen — searching there would freeze the board it
is drawn on.
Offline setup can seat the local player somewhere other than seat 0,
which is what lets you play Black. Seat 0 is White by definition, so
choosing a colour has to choose a seat.
FEN is read and written, so a table can start from a set position.
203 engine tests pass locally.
Three more card games, engine to screen.
Kaiser: 32-card prairie trick-taking with bidding, the five of hearts
worth five and the three of spades costing three. Bidding is capped at
ten because ten is all a hand can be worth, and a losing score gives the
game a floor — without one a table that keeps bidding and going down
never reaches a result.
Crazy 8s: eights are wild and name the suit in force, which is why the
table states that suit in words rather than leaving it to be read off
the pile. Blocked rounds are scored rather than hanging.
Wizard: the 60-card deck, one more card dealt each round until the pack
runs out, bid your tricks exactly. The shared card model gains WIZARD
and JESTER ranks, so every ordinary deck now builds from Rank.standard
rather than walking the whole enum.
Also fixes a lobby bug this would have hit: hosting seated the
catalogue minimum rather than the table the host had just set up, so a
six-handed game would have been rejected by its own rules engine.
164 engine tests pass locally.
Two separate faults. The pattern was wrong, and the scale was broken.
Pattern: the sett is published as K/4 R32 K32 Y/4, the slashes marking
pivots, which reflects out to K4 R32 K32 Y4 K32 R32 — 136 threads, about
half black and half red with a yellow line worth 3%. I had the structure
inverted: the yellow overstripe belongs down the centre of a wide black
band, and the narrow black guard belongs between two red blocks. Mine put
yellow between narrow blacks and the wide black elsewhere, which is why it
read as a generic check rather than as Wallace.
Scale: thread width was a fixed fraction of the screen while the sett grew
from 84 threads to 216, so a repeat became 933dp on a 360dp phone and only
0.39 of one was ever visible. The cloth had zoomed into a few enormous
blocks and stopped being a weave at all — that is the part that got worse,
not the colours. Thread width is now derived from the sett, so a couple of
full repeats are on screen whatever the threadcount says, and changing the
count rescales instead of silently magnifying.
The icon is generated from the same numbers at one repeat across the
adaptive safe zone.
Threadcount cross-checked against two independent sources rather than
guessed; recorded in Tartan.kt and the README so the next change starts
from the published figures.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
The old sett was three flat stripes in roughly equal measure, which read
as a generic red-and-black check rather than as Wallace. Rebuilt against
a reference swatch.
- Setts are now written as a half-sett and reflected about their pivots,
the way a threadcount is actually published, so the data stays in the
form it is quoted in instead of a flat list kept symmetric by hand.
- Wallace is gold 6, black 6, red 36, black 24, red 36, black 6: a red
ground at about two thirds, black bands alternating wide and narrow,
and one thin gold overstripe per repeat. The reflection puts black on
both sides of the gold line, which is what the reference shows.
- Colours taken from the swatch: a brighter scarlet and a stronger gold
than before.
- Both the cloth and the icon are turned 45 degrees. The renderer draws
well past the canvas edges so the corners stay covered once rotated.
The icon vector is generated from the same numbers rather than written
out by hand, so the icon and the splash cannot drift apart — changing
the sett rebrands both.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
- An 8-card variant, laid out as two rows of four. The existing column
scoring covers it without change.
- Opening cards are now chosen rather than dealt face up. Nothing starts
revealed; each player in turn turns over their allowance, and the hole
begins once everyone has picked. This needed a new SETUP phase in the
engine, since it is a real decision and has to be validated like any
other move rather than being decided at deal time.
- "Line up the final putt", off by default: throw a drawn card away
without turning your last face-down card over, so you are not forced to
close the hole on a card you did not choose. Only offered on the last
face-down card. Because a whole table could otherwise decline forever,
two full laps of nothing but thrown cards calls the hole and scores it
where it stands.
- The end of a hole now shows a score card — every player's strokes for
the hole and their running total, lowest first — and the next hole is
not dealt until it is dismissed. MatchController gained a general
awaitsConfirmation hook for this, so any game wanting a scoreboard
between rounds can stop the table the same way. Only the host waits;
clients render what they are sent.
Nine more Golf tests, covering the 8-card layout, the opening choice,
when the putt option is and is not offered, and that a table refusing to
putt out still finishes.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Two more games on the existing framework — no changes were needed to the
lobby, networking, settings or update layers, which is what the shared
GameRules/GameAi contract was for.
President
- Threes low, twos high. Lead any number of one rank; follow with the
same count of a higher rank, or pass. When everyone has passed the
pile comes down and the last player to play leads again.
- Toggles: player count, rounds, twos clear the pile, four of a kind
bombs, and the between-round card exchange.
- Plays are offered as whole sets rather than by selecting cards. A set
is the unit of the game, so choosing from the legal sets is both
easier to operate and impossible to get wrong.
Golf
- 4, 6 or 9 card grids. Draw from stock or discard, then either swap the
card in or — only if it came off the stock — throw it and turn one of
your own over. A card taken from the discard pile has to be used.
- Matching columns cancel to nothing; on the 3x3 board rows do too, but
not on the shallower grids where three cards are not a line.
- Face-down cards are hidden from everyone including their owner, which
is the point of the game: viewFor masks them with a fixed placeholder
so the real card never reaches the device that has not turned it over.
Both engines are pure Kotlin and unit tested — 71 tests now, covering
ranking, legal moves, pile and hole resolution, scoring, redaction, wire
round-trips, and full random and AI playthroughs. The Golf test also
checks the AI actually beats random play rather than merely being legal.
Also widened the setup screen's option chips to wrap: the reveal-count
row carries up to ten, and a chip off the edge of the screen is a
setting that cannot be changed — the same failure as the Euchre Pass
button.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
BoxWithConstraints exposes maxWidth and maxHeight on its own scope, but
inside Column the implicit receiver becomes ColumnScope and they are no
longer reachable, so the two font-size expressions failed to compile.
Read the constraints once into locals at the top of the BoxWithConstraints
body, which is what the Euchre and Sequence screens already do.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
- The launcher icon is a single square of the tartan, drawn as a vector
so it stays sharp at any density and ships no PNGs. The adaptive
icon's foreground is deliberately empty: the cloth is the icon.
- Tartan.kt draws the sett as warp, then as weft at half opacity. That
overlap is what gives the blended squares and reads as woven cloth
rather than a printed grid, and it is reused by both the icon and the
splash.
- The app opens on the tartan under "Pro Libertate Games" in Uncial
Antiqua, holds two seconds, then fades. It is an overlay rather than a
destination, so the menu is already composed behind it and the fade
reveals a live screen. The window background is set to a dark red so
launch does not flash white before Compose draws.
Two things to be aware of:
- WallaceSett is an approximation. It reads as Wallace — bold red and
black check with a narrow gold overstripe — but the registered
threadcount could not be verified here, so the counts were chosen to
look right. They are plain data and can be swapped for the registered
ones without touching anything else.
- Uncial Antiqua is bundled under the SIL Open Font License 1.1, with
the full licence at docs/licenses/UncialAntiqua-OFL.txt. The OFL
requires that to be distributed with the font, so it must not be
separated from it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
A release listed all eleven games with nine of them marked "Coming
soon", so most of the menu was a dead end for anyone installing from the
production channel. Production now lists only what can be played; dev
builds still show everything, which is where seeing the roadmap is
useful.
The menu reads the installed APK's own version name, so this follows the
build rather than the update channel selected for the future — a dev
build shows the full list even if its owner has switched back to
production for updates.
CatalogTest pins both views, including that filtering never empties a
category and strands its heading.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
A jack's power is carried by its artwork on a real deck — drawn in
profile it shows one eye and removes a chip, drawn face-on it shows two
and is wild. These cards render as rank and suit only, so J♦ and J♠ were
indistinguishable in hand despite doing opposite things.
Jacks in hand now carry the word on their face, and the hint under the
hand says what tapping will do rather than the generic "tap a highlighted
square", which was wrong for a one-eyed jack anyway — those target an
opponent's chip, not a highlighted empty square.
The caption is a general PlayingCardView option, so any later game whose
cards carry meaning beyond rank and suit can use it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
The real cause of being forced to pick up the turn card was the AI, not
the Pass button. Measured over 2000 deals, the computer ordered up on
100% of them and the dealer was dragged into picking up on 97.2% —
bidding never once reached the second round, so a human dealer never got
a say. Average hand scored about 11 against a bid threshold of 9, so
essentially every hand bid.
- Voids no longer count towards a hand unless there is trump left to ruff
with, which was inflating precisely the weak hands that should pass.
- Thresholds recalibrated against the actual score distribution.
- EuchreBiddingRateTest pins the result so this cannot drift back: now
32.8% ordered up in round one, dealer forced to pick up on 12.8%, and
round two reached on 67%.
Finished tricks are now held and swept, for this and any later
trick-taking game:
- The engine kept a trick only until it resolved, so the fourth card was
never visible — it vanished the instant it landed. Completed tricks are
now retained in state until the next card is played.
- The table holds them for a second, names the winner, then slides all
four towards that player's edge and fades them out. The hold scales
with the animation speed setting.
- MatchController gains a general holdBeforeNextMove hook so the AI waits
out the animation rather than playing over it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Clearing the name put "Player" straight back before a real one could be
typed. Three things combined to cause it:
- setPlayerName coerced a blank value to "Player" on write, so deleting
the last character immediately persisted the default.
- The settings flow then emitted that default, and the text field's
remember was keyed on the stored name, so it re-seeded itself from the
value that had just been written.
- The default also lived in the stored value, so a fresh install had
"Player" pre-filled and had to be cleared to begin with.
The stored name is now whatever the user typed, blank included. The
default moved to Settings.displayName, which is what the lobby and seat
labels use, so it applies where a name is shown rather than to what is
being edited. The field keeps its own edit state once touched, and shows
"Player" as placeholder text with a note explaining the fallback.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Passing was always legal in the first round of bidding, but the buttons
were laid out in a horizontally scrolling row with Pass appended last.
On a phone "Pick it up" and "Pick it up, alone" filled the width, so Pass
sat off the right edge and bidding looked compulsory. The second round
was worse, with up to six suit buttons ahead of it.
The bid options now wrap instead of scrolling, and Pass is lifted out of
that group into its own full-width button underneath, where it cannot be
clipped or scrolled away.
Stick the dealer is the one case where there genuinely is no Pass, so it
now explains itself rather than looking like the same missing button.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Update channels
- ci.yml now publishes each green push to dev as a prerelease tagged
v1.0.<n>-dev, so the dev channel has something to install. Workflow
artifacts could not serve this: downloading one needs authentication,
which the app does not have.
- Prereleases specifically, because GitHub's "latest release" endpoint
skips them — a production install can never be handed a dev build.
Pull requests build and check but never publish.
- Settings gains a channel picker. The two channels are numbered by
separate workflow run counters, so versions are only compared within a
channel; the app reads the -dev suffix in its own versionName to know
where it stands, and treats a channel change as an explicit switch. The
screen warns that such a switch can be a downgrade Android will refuse.
- PLG_RELEASE becomes PLG_CHANNEL, naming the channel rather than a flag.
Euchre
- Cards in a trick now sit on the side of the table belonging to whoever
played them, rotated to face them, labelled You / Left / Partner /
Right. Previously they were a flat row tagged with seat numbers, which
made it hard to tell who had played what.
- Bidding says what it will actually do. As dealer the button reads "Pick
it up" rather than "Order up", since the turn card comes to your hand,
and a line of explanatory text spells out the consequence either way.
Sequence
- Playable squares get a much heavier outline, scaled off cell size and
in a fixed amber that reads against a white card, a green corner and a
chip alike. The old 2dp theme-coloured border was hard to see.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Two ways the updater could have served or blocked the wrong build:
- release.yml also triggers on workflow_dispatch, which can be aimed at
any branch from the Actions UI. That would have built dev code and
published it as a real Release, which every install would then offer to
download. The job now refuses to run off anything but main.
- versionCode came from GITHUB_RUN_NUMBER, but that counter is per
workflow, so the dev pipeline had its own independent sequence. A debug
APK installed from a CI artifact already carried a higher versionCode
than the first several releases would, and the updater's check is
latest <= installed, so those users would have been told they were up
to date indefinitely. Only release.yml now sets PLG_RELEASE, and only
that stamps a real version; everything else stays at 1.0.0-dev.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Lint flagged ACCESS_FINE_LOCATION declared without ACCESS_COARSE_LOCATION,
which Android 12 requires to be requested together.
Rather than add the missing one, drop location entirely: the Bluetooth
transport only talks to already-paired devices, enumerating bonded
devices and opening an RFCOMM socket. Neither is a scan, so no location
access is needed. BLUETOOTH_SCAN and BLUETOOTH_ADVERTISE go for the same
reason — nothing was using them.
Discovery of unpaired devices would bring all of these back; the manifest
notes what that would require.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
- Transport.kt used the reified Json.encodeToString extension without
importing kotlinx.serialization.encodeToString, so it resolved to the
two-argument member overload and failed to compile.
- Opt in to ExperimentalMaterial3Api for the clickable game tile.
- Drop --stacktrace from the CI build. A one-line Kotlin error was being
buried under 150 lines of Gradle internals, which made the actual
message hard to find in the log.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
First vertical slice: the whole shell plus two games playable end to end,
so every layer is exercised before the other nine games are built on it.
Games
- Euchre: bowers, ordering up, dealer discard, going alone, stick the
dealer, and full scoring. Rule variants (deck size, points to win,
stick the dealer, going alone) are toggles in game setup.
- Sequence: two-eyed and one-eyed jacks, free corners, locked sequences,
the shared-chip limit, and dead-card exchange. Teams, players per team
and the sequence target are toggles.
- The board layout in SequenceBoard is generated, not the commercial
board — it holds every structural property the rules need, but does not
match a physical board. Documented in docs/RULES-sequence.md.
Multiplayer
- One transport abstraction over newline-delimited JSON, implemented
twice: LAN via NSD discovery and TCP, Bluetooth via Classic RFCOMM.
A lobby advertises on both at once, so a table can mix them.
- Host-authoritative: clients send move intents, the host re-validates
every one against the rules, and each client receives a state redacted
for its own seat so hidden information never crosses the wire.
- Seats start as AI and are handed to people as they join, which is what
makes any mix of humans and AI work without a separate code path.
Shell
- Main menu lists all eleven games; the nine without engines are marked
coming soon and are driven entirely by GameCatalog.
- Settings: sound, animation speed (one multiplier drives card movement
and AI thinking time), and OTA updates from the latest GitHub release.
- Layouts measure available space rather than assuming an orientation.
The game package is free of Android types, so the rules engines and AI
run under plain JUnit; 43 tests cover ranking, following suit, scoring,
sequence detection, redaction, wire round-trips and full random and AI
playthroughs.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
Mirrors the Holio setup:
- release.yml: on push to main (e.g. a merged PR), builds the debug APK
and publishes it as a GitHub Release tagged v1.0.<run-number>.
- ci.yml: pre-merge checks on the dev branch and on PRs targeting dev or
main — same build plus unit tests and lint, uploaded as artifacts only.
Both expect a Gradle Android project at the repo root with an :app module,
matching Holio's layout.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN