Commit Graph
133 Commits
Author SHA1 Message Date
Claude 831b2fc925 Screens for Yahtzee and Klondike, and both into the catalogue
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
2026-08-08 15:39:04 +00:00
Claude 3dbc4b5557 Count the ace low in Klondike
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
2026-08-08 15:30:15 +00:00
Claude 2b09bcdde8 Klondike, the first of the card solitaires
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
2026-08-08 15:23:47 +00:00
Claude 68f8da0379 Yahtzee, as rules and an AI
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
2026-08-08 15:14:16 +00:00
adrianoftyriel 3afae95067 Merge pull request #18: Hearts, peg solitaire, and a seat for one
Two new games and the first single-player one. GameRules needed no change
for a one-seat table; CatalogTest's floor of two players is the only
invariant that moved.

Verified by ci.yml run 86 on 480f0ba: build, unit tests and lint all green.
2026-08-08 08:30:08 -06:00
Claude 480f0ba164 Import the two new screens into PlayScreen
They live in ui.game and PlayScreen is in ui, so naming them was not enough.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
2026-08-08 14:19:43 +00:00
Claude 434ea86605 Screens for Hearts and peg solitaire, and a seat for one
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
2026-08-08 14:13:10 +00:00
Claude 51aa0c3518 Peg solitaire, on five boards
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
2026-08-08 13:01:07 +00:00
Claude 84a9b3ffd7 Keep the Hearts id, which the rules engine needs to compile
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
2026-08-08 05:16:57 +00:00
Claude 88948eac9e Hearts, as rules and an AI
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
2026-08-08 05:11:00 +00:00
adrianoftyriel 82c3479294 Merge pull request #17: promote 1.2 to main
Brings the rtk output-filtering hooks to the default branch, so sessions
cloned from main get them, and opens the 1.2 series on main.

Verified by ci.yml runs 76, 77 and 80; run 80 published v1.2.80-dev from
this tree.
2026-08-07 23:01:00 -06:00
adrianoftyriel c208afc213 Merge pull request #16: filter bash output through rtk, open 1.2
Adds a PreToolUse hook that rewrites Bash commands through rtk, a
SessionStart hook that installs the pinned binary into the empty sandbox
container, and moves the version series to 1.2.

Verified on the branch by ci.yml runs 76 and 77, both green.
2026-08-07 22:41:30 -06:00
Claude ee94d073dd Open the 1.2 series
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
2026-08-08 04:35:56 +00:00
Claude af6f27cf60 Filter bash output through rtk before it reaches the model
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
2026-08-08 04:30:36 +00:00
adrianoftyriel e08d43d10a Merge dev: cribbage, and an updater that cannot cross channels
Cribbage for two, three or four players; the fix that lets an
end-of-hand scoreboard be dismissed, which Golf, Wizard and Crazy 8s
have needed since they shipped; the update channel read off the running
APK rather than chosen in Settings; and a check on launch that finally
runs and only speaks when there is something to install.
2026-08-07 21:46:51 -06:00
adrianoftyriel f53a628c7a Merge pull request #14: each channel updates only from itself
The update channel is now a property of the installed APK rather than a
setting that could point a phone at the other channel's builds, and the
"check on launch" switch is wired to a check that speaks only when there
is something to install.
2026-08-07 21:37:07 -06:00
Claude cd7f86f47b Make "check on launch" actually check on launch
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
2026-08-08 03:29:42 +00:00
Claude 3372adf87a Let each channel update only from itself, with nothing to choose
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
2026-08-08 03:24:48 +00:00
adrianoftyriel 71e000a428 Merge pull request #13: cribbage for two, three or four players
Adds cribbage — the crib, the cut, the play and the show, for two head to
head, three playing singly, or four in partnerships — and fixes the hold
that left every end-of-hand scoreboard impossible to dismiss.
2026-08-07 16:05:37 -06:00
Claude 6d490a50b1 Test that a cribbage partnership plays for its own crib
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
2026-08-07 19:13:18 +00:00
Claude 136f5bc4f1 Let a scoreboard actually be dismissed
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
2026-08-07 19:11:59 +00:00
Claude 27af034095 Add cribbage for two, three or four players
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
2026-08-07 19:07:19 +00:00
adrianoftyriel 0e23d752c4 Merge dev: release 1.1
Verified on dev at 61db79e, the same tree: build, unit tests and Android
lint green, and it published v1.1.62-dev.
2026-08-07 10:07:34 -06:00
adrianoftyriel 61db79e91f Merge pull request #11: move to the 1.1 series
Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 59).
2026-08-07 09:56:31 -06:00
Claude e8c7162bc0 Move to the 1.1 series, from one place rather than three
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
2026-08-07 15:50:30 +00:00
adrianoftyriel 696b045e87 Merge pull request #10: players set their own Mastermind codes
Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 55).
2026-08-07 08:55:50 -06:00
Claude 7cd2b412e2 Let each Mastermind player set their own code
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
2026-08-07 14:49:46 +00:00
adrianoftyriel ae077573b3 Merge pull request #9: Checkers, Backgammon, Mastermind, Pirates and Bulgars
Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 51). Nothing in the catalogue is marked
coming soon any more.
2026-08-07 02:04:27 -06:00
Claude 284f9f3f95 Add Checkers, Backgammon, Mastermind, and Pirates and Bulgars
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
2026-08-07 07:58:12 +00:00
adrianoftyriel f16b21abe7 Merge dev: Nine Men's Morris
Verified on dev at 2328609, the same tree: build, unit tests and Android
lint green, and it published v1.0.49-dev.
2026-08-07 01:20:18 -06:00
adrianoftyriel 23286090f1 Merge pull request #7: Nine Men's Morris
Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 46). Also brings dev back in step with main.
2026-08-06 23:58:18 -06:00
Claude 94b51347c3 Add Nine Men's Morris
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
2026-08-07 05:52:26 +00:00
adrianoftyriel e3c311f8b4 Merge dev: the scorekeeper laid out as a score sheet
Verified on dev at 1c0b2f3, the same tree: build, unit tests and Android
lint green, and it published v1.0.44-dev.
2026-08-06 23:32:04 -06:00
adrianoftyriel 1c0b2f340b Merge pull request #5: lay the scorekeeper out as a score sheet
Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 41).
2026-08-06 21:58:53 -06:00
adrianoftyriel 71e5866883 Add Nine Men's Morris and Pirates and Bulgars to unimplemented list 2026-08-06 21:56:09 -06:00
Claude 97386aa942 Lay the scorekeeper out as a score sheet
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
2026-08-07 03:53:00 +00:00
adrianoftyriel 8106aca30c Merge dev: scorekeeper, and dev beside production
Verified on dev at 956ee4d, the same tree: build, unit tests and Android
lint green (ci.yml run 39).
2026-08-06 18:34:28 -06:00
adrianoftyriel 956ee4dc6f Merge pull request #3: a scorekeeper for games the app does not deal
Verified green on the feature branch before merging: build, unit tests
and Android lint (ci.yml run 36).
2026-08-06 17:55:37 -06:00
Claude b532749093 Add a scorekeeper for games the app does not deal
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
2026-08-06 23:33:11 +00:00
adrianoftyriel 8ca877c416 Merge pull request #2: install dev and production side by side
Install dev and production side by side, in the hunting sett
2026-08-06 17:07:44 -06:00
adrianoftyriel ceee219760 Merge pull request #1: record how changes must reach CI
Adds CLAUDE.md documenting that nothing the sandbox does — pushing,
opening a PR, merging one — triggers a workflow run, and that
workflow_dispatch is the only mechanism that does.

Verified on this branch: dispatching ci.yml on a feature branch runs
build, unit tests and lint while skipping the staging and publishing
steps, which are gated on refs/heads/dev. Run 31128906621 passed all
three and skipped both publish steps.
2026-08-06 16:24:08 -06:00
Claude 625a130366 Correct the rule: pull requests do not trigger CI either
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
2026-08-06 22:18:24 +00:00
Claude 9c9a40303e Add CLAUDE.md recording how changes must reach CI
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
2026-08-06 22:13:52 +00:00
Claude 7939395c3c Install dev and production side by side, in the hunting sett
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
2026-08-06 22:07:32 +00:00
Claude 742add4d07 Merge dev: remove Bluetooth from multiplayer
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
2026-08-06 21:58:01 +00:00
Claude 43394b8d45 Remove Bluetooth from multiplayer
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
2026-08-06 21:38:35 +00:00
Claude 35a44b8d2b Merge dev: Ta Yü, and the fix that left guests staring at an empty table
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
2026-08-06 20:13:11 +00:00
Claude e17272b909 Publish the dev channel from any build of dev, not only a push
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
2026-08-06 19:04:25 +00:00
Claude 396a36f529 Publish a dev build carrying Ta Yü
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
2026-08-06 18:59:07 +00:00
Claude 0a4dca87f5 Merge dev: Ta Yü
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
2026-08-06 18:16:10 +00:00