Claude 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.
2026-08-06 14:44:01 +00:00
2026-08-06 13:34:31 +00:00
2026-08-05 15:50:56 -06:00
2026-08-06 13:34:31 +00:00

Pro Libertate Games

A collection of card and board games for Android, playable against the computer or against other people over Wi-Fi (LAN) or Bluetooth.

What works today

The app shell is complete and eight games are playable end to end:

Playable Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess
Listed, not yet implemented Mastermind, Backgammon, Checkers

Unimplemented games appear in the menu marked Coming soon on dev builds only — a production release lists just the games that can actually be played, so nobody installs it and taps into a dead end. The menu decides this from the installed APK's own version name, so it follows the build rather than the channel selected for future updates. Adding one means writing a GameRules implementation, a GameAi, and a screen, then flipping available in GameCatalog — the menu, lobby, networking, settings and OTA layers are game-agnostic and need no changes.

Playing with other people

Pick a game, choose Host a game for others to join, and the device advertises itself on every radio it has. Other devices choose Join a game nearby and pick the host from the list. Seats start out filled by the computer and are handed to people as they join, so any mix of humans and AI works — whatever is still marked Computer when the host starts stays AI.

  • Wi-Fi (LAN) — both devices must be on the same network. Discovery uses mDNS/NSD; play runs over a TCP socket.
  • Bluetooth — devices must already be paired in Android's Bluetooth settings. Play runs over Classic RFCOMM. Scanning for unpaired devices is not implemented.

Both transports can be live at once, so some players can be on Wi-Fi and others on Bluetooth at the same table.

How a match is kept in step

The host is authoritative. Clients send intents ("I would like to play this card") and the host re-validates every one against the rules before it takes effect — a client cannot make an illegal move by sending a malformed message. Each client is then sent a state redacted for its own seat, so opponents' hands and the deck order are never transmitted to devices that should not see them.

Settings

  • Sound on/off.
  • Animation speed, 0.5×–2×. One multiplier drives card movement and how long the computer appears to think.
  • Updates — check on launch, or check on demand, from either of two channels:
    • Production — stable builds, published from main.
    • Dev — preview builds, published from dev on every green CI run.

Branding

The launcher icon is a single square of the Wallace tartan set on the bias, and the app opens on the same cloth carrying the name in a Celtic uncial hand for two seconds before fading into the menu.

The tartan is drawn, not an image: ui/theme/Tartan.kt lays the sett down as warp and then as weft at half opacity, which is what produces the blended squares instead of a flat grid. The icon is the same construction as a vector drawable, so it stays sharp at every density and needs no PNGs.

Two honest caveats:

  • The sett is the published threadcount: K/4 R32 K32 Y/4. The slashes are pivots, so WallaceSett holds the half-sett and reflects it out to K4 R32 K32 Y4 K32 R32 — 136 threads, about half black, half red, with a yellow line worth 3%. Two details define the pattern and are easy to get backwards: the yellow overstripe runs down the centre of a wide black band, and the narrow black guard sits between two red blocks. The icon is generated from the same numbers, so the icon and the splash cannot drift. Weave scale is derived from the sett rather than fixed, so changing the threadcount rescales both instead of silently zooming in.
  • The typeface is bundled under a licence. Uncial Antiqua, © 2011 Brian J. Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under the SIL Open Font License 1.1. The full licence is at docs/licenses/UncialAntiqua-OFL.txt and must stay with the font — the OFL requires it to be distributed alongside.

Screen sizes

There are no fixed orientations or hard-coded card sizes. Layouts measure the space they are given: the menu grid reflows by width, the Sequence board is always square and sized to fit whatever is left after the hand, and card widths are a fraction of the smaller screen dimension. Rotation is handled inside the activity, so a hand is not lost when the device turns.

Building

./gradlew assembleDebug          # APK at app/build/outputs/apk/debug/
./gradlew testDebugUnitTest      # rules engines and AI

Requires JDK 17 and the Android SDK (compileSdk 34).

CI

  • .github/workflows/ci.yml — builds, tests and lints on dev and on pull requests into dev or main. Its APK artifact is a dev build, versioned 1.0.0-dev.
  • .github/workflows/release.yml — on push to main, builds the APK and publishes it as a GitHub Release tagged v1.0.<run-number>. The in-app updater reads that tag, which is why the APK's versionCode is the same run number.

Update channels

Both pipelines publish, to two separate channels:

Channel Published by Tag Marked
Production release.yml, push to main v1.0.<n> release
Dev ci.yml, push to dev v1.0.<n>-dev prerelease

Dev builds are published as prereleases deliberately: GitHub's "latest release" endpoint skips prereleases, so a production-channel install can never be handed a dev build even by accident. Pull requests build and check but never publish, so a PR from a fork cannot ship anything.

release.yml additionally refuses to run off any branch but main, so a manual dispatch aimed elsewhere does nothing rather than shipping unreviewed code.

The two channels have independent version sequences — a build's versionCode is the run number of the workflow that produced it, and the two workflows count separately. Versions are therefore only ever compared within a channel; the app reads the -dev suffix in its own versionName to know which channel it is on, and treats a change of channel as an explicit switch rather than an upgrade. One consequence: switching channels can be a downgrade as far as Android is concerned, in which case the install is refused until the current copy is uninstalled. The settings screen says so when it applies.

The debug keystore is committed on purpose: every build is signed with the same key, so an OTA update installs over the previous one instead of being rejected for a signature mismatch.

Rules

The exact rules implemented, and the options that vary them, are documented per game:

Layout

game/          rules engines and AI — pure Kotlin, no Android, unit-tested
  cards/       deck, suits, ranks
  engine/      GameRules / GameAi contracts, seats, table config
  euchre/      Euchre model, rules, AI
  kaiser/      Kaiser model, bidding, rules, AI
  sequence/    Sequence model, board, rules, AI
  president/   President model, rules, AI
  golf/        Golf model, scoring, rules, AI
  wizard/      Wizard model, rules, AI
  crazyeights/ Crazy 8s model, rules, AI
  chess/       Chess model, FEN, move generation, search
net/           wire protocol, LAN and Bluetooth transports, lobby, match driver
settings/      DataStore-backed preferences
update/        GitHub Releases OTA updater
ui/            Compose screens

Keeping game/ free of Android types is deliberate: the same code drives the AI, the host's authoritative state and the JVM unit tests.

S
Description
No description provided
Readme GPL-3.0
1.9 MiB
Languages
Kotlin 99.7%
Shell 0.2%