b90058afdaeabaa42b57e2f69db45da59e6483e7
56
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b90058afda |
Katan: highlight rolled hexes, one-button Build menu on small screens, clearer harbours
- Hexes that paid out for the turn's roll are lit (not on a 7, not the robber's hex, not before the roll). - Compact layouts fold Road/Settlement/City into one Build button with a menu. - Harbour markers are drawn with a resource pictogram (? for 3:1), larger, and sit a full hex radius off the coast so the piers show which vertices they serve. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X |
||
|
|
3b0b307997 |
Katan: no roads past an opponent's settlement, piece limits, stable layout
Rules - A road may only start from a crossing where the player has a building, or where one of their roads ends AND no opponent's settlement or city stands. roadConnects ignored opponents' buildings, so a road (the computer's included) could be laid straight past a settlement. Also applies to Road Building. - Enforce the piece supply: 15 roads, 5 settlements, 4 cities. It was unlimited. - Tests: building past a settlement and a city, free roads, own settlement not blocking, and running out of each piece. UI - The layout rescaled the board whenever it became the player's turn, because the action buttons only existed then. Everything outside the board now has a fixed height: the action bar always shows every button (disabled, not absent), the prompt is pinned to two lines, player cards always draw every line, and the dice total is always present. Rule recorded in docs/UI-catan.md. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG |
||
|
|
30dcf026bd |
Katan: playable screen, setup options, dev-preview catalogue gate
Adds the Katan screen on top of the WALA-119 engine, built in separable layers so the look, wording, layout and interaction can each be changed alone: - CatanStyle: every colour and proportion (light/dark), via LocalCatanStyle - CatanText: every word on screen (pure functions of state) - CatanGeometry: lattice -> pixels and tap hit-testing (pure, unit-tested) - CatanInteraction: what is tappable and what a tap means, derived only from the engine's legal-move list (pure, unit-tested) - CatanBoardView / CatanPanels / CatanDialogs: stateless composables - CatanScreen: wiring to MatchController, portrait and landscape layouts - CatanPreviews: Android Studio previews of real positions - docs/UI-catan.md: how to adapt each layer, rules of the road, known gaps Wiring: PlayScreen branch, GameSetupScreen options (3-4 players, game to 5/8/10/12), and a GameDescriptor.devPreview flag so dev builds can open Katan while production still hides it until two-device play is verified. Tests: CatanGeometryTest, CatanInteractionTest, CatanTextTest, CatalogTest. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG |
||
|
|
fb59372802 |
Restore the Katan catalogue entry the engine depends on
The Option 2 migration dropped Gitea's
|
||
|
|
653e7a17a3 |
Merge feature/catan-engine into dev
Brings in the Katan rules engine, board, model and AI (WALA-119), its 51 unit tests and docs/RULES-catan.md. Only conflict was docs/README.md, which the GitHub-lineage migration had dropped: kept the engine branch's index and pointed the CI link (docs/CI.md no longer exists) at the root README. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG |
||
|
|
7b5b041c42 |
Test: wait for the guest's sheet before comparing it
The sheet arrives over the socket just after connected flips, so on a slower runner the comparison raced it (ScorekeeperSessionTest, line 167). Same pattern the sibling test already uses. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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]> |
||
|
|
5d1d2e204e | Merge remote-tracking branch 'origin/dev' into claude/locked-phone-turn-issue-x9pvbv | ||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
40ea68f4c0 |
FreeCell, Spider and Pyramid, as rules
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 |
||
|
|
7569f0b891 |
Share the ace-low convention rather than copy it
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
cdd515afc6 |
Add Ta Yü
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 |
||
|
|
d165e5efff |
Show every player the table from the moment the game starts
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. |
||
|
|
0927e735a2 |
Fix the hang that made a networked game impossible to leave
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.
|
||
|
|
d6e237ce4a |
Add chess
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. |