Commit Graph
136 Commits
Author SHA1 Message Date
adrianoftyrielandPaperclip 81feeee132 Catan Compose UI: board, placement flow, personal panel, event feed
- CatanScreen.kt: Canvas hex board using CatanBoard geometry (hexCenter, VERTEX_POS, EDGE_VERTICES)
- Tap-to-target + commit pattern with detectTapGestures (Ta Yü precedent)
- BoardGeometry for vertex/edge hit detection
- drawTerrains, drawBuildings, drawRoads, drawNumbers, drawPorts, drawRobber, drawDice
- Legal-target highlighting for settlement/road/city/SetupPlace
- Personal panel: resources, dev cards, VP, longest-road/largest-army banners
- Commit/End-turn/Roll buttons with 48dp targets
- CatanEventFeed: shows last 4 log entries (seat-redacted via CatanRules.viewFor)
- Layout: Column with Modifier.weight(1.6f) board region — rotation-safe, measures space
- PlayScreen.kt: CATAN case wired with CatanRules + CatanAi
- GameCatalog: CATAN entry (available=false, menus=Board) — dev builds show it

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 22:03:13 +00:00
adrianoftyrielandPaperclip 1f7fced24c WALA-120: Catan Compose screen skeleton + PlayScreen wiring (CatanScreen.kt, PlayScreen CATAN case)
- CatanScreen consumes only game/catan/ engine API (CatanState, CatanMove, CatanRules, CatanAi).
- Board renders to remaining space using Canvas (measure-based, no fixed dp, rotation-safe).
- Personal panel shows resources (counts), dev-card counts, VP context, roll/place buttons
  with targets ≥48dp or tap+commit; never reads hidden info — enforced by CatanRules.viewFor.
- Event feed reads current.log (public event feed).
- Registered in PlayScreen with CatanRules + CatanAi controller.
- Catalog entry CATAN remains available=false for integration task to flip.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 17:25:00 +00:00
adrianoftyrielandPaperclip 3a930f5423 Katan rules engine + AI (WALA-119)
CI / Build and check (pull_request) Failing after 8s
The island game as a pure-Kotlin state machine under game/catan/, wired to
the upstream GameRules / GameAi contract and fully unit-tested on the JVM.

- game/catan/: CatanModel (state, moves, phases), CatanBoard (19-hex
  geometry, seeded island generation with the 6/8 separation rule, shared
  vertex/edge graph), CatanRules (setup, production, the seven and the
  robber, domestic and maritime trade, building, development cards,
  Longest Road, Largest Army, win detection), CatanAi (a heuristic opponent
  that reads only public information).
- GameRules.isLegalMove: a defaulted structural legality check so a game
  with a combinatorial move space (Catan's trade offers) is gated the same
  way as one with an enumerated move list; MatchController uses it.
- GameCatalog.CATAN: the catalogue entry, listed but not yet available
  (descriptor and screen land with the screen task).
- Tests: 51 across CatanBoardTest, CatanRulesTest and CatanAiTest —
  geometry invariants, generation balance, setup, production, the
  seven/robber, both trade types, development-card timing, Longest Road
  edge cases, Largest Army, win detection, and AI games played to a result.
- docs/RULES-catan.md, in the per-game style, with the rule choices and
  the known gaps called out.

Two real engine bugs found and fixed while getting the suite green:
VERTEX_EDGES counted each shared edge once per adjacent hex, so an
interior vertex looked like it had six edges (which also double-counted
segments in the Longest Road search and duplicated setup moves); and the
applyRoll seven-transition was a discarded expression, so a seven never
left AWAIT_ROLL. Three test fixtures rolled from the PLAYING phase and two
assumed a two-seat rotation; corrected to the phases and seat order the
engine actually uses.

Verified: ./gradlew :app:testDebugUnitTest --offline — 679 tests, 0 failures.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 15:13:36 +00:00
adrianoftyrielandPaperclip 5c797c8807 Restore the Pro Libertate Games title and splash; list Katan under Board Games
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The bootstrap rebranded the app to Pilgrims of Katan (WALA-118 review);
the decision is to keep the upstream's own identity instead.

- Title and splash: the upstream originals restored exactly — the Wallace
  tartan splash under the "Pro Libertate Games" uncial wordmark
  (SplashScreen.kt, Tartan.kt), the tartan launcher icon, and the
  Theme.ProLibertateGames window theme. The game screens go back to the
  upstream WallaceGold/WallaceRed accents the tartan is woven from.
- Names and colours: launcher names back to "Pro Libertate Games" /
  "Pro Libertate Games DEV"; the channel colours back to the clan red /
  hunting green setts (resValue tartan_field and friends). The installed
  identity stays ca.wallacenet.pilgrimsofkatan (+ .dev) — that is the
  package, not the brand.
- Catalogue: Katan is listed under Board Games (3-4 players, original
  blurb, no third-party marks) with available = false, so dev builds show
  it as "Coming soon" and a production build hides it until its engine
  and screen land.
- Docs: README/CLAUDE.md/docs/CI.md updated to the restored identity;
  CatalogTest's invariant now names Katan as the one deliberate exception
  rather than asserting nothing is unfinished.

Structural bootstrap untouched: applicationId, OTA repointed at the Gitea
releases, CI workflows, version stamping.

Verified: ./gradlew assembleDebug and testDebugUnitTest green — 628 tests,
0 failures.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 02:15:30 +00:00
adrianoftyrielandPaperclip 05c443a4b5 Publish: verify the release's stored state after attaching the APK
Build and Release APK / Build APK and publish release (push) Canceled after 0s
A live publish on git.wallacenet.ca kept coming back invisible to the updater:
the create accepted "draft": false, and the release was still stored as a
draft once the APK was attached — so the anonymous list the app reads came
back empty even though every publish step reported success.

The script now attaches first, then reads the release back and PATCHes the
state if it did not stick, failing loudly if the correction is refused. A
draft is not a channel a preview build can silently become.

Also lists all 22 catalogue games in the README's table; seven were missing,
which read as "the app has 15 games".

Verified against the live instance: anonymous list shows
v1.4.1001-dev as a published prerelease with a tag-named asset, the
production /releases/latest correctly returns 404 while only a prerelease
exists, and an anonymous download through the URL the app builds matches the
built APK byte for byte.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:27:33 +00:00
adrianoftyrielandPaperclip a827a38ae6 OTA: name release assets after their tag, download from one host
Build and Release APK / Build APK and publish release (push) Canceled after 0s
Two things the first live release check on git.wallacenet.ca showed:

- The publish script uploaded the APK under whatever the build called it
  (app-debug.apk), which is the name the updater only finds by its fallback
  rule. The script now names the asset after its tag, so the primary rule —
  "the asset whose name contains the tag" — is the one that fires.
- The instance answers for two host names and reports asset URLs on the one
  it treats as canonical, so the app would have fetched its release list from
  git.wallacenet.ca and its APK from elsewhere. onReportedHost() now keeps the
  path the server chose and puts the download on the host the app already
  trusts, tested in UpdatePolicyTest.

Co-Authored-By: Paperclip <[email protected]>
v1.4.1001-dev
2026-10-13 22:13:09 +00:00
adrianoftyrielandPaperclip fad4778ad8 Bootstrap Pilgrims of Katan: rebrand, Gitea CI and OTA
Build and Release APK / Build APK and publish release (push) Canceled after 0s
Import of ProLibertateGames (upstream main, GPL-3.0) rebranded for
Wallacenet and repointed at git.wallacenet.ca.

- Identity: applicationId ca.wallacenet.pilgrimsofkatan (+ .dev for the dev
  channel), launcher names "Pilgrims of Katan" / "Pilgrims of Katan DEV",
  neutral hexagon branding replacing the Wallace tartan. Uncial Antiqua keeps
  its OFL licence, and the GPL-3.0 LICENSE plus upstream attribution stay.
- OTA: Updater.kt now reads this repo's own Gitea releases
  (/api/v1/repos/devadmin/PilgrimsOfKatan/releases...) and BuildConfig.
  OTA_ENABLED is on; Gitea answers the same JSON shape the code already parsed.
- Build numbers: versionCode = CI run number + plgVersionRunOffset (1000),
  with the tag and versionName derived from the same figure. The offset is
  permanent, because Gitea Actions restarts run numbers at 1 and a reset would
  otherwise make Android reject the next build as a downgrade.
- CI: ci.yml publishes a dev prerelease from dev, release.yml a production
  release from main, both through scripts/publish-release.sh (Gitea REST API,
  runnable by hand until an Actions runner is registered).
- Docs: docs/CI.md records the channel, numbering and runner decisions;
  docs/README.md seeds the per-game RULES-* conventions; CLAUDE.md carries the
  agent ground rules.

Verified: ./gradlew assembleDebug and testDebugUnitTest green on JDK 17 /
compileSdk 34, both variants stamped, installing side by side under the new
identity.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:03:27 +00:00
adrianoftyriel d27c2db894 Merge pull request #38 from adrianoftyriel/dev
Release: multi-device scorekeeper sharing and tally scoring
2026-10-12 22:04:30 -06:00
adrianoftyriel 4037f75136 Merge pull request #37 from adrianoftyriel/feature/tally-number-box
CI / Build and check (push) Failing after 1m40s
Tally scoring: inline number box instead of dialog
2026-10-12 21:24:56 -06:00
devadminandClaude Sonnet 5.5 9df9fcd556 Tally: replace the custom-amount dialog with an inline number box
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 03:24:33 +00:00
adrianoftyriel 4ae16c7ca4 Merge pull request #36 from adrianoftyriel/feature/tally-scoring
Scorekeeper: round-based or tally scoring
2026-10-12 17:15:56 -06:00
devadminandClaude Sonnet 5.5 5c0a44c11d Scorekeeper: choose round-based or tally scoring when starting a game
Tally sheets let any player score at any moment (+1, -1 or a custom amount)
with a log of entries that can be taken back. Entries are stored as one-player
rows so totals, undo and sharing work unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:43:37 +00:00
adrianoftyriel 6030e62bbe Merge pull request #35 from adrianoftyriel/fix/scorekeeper-join-diagnostics
Scorekeeper sharing: join from any sheet, show waiting requests, diagnostics
2026-10-09 21:29:09 -06:00
James WallaceandClaude Sonnet 5.5 d9fdb598ac Scorekeeper sharing: join from any sheet, show waiting requests, diagnostics
A guest was timing out with nothing ever shown on the host. Without a
device to look at, this makes the next test say where it stops, and fixes
what was found on the way.

- The host's Sharing panel shows links received and join requests read, which
  tells "nobody reached this phone" from "reached it and not understood".
- The guest says when its link is up and it is waiting for an answer.
- A request waiting shows on the host's top bar ("Sharing . 1 waiting"), and
  the Sharing panel steps aside while one is up so two dialogs never stack.
- Join is a top-bar action on a sheet already under way; it was only offered
  on an empty one.
- LanTransport: a host being cleaned up no longer unregisters the
  advertisement or clears the address of a newer host started in its place.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
2026-10-10 02:56:36 +00:00
adrianoftyriel 3e277754e9 Merge pull request #34 from adrianoftyriel/feature/multidevice-scorekeeper
Scorekeeper: share a sheet between devices
2026-10-09 19:55:50 -06:00
James WallaceandClaude Sonnet 5.5 89b19a894a Scorekeeper: share a sheet between devices
A host can share its score sheet and others nearby can join it, found the
same way games are, or by typed address over a hotspot.

- A joining guest is not let in on its own say-so. The host is prompted with
  the guest's name and answers Allow or Deny. A device already let in that
  drops out (locked phone) is let straight back; the host can remove anyone.
- The host's sheet is authoritative. What travels is what was done (SheetOp:
  add round, rename, move column, ...), not whole sheets, so changes made at
  the same moment all land. A guest's own changes show at once and are laid
  over the host's sheet until confirmed (SheetReplica).
- Sync asks the host for the sheet as it stands, and runs on its own when
  the app returns to the front, as it does for a game table.
- Hello carries a purpose and hosts advertise one, so a game join screen does
  not list score sheets and a game guest typing a sheet's address is told so.
- The Scorekeeper screen now expresses every edit as a SheetOp, so one grid
  serves a local sheet, a hosted one and a joined one.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
2026-10-10 01:27:40 +00:00
adrianoftyriel e6ce1aebd5 Merge pull request #33 from adrianoftyriel/dev
Release 1.4
2026-10-09 14:13:52 -06:00
adrianoftyriel 16f9321ca3 Merge pull request #32 from adrianoftyriel/fix/ci-setup-android-v4
CI: bump setup-android to v4
2026-10-09 14:02:08 -06:00
James WallaceandClaude Sonnet 5.5 c89c8d15c7 CI: bump setup-android to v4
v3 runs sdkmanager with the tools package, which Google's repository no
longer has, so the Set up Android SDK step failed before any build.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
2026-10-09 19:34:12 +00:00
James Wallace f72405f28f Merge branch 'claude/card-game-physics-animations-5b6bfi' into dev
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	app/src/main/java/org/prolibertate/games/net/LanTransport.kt
2026-10-09 15:47:47 +00:00
James WallaceandClaude Sonnet 5.5 45bc16594e Bump version series to 1.4
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-02 21:23:38 +00:00
James Wallace 6913ee2fe9 Merge branch 'fix/scorekeeper-zero-and-cell-edit' into dev 2026-10-02 21:19:59 +00:00
James WallaceandClaude Sonnet 5.5 b98d464954 Scorekeeper: show an entered 0, tap a past score to edit it
Zeros are now stored so a typed 0 shows as 0 while a blank cell stays blank.
Tapping a score in an earlier round opens that round for correction with the
cursor in the tapped cell, instead of needing the row number first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-02 21:19:58 +00:00
adrianoftyriel 26ae3fecaa Merge pull request #31: get back into a game a locked phone dropped out of
Get back into a game a locked phone dropped out of
2026-08-17 09:45:40 -06:00
Claude 5d1d2e204e Merge remote-tracking branch 'origin/dev' into claude/locked-phone-turn-issue-x9pvbv 2026-08-17 15:33:41 +00:00
Claude f0c8895747 Get back into a game a locked phone dropped out of
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
2026-08-16 00:28:05 +00:00
Claude deb4acf96b Give the hotspot's owner a route to its guests too
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
2026-08-16 00:25:54 +00:00
Claude f8f02dd93e Make LAN play work over a phone's hotspot
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
2026-08-15 23:52:39 +00:00
adrianoftyriel d7a98c31c8 Merge pull request #30: play onto the pile, and keep the tricks you win
Play onto the pile, and keep the tricks you win
2026-08-13 16:17:05 -06:00
Claude cba66906bb Keep won tricks in front of the player who took them
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
2026-08-13 22:10:04 +00:00
Claude a7427b0cb5 Play onto the pile rather than instead of it
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
2026-08-13 22:00:58 +00:00
adrianoftyriel 8ec4066ca9 Merge pull request #29: card play as cards landing on a table
Model card play as cards landing on a table
2026-08-13 14:58:11 -06:00
Claude 7c839a9242 Model card play as cards landing on a table
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
2026-08-13 20:45:37 +00:00
adrianoftyriel 32ab075ae2 Merge pull request #28: the Yahtzee dice fixes
Holding dice works whatever order they are tapped in, and the hold survives
the throw. Verified on dev by ci.yml runs 118 and 121 and published as
v1.2.123-dev from this tree.
2026-08-08 15:40:04 -06:00
adrianoftyriel c471839b60 Merge pull request #27: keep held dice held between throws
The selection survives a throw and clears on a turn boundary, keyed on the
round as well as the seat so a solo game resets too. Verified by ci.yml
run 121.
2026-08-08 14:02:04 -06:00
Claude 39ce5aa0ee Keep held dice held between throws
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
2026-08-08 19:56:19 +00:00
adrianoftyriel 81914e9b34 Merge pull request #26: keeping dice is a set, not a list
Holding dice did nothing unless they were tapped left to right: a tap-ordered
list was not among the ascending ones legalMoves offered, and the controller
drops what it cannot find there. Verified by ci.yml run 118.
2026-08-08 13:46:22 -06:00
Claude e5c9561fef Keeping dice is a set, not a list
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
2026-08-08 19:40:38 +00:00
adrianoftyriel 8a5a3bd0c3 Merge pull request #25: one menu per game
Priority is the GameMenu declaration order. Verified on dev by ci.yml run
114 and published as v1.2.116-dev from this tree.
2026-08-08 13:31:03 -06:00
adrianoftyriel 6231009954 Merge pull request #24: one menu per game
Priority is the GameMenu declaration order: solitaire, trick-taking, cards,
board. Verified by ci.yml run 114.
2026-08-08 13:20:30 -06:00
Claude b40f4a0206 File every game in one menu, by priority
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
2026-08-08 19:14:38 +00:00
adrianoftyriel 2083847967 Merge pull request #23: promote to main
Seven new games, single-player support and a menu tree. Verified on dev by
ci.yml through run 109 and published as v1.2.112-dev from this tree.
2026-08-08 11:54:19 -06:00
adrianoftyriel a8b8cd8838 Merge pull request #22: one centred column for the main menu
A short list spread across a grid read as a page half empty. Verified by
ci.yml run 109.
2026-08-08 11:38:27 -06:00
Claude e5e1d134a7 One centred column for the main menu
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
2026-08-08 17:32:35 +00:00
adrianoftyriel cbe25898c8 Merge pull request #21: a menu tree
Solitaire, Card Games and Board Games, with Trick-taking under Card Games,
and a game listed in every menu it belongs to rather than just one.

Verified by ci.yml run 105.
2026-08-08 11:22:02 -06:00
Claude b12a0ed3c9 A menu tree, and games that can be in more than one branch of it
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
2026-08-08 17:16:16 +00:00
adrianoftyriel f6767c48bf Merge pull request #20: FreeCell, Spider and Pyramid
Three more patiences, all playable, and the Klondike table laid out the way
a real one is.

Verified by ci.yml runs 100 and 101, green across build, tests and lint.
2026-08-08 10:29:17 -06:00
Claude 459670012e Screens for FreeCell, Spider and Pyramid, and all three into the catalogue
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
2026-08-08 16:23:17 +00:00
Claude 4e7aa98b61 A cell can strand a card, and the test said otherwise
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
2026-08-08 16:14:06 +00:00
Claude deaf85938c Lay the Klondike table out the way a real one is
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
2026-08-08 16:07:39 +00:00