From 4312616efaec9ece027691dda62d81e5d2816e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:54:13 +0000 Subject: [PATCH 1/5] Add CI and release pipelines for Android APK builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Holio setup: - release.yml: on push to main (e.g. a merged PR), builds the debug APK and publishes it as a GitHub Release tagged v1.0.. - ci.yml: pre-merge checks on the dev branch and on PRs targeting dev or main — same build plus unit tests and lint, uploaded as artifacts only. Both expect a Gradle Android project at the repo root with an :app module, matching Holio's layout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .github/workflows/ci.yml | 66 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e3d235 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +# Pre-merge checks. Runs on the dev branch and on every pull request aimed at +# dev or main, so breakage is caught before it reaches main and triggers a +# release. Builds the same debug APK as the release pipeline, but publishes it +# only as a workflow artifact — no tag, no GitHub Release. +on: + push: + branches: [ dev ] + pull_request: + branches: [ main, dev ] + workflow_dispatch: + +permissions: + contents: read + +# A newer push to the same branch or PR makes the in-flight run obsolete. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon --stacktrace + + - name: Run unit tests + run: ./gradlew testDebugUnitTest --no-daemon --stacktrace + + - name: Run Android lint + run: ./gradlew lintDebug --no-daemon --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: ProLibertateGames-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + retention-days: 14 + + - name: Upload lint and test reports + # Reports are most useful precisely when a step above failed. + if: always() + uses: actions/upload-artifact@v4 + with: + name: ProLibertateGames-reports + path: | + app/build/reports/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c2976f9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Build and Release APK + +# Build an APK and publish it as a GitHub Release whenever commits land on main +# (for example when a pull request is merged). Can also be run manually. +on: + push: + branches: [ main ] + workflow_dispatch: + +# Allow the built-in token to create releases and upload the APK. +permissions: + contents: write + +# Don't let two pushes to main race on the same release. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build APK and publish release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon --stacktrace + + - name: Stage APK for release + id: stage + run: | + set -euo pipefail + version="v1.0.${{ github.run_number }}" + src="app/build/outputs/apk/debug/app-debug.apk" + dest="ProLibertateGames-${version}.apk" + cp "$src" "$dest" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "apk=$dest" >> "$GITHUB_OUTPUT" + + - name: Upload APK as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: ProLibertateGames-apk + path: ${{ steps.stage.outputs.apk }} + if-no-files-found: error + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release create "${{ steps.stage.outputs.version }}" \ + "${{ steps.stage.outputs.apk }}" \ + --repo "${{ github.repository }}" \ + --target "${{ github.sha }}" \ + --title "ProLibertateGames ${{ steps.stage.outputs.version }}" \ + --notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`. + + Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build." From 487c68419e7ee2cbb878c294a505977a034f40d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:37:05 +0000 Subject: [PATCH 2/5] Add app shell with Euchre and Sequence, LAN/Bluetooth play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First vertical slice: the whole shell plus two games playable end to end, so every layer is exercised before the other nine games are built on it. Games - Euchre: bowers, ordering up, dealer discard, going alone, stick the dealer, and full scoring. Rule variants (deck size, points to win, stick the dealer, going alone) are toggles in game setup. - Sequence: two-eyed and one-eyed jacks, free corners, locked sequences, the shared-chip limit, and dead-card exchange. Teams, players per team and the sequence target are toggles. - The board layout in SequenceBoard is generated, not the commercial board — it holds every structural property the rules need, but does not match a physical board. Documented in docs/RULES-sequence.md. Multiplayer - One transport abstraction over newline-delimited JSON, implemented twice: LAN via NSD discovery and TCP, Bluetooth via Classic RFCOMM. A lobby advertises on both at once, so a table can mix them. - Host-authoritative: clients send move intents, the host re-validates every one against the rules, and each client receives a state redacted for its own seat so hidden information never crosses the wire. - Seats start as AI and are handed to people as they join, which is what makes any mix of humans and AI work without a separate code path. Shell - Main menu lists all eleven games; the nine without engines are marked coming soon and are driven entirely by GameCatalog. - Settings: sound, animation speed (one multiplier drives card movement and AI thinking time), and OTA updates from the latest GitHub release. - Layouts measure available space rather than assuming an orientation. The game package is free of Android types, so the rules engines and AI run under plain JUnit; 43 tests cover ranking, following suit, scoring, sequence detection, redaction, wire round-trips and full random and AI playthroughs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .gitignore | 44 ++ README.md | 107 +++++ app/build.gradle.kts | 96 +++++ app/debug.keystore | Bin 0 -> 2666 bytes app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 71 ++++ .../org/prolibertate/games/MainActivity.kt | 77 ++++ .../org/prolibertate/games/game/Catalog.kt | 159 +++++++ .../prolibertate/games/game/cards/Cards.kt | 97 +++++ .../prolibertate/games/game/engine/Engine.kt | 87 ++++ .../games/game/euchre/EuchreAi.kt | 175 ++++++++ .../games/game/euchre/EuchreModel.kt | 154 +++++++ .../games/game/euchre/EuchreRules.kt | 377 +++++++++++++++++ .../games/game/sequence/SequenceAi.kt | 122 ++++++ .../games/game/sequence/SequenceModel.kt | 144 +++++++ .../games/game/sequence/SequenceRules.kt | 302 ++++++++++++++ .../games/net/BluetoothTransport.kt | 176 ++++++++ .../prolibertate/games/net/LanTransport.kt | 215 ++++++++++ .../prolibertate/games/net/LobbyController.kt | 262 ++++++++++++ .../prolibertate/games/net/MatchController.kt | 264 ++++++++++++ .../org/prolibertate/games/net/Protocol.kt | 92 ++++ .../org/prolibertate/games/net/Transport.kt | 137 ++++++ .../games/settings/SettingsRepository.kt | 74 ++++ .../java/org/prolibertate/games/ui/AppRoot.kt | 275 ++++++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 240 +++++++++++ .../org/prolibertate/games/ui/LobbyScreen.kt | 187 +++++++++ .../org/prolibertate/games/ui/PlayScreen.kt | 104 +++++ .../prolibertate/games/ui/SettingsScreen.kt | 180 ++++++++ .../games/ui/common/Components.kt | 106 +++++ .../games/ui/game/EuchreScreen.kt | 282 +++++++++++++ .../games/ui/game/SequenceScreen.kt | 296 +++++++++++++ .../org/prolibertate/games/ui/theme/Theme.kt | 50 +++ .../org/prolibertate/games/update/Updater.kt | 127 ++++++ .../res/drawable/ic_launcher_background.xml | 11 + .../res/drawable/ic_launcher_foreground.xml | 14 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap/ic_launcher.xml | 17 + app/src/main/res/mipmap/ic_launcher_round.xml | 17 + app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 9 + app/src/main/res/xml/file_paths.xml | 5 + .../games/game/euchre/EuchreRulesTest.kt | 392 ++++++++++++++++++ .../games/game/sequence/SequenceRulesTest.kt | 374 +++++++++++++++++ build.gradle.kts | 6 + docs/RULES-euchre.md | 81 ++++ docs/RULES-sequence.md | 77 ++++ gradle.properties | 13 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 +++++++++++ gradlew.bat | 94 +++++ settings.gradle.kts | 18 + 54 files changed, 6487 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/debug.keystore create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/org/prolibertate/games/MainActivity.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/Catalog.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/cards/Cards.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/engine/Engine.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/LanTransport.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/LobbyController.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/MatchController.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/Protocol.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/Transport.kt create mode 100644 app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/AppRoot.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/common/Components.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt create mode 100644 app/src/main/java/org/prolibertate/games/update/Updater.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap/ic_launcher.xml create mode 100644 app/src/main/res/mipmap/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt create mode 100644 build.gradle.kts create mode 100644 docs/RULES-euchre.md create mode 100644 docs/RULES-sequence.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..095b7fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log Files +*.log + +# Android Studio / IntelliJ +*.iml +.idea/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Keystore files +*.jks +*.keystore +# ...except the shared debug keystore, committed so every CI build signs +# identically and the in-app updater can install updates over each other. +!app/debug.keystore + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a85fc6a --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# 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 two games are playable end to end: + +| | | +| --- | --- | +| **Playable** | Euchre, Sequence | +| **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | + +Unimplemented games appear in the menu marked *Coming soon*. 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. Updates are pulled from the + latest GitHub Release of this repository, which CI publishes on every push to + `main`. + +## 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 + +```sh +./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`. +- `.github/workflows/release.yml` — on push to `main`, builds the APK and + publishes it as a GitHub Release tagged `v1.0.`. The in-app updater + reads that tag, which is why the APK's `versionCode` is the same run number. + +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: + +- [Euchre](docs/RULES-euchre.md) +- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** + +## 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 + sequence/ Sequence model, board, rules, AI +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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d97dd7c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,96 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") +} + +// On CI, GITHUB_RUN_NUMBER is set automatically and matches the release tag +// (v1.0.), so the shipped APK knows its own version and the +// in-app updater can tell whether a newer release exists. Locally it's a dev build. +val ciRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 + +android { + namespace = "org.prolibertate.games" + compileSdk = 34 + + defaultConfig { + applicationId = "org.prolibertate.games" + minSdk = 24 + targetSdk = 34 + versionCode = if (ciRunNumber > 0) ciRunNumber else 1 + versionName = if (ciRunNumber > 0) "1.0.$ciRunNumber" else "1.0.0-dev" + } + + signingConfigs { + // A fixed, checked-in debug keystore (standard "android" password) so + // every build — local or CI — is signed with the same key. Without this, + // each CI runner would generate a random debug key and OTA updates would + // fail to install with a signature mismatch. + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + composeOptions { + // Must stay in step with the Kotlin version declared in the root build file. + kotlinCompilerExtensionVersion = "1.5.14" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") + + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Settings persistence for sound / animation speed / update preferences. + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Wire format for the LAN and Bluetooth game protocol. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + testImplementation("junit:junit:4.13.2") +} diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..5323065c2f1577392e7312a760673dfaf5a130b1 GIT binary patch literal 2666 zcma)8XE+>)7M*6yj9#-w$&jKm2&0QcVn*~5L~lb@TOvefVlv7MOCm(?24i=k6D`pR zBC;DTTGWJ?RUV=}?|bjde(%?NKkj$#J?EbL^L!UgK;S_@Fq(kigu0hxgX z1OyO5fS;fV@Iy3#sq?=irk4-`Q}sDrabBfRmjAd|5kOD@f$91=sfot_a4@lpy)Y_N0QQ8`~#N?UhuM$(_i6ySWayKjL zB}YPKe;x?gv!XZyd|Og-Ryy;$#L}0LmDD`%){8<;UQRiDMV zL4MN1B55eMjfJWUm(0$fsiJ8Dp2O=FsWzVMKK4sncsJ|Aa-Q~$m8u68Omw2^Z3kyR z>C7{k&fYBIh)k>7N?%Y#Glx&9%o_}RDA7|!(z^-ZqTb(vd{Vc-o%U?D(Lds(eY!;+U&N)4Vz)m!1z(~%_>CDuemjoq z9w3X<+1>AJCHkXS_tnpUW&}R#i{N*#MaoN2y8i5TPr(^TiWRE97gc46dVfb!V*)EY zF9|Dnq@AyfA~Ua<7aH!oUkHB3oAT1(M%V3x#l$OrjZ{^H7|t7!FI_C7*Wd;*Z$yX7 zgp(pBi;a^#Pzd%8kGB?#KzGa7F$jTpB)wp6!O8cyZU=w7JxT}euloK8P@kg&J4!7_ z+%B^3X%0t4h!fxk(@~)QN5^6 zcT*geOfY{YShDy~_-@5B%WV6$XM9jUs8o+C%=3>j?2KdhU9@Fu%f4|cctDq)7jw$* zx8pl1GkGLR<~lCO7G}nkoOKi=&hH+rL!gFBQxYilcCv?-jGPVKl1fuD%?fT14p5r zJy=dWRGY@hHXKZj>P{KJeCVVsQ?OR^7DH`wr|FpAf0Kc}(&6TuX8lNW9=YEf9x_^E zQ2#Q|7M?3J+n_SF*B)^E^^2(!zu@4Q$EB@oseD`5ws_NUqItZP)$KEA8fJFE4yG?+&S(viP>13N0o;G^Kn2L8aQha+EtXel zh`D=>s)hTs_&~FykG+E%W%4?$cw4VzNLZOnSfnMr*?zUPuKQ_yL|=vEetVZ5qE>B| zM^eEYE&C%b;k*hc5fOk3zz^UK2n6^8?w_MOzysh4cnI(Wc%OHnXwkpd`4o5|+?Mx! zJx~f5)oW^M*OZl16_n6u0z~1*5;L@b01-QJ`{mV1IV=G zHR#voZz*_p{XfB-WtR)fRd+>&z}Uz1GR0eC2;c&mtzd85t4IN(`Vs{Em}G)yHTeJ^ zCeAD?KJU z!KS{&THD*TU*aL8I~9Xlc=Vi!X{AuxrN#H}o%6sf9eGA|CT>;;9N9B|FnMfc7a8Xv z)FI#&@e0^?y<*C1XZn)3{MGmXtiV@b^RsE>9R9A;!73fk(g?m{af^taX!8==`JAR zYg{7?yDPj-PpMO);*VhB?`#V12nnYf8%4?uW#tO(cNqostzKH_>I>*_|3Fq&H~G-y zZVs!|9xU<9J9BEM^R3wh8?kX@iJf(LC6JO3NmJgt2N7fID(smR)M+XV8KF|R%^GXd z6P+Jm*a8Y=4v9KRht84aere6EY?L3;L4I+16)s#KI%bx9rY7l9gw#chO|nkxq|Q__ zxrvbIpP9pAv;F;tRZDI>&fL*NVcTqh{6a@CExP*&RO`gjNs`CTpYv(e0SXS)wUak!lM`yf(rZ<(VaXhF**j5?~z zGuOT1a9?NmtWj}88b8bLvbOh`m%B|n@#sPyOt%R zoL%;Dko1;Y9$)h|bWQ`RwTQ(zxQ^6!c%eU+n7ZPspvH9 zc|1;{c-c sHk1Q@*F!o( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt new file mode 100644 index 0000000..eb041c1 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -0,0 +1,77 @@ +package org.prolibertate.games + +import android.Manifest +import android.os.Build +import android.os.Bundle +import android.provider.Settings as AndroidSettings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope +import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.settings.SettingsRepository +import org.prolibertate.games.ui.AppEnv +import org.prolibertate.games.ui.AppRoot +import org.prolibertate.games.ui.theme.ProLibertateTheme +import org.prolibertate.games.update.Updater +import java.util.UUID + +class MainActivity : ComponentActivity() { + + private lateinit var env: AppEnv + + /** + * Bluetooth needs runtime consent from Android 12 onwards. The result is + * ignored deliberately: refusing simply means the Bluetooth transport + * reports itself unavailable and the lobby falls back to Wi-Fi. + */ + private val bluetoothPermissions = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + env = AppEnv( + settingsRepository = SettingsRepository(applicationContext), + updater = Updater(this), + lobby = LobbyController(applicationContext, lifecycleScope), + peerId = stablePeerId(), + requestBluetoothPermissions = ::requestBluetoothPermissions, + ) + + setContent { + ProLibertateTheme { + AppRoot(env) + } + } + } + + private fun requestBluetoothPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissions.launch( + arrayOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_ADVERTISE, + ) + ) + } + } + + /** + * Identifies this device to a host for the length of an install. ANDROID_ID + * is per-app-signing-key and survives restarts, which is all a lobby needs + * to tell two phones apart. + */ + private fun stablePeerId(): String { + val androidId = runCatching { + AndroidSettings.Secure.getString(contentResolver, AndroidSettings.Secure.ANDROID_ID) + }.getOrNull() + return androidId ?: UUID.randomUUID().toString() + } + + override fun onDestroy() { + super.onDestroy() + if (::env.isInitialized) env.lobby.stop() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt new file mode 100644 index 0000000..1667c94 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -0,0 +1,159 @@ +package org.prolibertate.games.game + +/** + * The catalogue the main menu is built from. + * + * Every game the app intends to ship appears here, whether or not its rules + * engine exists yet. [GameDescriptor.available] is what the menu keys off to + * decide between "play" and "coming soon", so adding a game is a matter of + * flipping that flag once its engine and screen land. + */ +enum class GameCategory(val label: String) { + CARD("Playing Card Games"), + BOARD("Board Games"), +} + +data class GameDescriptor( + val id: String, + val title: String, + val category: GameCategory, + val minPlayers: Int, + val maxPlayers: Int, + /** True when players are grouped into partnerships rather than playing solo. */ + val teamBased: Boolean, + val blurb: String, + val available: Boolean, +) + +object GameCatalog { + + const val EUCHRE = "euchre" + const val KAISER = "kaiser" + const val PRESIDENT = "president" + const val GOLF = "golf" + const val WIZARD = "wizard" + const val CRAZY_EIGHTS = "crazy8s" + const val SEQUENCE = "sequence" + const val MASTERMIND = "mastermind" + const val BACKGAMMON = "backgammon" + const val CHESS = "chess" + const val CHECKERS = "checkers" + + val all: List = listOf( + GameDescriptor( + id = EUCHRE, + title = "Euchre", + category = GameCategory.CARD, + minPlayers = 4, + maxPlayers = 4, + teamBased = true, + blurb = "Four players, two partnerships, 24-card deck. Bowers are trump.", + available = true, + ), + GameDescriptor( + id = KAISER, + title = "Kaiser", + category = GameCategory.CARD, + minPlayers = 4, + maxPlayers = 4, + teamBased = true, + blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.", + available = false, + ), + GameDescriptor( + id = PRESIDENT, + title = "President", + category = GameCategory.CARD, + minPlayers = 3, + maxPlayers = 7, + teamBased = false, + blurb = "Shed your hand first and rule the table. Last one out serves the drinks.", + available = false, + ), + GameDescriptor( + id = GOLF, + title = "Golf", + category = GameCategory.CARD, + minPlayers = 2, + maxPlayers = 6, + teamBased = false, + blurb = "Lowest score wins over nine holes. Match pairs to cancel them out.", + available = false, + ), + GameDescriptor( + id = WIZARD, + title = "Wizard", + category = GameCategory.CARD, + minPlayers = 3, + maxPlayers = 6, + teamBased = false, + blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.", + available = false, + ), + GameDescriptor( + id = CRAZY_EIGHTS, + title = "Crazy 8s", + category = GameCategory.CARD, + minPlayers = 2, + maxPlayers = 6, + teamBased = false, + blurb = "Match rank or suit, and eights change everything.", + available = false, + ), + GameDescriptor( + id = SEQUENCE, + title = "Sequence", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 12, + teamBased = true, + blurb = "Play a card, claim its square, and build five in a row.", + available = true, + ), + GameDescriptor( + id = MASTERMIND, + title = "Mastermind", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Break the hidden code from black and white peg clues.", + available = false, + ), + GameDescriptor( + id = BACKGAMMON, + title = "Backgammon", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Race your checkers home, and hit your opponent on the way.", + available = false, + ), + GameDescriptor( + id = CHESS, + title = "Chess", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "The old game. Full legal moves, castling, en passant, promotion.", + available = false, + ), + GameDescriptor( + id = CHECKERS, + title = "Checkers", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Forced captures, kings, and the long diagonal.", + available = false, + ), + ) + + fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } + + fun byCategory(category: GameCategory): List = + all.filter { it.category == category } +} diff --git a/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt b/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt new file mode 100644 index 0000000..652cc93 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt @@ -0,0 +1,97 @@ +package org.prolibertate.games.game.cards + +import kotlinx.serialization.Serializable +import kotlin.random.Random + +@Serializable +enum class Suit(val symbol: String, val isRed: Boolean) { + CLUBS("♣", false), + DIAMONDS("♦", true), + HEARTS("♥", true), + SPADES("♠", false); + + /** Euchre's left bower lives in the suit of the same colour. */ + fun sameColour(): Suit = when (this) { + CLUBS -> SPADES + SPADES -> CLUBS + DIAMONDS -> HEARTS + HEARTS -> DIAMONDS + } +} + +@Serializable +enum class Rank(val short: String, val order: Int) { + TWO("2", 2), + THREE("3", 3), + FOUR("4", 4), + FIVE("5", 5), + SIX("6", 6), + SEVEN("7", 7), + EIGHT("8", 8), + NINE("9", 9), + TEN("10", 10), + JACK("J", 11), + QUEEN("Q", 12), + KING("K", 13), + ACE("A", 14), +} + +@Serializable +data class Card(val rank: Rank, val suit: Suit) { + /** Compact form used in logs and in the Sequence board layout table. */ + val code: String get() = "${rank.short}${suit.name.first()}" + + val label: String get() = "${rank.short}${suit.symbol}" + + override fun toString(): String = label + + companion object { + /** Parses the [code] form, e.g. "10H", "AS", "9D". */ + fun parse(code: String): Card { + val suitChar = code.last() + val rankPart = code.dropLast(1) + val suit = Suit.entries.first { it.name.first() == suitChar } + val rank = Rank.entries.first { it.short == rankPart } + return Card(rank, suit) + } + } +} + +object Decks { + + /** Standard 52-card deck. */ + fun standard52(): List = + Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } } + + /** + * Euchre decks. 24 cards (9 through ace) is the common game; 32 cards + * (7 through ace) is a widespread variant that lengthens the hand. + */ + fun euchre(size: Int): List { + val lowest = when (size) { + 24 -> Rank.NINE + 32 -> Rank.SEVEN + else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size") + } + return Suit.entries.flatMap { suit -> + Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) } + } + } + + /** Two full decks shuffled together, as Sequence uses. */ + fun double52(): List = standard52() + standard52() +} + +/** + * Deterministic shuffle. Every shuffle in the app runs through a seeded + * [Random] so a networked hand can be reproduced from its seed and so tests + * are repeatable — the host generates the seed and ships it with the state. + */ +fun List.shuffledWith(random: Random): List = toMutableList().apply { + for (i in indices.reversed()) { + val j = random.nextInt(i + 1) + val tmp = this[i] + this[i] = this[j] + this[j] = tmp + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt new file mode 100644 index 0000000..d44b825 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -0,0 +1,87 @@ +package org.prolibertate.games.game.engine + +import kotlinx.serialization.Serializable + +@Serializable +enum class PlayerKind { + /** A person on this device. */ + HUMAN_LOCAL, + + /** A person on another device, reached over LAN or Bluetooth. */ + HUMAN_REMOTE, + + /** Driven by the host's AI. */ + AI, +} + +@Serializable +data class PlayerSlot( + val seat: Int, + val name: String, + val kind: PlayerKind, + /** Partnership index. Solo games put every seat on its own team. */ + val team: Int, + /** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */ + val peerId: String? = null, +) + +@Serializable +data class TableConfig( + val gameId: String, + val seats: List, + /** Game-specific options, serialised by the owning rules implementation. */ + val optionsJson: String, + /** Shared shuffle seed so a hand is reproducible from its state. */ + val seed: Long, +) + +/** + * A game's rules, expressed as a pure state machine. + * + * Implementations must be free of Android dependencies and free of side + * effects: [applyMove] returns a new state rather than mutating. That is what + * lets the same code drive the local AI, the host's authoritative copy, and + * the unit tests. + */ +interface GameRules { + + val gameId: String + + fun initialState(config: TableConfig): S + + /** The seat expected to move, or null when nobody is on the clock. */ + fun currentSeat(state: S): Int? + + fun legalMoves(state: S, seat: Int): List + + /** + * Applies [move] on behalf of [seat]. + * + * @throws IllegalArgumentException if the move is not currently legal — + * the host validates every intent that arrives over the network this way. + */ + fun applyMove(state: S, seat: Int, move: M): S + + fun isFinished(state: S): Boolean + + fun summary(state: S): String + + /** + * Strips information [seat] is not entitled to see. + * + * The host holds the true state and sends each client only its own view, + * so an opponent's hand never crosses the wire in the first place. Any + * hidden-information game must implement this meaningfully. + */ + fun viewFor(state: S, seat: Int): S + + fun encodeState(state: S): String + fun decodeState(json: String): S + fun encodeMove(move: M): String + fun decodeMove(json: String): M +} + +/** Chooses moves for unoccupied seats. Always runs on the host. */ +interface GameAi { + fun chooseMove(state: S, seat: Int, legal: List): M +} diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt new file mode 100644 index 0000000..ea8f6ee --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt @@ -0,0 +1,175 @@ +package org.prolibertate.games.game.euchre + +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Euchre opponent. + * + * It does not look at anybody else's cards and it does not search — it counts + * trump, respects its partner, and plays the cheapest card that does the job. + * That is enough to be a reasonable table opponent without pretending to be a + * solver. + */ +class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : + GameAi { + + enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) { + /** Bids loosely and plays straightforwardly. */ + EASY(bidThreshold = 7, aloneThreshold = 26), + + NORMAL(bidThreshold = 9, aloneThreshold = 24), + + /** Bids tighter and is harder to sneak a trick past. */ + CAUTIOUS(bidThreshold = 11, aloneThreshold = 22), + } + + override fun chooseMove(state: EuchreState, seat: Int, legal: List): EuchreMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return when (state.phase) { + EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal) + EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal) + EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal) + EuchrePhase.PLAYING -> choosePlay(state, seat, legal) + else -> legal.first() + } + } + + // ----------------------------------------------------------------------- + // Bidding + // ----------------------------------------------------------------------- + + /** + * Rates a hand for a candidate trump suit. Bowers and trump length carry + * the hand; off-suit aces are worth a trick more often than not. + */ + private fun handValue(hand: List, trump: Suit): Int { + var value = 0 + for (card in hand) { + value += when { + isRightBower(card, trump) -> 9 + isLeftBower(card, trump) -> 7 + isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2 + card.rank == Rank.ACE -> 3 + card.rank == Rank.KING -> 1 + else -> 0 + } + } + // A void in a side suit means an early chance to trump in. + val sideSuits = Suit.entries.filter { it != trump } + value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2 + return value + } + + private fun chooseBidRound1( + state: EuchreState, + seat: Int, + legal: List, + ): EuchreMove { + val upCard = state.upCard ?: return Pass + val trump = upCard.suit + var hand = state.hands[seat] + + // If our partner is the dealer they gain the turn card, and if we are + // the dealer we gain it ourselves — both make the hand stronger than it + // looks. Ordering up into an opponent dealer instead hands them a card. + if (seat == state.dealer) hand = hand + upCard + var value = handValue(hand, trump) + if (partnerOf(seat) == state.dealer) value += 2 + if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2 + + val alone = OrderUp(alone = true) + if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone + val order = OrderUp(alone = false) + if (value >= difficulty.bidThreshold && legal.contains(order)) return order + return if (legal.contains(Pass)) Pass else legal.first() + } + + private fun chooseBidRound2( + state: EuchreState, + seat: Int, + legal: List, + ): EuchreMove { + val hand = state.hands[seat] + val callable = legal.filterIsInstance() + if (callable.isEmpty()) return legal.first() + + val best = callable + .filter { !it.alone } + .maxByOrNull { handValue(hand, it.suit) } + ?: callable.first() + val bestValue = handValue(hand, best.suit) + + val alone = callable.firstOrNull { it.alone && it.suit == best.suit } + if (alone != null && bestValue >= difficulty.aloneThreshold) return alone + if (bestValue >= difficulty.bidThreshold) return best + // Stuck as dealer with a bad hand: name the least-bad suit anyway. + return if (legal.contains(Pass)) Pass else best + } + + private fun chooseDiscard(state: EuchreState, legal: List): EuchreMove { + val trump = requireNotNull(state.trump) + val discards = legal.filterIsInstance() + // Prefer to void a side suit entirely; otherwise throw the lowest card. + val hand = state.hands[state.dealer] + val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount() + return discards.minByOrNull { discard -> + val card = discard.card + val suit = effectiveSuit(card, trump) + val trumpPenalty = if (suit == trump) 1000 else 0 + val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0 + trumpPenalty + singletonBonus + card.rank.order + } ?: discards.first() + } + + // ----------------------------------------------------------------------- + // Card play + // ----------------------------------------------------------------------- + + private fun choosePlay( + state: EuchreState, + seat: Int, + legal: List, + ): EuchreMove { + val trump = requireNotNull(state.trump) + val cards = legal.filterIsInstance().map { it.card } + if (cards.size == 1) return PlayCard(cards.first()) + + if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump)) + + val ledSuit = effectiveSuit(state.trick.first().card, trump) + val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) } + val partnerWinning = partnerOf(bestSoFar.seat) == seat + val lastToPlay = state.trick.size == state.activeSeatCount - 1 + + // Partner already has it and we are last: never waste a good card. + if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit)) + + val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit) + val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength } + return when { + // Take it as cheaply as we can. + winners.isNotEmpty() && (!partnerWinning || !lastToPlay) -> + PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) }) + + else -> PlayCard(lowest(cards, trump, ledSuit)) + } + } + + private fun chooseLead(cards: List, trump: Suit): Card { + // Right bower up front pulls trump and protects the left. + cards.firstOrNull { isRightBower(it, trump) }?.let { return it } + // Off-suit aces are most likely to survive early. + cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE } + .maxByOrNull { it.rank.order } + ?.let { return it } + val offSuit = cards.filter { !isTrumpCard(it, trump) } + if (offSuit.isNotEmpty()) return offSuit.maxBy { it.rank.order } + return cards.maxBy { trumpStrength(it, trump) } + } + + private fun lowest(cards: List, trump: Suit, ledSuit: Suit): Card = + cards.minBy { trickStrength(it, trump, ledSuit).takeIf { s -> s > 0 } ?: it.rank.order } +} diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt new file mode 100644 index 0000000..3087724 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt @@ -0,0 +1,154 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit + +/** + * Rule toggles. Euchre is played differently in almost every kitchen, so the + * variations that actually change play are surfaced in the game setup screen + * rather than baked in. Defaults are the common North American game. + */ +@Serializable +data class EuchreOptions( + /** 24 cards (9–A) is standard; 32 (7–A) is a widespread longer variant. */ + val deckSize: Int = 24, + /** Dealer must name trump rather than pass the hand out. Also called screw the dealer. */ + val stickTheDealer: Boolean = true, + /** Allow a maker to play the hand without their partner for a bigger score. */ + val allowGoingAlone: Boolean = true, + /** First team to this many points wins. 10 is standard; 11 and 15 are common. */ + val pointsToWin: Int = 10, +) { + init { + require(deckSize == 24 || deckSize == 32) { "Euchre deck must be 24 or 32 cards" } + require(pointsToWin > 0) { "pointsToWin must be positive" } + } +} + +@Serializable +enum class EuchrePhase { + /** Players may order up the turned card, in turn, starting left of the dealer. */ + BID_ROUND_1, + + /** The turn card is down; players may name any other suit. */ + BID_ROUND_2, + + /** The dealer has picked up the turn card and must discard one. */ + DEALER_DISCARD, + + PLAYING, + + /** Five tricks played and scored; waiting to deal the next hand. */ + HAND_OVER, + + GAME_OVER, +} + +@Serializable +data class PlayedCard(val seat: Int, val card: Card) + +@Serializable +data class EuchreState( + val options: EuchreOptions, + val seed: Long, + val handNumber: Int, + val dealer: Int, + val phase: EuchrePhase, + /** Per-seat hands. Redacted to empty for seats other than the viewer. */ + val hands: List>, + /** Card counts survive redaction so the table can still be drawn. */ + val handCounts: List, + val upCard: Card?, + /** The turned-down card in round two; its suit may not be named. */ + val turnedDown: Card?, + val trump: Suit?, + val maker: Int?, + val aloneSeat: Int?, + val turn: Int, + val passes: Int, + val trick: List, + val leader: Int, + val tricksWon: List, + /** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */ + val scores: List, + val lastTrickWinner: Int?, + val log: List, +) { + val sittingOut: Int? get() = aloneSeat?.let { partnerOf(it) } + + fun isActive(seat: Int): Boolean = seat != sittingOut + + val activeSeatCount: Int get() = if (aloneSeat != null) 3 else 4 +} + +@Serializable +sealed interface EuchreMove + +@Serializable +@SerialName("pass") +data object Pass : EuchreMove + +@Serializable +@SerialName("order_up") +data class OrderUp(val alone: Boolean) : EuchreMove + +@Serializable +@SerialName("call_trump") +data class CallTrump(val suit: Suit, val alone: Boolean) : EuchreMove + +@Serializable +@SerialName("discard") +data class Discard(val card: Card) : EuchreMove + +@Serializable +@SerialName("play") +data class PlayCard(val card: Card) : EuchreMove + +// --------------------------------------------------------------------------- +// Seating and card ranking +// --------------------------------------------------------------------------- + +fun partnerOf(seat: Int): Int = (seat + 2) % 4 + +fun teamOf(seat: Int): Int = seat % 2 + +/** The jack of trump: the highest card in the game. */ +fun isRightBower(card: Card, trump: Suit): Boolean = + card.rank == Rank.JACK && card.suit == trump + +/** + * The jack of the same colour as trump. It counts as a trump card and outranks + * every trump except the right bower — the rule that catches every new player. + */ +fun isLeftBower(card: Card, trump: Suit): Boolean = + card.rank == Rank.JACK && card.suit == trump.sameColour() + +/** The suit a card counts as, which is trump for the left bower. */ +fun effectiveSuit(card: Card, trump: Suit?): Suit = + if (trump != null && isLeftBower(card, trump)) trump else card.suit + +fun isTrumpCard(card: Card, trump: Suit?): Boolean = + trump != null && effectiveSuit(card, trump) == trump + +/** + * Ranks a card within a trick. Higher wins. Cards that neither follow the led + * suit nor trump score zero and can never take the trick. + */ +fun trickStrength(card: Card, trump: Suit, ledSuit: Suit): Int = when { + isRightBower(card, trump) -> 1000 + isLeftBower(card, trump) -> 900 + isTrumpCard(card, trump) -> 800 + card.rank.order + effectiveSuit(card, trump) == ledSuit -> 100 + card.rank.order + else -> 0 +} + +/** Ranks a card in isolation, for AI hand evaluation. */ +fun trumpStrength(card: Card, trump: Suit): Int = when { + isRightBower(card, trump) -> 1000 + isLeftBower(card, trump) -> 900 + isTrumpCard(card, trump) -> 800 + card.rank.order + else -> card.rank.order +} diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt new file mode 100644 index 0000000..3c7084a --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt @@ -0,0 +1,377 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Euchre as a pure state machine. See RULES-euchre.md for the ruleset and the + * variants exposed through [EuchreOptions]. + */ +object EuchreRules : GameRules { + + override val gameId: String = GameCatalog.EUCHRE + + private const val CARDS_PER_HAND = 5 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + // ----------------------------------------------------------------------- + // Setup + // ----------------------------------------------------------------------- + + override fun initialState(config: TableConfig): EuchreState { + require(config.seats.size == 4) { "Euchre is a four-handed game" } + val options = json.decodeFromString(config.optionsJson) + return deal( + options = options, + seed = config.seed, + handNumber = 0, + dealer = 0, + scores = listOf(0, 0), + log = listOf("Hand 1 — dealer is seat 0."), + ) + } + + /** Deals a fresh hand and opens the first round of bidding. */ + private fun deal( + options: EuchreOptions, + seed: Long, + handNumber: Int, + dealer: Int, + scores: List, + log: List, + ): EuchreState { + // Derived per hand so a given (seed, handNumber) always reproduces the + // same deal — handy for tests and for replaying a networked game. + val random = Random(seed + handNumber * 7919L) + val deck = Decks.euchre(options.deckSize).shuffledWith(random) + val hands = (0 until 4).map { seat -> + deck.subList(seat * CARDS_PER_HAND, (seat + 1) * CARDS_PER_HAND).toList() + } + val upCard = deck[4 * CARDS_PER_HAND] + return EuchreState( + options = options, + seed = seed, + handNumber = handNumber, + dealer = dealer, + phase = EuchrePhase.BID_ROUND_1, + hands = hands, + handCounts = hands.map { it.size }, + upCard = upCard, + turnedDown = null, + trump = null, + maker = null, + aloneSeat = null, + turn = (dealer + 1) % 4, + passes = 0, + trick = emptyList(), + leader = (dealer + 1) % 4, + tricksWon = listOf(0, 0, 0, 0), + scores = scores, + lastTrickWinner = null, + log = log, + ) + } + + /** + * Advances from [EuchrePhase.HAND_OVER] into the next deal. Driven by the + * session controller once the finished hand has been on screen long enough + * to read, rather than by a player move. + */ + fun nextHand(state: EuchreState): EuchreState { + check(state.phase == EuchrePhase.HAND_OVER) { "Hand is not over" } + val dealer = (state.dealer + 1) % 4 + return deal( + options = state.options, + seed = state.seed, + handNumber = state.handNumber + 1, + dealer = dealer, + scores = state.scores, + log = state.log + "Hand ${state.handNumber + 2} — dealer is seat $dealer.", + ) + } + + // ----------------------------------------------------------------------- + // Turn order + // ----------------------------------------------------------------------- + + private fun nextActive(state: EuchreState, from: Int): Int { + var seat = (from + 1) % 4 + while (seat == state.sittingOut) seat = (seat + 1) % 4 + return seat + } + + override fun currentSeat(state: EuchreState): Int? = when (state.phase) { + EuchrePhase.BID_ROUND_1, + EuchrePhase.BID_ROUND_2, + EuchrePhase.DEALER_DISCARD, + EuchrePhase.PLAYING, + -> state.turn + + EuchrePhase.HAND_OVER, EuchrePhase.GAME_OVER -> null + } + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: EuchreState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + return when (state.phase) { + EuchrePhase.BID_ROUND_1 -> buildList { + add(OrderUp(alone = false)) + if (state.options.allowGoingAlone) add(OrderUp(alone = true)) + add(Pass) + } + + EuchrePhase.BID_ROUND_2 -> buildList { + val barred = state.turnedDown?.suit + for (suit in Suit.entries) { + if (suit == barred) continue + add(CallTrump(suit, alone = false)) + if (state.options.allowGoingAlone) add(CallTrump(suit, alone = true)) + } + // Stick the dealer: with three passes already in, the dealer has + // to name something rather than throw the hand in. + val dealerIsStuck = + state.options.stickTheDealer && seat == state.dealer && state.passes == 3 + if (!dealerIsStuck) add(Pass) + } + + EuchrePhase.DEALER_DISCARD -> state.hands[state.dealer].map { Discard(it) } + + EuchrePhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) } + + EuchrePhase.HAND_OVER, EuchrePhase.GAME_OVER -> emptyList() + } + } + + /** Follow the led suit if you can; otherwise anything goes. */ + fun playableCards(state: EuchreState, seat: Int): List { + val hand = state.hands[seat] + val led = state.trick.firstOrNull() ?: return hand + val ledSuit = effectiveSuit(led.card, state.trump) + val following = hand.filter { effectiveSuit(it, state.trump) == ledSuit } + return following.ifEmpty { hand } + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: EuchreState, seat: Int, move: EuchreMove): EuchreState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + return when (move) { + is Pass -> applyPass(state, seat) + is OrderUp -> applyOrderUp(state, seat, move.alone) + is CallTrump -> applyCallTrump(state, seat, move.suit, move.alone) + is Discard -> applyDiscard(state, move.card) + is PlayCard -> applyPlay(state, seat, move.card) + }.normalised() + } + + private fun EuchreState.normalised(): EuchreState = copy(handCounts = hands.map { it.size }) + + private fun applyPass(state: EuchreState, seat: Int): EuchreState { + val passes = state.passes + 1 + val log = state.log + "Seat $seat passes." + return when (state.phase) { + EuchrePhase.BID_ROUND_1 -> if (passes >= 4) { + state.copy( + phase = EuchrePhase.BID_ROUND_2, + turnedDown = state.upCard, + upCard = null, + turn = (state.dealer + 1) % 4, + passes = 0, + log = log + "${state.upCard?.label} turned down.", + ) + } else { + state.copy(turn = (seat + 1) % 4, passes = passes, log = log) + } + + EuchrePhase.BID_ROUND_2 -> if (passes >= 4) { + // Nobody wanted it and the dealer wasn't stuck: throw the hand in. + deal( + options = state.options, + seed = state.seed, + handNumber = state.handNumber + 1, + dealer = (state.dealer + 1) % 4, + scores = state.scores, + log = log + "Hand thrown in; redealing.", + ) + } else { + state.copy(turn = (seat + 1) % 4, passes = passes, log = log) + } + + else -> error("Pass is not legal in ${state.phase}") + } + } + + private fun applyOrderUp(state: EuchreState, seat: Int, alone: Boolean): EuchreState { + val upCard = requireNotNull(state.upCard) { "No turn card to order up" } + // The dealer takes the turn card into hand and will discard one back. + val hands = state.hands.toMutableList() + hands[state.dealer] = hands[state.dealer] + upCard + return state.copy( + phase = EuchrePhase.DEALER_DISCARD, + hands = hands, + trump = upCard.suit, + maker = seat, + aloneSeat = if (alone) seat else null, + upCard = null, + turn = state.dealer, + log = state.log + buildString { + append("Seat $seat orders up ${upCard.suit.symbol}") + if (alone) append(" and goes alone") + append(".") + }, + ) + } + + private fun applyCallTrump( + state: EuchreState, + seat: Int, + suit: Suit, + alone: Boolean, + ): EuchreState { + val withTrump = state.copy( + trump = suit, + maker = seat, + aloneSeat = if (alone) seat else null, + log = state.log + buildString { + append("Seat $seat names ${suit.symbol}") + if (alone) append(" and goes alone") + append(".") + }, + ) + return beginPlay(withTrump) + } + + private fun applyDiscard(state: EuchreState, card: Card): EuchreState { + val hands = state.hands.toMutableList() + hands[state.dealer] = hands[state.dealer] - card + return beginPlay(state.copy(hands = hands, log = state.log + "Dealer discards.")) + } + + /** Trump is settled: clear any lone hand's partner and lead to the first trick. */ + private fun beginPlay(state: EuchreState): EuchreState { + val sittingOut = state.sittingOut + val hands = state.hands.toMutableList() + if (sittingOut != null) hands[sittingOut] = emptyList() + val leader = nextActive(state, state.dealer) + return state.copy( + phase = EuchrePhase.PLAYING, + hands = hands, + leader = leader, + turn = leader, + trick = emptyList(), + passes = 0, + ) + } + + private fun applyPlay(state: EuchreState, seat: Int, card: Card): EuchreState { + val hands = state.hands.toMutableList() + hands[seat] = hands[seat] - card + val trick = state.trick + PlayedCard(seat, card) + val played = state.copy(hands = hands, trick = trick) + + if (trick.size < state.activeSeatCount) { + return played.copy(turn = nextActive(state, seat)) + } + + // Trick complete: resolve it. + val trump = requireNotNull(state.trump) + val ledSuit = effectiveSuit(trick.first().card, trump) + val winner = trick.maxBy { trickStrength(it.card, trump, ledSuit) }.seat + val tricksWon = state.tricksWon.toMutableList() + tricksWon[winner] = tricksWon[winner] + 1 + + val afterTrick = played.copy( + trick = emptyList(), + tricksWon = tricksWon, + leader = winner, + turn = winner, + lastTrickWinner = winner, + log = state.log + "Seat $winner takes the trick.", + ) + + val handFinished = afterTrick.hands.all { it.isEmpty() } + return if (handFinished) scoreHand(afterTrick) else afterTrick + } + + // ----------------------------------------------------------------------- + // Scoring + // ----------------------------------------------------------------------- + + private fun scoreHand(state: EuchreState): EuchreState { + val maker = requireNotNull(state.maker) + val makerTeam = teamOf(maker) + val makerTricks = state.tricksWon.filterIndexed { seat, _ -> teamOf(seat) == makerTeam }.sum() + val scores = state.scores.toMutableList() + + val note: String + if (makerTricks >= 3) { + val points = when { + makerTricks == 5 && state.aloneSeat != null -> 4 + makerTricks == 5 -> 2 + else -> 1 + } + scores[makerTeam] = scores[makerTeam] + points + note = "Team $makerTeam took $makerTricks tricks for $points point(s)." + } else { + // Euchred: the defenders score two regardless of how it fell apart. + val defenders = 1 - makerTeam + scores[defenders] = scores[defenders] + 2 + note = "Team $makerTeam is euchred — team $defenders scores 2." + } + + val winner = scores.indexOfFirst { it >= state.options.pointsToWin } + return state.copy( + phase = if (winner >= 0) EuchrePhase.GAME_OVER else EuchrePhase.HAND_OVER, + scores = scores, + log = state.log + note + if (winner >= 0) listOf("Team $winner wins.") else emptyList(), + ) + } + + override fun isFinished(state: EuchreState): Boolean = state.phase == EuchrePhase.GAME_OVER + + override fun summary(state: EuchreState): String = when (state.phase) { + EuchrePhase.GAME_OVER -> { + val winner = state.scores.indexOfFirst { it >= state.options.pointsToWin } + "Team $winner wins ${state.scores[0]}–${state.scores[1]}" + } + + else -> "We ${state.scores[0]} — They ${state.scores[1]}" + } + + // ----------------------------------------------------------------------- + // Redaction and wire format + // ----------------------------------------------------------------------- + + override fun viewFor(state: EuchreState, seat: Int): EuchreState = state.copy( + hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, + handCounts = state.handCounts, + ) + + override fun encodeState(state: EuchreState): String = json.encodeToString(state) + + override fun decodeState(json: String): EuchreState = + this.json.decodeFromString(json) + + override fun encodeMove(move: EuchreMove): String = json.encodeToString(move) + + override fun decodeMove(json: String): EuchreMove = + this.json.decodeFromString(json) +} diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt new file mode 100644 index 0000000..20f1871 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt @@ -0,0 +1,122 @@ +package org.prolibertate.games.game.sequence + +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Sequence opponent. + * + * It scores every legal placement by how much it advances one of its own runs + * and how badly it spoils an opponent's, holds its jacks back until they are + * worth spending, and never looks at anyone's hand. + */ +class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) : + GameAi { + + enum class Difficulty(val blockWeight: Int, val jackPatience: Int) { + /** Builds its own runs and mostly ignores yours. */ + EASY(blockWeight = 1, jackPatience = 0), + + NORMAL(blockWeight = 3, jackPatience = 3), + + /** Blocks aggressively and saves jacks for real threats. */ + CAUTIOUS(blockWeight = 5, jackPatience = 4), + } + + override fun chooseMove( + state: SequenceState, + seat: Int, + legal: List, + ): SequenceMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val team = state.teamOf(seat) + + // Clearing a dead card is free value when nothing better is on offer. + val exchange = legal.filterIsInstance().firstOrNull() + if (exchange != null && legal.none { it is PlaceChip }) return exchange + + val best = legal + .filterIsInstance() + .maxByOrNull { scorePlacement(state, it, team) } + + val removal = legal + .filterIsInstance() + .maxByOrNull { threatAt(state, it.cell, state.chips[it.cell]) } + + // Spend a one-eyed jack only on a genuinely dangerous chip. + if (removal != null) { + val threat = threatAt(state, removal.cell, state.chips[removal.cell]) + if (threat >= difficulty.jackPatience) return removal + } + + return best ?: removal ?: exchange ?: legal.first() + } + + /** + * Rates placing a chip: how long a run it joins for us, plus how much of an + * opponent's run it interrupts. Wild jacks are discounted so they are not + * burned on an ordinary square. + */ + private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int { + val ours = runPotential(state, move.cell, team) + val theirs = (0 until state.options.teamCount) + .filter { it != team } + .maxOfOrNull { runPotential(state, move.cell, it) } + ?: 0 + + var score = ours * 4 + theirs * difficulty.blockWeight + // Central squares sit on more possible lines than edge squares. + val row = SequenceBoard.rowOf(move.cell) + val col = SequenceBoard.colOf(move.cell) + score += 4 - (kotlin.math.abs(row - 4) + kotlin.math.abs(col - 4)) / 2 + if (isTwoEyedJack(move.card)) score -= 12 + return score + } + + /** + * The best run this team could still build through [cell]: the highest + * count of friendly-or-free squares in any five-window containing it, once + * windows blocked by an opponent are discarded. + */ + private fun runPotential(state: SequenceState, cell: Int, team: Int): Int { + val row = SequenceBoard.rowOf(cell) + val col = SequenceBoard.colOf(cell) + var best = 0 + + for ((dr, dc) in DIRECTIONS) { + for (offset in -(RUN_LENGTH - 1)..0) { + var count = 0 + var blocked = false + for (step in 0 until RUN_LENGTH) { + val r = row + (offset + step) * dr + val c = col + (offset + step) * dc + if (r !in 0 until BOARD_SIZE || c !in 0 until BOARD_SIZE) { + blocked = true + break + } + val target = SequenceBoard.cellAt(r, c) + val occupant = state.chips[target] + when { + SequenceBoard.isCorner(target) -> count++ + target == cell -> count++ + occupant == team -> count++ + occupant == NO_TEAM -> Unit + else -> { + blocked = true + } + } + if (blocked) break + } + if (!blocked && count > best) best = count + } + } + return best + } + + /** How close [team] is to completing a run through [cell]. */ + private fun threatAt(state: SequenceState, cell: Int, team: Int): Int = + if (team == NO_TEAM) 0 else runPotential(state, cell, team) + + private companion object { + val DIRECTIONS = listOf(1 to 0, 0 to 1, 1 to 1, 1 to -1) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt new file mode 100644 index 0000000..815cd83 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt @@ -0,0 +1,144 @@ +package org.prolibertate.games.game.sequence + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit + +const val BOARD_SIZE = 10 +const val BOARD_CELLS = BOARD_SIZE * BOARD_SIZE +const val RUN_LENGTH = 5 + +/** Sentinel stored in [SequenceState.chips] for an unoccupied square. */ +const val NO_TEAM = -1 + +/** + * The printed board. + * + * IMPORTANT: this is a generated layout, not a reproduction of the commercial + * Sequence board. It satisfies every structural property the rules depend on — + * each of the 48 non-jack cards appears exactly twice, the four corners are + * free squares, and no card sits next to its own twin — so play is correct, but + * square-for-square it will not match a physical board. Replace [LAYOUT] with + * the real arrangement to match one; nothing else needs to change. + */ +object SequenceBoard { + + private val LAYOUT: List = listOf( + "FREE 10C 6D 4C 7C 8H 9S 10C 10H FREE", + " 3H 8S 2H 9C AH 10S 4S 5H QH 4H", + " 3S AS KD KC AC KS 3D 10D 6D KH", + " 6H 3D 3C QC 7D 8H 9D 5S 4H 6C", + " AD 5D 8S 9S 4S 2D 10H QD 5D QC", + " 7H 9D QH 2C 4C AS AD 8C 6C QS", + " 2S 6S 8D KH 7C 7S 4D 7S 3H 3S", + " 10S 4D 7H AC 2H 2C 3C 6S 2S 7D", + " 8C 10D KD 5H 9H 5C QS 8D 9H 9C", + "FREE 6H KS 2D AH 5S QD KC 5C FREE", + ) + + /** Card printed on each square, or null for the four free corners. */ + val cells: List = LAYOUT.flatMap { row -> + row.trim().split(Regex("\\s+")).map { token -> + if (token == "FREE") null else Card.parse(token) + } + }.also { parsed -> + require(parsed.size == BOARD_CELLS) { "Board must have $BOARD_CELLS squares" } + } + + /** Squares showing a given card. Every non-jack card occupies exactly two. */ + val squaresByCard: Map> = cells + .withIndex() + .filter { it.value != null } + .groupBy({ it.value!! }, { it.index }) + + fun isCorner(cell: Int): Boolean = cells[cell] == null + + fun rowOf(cell: Int): Int = cell / BOARD_SIZE + fun colOf(cell: Int): Int = cell % BOARD_SIZE + fun cellAt(row: Int, col: Int): Int = row * BOARD_SIZE + col +} + +/** Jacks do not appear on the board; they are the two wild actions instead. */ +fun isTwoEyedJack(card: Card): Boolean = + card.rank == Rank.JACK && (card.suit == Suit.DIAMONDS || card.suit == Suit.CLUBS) + +fun isOneEyedJack(card: Card): Boolean = + card.rank == Rank.JACK && (card.suit == Suit.HEARTS || card.suit == Suit.SPADES) + +@Serializable +data class SequenceOptions( + /** Two teams need two sequences; three teams need one. */ + val teamCount: Int = 2, + val playersPerTeam: Int = 1, + val sequencesToWin: Int = 2, + /** Allow swapping a card whose squares are both taken. */ + val deadCardExchange: Boolean = true, +) { + init { + require(teamCount in 2..3) { "Sequence supports two or three teams" } + require(playersPerTeam >= 1) { "Each team needs at least one player" } + require(sequencesToWin >= 1) { "sequencesToWin must be positive" } + } + + val playerCount: Int get() = teamCount * playersPerTeam +} + +/** + * Cards dealt per player, which shrinks as the table grows. These are the + * standard counts; unusual player counts fall back to the nearest band. + */ +fun handSizeFor(players: Int): Int = when { + players <= 2 -> 7 + players <= 4 -> 6 + players <= 6 -> 5 + players <= 9 -> 4 + else -> 3 +} + +@Serializable +enum class SequencePhase { PLAYING, GAME_OVER } + +@Serializable +data class SequenceState( + val options: SequenceOptions, + val seed: Long, + /** Team index per seat. */ + val teams: List, + /** Occupying team per square, or [NO_TEAM]. */ + val chips: List, + /** Squares belonging to a completed sequence: safe from one-eyed jacks. */ + val locked: List, + val hands: List>, + val handCounts: List, + val drawPile: List, + val discardPile: List, + val turn: Int, + val sequencesByTeam: List, + val winner: Int?, + val phase: SequencePhase, + /** Set once per turn when a dead card has already been exchanged. */ + val exchangedThisTurn: Boolean, + val lastPlacedCell: Int?, + val log: List, +) { + val playerCount: Int get() = teams.size + + fun teamOf(seat: Int): Int = teams[seat] +} + +@Serializable +sealed interface SequenceMove + +@Serializable +@SerialName("place") +data class PlaceChip(val card: Card, val cell: Int) : SequenceMove + +@Serializable +@SerialName("remove") +data class RemoveChip(val card: Card, val cell: Int) : SequenceMove + +@Serializable +@SerialName("exchange") +data class ExchangeDeadCard(val card: Card) : SequenceMove diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt new file mode 100644 index 0000000..ef3eaf0 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt @@ -0,0 +1,302 @@ +package org.prolibertate.games.game.sequence + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Sequence as a pure state machine. See RULES-sequence.md for the ruleset and + * the caveat about the board layout. + */ +object SequenceRules : GameRules { + + override val gameId: String = GameCatalog.SEQUENCE + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** The four straight lines a run of five can follow. */ + private val DIRECTIONS = listOf( + 1 to 0, // horizontal + 0 to 1, // vertical + 1 to 1, // diagonal down-right + 1 to -1, // diagonal down-left + ) + + override fun initialState(config: TableConfig): SequenceState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == options.playerCount) { + "Seat count ${config.seats.size} does not match ${options.playerCount} players" + } + + val random = Random(config.seed) + var deck = Decks.double52().shuffledWith(random) + val handSize = handSizeFor(options.playerCount) + val hands = mutableListOf>() + for (seat in 0 until options.playerCount) { + hands += deck.take(handSize) + deck = deck.drop(handSize) + } + + // Seats alternate around the table so partners are never adjacent. + val teams = (0 until options.playerCount).map { it % options.teamCount } + + return SequenceState( + options = options, + seed = config.seed, + teams = teams, + chips = List(BOARD_CELLS) { NO_TEAM }, + locked = List(BOARD_CELLS) { false }, + hands = hands, + handCounts = hands.map { it.size }, + drawPile = deck, + discardPile = emptyList(), + turn = 0, + sequencesByTeam = List(options.teamCount) { 0 }, + winner = null, + phase = SequencePhase.PLAYING, + exchangedThisTurn = false, + lastPlacedCell = null, + log = listOf("Sequence — ${options.teamCount} teams, first to ${options.sequencesToWin}."), + ) + } + + override fun currentSeat(state: SequenceState): Int? = + if (state.phase == SequencePhase.PLAYING) state.turn else null + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: SequenceState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + val team = state.teamOf(seat) + val moves = mutableListOf() + + for (card in state.hands[seat].distinct()) { + when { + isTwoEyedJack(card) -> + // Wild: any empty square on the board. + for (cell in 0 until BOARD_CELLS) { + if (!SequenceBoard.isCorner(cell) && state.chips[cell] == NO_TEAM) { + moves += PlaceChip(card, cell) + } + } + + isOneEyedJack(card) -> + // Removes one opposing chip that is not part of a sequence. + for (cell in 0 until BOARD_CELLS) { + val occupant = state.chips[cell] + if (occupant != NO_TEAM && occupant != team && !state.locked[cell]) { + moves += RemoveChip(card, cell) + } + } + + else -> { + val squares = SequenceBoard.squaresByCard[card].orEmpty() + val open = squares.filter { state.chips[it] == NO_TEAM } + if (open.isEmpty()) { + // Dead card: both printed squares are taken. + if (state.options.deadCardExchange && !state.exchangedThisTurn) { + moves += ExchangeDeadCard(card) + } + } else { + open.forEach { moves += PlaceChip(card, it) } + } + } + } + } + return moves + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: SequenceState, seat: Int, move: SequenceMove): SequenceState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + return when (move) { + is PlaceChip -> applyPlace(state, seat, move) + is RemoveChip -> applyRemove(state, seat, move) + is ExchangeDeadCard -> applyExchange(state, seat, move) + } + } + + private fun applyPlace(state: SequenceState, seat: Int, move: PlaceChip): SequenceState { + val team = state.teamOf(seat) + val chips = state.chips.toMutableList() + chips[move.cell] = team + + val locked = state.locked.toMutableList() + val newSequences = claimSequences(chips, locked, move.cell, team) + + val sequences = state.sequencesByTeam.toMutableList() + sequences[team] = sequences[team] + newSequences + + val afterCard = discardAndDraw(state, seat, move.card) + val won = sequences[team] >= state.options.sequencesToWin + + return afterCard.copy( + chips = chips, + locked = locked, + sequencesByTeam = sequences, + lastPlacedCell = move.cell, + winner = if (won) team else null, + phase = if (won) SequencePhase.GAME_OVER else SequencePhase.PLAYING, + log = afterCard.log + buildString { + append("Seat $seat plays ${move.card.label}") + if (newSequences > 0) append(" — sequence!") + append(".") + }, + ).advanceTurn(skip = won) + } + + private fun applyRemove(state: SequenceState, seat: Int, move: RemoveChip): SequenceState { + val chips = state.chips.toMutableList() + chips[move.cell] = NO_TEAM + return discardAndDraw(state, seat, move.card) + .copy( + chips = chips, + log = state.log + "Seat $seat removes a chip with ${move.card.label}.", + ) + .advanceTurn(skip = false) + } + + /** Swapping a dead card costs the card but not the turn. */ + private fun applyExchange( + state: SequenceState, + seat: Int, + move: ExchangeDeadCard, + ): SequenceState = discardAndDraw(state, seat, move.card).copy( + exchangedThisTurn = true, + log = state.log + "Seat $seat swaps dead card ${move.card.label}.", + ) + + /** Moves the played card to the discard pile and replaces it from the deck. */ + private fun discardAndDraw( + state: SequenceState, + seat: Int, + card: Card, + ): SequenceState { + val hands = state.hands.toMutableList() + hands[seat] = hands[seat] - card + + var draw = state.drawPile + var discard = state.discardPile + + if (draw.isEmpty() && discard.isNotEmpty()) { + // Deck exhausted: the discards are shuffled back in and play + // continues. This happens before the card just played joins the + // pile, so a swapped dead card can never come straight back — + // which would otherwise let a player exchange the same card + // forever. + val random = Random(state.seed + state.log.size) + draw = discard.shuffledWith(random) + discard = emptyList() + } + discard = discard + card + + if (draw.isNotEmpty()) { + hands[seat] = hands[seat] + draw.first() + draw = draw.drop(1) + } + + return state.copy( + hands = hands, + handCounts = hands.map { it.size }, + drawPile = draw, + discardPile = discard, + ) + } + + private fun SequenceState.advanceTurn(skip: Boolean): SequenceState = + if (skip) this + else copy(turn = (turn + 1) % playerCount, exchangedThisTurn = false) + + // ----------------------------------------------------------------------- + // Sequence detection + // ----------------------------------------------------------------------- + + /** + * Counts and locks any runs of five completed by the chip just played at + * [cell]. + * + * Two sequences may share at most one chip, so a candidate run is only + * accepted when at most one of its squares is already locked. Corners are + * free squares that count for every team. + */ + private fun claimSequences( + chips: List, + locked: MutableList, + cell: Int, + team: Int, + ): Int { + fun ownedBy(c: Int): Boolean = SequenceBoard.isCorner(c) || chips[c] == team + + val row = SequenceBoard.rowOf(cell) + val col = SequenceBoard.colOf(cell) + var found = 0 + + for ((dr, dc) in DIRECTIONS) { + // Every window of five that includes the new chip. + for (offset in -(RUN_LENGTH - 1)..0) { + val window = (0 until RUN_LENGTH).map { step -> + val r = row + (offset + step) * dr + val c = col + (offset + step) * dc + if (r !in 0 until BOARD_SIZE || c !in 0 until BOARD_SIZE) return@map -1 + SequenceBoard.cellAt(r, c) + } + if (window.any { it < 0 }) continue + if (!window.all { ownedBy(it) }) continue + // Free corners are shared by everyone and never count as the + // already-locked chip for the shared-chip limit. + val lockedInWindow = window.count { locked[it] && !SequenceBoard.isCorner(it) } + if (lockedInWindow > 1) continue + + window.forEach { locked[it] = true } + found++ + break // At most one new sequence per direction. + } + } + return found + } + + // ----------------------------------------------------------------------- + // Results, redaction and wire format + // ----------------------------------------------------------------------- + + override fun isFinished(state: SequenceState): Boolean = + state.phase == SequencePhase.GAME_OVER + + override fun summary(state: SequenceState): String = when (val winner = state.winner) { + null -> state.sequencesByTeam + .mapIndexed { team, count -> "Team $team: $count" } + .joinToString(" ") + + else -> "Team $winner wins" + } + + override fun viewFor(state: SequenceState, seat: Int): SequenceState = state.copy( + hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, + handCounts = state.handCounts, + // The deck order is hidden information too. + drawPile = emptyList(), + ) + + override fun encodeState(state: SequenceState): String = json.encodeToString(state) + + override fun decodeState(json: String): SequenceState = this.json.decodeFromString(json) + + override fun encodeMove(move: SequenceMove): String = json.encodeToString(move) + + override fun decodeMove(json: String): SequenceMove = this.json.decodeFromString(json) +} diff --git a/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt b/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt new file mode 100644 index 0000000..a39d74e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt @@ -0,0 +1,176 @@ +package org.prolibertate.games.net + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +/** + * Bluetooth play over Classic RFCOMM. + * + * RFCOMM rather than BLE: this is a sustained, ordered, two-way stream of small + * messages, which is exactly what a serial profile is for. BLE would mean + * chunking everything into 20-byte GATT writes for no benefit. + * + * Discovery is limited to *paired* devices. Pair the two devices once in + * Android's Bluetooth settings and they will see each other here; scanning for + * unpaired devices needs a foreground scan plus location permission on older + * releases and is not worth the friction for a game lobby. + */ +class BluetoothTransport(private val context: Context) : Transport { + + override val kind: TransportKind = TransportKind.BLUETOOTH + + private val serviceUuid: UUID = UUID.fromString(RFCOMM_UUID) + + private val adapter: BluetoothAdapter? + get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + + private var serverSocket: BluetoothServerSocket? = null + + override fun isAvailable(): Boolean = adapter?.isEnabled == true && hasConnectPermission() + + /** + * Android 12 replaced the blanket BLUETOOTH permission with BLUETOOTH_CONNECT. + * Below 31 the manifest permission is granted at install time. + */ + fun hasConnectPermission(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.BLUETOOTH_CONNECT, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + // ----------------------------------------------------------------------- + // Hosting + // ----------------------------------------------------------------------- + + @SuppressLint("MissingPermission") + override fun host(displayName: String, scope: CoroutineScope): Flow = callbackFlow { + val bluetooth = adapter + if (bluetooth == null || !hasConnectPermission()) { + close() + return@callbackFlow + } + + val server = runCatching { + bluetooth.listenUsingRfcommWithServiceRecord(SERVICE_NAME, serviceUuid) + }.getOrNull() + if (server == null) { + close() + return@callbackFlow + } + serverSocket = server + + val acceptJob = scope.launch(Dispatchers.IO) { + while (isActive) { + val socket: BluetoothSocket = runCatching { server.accept() }.getOrNull() ?: break + val connection = StreamConnection( + peerId = runCatching { socket.remoteDevice?.address }.getOrNull() ?: "bt", + kind = TransportKind.BLUETOOTH, + input = socket.inputStream, + output = socket.outputStream, + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + trySend(connection) + } + } + + awaitClose { + acceptJob.cancel() + runCatching { server.close() } + serverSocket = null + } + } + + // ----------------------------------------------------------------------- + // Discovery + // ----------------------------------------------------------------------- + + /** + * Paired devices are re-published on a slow poll: there is no callback for + * "a device became reachable", and a host that is not listening simply + * refuses the connection when joined. + */ + @SuppressLint("MissingPermission") + override fun discover(scope: CoroutineScope): Flow> = callbackFlow { + if (!hasConnectPermission()) { + trySend(emptyList()) + close() + return@callbackFlow + } + + val pollJob = scope.launch { + while (isActive) { + val bonded = runCatching { adapter?.bondedDevices.orEmpty() }.getOrDefault(emptySet()) + trySend( + bonded.map { device -> + DiscoveredHost( + id = "bt:${device.address}", + name = runCatching { device.name }.getOrNull() ?: device.address, + kind = TransportKind.BLUETOOTH, + address = device.address, + ) + } + ) + delay(POLL_INTERVAL_MS) + } + } + + awaitClose { pollJob.cancel() } + } + + // ----------------------------------------------------------------------- + // Joining + // ----------------------------------------------------------------------- + + @SuppressLint("MissingPermission") + override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection = + withContext(Dispatchers.IO) { + val bluetooth = requireNotNull(adapter) { "No Bluetooth adapter" } + require(hasConnectPermission()) { "Bluetooth permission not granted" } + + val device = bluetooth.getRemoteDevice(host.address) + // Discovery is expensive and slows down a connect attempt sharply. + runCatching { bluetooth.cancelDiscovery() } + + val socket = device.createRfcommSocketToServiceRecord(serviceUuid) + socket.connect() + StreamConnection( + peerId = host.id, + kind = TransportKind.BLUETOOTH, + input = socket.inputStream, + output = socket.outputStream, + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + } + + override fun stop() { + runCatching { serverSocket?.close() } + serverSocket = null + } + + private companion object { + const val POLL_INTERVAL_MS = 4_000L + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/LanTransport.kt b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt new file mode 100644 index 0000000..506b550 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt @@ -0,0 +1,215 @@ +package org.prolibertate.games.net + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.Collections + +/** + * LAN play: mDNS service discovery to find the host, then a plain TCP socket + * per client. + * + * NSD is used rather than a UDP broadcast ping because it works across the + * Wi-Fi isolation settings most home routers ship with, and because Android + * gives us the resolver for free. + */ +class LanTransport(private val context: Context) : Transport { + + override val kind: TransportKind = TransportKind.LAN + + private val nsdManager: NsdManager + get() = context.getSystemService(Context.NSD_SERVICE) as NsdManager + + private var serverSocket: ServerSocket? = null + private var registrationListener: NsdManager.RegistrationListener? = null + private var discoveryListener: NsdManager.DiscoveryListener? = null + + override fun isAvailable(): Boolean = true + + // ----------------------------------------------------------------------- + // Hosting + // ----------------------------------------------------------------------- + + override fun host(displayName: String, scope: CoroutineScope): Flow = callbackFlow { + // Port 0 lets the OS pick a free port, which is then advertised. + val server = ServerSocket(0) + serverSocket = server + + val serviceInfo = NsdServiceInfo().apply { + serviceName = "$SERVICE_NAME @ $displayName" + serviceType = SERVICE_TYPE + port = server.localPort + } + val listener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(info: NsdServiceInfo) = Unit + override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) = Unit + override fun onServiceUnregistered(info: NsdServiceInfo) = Unit + override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) = Unit + } + registrationListener = listener + runCatching { + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener) + } + + val acceptJob = scope.launch(Dispatchers.IO) { + while (isActive && !server.isClosed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + socket.tcpNoDelay = true + val connection = StreamConnection( + peerId = socket.inetAddress?.hostAddress ?: "unknown", + kind = TransportKind.LAN, + input = socket.getInputStream(), + output = socket.getOutputStream(), + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + trySend(connection) + } + } + + awaitClose { + acceptJob.cancel() + stopAdvertising() + runCatching { server.close() } + } + } + + // ----------------------------------------------------------------------- + // Discovery + // ----------------------------------------------------------------------- + + override fun discover(scope: CoroutineScope): Flow> = callbackFlow { + val found = Collections.synchronizedMap(linkedMapOf()) + + fun publish() { + trySend(synchronized(found) { found.values.toList() }) + } + + // Resolutions are queued one at a time: NsdManager rejects concurrent + // resolve calls with FAILURE_ALREADY_ACTIVE on many devices. + val pending = ArrayDeque() + var resolving = false + + fun resolveNext() { + if (resolving) return + val next = synchronized(pending) { pending.removeFirstOrNull() } ?: return + resolving = true + val resolveListener = object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { + resolving = false + resolveNext() + } + + override fun onServiceResolved(info: NsdServiceInfo) { + val address = info.host?.hostAddress + if (address != null) { + val host = DiscoveredHost( + id = "lan:$address:${info.port}", + name = info.serviceName.substringAfter("@ ").trim() + .ifBlank { info.serviceName }, + kind = TransportKind.LAN, + address = address, + port = info.port, + ) + synchronized(found) { found[host.id] = host } + publish() + } + resolving = false + resolveNext() + } + } + @Suppress("DEPRECATION") + runCatching { nsdManager.resolveService(next, resolveListener) } + .onFailure { + resolving = false + resolveNext() + } + } + + val listener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) = Unit + + override fun onServiceFound(info: NsdServiceInfo) { + if (info.serviceType?.contains("plgames") != true) return + synchronized(pending) { pending.addLast(info) } + resolveNext() + } + + override fun onServiceLost(info: NsdServiceInfo) { + synchronized(found) { + val key = found.entries.firstOrNull { it.value.name == info.serviceName }?.key + if (key != null) found.remove(key) + } + publish() + } + + override fun onDiscoveryStopped(serviceType: String) = Unit + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + close() + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit + } + discoveryListener = listener + + publish() + runCatching { + nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + }.onFailure { close() } + + awaitClose { stopDiscovery() } + } + + // ----------------------------------------------------------------------- + // Joining + // ----------------------------------------------------------------------- + + override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection = + withContext(Dispatchers.IO) { + val socket = Socket() + socket.connect(InetSocketAddress(host.address, host.port), CONNECT_TIMEOUT_MS) + socket.tcpNoDelay = true + StreamConnection( + peerId = host.id, + kind = TransportKind.LAN, + input = socket.getInputStream(), + output = socket.getOutputStream(), + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + } + + // ----------------------------------------------------------------------- + + private fun stopAdvertising() { + registrationListener?.let { runCatching { nsdManager.unregisterService(it) } } + registrationListener = null + } + + private fun stopDiscovery() { + discoveryListener?.let { runCatching { nsdManager.stopServiceDiscovery(it) } } + discoveryListener = null + } + + override fun stop() { + stopAdvertising() + stopDiscovery() + runCatching { serverSocket?.close() } + serverSocket = null + } + + private companion object { + const val CONNECT_TIMEOUT_MS = 8_000 + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt new file mode 100644 index 0000000..6728a2f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -0,0 +1,262 @@ +package org.prolibertate.games.net + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * The pre-game lobby. + * + * The host advertises on every available transport at once, so a table can mix + * players arriving over Wi-Fi with players arriving over Bluetooth — from the + * seat list's point of view a peer is a peer. Seats start filled with AI and + * are handed over to people as they join, which is what makes "AI, humans, or + * any combination" fall out for free: whatever is still AI when the host + * starts, stays AI. + */ +class LobbyController( + context: Context, + private val scope: CoroutineScope, +) { + + data class State( + val hosting: Boolean = false, + val joining: Boolean = false, + val connected: Boolean = false, + val gameId: String? = null, + val optionsJson: String = "{}", + val hostName: String = "", + val seats: List = emptyList(), + val discovered: List = emptyList(), + val message: String? = null, + val started: TableConfig? = null, + ) + + private val lan = LanTransport(context) + private val bluetooth = BluetoothTransport(context) + + val transports: List = listOf(lan, bluetooth) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + /** Peer id -> live connection, for every seated remote player. */ + private val connections = linkedMapOf() + private var clientLink: Connection? = null + private val jobs = mutableListOf() + + val hostConnections: List get() = connections.values.toList() + val clientConnection: Connection? get() = clientLink + + fun availableTransports(): List = transports.filter { it.isAvailable() } + + // ----------------------------------------------------------------------- + // Hosting + // ----------------------------------------------------------------------- + + fun startHosting( + gameId: String, + optionsJson: String, + hostName: String, + seatCount: Int, + teamOf: (Int) -> Int, + ) { + stop() + val seats = (0 until seatCount).map { seat -> + PlayerSlot( + seat = seat, + name = if (seat == 0) hostName else "Computer $seat", + kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, + team = teamOf(seat), + ) + } + _state.value = State( + hosting = true, + gameId = gameId, + optionsJson = optionsJson, + hostName = hostName, + seats = seats, + message = "Waiting for players…", + ) + + for (transport in availableTransports()) { + jobs += scope.launch { + transport.host(hostName, scope).collect { connection -> + onClientConnected(connection) + } + } + } + } + + private fun onClientConnected(connection: Connection) { + jobs += scope.launch { + connection.incoming.collect { message -> + if (message !is Hello) return@collect + if (message.protocol != PROTOCOL_VERSION) { + connection.send( + Welcome( + hostName = _state.value.hostName, + gameId = _state.value.gameId.orEmpty(), + accepted = false, + reason = "Different app version — update both devices.", + ) + ) + connection.close() + return@collect + } + seatPlayer(connection, message) + } + } + } + + /** Gives an arriving player the first seat still held by the computer. */ + private suspend fun seatPlayer(connection: Connection, hello: Hello) { + val current = _state.value + val seats = current.seats.toMutableList() + val index = seats.indexOfFirst { it.kind == PlayerKind.AI } + if (index < 0) { + connection.send( + Welcome(current.hostName, current.gameId.orEmpty(), accepted = false, reason = "The table is full.") + ) + connection.close() + return + } + + seats[index] = seats[index].copy( + name = hello.displayName, + kind = PlayerKind.HUMAN_REMOTE, + peerId = connection.peerId, + ) + connections[connection.peerId] = connection + _state.value = current.copy( + seats = seats, + message = "${hello.displayName} joined over ${connection.kind.label}.", + ) + + connection.send(Welcome(current.hostName, current.gameId.orEmpty(), accepted = true)) + broadcastLobby() + } + + private suspend fun broadcastLobby() { + val current = _state.value + val update = LobbyUpdate( + gameId = current.gameId.orEmpty(), + seats = current.seats, + optionsJson = current.optionsJson, + hostName = current.hostName, + ) + connections.values.forEach { it.send(update) } + } + + /** Host: lock the table in and tell everyone to build the same match. */ + fun startMatch(seed: Long): TableConfig { + val current = _state.value + val config = TableConfig( + gameId = current.gameId.orEmpty(), + seats = current.seats, + optionsJson = current.optionsJson, + seed = seed, + ) + scope.launch { connections.values.forEach { it.send(StartGame(config)) } } + _state.value = current.copy(started = config) + return config + } + + /** Replaces a seat's occupant — used by the host to swap a person for AI. */ + fun setSeatKind(seat: Int, kind: PlayerKind, name: String? = null) { + val current = _state.value + val seats = current.seats.toMutableList() + if (seat !in seats.indices) return + seats[seat] = seats[seat].copy( + kind = kind, + name = name ?: seats[seat].name, + peerId = if (kind == PlayerKind.AI) null else seats[seat].peerId, + ) + _state.value = current.copy(seats = seats) + scope.launch { broadcastLobby() } + } + + // ----------------------------------------------------------------------- + // Joining + // ----------------------------------------------------------------------- + + fun startDiscovery() { + stop() + _state.value = State(joining = true, message = "Looking for games…") + for (transport in availableTransports()) { + jobs += scope.launch { + transport.discover(scope).collect { hosts -> + val others = _state.value.discovered.filter { it.kind != transport.kind } + _state.value = _state.value.copy(discovered = others + hosts) + } + } + } + } + + fun join(host: DiscoveredHost, displayName: String, peerId: String) { + jobs += scope.launch { + _state.value = _state.value.copy(message = "Connecting to ${host.name}…") + val transport = transports.firstOrNull { it.kind == host.kind } ?: return@launch + val connection = runCatching { transport.join(host, scope) }.getOrElse { error -> + _state.value = _state.value.copy(message = "Couldn't connect: ${error.message}") + return@launch + } + clientLink = connection + + launch { + connection.incoming.collect { message -> + when (message) { + is Welcome -> _state.value = if (message.accepted) { + _state.value.copy( + connected = true, + gameId = message.gameId, + hostName = message.hostName, + message = "Joined ${message.hostName}. Waiting for the host to start…", + ) + } else { + _state.value.copy(message = message.reason ?: "The host declined.") + } + + is LobbyUpdate -> _state.value = _state.value.copy( + seats = message.seats, + gameId = message.gameId, + optionsJson = message.optionsJson, + hostName = message.hostName, + ) + + is StartGame -> _state.value = _state.value.copy(started = message.config) + + is Bye -> _state.value = + _state.value.copy(connected = false, message = "The host left.") + + else -> Unit + } + } + } + + connection.send(Hello(peerId = peerId, displayName = displayName)) + } + } + + fun clearStarted() { + _state.value = _state.value.copy(started = null) + } + + fun stop() { + jobs.forEach { it.cancel() } + jobs.clear() + connections.values.forEach { runCatching { it.close() } } + connections.clear() + runCatching { clientLink?.close() } + clientLink = null + transports.forEach { runCatching { it.stop() } } + _state.value = State() + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt new file mode 100644 index 0000000..1ed9c2e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -0,0 +1,264 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.prolibertate.games.game.engine.GameAi +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.TableConfig + +/** + * Drives one match, in either of two roles. + * + * As HOST it owns the authoritative state: it validates every move (local, AI + * or remote), advances the game, and pushes each participant a view redacted + * for their seat. As CLIENT it holds only what the host has sent and turns + * local input into [MoveIntent]s. + * + * A purely offline game against AI is just a host with no connections, so + * there is only one code path to get right. + */ +class MatchController( + private val rules: GameRules, + private val ai: GameAi, + private val config: TableConfig, + private val scope: CoroutineScope, + val role: Role, + /** Seats played by a person on this device. */ + private val localSeats: Set, + /** The local player's primary seat, used to pick the view to render. */ + val primarySeat: Int, + /** + * Advances a state that nobody is waiting on — Euchre's gap between a + * scored hand and the next deal. Returning null means the game is over. + */ + private val advanceIdle: (S) -> S? = { null }, + /** Scaled by the animation-speed setting so the table paces itself. */ + private val aiThinkingMillis: () -> Long = { 700L }, +) { + + enum class Role { HOST, CLIENT } + + private val _state = MutableStateFlow(null) + + /** What the local player should see: redacted on the host, as-sent on a client. */ + val state: StateFlow = _state.asStateFlow() + + private val _legalMoves = MutableStateFlow>(emptyList()) + val legalMoves: StateFlow> = _legalMoves.asStateFlow() + + private val _finished = MutableStateFlow(false) + val finished: StateFlow = _finished.asStateFlow() + + private val _notice = MutableStateFlow(null) + val notice: StateFlow = _notice.asStateFlow() + + /** Host only: the true state. Clients never see this. */ + private var authoritative: S? = null + private val lock = Mutex() + private var sequence = 0 + + private val peers = mutableMapOf() + private var hostLink: Connection? = null + + private val seatKinds: Map = + config.seats.associate { it.seat to it.kind } + private val seatPeers: Map = + config.seats.associate { it.seat to it.peerId } + + // ----------------------------------------------------------------------- + // Start-up + // ----------------------------------------------------------------------- + + /** Host: begin the match and start pushing state to [clients]. */ + fun startAsHost(clients: List) { + check(role == Role.HOST) { "Not hosting" } + clients.forEach { connection -> + peers[connection.peerId] = connection + scope.launch { + connection.incoming.collect { message -> onHostMessage(connection, message) } + } + } + scope.launch { + lock.withLock { + authoritative = rules.initialState(config) + publish() + } + driveIdleSeats() + } + } + + /** Client: render whatever arrives from [link]. */ + fun startAsClient(link: Connection) { + check(role == Role.CLIENT) { "Not a client" } + hostLink = link + scope.launch { + link.incoming.collect { message -> + when (message) { + is StateSync -> { + _state.value = rules.decodeState(message.stateJson) + _legalMoves.value = message.legalMoves.map { rules.decodeMove(it) } + _finished.value = _state.value?.let { rules.isFinished(it) } ?: false + } + + is Rejected -> _notice.value = message.reason + is Bye -> _notice.value = "The host left the game." + else -> Unit + } + } + } + } + + // ----------------------------------------------------------------------- + // Input + // ----------------------------------------------------------------------- + + /** Called by the UI when the local player commits to a move. */ + fun submit(move: M) { + scope.launch { + when (role) { + Role.HOST -> applyIfLegal(primarySeatOnClock(), move) + Role.CLIENT -> hostLink?.send(MoveIntent(rules.encodeMove(move))) + } + } + } + + private fun primarySeatOnClock(): Int { + val current = authoritative?.let { rules.currentSeat(it) } + // With several local seats (pass-and-play or AI fill), the seat on the + // clock is the one submitting, provided it is actually ours. + return if (current != null && current in localSeats) current else primarySeat + } + + private suspend fun onHostMessage(connection: Connection, message: NetMessage) { + if (message !is MoveIntent) return + val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key + if (seat == null) { + connection.send(Rejected("You are not seated at this table.")) + return + } + val move = runCatching { rules.decodeMove(message.moveJson) }.getOrNull() + if (move == null) { + connection.send(Rejected("Unreadable move.")) + return + } + applyIfLegal(seat, move) + } + + /** + * The single choke point where a move becomes real. Everything — local + * taps, AI picks and anything arriving over the wire — lands here and is + * re-checked against the rules. + */ + private suspend fun applyIfLegal(seat: Int, move: M) { + lock.withLock { + val current = authoritative ?: return + if (rules.currentSeat(current) != seat) { + seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) } + return + } + if (move !in rules.legalMoves(current, seat)) { + seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) } + return + } + authoritative = rules.applyMove(current, seat, move) + publish() + } + driveIdleSeats() + } + + // ----------------------------------------------------------------------- + // Host loop + // ----------------------------------------------------------------------- + + /** + * Plays out every seat the humans are not responsible for, then advances + * through any state nobody is waiting on, until a person is on the clock. + */ + private suspend fun driveIdleSeats() { + while (true) { + val current = lock.withLock { authoritative } ?: return + if (rules.isFinished(current)) { + _finished.value = true + return + } + + val seat = rules.currentSeat(current) + if (seat == null) { + // Nobody on the clock: a hand has been scored, or the game ended. + delay(aiThinkingMillis() * 2) + val advanced = advanceIdle(current) + if (advanced == null) { + _finished.value = true + return + } + lock.withLock { + authoritative = advanced + publish() + } + continue + } + + if (seatKinds[seat] != PlayerKind.AI) return // a person's turn + + delay(aiThinkingMillis()) + val chosen = lock.withLock { + val live = authoritative ?: return + if (rules.currentSeat(live) != seat) return@withLock null + val legal = rules.legalMoves(live, seat) + if (legal.isEmpty()) return@withLock null + val move = ai.chooseMove(live, seat, legal) + authoritative = rules.applyMove(live, seat, move) + publish() + move + } + if (chosen == null) return + } + } + + /** Pushes the local view and every remote seat's view. Call under [lock]. */ + private suspend fun publish() { + val current = authoritative ?: return + sequence++ + + _state.value = rules.viewFor(current, primarySeat) + _legalMoves.value = if (rules.currentSeat(current) in localSeats) { + rules.legalMoves(current, rules.currentSeat(current)!!) + } else { + emptyList() + } + _finished.value = rules.isFinished(current) + + for (slot in config.seats) { + val peerId = slot.peerId ?: continue + val connection = peers[peerId] ?: continue + val seatView = rules.viewFor(current, slot.seat) + val moves = if (rules.currentSeat(current) == slot.seat) { + rules.legalMoves(current, slot.seat).map { rules.encodeMove(it) } + } else { + emptyList() + } + connection.send( + StateSync( + seq = sequence, + yourSeat = slot.seat, + stateJson = rules.encodeState(seatView), + legalMoves = moves, + ) + ) + } + } + + fun close() { + peers.values.forEach { runCatching { it.close() } } + peers.clear() + runCatching { hostLink?.close() } + hostLink = null + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/Protocol.kt b/app/src/main/java/org/prolibertate/games/net/Protocol.kt new file mode 100644 index 0000000..5fef50f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/Protocol.kt @@ -0,0 +1,92 @@ +package org.prolibertate.games.net + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * The wire protocol, shared by the LAN and Bluetooth transports. + * + * The host is authoritative: clients send [MoveIntent] and render whatever + * [StateSync] tells them. Nothing is trusted from a client beyond "I would like + * to make this move", which the host re-validates against the rules before it + * takes effect. Each client is sent a state redacted for its own seat, so an + * opponent's hand is never transmitted in the first place. + */ +const val PROTOCOL_VERSION = 1 + +/** Service type advertised over mDNS, and the RFCOMM service name. */ +const val SERVICE_TYPE = "_plgames._tcp" +const val SERVICE_NAME = "ProLibertateGames" + +/** Fixed RFCOMM UUID. Both ends must agree; this is ours. */ +const val RFCOMM_UUID = "8f1d5a20-3a4e-4b26-9d31-6c0d2f7a91b4" + +val protocolJson: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + classDiscriminator = "t" +} + +@Serializable +sealed interface NetMessage + +/** First message a joining client sends. */ +@Serializable +@SerialName("hello") +data class Hello( + val peerId: String, + val displayName: String, + val protocol: Int = PROTOCOL_VERSION, +) : NetMessage + +/** Host's reply, or a refusal if the lobby is full or the versions differ. */ +@Serializable +@SerialName("welcome") +data class Welcome( + val hostName: String, + val gameId: String, + val accepted: Boolean, + val reason: String? = null, +) : NetMessage + +/** Broadcast whenever the seat list or options change. */ +@Serializable +@SerialName("lobby") +data class LobbyUpdate( + val gameId: String, + val seats: List, + val optionsJson: String, + val hostName: String, +) : NetMessage + +/** Host starts the match; everyone builds the same initial state from this. */ +@Serializable +@SerialName("start") +data class StartGame(val config: TableConfig) : NetMessage + +/** A client's redacted view of the authoritative state. */ +@Serializable +@SerialName("state") +data class StateSync( + val seq: Int, + val yourSeat: Int, + val stateJson: String, + /** Encoded moves this seat may currently make. Empty when it is not its turn. */ + val legalMoves: List, +) : NetMessage + +/** A client asking to play a move. The host validates it. */ +@Serializable +@SerialName("intent") +data class MoveIntent(val moveJson: String) : NetMessage + +@Serializable +@SerialName("rejected") +data class Rejected(val reason: String) : NetMessage + +@Serializable +@SerialName("bye") +data object Bye : NetMessage diff --git a/app/src/main/java/org/prolibertate/games/net/Transport.kt b/app/src/main/java/org/prolibertate/games/net/Transport.kt new file mode 100644 index 0000000..8bf2523 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/Transport.kt @@ -0,0 +1,137 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.Closeable +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.atomic.AtomicBoolean + +enum class TransportKind(val label: String) { + LAN("Wi-Fi"), + BLUETOOTH("Bluetooth"), +} + +/** + * A host someone could join, found by one of the transports. [address] is + * transport-specific: an IP for LAN, a MAC for Bluetooth. + */ +data class DiscoveredHost( + val id: String, + val name: String, + val kind: TransportKind, + val address: String, + val port: Int = 0, +) + +/** + * One peer-to-peer link carrying [NetMessage]s. + * + * Both transports end up with a plain pair of streams, so the framing and + * serialisation live here once rather than twice. + */ +interface Connection : Closeable { + val peerId: String + val kind: TransportKind + val incoming: SharedFlow + suspend fun send(message: NetMessage) + val isOpen: Boolean +} + +/** + * Newline-delimited JSON over a stream pair. + * + * Line framing keeps this debuggable — you can watch a session with netcat — + * and JSON payloads never contain a raw newline, so the framing is safe. + */ +class StreamConnection( + override val peerId: String, + override val kind: TransportKind, + private val input: InputStream, + private val output: OutputStream, + private val scope: CoroutineScope, + private val onClosed: (StreamConnection) -> Unit = {}, +) : Connection { + + private val reader: BufferedReader = input.bufferedReader() + private val writer: BufferedWriter = output.bufferedWriter() + private val closed = AtomicBoolean(false) + + private val _incoming = MutableSharedFlow(replay = 0, extraBufferCapacity = 64) + override val incoming: SharedFlow = _incoming + + override val isOpen: Boolean get() = !closed.get() + + init { + scope.launch(Dispatchers.IO) { + try { + while (isActive && !closed.get()) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val message = runCatching { + protocolJson.decodeFromString(line) + }.getOrNull() ?: continue // ignore anything we can't parse + _incoming.emit(message) + } + } catch (_: Exception) { + // A dropped link is normal: someone walked out of range or + // closed the app. Fall through to close(). + } finally { + close() + } + } + } + + override suspend fun send(message: NetMessage) { + if (closed.get()) return + withContext(Dispatchers.IO) { + try { + writer.write(protocolJson.encodeToString(message)) + writer.newLine() + writer.flush() + } catch (_: Exception) { + close() + } + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + runCatching { reader.close() } + runCatching { writer.close() } + runCatching { input.close() } + runCatching { output.close() } + onClosed(this) + } +} + +/** + * Discovery and connection setup. Everything after the handshake is the same + * for both transports, which is what lets a lobby accept players over Wi-Fi and + * Bluetooth at the same time. + */ +interface Transport { + + val kind: TransportKind + + /** Whether the radio exists and is usable right now. */ + fun isAvailable(): Boolean + + /** Starts advertising and emits each client that connects. */ + fun host(displayName: String, scope: CoroutineScope): Flow + + /** Emits the current list of visible hosts as it changes. */ + fun discover(scope: CoroutineScope): Flow> + + suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection + + fun stop() +} diff --git a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt new file mode 100644 index 0000000..c27af5d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt @@ -0,0 +1,74 @@ +package org.prolibertate.games.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +/** + * User settings. + * + * [animationSpeed] is a multiplier rather than a duration so every animated + * thing in the app can scale off one number: 0.5x is slow and deliberate, 2x + * is for people who already know the rules. + */ +data class Settings( + val soundEnabled: Boolean = true, + val animationSpeed: Float = 1.0f, + val checkForUpdatesOnLaunch: Boolean = true, + val playerName: String = "Player", +) { + /** Scales a nominal duration by the chosen speed. */ + fun scaled(millis: Long): Long = + (millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong() + + companion object { + const val MIN_SPEED = 0.5f + const val MAX_SPEED = 2.0f + } +} + +class SettingsRepository(private val context: Context) { + + private object Keys { + val SOUND = booleanPreferencesKey("sound_enabled") + val SPEED = floatPreferencesKey("animation_speed") + val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch") + val NAME = stringPreferencesKey("player_name") + } + + val settings: Flow = context.dataStore.data.map { prefs -> + Settings( + soundEnabled = prefs[Keys.SOUND] ?: true, + animationSpeed = prefs[Keys.SPEED] ?: 1.0f, + checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, + playerName = prefs[Keys.NAME] ?: "Player", + ) + } + + suspend fun setSoundEnabled(enabled: Boolean) { + context.dataStore.edit { it[Keys.SOUND] = enabled } + } + + suspend fun setAnimationSpeed(speed: Float) { + context.dataStore.edit { + it[Keys.SPEED] = speed.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED) + } + } + + suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) { + context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled } + } + + suspend fun setPlayerName(name: String) { + context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt new file mode 100644 index 0000000..4abbe14 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -0,0 +1,275 @@ +package org.prolibertate.games.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.GameCategory +import org.prolibertate.games.game.GameDescriptor +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.settings.Settings +import org.prolibertate.games.settings.SettingsRepository +import org.prolibertate.games.update.Updater + +/** Everything the screens need, assembled once by the activity. */ +class AppEnv( + val settingsRepository: SettingsRepository, + val updater: Updater, + val lobby: LobbyController, + val peerId: String, + val requestBluetoothPermissions: () -> Unit, +) + +/** + * Where the app currently is. + * + * A hand-rolled stack rather than the navigation library: there are six + * destinations, two of which carry a live [TableConfig], and a typed sealed + * class is both smaller and harder to get wrong than argument encoding. + */ +sealed interface Route { + data object Menu : Route + data object Settings : Route + data class Setup(val gameId: String) : Route + data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route + data class Play( + val gameId: String, + val config: TableConfig, + val hosting: Boolean, + val localSeat: Int, + ) : Route +} + +@Composable +fun AppRoot(env: AppEnv) { + val settings by env.settingsRepository.settings.collectAsState(initial = Settings()) + var stack by remember { mutableStateOf(listOf(Route.Menu)) } + + fun push(route: Route) { + stack = stack + route + } + + fun pop() { + if (stack.size > 1) { + // Leaving a lobby or a table tears the networking down with it. + when (stack.last()) { + is Route.Lobby, is Route.Play -> env.lobby.stop() + else -> Unit + } + stack = stack.dropLast(1) + } + } + + BackHandler(enabled = stack.size > 1) { pop() } + + when (val route = stack.last()) { + is Route.Menu -> MainMenuScreen( + onPickGame = { push(Route.Setup(it.id)) }, + onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, + onSettings = { push(Route.Settings) }, + ) + + is Route.Settings -> SettingsScreen( + env = env, + settings = settings, + onBack = { pop() }, + ) + + is Route.Setup -> GameSetupScreen( + descriptor = GameCatalog.byId(route.gameId)!!, + playerName = settings.playerName, + onBack = { pop() }, + onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) }, + onHostOnline = { optionsJson -> + push(Route.Lobby(route.gameId, optionsJson, hosting = true)) + }, + ) + + is Route.Lobby -> LobbyScreen( + env = env, + route = route, + playerName = settings.playerName, + onBack = { pop() }, + onStart = { config, hosting, seat -> + push(Route.Play(config.gameId, config, hosting, seat)) + }, + ) + + is Route.Play -> PlayScreen( + env = env, + route = route, + settings = settings, + onExit = { pop() }, + ) + } +} + +// --------------------------------------------------------------------------- +// Main menu +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainMenuScreen( + onPickGame: (GameDescriptor) -> Unit, + onJoinGame: () -> Unit, + onSettings: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pro Libertate Games") }, + actions = { TextButton(onClick = onSettings) { Text("Settings") } }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") } + } + + // The column count follows the available width, so a phone in + // portrait gets two and a tablet in landscape gets four or more. + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 172.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + GameCategory.entries.forEach { category -> + item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + Text( + text = category.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + } + items(GameCatalog.byCategory(category)) { game -> + GameTile(game = game, onClick = { onPickGame(game) }) + } + } + } + } + } +} + +@Composable +private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { + Card( + onClick = onClick, + enabled = game.available, + colors = CardDefaults.cardColors( + containerColor = if (game.available) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = game.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = game.blurb, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = if (game.available) { + if (game.minPlayers == game.maxPlayers) { + "${game.minPlayers} players" + } else { + "${game.minPlayers}–${game.maxPlayers} players" + } + } else { + "Coming soon" + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + +// --------------------------------------------------------------------------- +// Shared chrome +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScreenScaffold( + title: String, + onBack: () -> Unit, + actions: @Composable () -> Unit = {}, + content: @Composable (Modifier) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { TextButton(onClick = onBack) { Text("Back") } }, + actions = { actions() }, + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.TopStart, + ) { + content(Modifier.fillMaxSize().padding(12.dp)) + } + } +} + +@Composable +fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) { + Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text(text) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt new file mode 100644 index 0000000..42fd5e3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -0,0 +1,240 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.GameDescriptor +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.game.euchre.EuchreOptions +import org.prolibertate.games.game.sequence.SequenceOptions + +private val setupJson = Json { encodeDefaults = true } + +/** + * Choose who is playing and which house rules apply. + * + * Rule variations are per game and live here rather than in a global settings + * screen, because they change what the engine does rather than how it looks. + */ +@Composable +fun GameSetupScreen( + descriptor: GameDescriptor, + playerName: String, + onBack: () -> Unit, + onPlayOffline: (TableConfig) -> Unit, + onHostOnline: (String) -> Unit, +) { + var euchre by remember { mutableStateOf(EuchreOptions()) } + var sequence by remember { mutableStateOf(SequenceOptions()) } + + val optionsJson = when (descriptor.id) { + GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) + GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence) + else -> "{}" + } + val seatCount = when (descriptor.id) { + GameCatalog.EUCHRE -> 4 + GameCatalog.SEQUENCE -> sequence.playerCount + else -> descriptor.minPlayers + } + + ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium) + + Divider() + Text("House rules", fontWeight = FontWeight.Bold) + + when (descriptor.id) { + GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it } + GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it } + else -> Text("No options yet for this game.") + } + + Divider() + Text("Opponents", fontWeight = FontWeight.Bold) + Text( + text = "Play now and every other seat is taken by the computer. Host a game " + + "instead and people can claim those seats as they join — whatever is " + + "still empty when you start stays computer-controlled.", + style = MaterialTheme.typography.bodySmall, + ) + + PrimaryAction(text = "Play against the computer") { + onPlayOffline( + offlineConfig(descriptor.id, optionsJson, seatCount, playerName) + ) + } + PrimaryAction(text = "Host a game for others to join") { + onHostOnline(optionsJson) + } + } + } +} + +/** Seat 0 is the local player; the rest start as AI. */ +private fun offlineConfig( + gameId: String, + optionsJson: String, + seatCount: Int, + playerName: String, +): TableConfig = TableConfig( + gameId = gameId, + seats = (0 until seatCount).map { seat -> + PlayerSlot( + seat = seat, + name = if (seat == 0) playerName else "Computer $seat", + kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, + team = teamForSeat(gameId, seat), + ) + }, + optionsJson = optionsJson, + // Seeded from the clock so consecutive games are not identical. + seed = System.currentTimeMillis(), +) + +fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { + // Partners sit opposite each other. + GameCatalog.EUCHRE -> seat % 2 + else -> seat % 2 +} + +@Composable +private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Deck", + values = listOf(24, 32), + selected = options.deckSize, + display = { "$it cards" }, + onSelect = { onChange(options.copy(deckSize = it)) }, + ) + ChipRow( + label = "Game to", + values = listOf(10, 11, 15), + selected = options.pointsToWin, + display = { "$it points" }, + onSelect = { onChange(options.copy(pointsToWin = it)) }, + ) + ToggleRow( + title = "Stick the dealer", + subtitle = "If everyone passes twice, the dealer must name trump", + checked = options.stickTheDealer, + onChange = { onChange(options.copy(stickTheDealer = it)) }, + ) + ToggleRow( + title = "Allow going alone", + subtitle = "A maker may sit their partner out for a bigger score", + checked = options.allowGoingAlone, + onChange = { onChange(options.copy(allowGoingAlone = it)) }, + ) + } +} + +@Composable +private fun SequenceOptionsEditor(options: SequenceOptions, onChange: (SequenceOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Teams", + values = listOf(2, 3), + selected = options.teamCount, + display = { "$it teams" }, + onSelect = { teams -> + // Three teams race to a single sequence; two teams need two. + onChange(options.copy(teamCount = teams, sequencesToWin = if (teams == 3) 1 else 2)) + }, + ) + ChipRow( + label = "Players per team", + values = listOf(1, 2, 3, 4), + selected = options.playersPerTeam, + display = { "$it" }, + onSelect = { onChange(options.copy(playersPerTeam = it)) }, + ) + ChipRow( + label = "Sequences to win", + values = listOf(1, 2, 3), + selected = options.sequencesToWin, + display = { "$it" }, + onSelect = { onChange(options.copy(sequencesToWin = it)) }, + ) + ToggleRow( + title = "Dead card exchange", + subtitle = "Swap a card whose squares are both already taken", + checked = options.deadCardExchange, + onChange = { onChange(options.copy(deadCardExchange = it)) }, + ) + Text( + text = "${options.playerCount} players in total.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun ChipRow( + label: String, + values: List, + selected: T, + display: (T) -> String, + onSelect: (T) -> Unit, +) { + Column { + Text(label, style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + values.forEach { value -> + FilterChip( + selected = value == selected, + onClick = { onSelect(value) }, + label = { Text(display(value)) }, + ) + } + } + } +} + +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text(title) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + } + Switch(checked = checked, onCheckedChange = onChange) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt new file mode 100644 index 0000000..1d0eb6c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -0,0 +1,187 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.net.DiscoveredHost +import org.prolibertate.games.net.LobbyController + +/** + * Hosting or joining a table. + * + * A host advertises on Wi-Fi and Bluetooth simultaneously, and the seat list + * does not care which one a given player arrived on. + */ +@Composable +fun LobbyScreen( + env: AppEnv, + route: Route.Lobby, + playerName: String, + onBack: () -> Unit, + onStart: (TableConfig, Boolean, Int) -> Unit, +) { + val state by env.lobby.state.collectAsState() + + LaunchedEffect(route) { + env.requestBluetoothPermissions() + if (route.hosting) { + val descriptor = GameCatalog.byId(route.gameId) + val seatCount = descriptor?.let { + if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers + } ?: 4 + env.lobby.startHosting( + gameId = route.gameId, + optionsJson = route.optionsJson, + hostName = playerName, + seatCount = seatCount, + teamOf = { seat -> teamForSeat(route.gameId, seat) }, + ) + } else { + env.lobby.startDiscovery() + } + } + + // Both roles leave for the table the moment the host commits. + LaunchedEffect(state.started) { + val config = state.started ?: return@LaunchedEffect + val seat = if (route.hosting) { + 0 + } else { + config.seats.firstOrNull { it.peerId == env.peerId }?.seat + ?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat + ?: 0 + } + env.lobby.clearStarted() + onStart(config, route.hosting, seat) + } + + val title = if (route.hosting) "Hosting" else "Join a game" + + ScreenScaffold(title = title, onBack = onBack) { modifier -> + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + val available = env.lobby.availableTransports() + Text( + text = if (available.isEmpty()) { + "No Wi-Fi or Bluetooth available. Turn one on to play with others." + } else { + (if (route.hosting) "Visible over " else "Searching over ") + + available.joinToString(" and ") { it.kind.label } + "." + }, + style = MaterialTheme.typography.bodySmall, + ) + + state.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + + Divider() + + if (route.hosting) { + HostingBody(env.lobby, state) + } else { + JoiningBody(env, state.discovered, playerName) + } + } + } +} + +@Composable +private fun HostingBody(lobby: LobbyController, state: LobbyController.State) { + Text("Seats", fontWeight = FontWeight.Bold) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items(state.seats) { slot -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Seat ${slot.seat} — ${slot.name}") + Text( + text = when (slot.kind) { + PlayerKind.HUMAN_LOCAL -> "You" + PlayerKind.HUMAN_REMOTE -> "Joined" + PlayerKind.AI -> "Computer" + } + " · Team ${slot.team}", + style = MaterialTheme.typography.bodySmall, + ) + } + if (slot.kind == PlayerKind.HUMAN_REMOTE) { + TextButton(onClick = { + lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}") + }) { Text("Drop") } + } + } + } + } + } + + PrimaryAction( + text = "Start game", + enabled = state.seats.isNotEmpty(), + ) { + lobby.startMatch(seed = System.currentTimeMillis()) + } + Text( + text = "Any seat still showing Computer will be played by the AI.", + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun JoiningBody( + env: AppEnv, + discovered: List, + playerName: String, +) { + if (discovered.isEmpty()) { + Text("No games found yet. Make sure the host has started theirs.") + Text( + text = "Bluetooth only shows devices already paired in Android's " + + "Bluetooth settings.", + style = MaterialTheme.typography.bodySmall, + ) + return + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(discovered) { host -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { env.lobby.join(host, playerName, env.peerId) } + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(host.name, fontWeight = FontWeight.Bold) + Text( + text = "over ${host.kind.label}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt new file mode 100644 index 0000000..aae16d7 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -0,0 +1,104 @@ +package org.prolibertate.games.ui + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.euchre.EuchreAi +import org.prolibertate.games.game.euchre.EuchreMove +import org.prolibertate.games.game.euchre.EuchrePhase +import org.prolibertate.games.game.euchre.EuchreRules +import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.sequence.SequenceAi +import org.prolibertate.games.game.sequence.SequenceMove +import org.prolibertate.games.game.sequence.SequenceRules +import org.prolibertate.games.game.sequence.SequenceState +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.settings.Settings +import org.prolibertate.games.ui.game.EuchreScreen +import org.prolibertate.games.ui.game.SequenceScreen + +/** + * Builds the match for the chosen game and hands it to that game's screen. + * + * Offline play and hosting are the same thing here — an offline game is simply + * a host with no connections — so there is one path through this code whether + * or not anyone else is at the table. + */ +@Composable +fun PlayScreen( + env: AppEnv, + route: Route.Play, + settings: Settings, + onExit: () -> Unit, +) { + val scope = rememberCoroutineScope() + val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT + + when (route.gameId) { + GameCatalog.EUCHRE -> { + val controller = remember(route) { + MatchController( + rules = EuchreRules, + ai = EuchreAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A scored hand pauses on screen, then the next one is dealt. + advanceIdle = { state -> + if (state.phase == EuchrePhase.HAND_OVER) { + EuchreRules.nextHand(state) + } else { + null + } + }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.SEQUENCE -> { + val controller = remember(route) { + MatchController( + rules = SequenceRules, + ai = SequenceAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + else -> Text("That game isn't playable yet.") + } +} + +@Composable +private fun StartMatch( + env: AppEnv, + controller: MatchController, + route: Route.Play, +) { + LaunchedEffect(controller) { + if (route.hosting) { + controller.startAsHost(env.lobby.hostConnections) + } else { + env.lobby.clientConnection?.let { controller.startAsClient(it) } + } + } + DisposableEffect(controller) { + onDispose { controller.close() } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt new file mode 100644 index 0000000..cd7a41c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -0,0 +1,180 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.prolibertate.games.settings.Settings +import org.prolibertate.games.update.Updater + +@Composable +fun SettingsScreen( + env: AppEnv, + settings: Settings, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var name by remember(settings.playerName) { mutableStateOf(settings.playerName) } + var updateStatus by remember { mutableStateOf(null) } + var pendingRelease by remember { mutableStateOf(null) } + + ScreenScaffold(title = "Settings", onBack = onBack) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingRow( + title = "Sound", + subtitle = "Card and chip effects", + ) { + Switch( + checked = settings.soundEnabled, + onCheckedChange = { enabled -> + scope.launch { env.settingsRepository.setSoundEnabled(enabled) } + }, + ) + } + + Divider() + + Column { + Text("Animation speed", fontWeight = FontWeight.Bold) + Text( + text = "${"%.1f".format(settings.animationSpeed)}× — " + + "how quickly cards move and the computer takes its turn", + style = MaterialTheme.typography.bodySmall, + ) + Slider( + value = settings.animationSpeed, + onValueChange = { speed -> + scope.launch { env.settingsRepository.setAnimationSpeed(speed) } + }, + valueRange = Settings.MIN_SPEED..Settings.MAX_SPEED, + // Half-step increments across 0.5x-2.0x. + steps = 5, + ) + } + + Divider() + + Column { + Text("Your name", fontWeight = FontWeight.Bold) + Text( + "Shown to other players in a lobby", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = name, + onValueChange = { entered -> + name = entered + scope.launch { env.settingsRepository.setPlayerName(entered) } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + + Divider() + + Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Text( + text = "Installed: v${env.updater.installedVersionName()}. Updates come from " + + "the latest release published on the project's main branch.", + style = MaterialTheme.typography.bodySmall, + ) + + SettingRow( + title = "Check on launch", + subtitle = "Look for a newer release when the app starts", + ) { + Switch( + checked = settings.checkForUpdatesOnLaunch, + onCheckedChange = { enabled -> + scope.launch { env.settingsRepository.setCheckForUpdatesOnLaunch(enabled) } + }, + ) + } + + PrimaryAction(text = "Check for updates now") { + scope.launch { + updateStatus = "Checking…" + when (val result = env.updater.check()) { + is Updater.Result.UpToDate -> { + updateStatus = "You're on the latest version." + pendingRelease = null + } + + is Updater.Result.Available -> { + pendingRelease = result.release + updateStatus = "Version ${result.release.tag} is available." + } + + is Updater.Result.Failed -> { + updateStatus = result.reason + pendingRelease = null + } + } + } + } + + pendingRelease?.let { release -> + PrimaryAction(text = "Download and install ${release.tag}") { + scope.launch { + if (env.updater.needsInstallPermission()) { + updateStatus = + "Allow installs from this app, then tap install again." + env.updater.requestInstallPermission() + } else { + updateStatus = "Downloading…" + val error = env.updater.downloadAndInstall(release) + updateStatus = error ?: "Handing over to the installer…" + } + } + } + } + + updateStatus?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun SettingRow( + title: String, + subtitle: String, + control: @Composable () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + } + control() + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/common/Components.kt b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt new file mode 100644 index 0000000..3808e66 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt @@ -0,0 +1,106 @@ +package org.prolibertate.games.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.ui.theme.CardBlack +import org.prolibertate.games.ui.theme.CardRed + +/** + * A playing card. + * + * Sized by width with a fixed 5:7 aspect ratio so the same composable works + * from a phone in portrait up to a tablet in landscape — callers pass a width + * derived from the space they actually have rather than a hard-coded dp. + */ +@Composable +fun PlayingCardView( + card: Card, + modifier: Modifier = Modifier, + width: Dp = 64.dp, + selected: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, +) { + val ink = if (card.suit.isRed) CardRed else CardBlack + Box( + modifier = modifier + .width(width) + .aspectRatio(CARD_ASPECT) + .clip(RoundedCornerShape(width * 0.12f)) + .background(Color.White) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else Color(0x33000000), + shape = RoundedCornerShape(width * 0.12f), + ) + .alpha(if (enabled) 1f else 0.45f) + .then( + if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = card.rank.short, + color = ink, + fontWeight = FontWeight.Bold, + fontSize = (width.value * 0.34f).sp, + ) + Text( + text = card.suit.symbol, + color = ink, + fontSize = (width.value * 0.34f).sp, + ) + } + } +} + +/** The back of a card, for hands you are not allowed to see. */ +@Composable +fun CardBackView( + modifier: Modifier = Modifier, + width: Dp = 64.dp, +) { + Box( + modifier = modifier + .width(width) + .aspectRatio(CARD_ASPECT) + .clip(RoundedCornerShape(width * 0.12f)) + .background(MaterialTheme.colorScheme.primary) + .border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(width * 0.12f) + .border(1.dp, Color(0x55FFFFFF), RoundedCornerShape(width * 0.06f)), + ) + } +} + +const val CARD_ASPECT = 5f / 7f diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt new file mode 100644 index 0000000..489b05b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -0,0 +1,282 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.euchre.CallTrump +import org.prolibertate.games.game.euchre.Discard +import org.prolibertate.games.game.euchre.EuchreMove +import org.prolibertate.games.game.euchre.EuchrePhase +import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.euchre.OrderUp +import org.prolibertate.games.game.euchre.Pass +import org.prolibertate.games.game.euchre.PlayCard +import org.prolibertate.games.game.euchre.teamOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView +import org.prolibertate.games.ui.theme.FeltGreenDark + +/** + * The Euchre table. + * + * Layout is driven by the space available rather than by orientation checks: + * card width is a fraction of the narrower dimension, so the same code lays out + * sensibly on a phone held either way and on a tablet. + */ +@Composable +fun EuchreScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val notice by controller.notice.collectAsState() + + val current = state + if (current == null) { + ScreenScaffold(title = "Euchre", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + } + return + } + + ScreenScaffold(title = "Euchre", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val compact = maxWidth < 600.dp + val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f) + .coerceIn(48.dp, 104.dp) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ScoreBar(current, localSeat) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(FeltGreenDark), + contentAlignment = Alignment.Center, + ) { + TrickArea(current, cardWidth) + } + + notice?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + + when (current.phase) { + EuchrePhase.BID_ROUND_1, + EuchrePhase.BID_ROUND_2, + -> BiddingControls(current, legal) { controller.submit(it) } + + EuchrePhase.HAND_OVER -> Text( + text = current.log.lastOrNull().orEmpty(), + style = MaterialTheme.typography.titleSmall, + ) + + EuchrePhase.GAME_OVER -> { + Text("Game over.", fontWeight = FontWeight.Bold) + PrimaryAction(text = "Back to the menu") { onExit() } + } + + else -> Unit + } + + HandRow( + state = current, + seat = localSeat, + legal = legal, + cardWidth = cardWidth, + onPlay = { controller.submit(it) }, + ) + } + } + } +} + +@Composable +private fun ScoreBar(state: EuchreState, localSeat: Int) { + val myTeam = teamOf(localSeat) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text("Us ${state.scores[myTeam]} — Them ${state.scores[1 - myTeam]}", + fontWeight = FontWeight.Bold) + val tricks = state.tricksWon.filterIndexed { seat, _ -> teamOf(seat) == myTeam }.sum() + Text("Tricks this hand: $tricks", style = MaterialTheme.typography.bodySmall) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = state.trump?.let { "Trump ${it.symbol}" } ?: "Bidding", + fontWeight = FontWeight.Bold, + ) + state.aloneSeat?.let { + Text("Seat $it is alone", style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + state.upCard?.let { up -> + Text("Turned up", color = MaterialTheme.colorScheme.onPrimary) + PlayingCardView(card = up, width = cardWidth) + } + if (state.trick.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.trick.forEach { played -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Seat ${played.seat}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + PlayingCardView(card = played.card, width = cardWidth) + } + } + } + } else if (state.upCard == null && state.phase == EuchrePhase.PLAYING) { + Text("Lead a card", color = MaterialTheme.colorScheme.onPrimary) + } + + // Opponents' hands are only ever card counts on this device. + Row( + modifier = Modifier.padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + state.handCounts.forEachIndexed { seat, count -> + if (seat != state.turn) return@forEachIndexed + repeat(count.coerceAtMost(5)) { + CardBackView(width = cardWidth * 0.4f) + } + } + } + } +} + +@Composable +private fun BiddingControls( + state: EuchreState, + legal: List, + onMove: (EuchreMove) -> Unit, +) { + if (legal.isEmpty()) { + Text("Waiting for seat ${state.turn}…") + return + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = if (state.phase == EuchrePhase.BID_ROUND_1) { + "Order up ${state.upCard?.label.orEmpty()}?" + } else { + "Name a suit" + }, + fontWeight = FontWeight.Bold, + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + legal.forEach { move -> + val label = when (move) { + is Pass -> "Pass" + is OrderUp -> if (move.alone) "Order up alone" else "Order up" + is CallTrump -> { + (if (move.alone) "Alone " else "") + move.suit.symbol + } + + is Discard -> "Discard ${move.card.label}" + is PlayCard -> move.card.label + } + if (move is Pass) { + OutlinedButton(onClick = { onMove(move) }) { Text(label) } + } else { + Button(onClick = { onMove(move) }) { Text(label) } + } + } + } + } +} + +@Composable +private fun HandRow( + state: EuchreState, + seat: Int, + legal: List, + cardWidth: androidx.compose.ui.unit.Dp, + onPlay: (EuchreMove) -> Unit, +) { + val hand = state.hands.getOrNull(seat).orEmpty() + val playable = legal.filterIsInstance().map { it.card }.toSet() + val discardable = legal.filterIsInstance().map { it.card }.toSet() + + Column { + Text( + text = when { + discardable.isNotEmpty() -> "Discard a card" + playable.isNotEmpty() -> "Your turn" + else -> "Your hand" + }, + style = MaterialTheme.typography.labelLarge, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (hand.isEmpty()) { + Box(modifier = Modifier.size(cardWidth)) { + Text("—", style = MaterialTheme.typography.bodySmall) + } + } + hand.forEach { card -> + val canPlay = card in playable + val canDiscard = card in discardable + PlayingCardView( + card = card, + width = cardWidth, + enabled = canPlay || canDiscard, + onClick = { + when { + canDiscard -> onPlay(Discard(card)) + canPlay -> onPlay(PlayCard(card)) + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt new file mode 100644 index 0000000..be00e6f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt @@ -0,0 +1,296 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.sequence.BOARD_SIZE +import org.prolibertate.games.game.sequence.ExchangeDeadCard +import org.prolibertate.games.game.sequence.NO_TEAM +import org.prolibertate.games.game.sequence.PlaceChip +import org.prolibertate.games.game.sequence.RemoveChip +import org.prolibertate.games.game.sequence.SequenceBoard +import org.prolibertate.games.game.sequence.SequenceMove +import org.prolibertate.games.game.sequence.SequenceState +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.TeamColours + +/** + * The Sequence board. + * + * The board is always square and always fits: its side is the smaller of the + * available width and the height left over after the hand, so rotating the + * device rescales rather than clipping. + */ +@Composable +fun SequenceScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + var selected by remember { mutableStateOf(null) } + + val current = state + if (current == null) { + ScreenScaffold(title = "Sequence", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + } + return + } + + // Squares the selected card could legally claim or clear. + val placements = legal.filterIsInstance().filter { it.card == selected } + val removals = legal.filterIsInstance().filter { it.card == selected } + val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet() + + ScreenScaffold(title = "Sequence", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val boardSide = if (wide) { + minOf(maxWidth * 0.62f, maxHeight) + } else { + minOf(maxWidth, maxHeight * 0.68f) + } + val cell = boardSide / BOARD_SIZE + + val board: @Composable () -> Unit = { + BoardGrid( + state = current, + cellSize = cell, + highlighted = highlighted, + onCellTap = { index -> + val placement = placements.firstOrNull { it.cell == index } + val removal = removals.firstOrNull { it.cell == index } + when { + placement != null -> { + controller.submit(placement) + selected = null + } + + removal != null -> { + controller.submit(removal) + selected = null + } + } + }, + ) + } + + val side: @Composable () -> Unit = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusPanel(current, localSeat) + HandStrip( + state = current, + seat = localSeat, + legal = legal, + selected = selected, + cardWidth = (cell * 1.4f).coerceIn(40.dp, 84.dp), + onSelect = { card -> selected = if (selected == card) null else card }, + onExchange = { controller.submit(it); selected = null }, + ) + if (controller.finished.collectAsState().value) { + PrimaryAction("Back to the menu") { onExit() } + } + } + } + + if (wide) { + Row(modifier = Modifier.fillMaxSize()) { + board() + Box(modifier = Modifier.weight(1f)) { side() } + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + board() + side() + } + } + } + } +} + +@Composable +private fun BoardGrid( + state: SequenceState, + cellSize: Dp, + highlighted: Set, + onCellTap: (Int) -> Unit, +) { + Column(modifier = Modifier.background(FeltGreenDark)) { + for (row in 0 until BOARD_SIZE) { + Row { + for (col in 0 until BOARD_SIZE) { + val index = SequenceBoard.cellAt(row, col) + BoardCell( + card = SequenceBoard.cells[index], + team = state.chips[index], + locked = state.locked[index], + highlighted = index in highlighted, + size = cellSize, + onClick = { onCellTap(index) }, + ) + } + } + } + } +} + +@Composable +private fun BoardCell( + card: Card?, + team: Int, + locked: Boolean, + highlighted: Boolean, + size: Dp, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(size) + .padding(0.5.dp) + .clip(RoundedCornerShape(size * 0.12f)) + .background(if (card == null) MaterialTheme.colorScheme.primary else Color.White) + .border( + width = if (highlighted) 2.dp else 0.5.dp, + color = if (highlighted) MaterialTheme.colorScheme.tertiary else Color(0x33000000), + shape = RoundedCornerShape(size * 0.12f), + ) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + if (card == null) { + // A free corner, wild for every team. + Text("★", fontSize = (size.value * 0.45f).sp, color = Color.White) + } else { + Text( + text = card.label, + fontSize = (size.value * 0.26f).sp, + color = if (card.suit.isRed) Color(0xFFB3261E) else Color.Black, + ) + } + if (team != NO_TEAM) { + Box( + modifier = Modifier + .size(size * 0.62f) + .clip(CircleShape) + .background(TeamColours.getOrElse(team) { Color.Gray }) + .border( + width = if (locked) 2.dp else 0.dp, + color = if (locked) Color.White else Color.Transparent, + shape = CircleShape, + ) + ) + } + } +} + +@Composable +private fun StatusPanel(state: SequenceState, localSeat: Int) { + val myTeam = state.teamOf(localSeat) + Column { + Text("You are team $myTeam", fontWeight = FontWeight.Bold) + state.sequencesByTeam.forEachIndexed { team, count -> + Text( + text = "Team $team: $count / ${state.options.sequencesToWin} sequences", + style = MaterialTheme.typography.bodySmall, + color = TeamColours.getOrElse(team) { Color.Gray }, + ) + } + state.winner?.let { + Text("Team $it wins!", fontWeight = FontWeight.Bold) + } ?: Text( + text = if (state.turn == localSeat) "Your turn" else "Seat ${state.turn} is playing…", + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun HandStrip( + state: SequenceState, + seat: Int, + legal: List, + selected: Card?, + cardWidth: Dp, + onSelect: (Card) -> Unit, + onExchange: (ExchangeDeadCard) -> Unit, +) { + val hand = state.hands.getOrNull(seat).orEmpty() + val playable = legal.mapNotNull { + when (it) { + is PlaceChip -> it.card + is RemoveChip -> it.card + is ExchangeDeadCard -> it.card + } + }.toSet() + val exchanges = legal.filterIsInstance().associateBy { it.card } + + Column { + Text("Your hand", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + hand.forEach { card -> + org.prolibertate.games.ui.common.PlayingCardView( + card = card, + width = cardWidth, + selected = card == selected, + enabled = card in playable, + onClick = { + val dead = exchanges[card] + if (dead != null) onExchange(dead) else onSelect(card) + }, + ) + } + } + selected?.let { + Text( + text = "Tap a highlighted square to play ${it.label}.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt new file mode 100644 index 0000000..45aa6a7 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt @@ -0,0 +1,50 @@ +package org.prolibertate.games.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** Card-table green, which the board and table surfaces are drawn on. */ +val FeltGreen = Color(0xFF1B5E20) +val FeltGreenDark = Color(0xFF0B3D12) +val Parchment = Color(0xFFF4EFE4) +val CardRed = Color(0xFFB3261E) +val CardBlack = Color(0xFF1C1B1F) + +val TeamColours: List = listOf( + Color(0xFF1565C0), // blue + Color(0xFFC62828), // red + Color(0xFF2E7D32), // green, only used with three teams +) + +private val DarkColours = darkColorScheme( + primary = Color(0xFF7BC67E), + onPrimary = Color(0xFF00390A), + secondary = Color(0xFFB9CCB4), + background = Color(0xFF10140F), + surface = Color(0xFF1A1F18), + surfaceVariant = Color(0xFF2A2F27), +) + +private val LightColours = lightColorScheme( + primary = FeltGreen, + onPrimary = Color.White, + secondary = Color(0xFF52634F), + background = Parchment, + surface = Color(0xFFFBF8F1), + surfaceVariant = Color(0xFFE0E4D8), +) + +@Composable +fun ProLibertateTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColours else LightColours, + content = content, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt new file mode 100644 index 0000000..028fc78 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -0,0 +1,127 @@ +package org.prolibertate.games.update + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * Over-the-air updates from the repository's GitHub Releases. + * + * Release tags are v1.0. and the APK's versionCode is that same run + * number, so comparing the two is enough to tell whether a newer build exists. + * The repository's releases must be publicly downloadable — no token is + * embedded in the app, and GitHub blocks anonymous access to private assets. + */ +class Updater(private val activity: Activity) { + + data class Release(val tag: String, val versionCode: Int, val apkUrl: String, val apkName: String) + + sealed interface Result { + data class Available(val release: Release) : Result + data object UpToDate : Result + data class Failed(val reason: String) : Result + } + + suspend fun check(): Result = withContext(Dispatchers.IO) { + try { + val latest = fetchLatestRelease() + ?: return@withContext Result.Failed("No published release found.") + if (latest.versionCode <= installedVersionCode()) { + Result.UpToDate + } else { + Result.Available(latest) + } + } catch (e: Exception) { + Result.Failed(e.message ?: "Update check failed.") + } + } + + private fun fetchLatestRelease(): Release? { + val url = URL("https://api.github.com/repos/$OWNER/$REPO/releases/latest") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("User-Agent", "ProLibertateGames-Updater") + connectTimeout = 15_000 + readTimeout = 15_000 + } + try { + if (connection.responseCode != 200) return null + val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) + val tag = json.getString("tag_name") + val versionCode = tag.substringAfterLast('.').toIntOrNull() ?: return null + val assets = json.getJSONArray("assets") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.getString("name") + if (name.endsWith(".apk")) { + return Release(tag, versionCode, asset.getString("browser_download_url"), name) + } + } + return null + } finally { + connection.disconnect() + } + } + + /** Downloads the APK and hands it to the system installer. */ + suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) { + try { + val target = File(activity.cacheDir, "update.apk") + URL(release.apkUrl).openStream().use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + withContext(Dispatchers.Main) { launchInstaller(target) } + null + } catch (e: Exception) { + e.message ?: "Download failed." + } + } + + /** API 26+ requires the user to allow installs from this app first. */ + fun needsInstallPermission(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !activity.packageManager.canRequestPackageInstalls() + + fun requestInstallPermission() { + runCatching { + activity.startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${activity.packageName}"), + ) + ) + } + } + + private fun launchInstaller(apk: File) { + if (activity.isFinishing) return + val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", apk) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { activity.startActivity(intent) } + } + + @Suppress("DEPRECATION") + fun installedVersionCode(): Int = + activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode + + fun installedVersionName(): String = + activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown" + + private companion object { + const val OWNER = "adrianoftyriel" + const val REPO = "ProLibertateGames" + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a1cb143 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5c8a39a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml new file mode 100644 index 0000000..8ed1d4d --- /dev/null +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml new file mode 100644 index 0000000..69e9235 --- /dev/null +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..48d3808 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #1B5E20 + #FF000000 + #FFFFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1eff924 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Pro Libertate Games + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bf1e235 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bb2112d --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt new file mode 100644 index 0000000..56b7e50 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt @@ -0,0 +1,392 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class EuchreRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: EuchreOptions = EuchreOptions(), seed: Long = 42L) = TableConfig( + gameId = "euchre", + seats = (0 until 4).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + // -- Deck --------------------------------------------------------------- + + @Test + fun `24 card deck runs nine to ace`() { + val deck = Decks.euchre(24) + assertEquals(24, deck.size) + assertEquals(24, deck.distinct().size) + assertTrue(deck.none { it.rank.order < Rank.NINE.order }) + } + + @Test + fun `32 card deck runs seven to ace`() { + val deck = Decks.euchre(32) + assertEquals(32, deck.size) + assertTrue(deck.any { it.rank == Rank.SEVEN }) + } + + // -- Bower ranking ------------------------------------------------------ + + @Test + fun `left bower counts as trump not its printed suit`() { + val trump = Suit.SPADES + val leftBower = Card(Rank.JACK, Suit.CLUBS) + assertTrue(isLeftBower(leftBower, trump)) + assertEquals(Suit.SPADES, effectiveSuit(leftBower, trump)) + assertTrue(isTrumpCard(leftBower, trump)) + } + + @Test + fun `right bower outranks left bower which outranks the ace of trump`() { + val trump = Suit.SPADES + val right = trickStrength(Card(Rank.JACK, Suit.SPADES), trump, trump) + val left = trickStrength(Card(Rank.JACK, Suit.CLUBS), trump, trump) + val aceTrump = trickStrength(Card(Rank.ACE, Suit.SPADES), trump, trump) + assertTrue("right > left", right > left) + assertTrue("left > ace of trump", left > aceTrump) + } + + @Test + fun `off suit card cannot win a trick`() { + val trump = Suit.SPADES + val ledSuit = Suit.HEARTS + val offSuit = trickStrength(Card(Rank.ACE, Suit.DIAMONDS), trump, ledSuit) + assertEquals(0, offSuit) + } + + // -- Following suit ----------------------------------------------------- + + private fun playingState( + hands: List>, + trump: Suit, + trick: List = emptyList(), + turn: Int = 0, + ) = EuchreState( + options = EuchreOptions(), + seed = 1L, + handNumber = 0, + dealer = 3, + phase = EuchrePhase.PLAYING, + hands = hands, + handCounts = hands.map { it.size }, + upCard = null, + turnedDown = null, + trump = trump, + maker = 0, + aloneSeat = null, + turn = turn, + passes = 0, + trick = trick, + leader = 0, + tricksWon = listOf(0, 0, 0, 0), + scores = listOf(0, 0), + lastTrickWinner = null, + log = emptyList(), + ) + + @Test + fun `left bower must be played when trump is led`() { + val trump = Suit.SPADES + val hand = listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.KING, Suit.HEARTS)) + val state = playingState( + hands = listOf(emptyList(), hand, emptyList(), emptyList()), + trump = trump, + trick = listOf(PlayedCard(0, Card(Rank.NINE, Suit.SPADES))), + turn = 1, + ) + val playable = EuchreRules.playableCards(state, 1) + assertEquals(listOf(Card(Rank.JACK, Suit.CLUBS)), playable) + } + + @Test + fun `left bower does not satisfy following its printed suit`() { + val trump = Suit.SPADES + // Clubs is led, and the only club held is the left bower — which is a + // spade for this hand, so the player is void in clubs and may play + // anything. + val hand = listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.KING, Suit.HEARTS)) + val state = playingState( + hands = listOf(emptyList(), hand, emptyList(), emptyList()), + trump = trump, + trick = listOf(PlayedCard(0, Card(Rank.NINE, Suit.CLUBS))), + turn = 1, + ) + assertEquals(hand.toSet(), EuchreRules.playableCards(state, 1).toSet()) + } + + // -- Bidding ------------------------------------------------------------ + + @Test + fun `ordering up sets trump and sends the dealer to discard`() { + val state = EuchreRules.initialState(config()) + val upSuit = state.upCard!!.suit + val next = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false)) + assertEquals(EuchrePhase.DEALER_DISCARD, next.phase) + assertEquals(upSuit, next.trump) + assertEquals(next.dealer, next.turn) + assertEquals(6, next.hands[next.dealer].size) + } + + @Test + fun `dealer discard returns the hand to five cards and starts play`() { + var state = EuchreRules.initialState(config()) + state = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false)) + val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard + state = EuchreRules.applyMove(state, state.dealer, discard) + assertEquals(EuchrePhase.PLAYING, state.phase) + assertEquals(5, state.hands[state.dealer].size) + assertEquals((state.dealer + 1) % 4, state.turn) + } + + @Test + fun `four passes turn the card down and open the second round`() { + var state = EuchreRules.initialState(config()) + val up = state.upCard!! + repeat(4) { state = EuchreRules.applyMove(state, state.turn, Pass) } + assertEquals(EuchrePhase.BID_ROUND_2, state.phase) + assertEquals(up, state.turnedDown) + assertTrue("turned-down suit cannot be named", + EuchreRules.legalMoves(state, state.turn) + .filterIsInstance() + .none { it.suit == up.suit }) + } + + @Test + fun `stick the dealer removes the dealers option to pass`() { + var state = EuchreRules.initialState(config(EuchreOptions(stickTheDealer = true))) + repeat(4) { state = EuchreRules.applyMove(state, state.turn, Pass) } + repeat(3) { state = EuchreRules.applyMove(state, state.turn, Pass) } + assertEquals(state.dealer, state.turn) + val moves = EuchreRules.legalMoves(state, state.dealer) + assertFalse("dealer must name a suit", moves.contains(Pass)) + assertTrue(moves.all { it is CallTrump }) + } + + @Test + fun `without stick the dealer a passed out hand is redealt`() { + var state = EuchreRules.initialState(config(EuchreOptions(stickTheDealer = false))) + val firstDealer = state.dealer + repeat(8) { state = EuchreRules.applyMove(state, state.turn, Pass) } + assertEquals(EuchrePhase.BID_ROUND_1, state.phase) + assertEquals((firstDealer + 1) % 4, state.dealer) + } + + // -- Going alone -------------------------------------------------------- + + @Test + fun `going alone sits the partner out`() { + var state = EuchreRules.initialState(config()) + val bidder = state.turn + state = EuchreRules.applyMove(state, bidder, OrderUp(alone = true)) + val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard + state = EuchreRules.applyMove(state, state.dealer, discard) + assertEquals(bidder, state.aloneSeat) + assertEquals(partnerOf(bidder), state.sittingOut) + assertEquals(3, state.activeSeatCount) + assertTrue(state.hands[partnerOf(bidder)].isEmpty()) + assertTrue("the sitting-out seat never gets the turn", state.turn != state.sittingOut) + } + + // -- Scoring ------------------------------------------------------------ + + /** Plays a hand out with a fixed strategy so scoring can be asserted. */ + private fun playOutHand(state: EuchreState, pick: (EuchreState, Int) -> EuchreMove): EuchreState { + var current = state + var guard = 0 + while (current.phase == EuchrePhase.PLAYING && guard++ < 100) { + val seat = EuchreRules.currentSeat(current)!! + current = EuchreRules.applyMove(current, seat, pick(current, seat)) + } + return current + } + + @Test + fun `taking all five tricks scores two, or four when alone`() { + // Team 0 holds every trump; team 1 cannot win a trick. + val trump = Suit.SPADES + val strong = listOf( + Card(Rank.JACK, Suit.SPADES), Card(Rank.JACK, Suit.CLUBS), + Card(Rank.ACE, Suit.SPADES), Card(Rank.KING, Suit.SPADES), + Card(Rank.QUEEN, Suit.SPADES), + ) + val weak0 = listOf( + Card(Rank.NINE, Suit.HEARTS), Card(Rank.TEN, Suit.HEARTS), + Card(Rank.QUEEN, Suit.HEARTS), Card(Rank.KING, Suit.HEARTS), + Card(Rank.ACE, Suit.HEARTS), + ) + val weak1 = listOf( + Card(Rank.NINE, Suit.DIAMONDS), Card(Rank.TEN, Suit.DIAMONDS), + Card(Rank.QUEEN, Suit.DIAMONDS), Card(Rank.KING, Suit.DIAMONDS), + Card(Rank.ACE, Suit.DIAMONDS), + ) + val weak2 = listOf( + Card(Rank.NINE, Suit.CLUBS), Card(Rank.TEN, Suit.CLUBS), + Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS), + Card(Rank.ACE, Suit.CLUBS), + ) + + val state = playingState( + hands = listOf(strong, weak0, weak1, weak2), + trump = trump, + ).copy(maker = 0, leader = 0, turn = 0) + + val finished = playOutHand(state) { s, seat -> + PlayCard(EuchreRules.playableCards(s, seat).first()) + } + assertEquals(5, finished.tricksWon[0]) + assertEquals(2, finished.scores[0]) + assertEquals(0, finished.scores[1]) + } + + @Test + fun `failing to take three tricks hands the defenders two points`() { + val trump = Suit.SPADES + // Seat 0 makes it but holds nothing; seat 1 holds all the trump. + val makerHand = listOf( + Card(Rank.NINE, Suit.HEARTS), Card(Rank.TEN, Suit.HEARTS), + Card(Rank.QUEEN, Suit.HEARTS), Card(Rank.KING, Suit.HEARTS), + Card(Rank.ACE, Suit.HEARTS), + ) + val trumpHand = listOf( + Card(Rank.JACK, Suit.SPADES), Card(Rank.JACK, Suit.CLUBS), + Card(Rank.ACE, Suit.SPADES), Card(Rank.KING, Suit.SPADES), + Card(Rank.QUEEN, Suit.SPADES), + ) + val filler1 = listOf( + Card(Rank.NINE, Suit.DIAMONDS), Card(Rank.TEN, Suit.DIAMONDS), + Card(Rank.QUEEN, Suit.DIAMONDS), Card(Rank.KING, Suit.DIAMONDS), + Card(Rank.ACE, Suit.DIAMONDS), + ) + val filler2 = listOf( + Card(Rank.NINE, Suit.CLUBS), Card(Rank.TEN, Suit.CLUBS), + Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS), + Card(Rank.ACE, Suit.CLUBS), + ) + + val state = playingState( + hands = listOf(makerHand, trumpHand, filler1, filler2), + trump = trump, + ).copy(maker = 0, leader = 0, turn = 0) + + // Seat 1 always trumps in; everyone else follows or throws off. + val finished = playOutHand(state) { s, seat -> + val playable = EuchreRules.playableCards(s, seat) + val card = if (seat == 1) { + playable.maxByOrNull { trumpStrength(it, trump) }!! + } else { + playable.first() + } + PlayCard(card) + } + assertEquals(EuchrePhase.HAND_OVER, finished.phase) + assertTrue("makers took fewer than three", finished.tricksWon[0] + finished.tricksWon[2] < 3) + assertEquals(2, finished.scores[1]) + assertEquals(0, finished.scores[0]) + } + + // -- Full games --------------------------------------------------------- + + @Test + fun `random legal play always reaches a winner`() { + repeat(25) { iteration -> + val random = Random(iteration.toLong()) + var state = EuchreRules.initialState(config(seed = iteration.toLong())) + var guard = 0 + while (!EuchreRules.isFinished(state) && guard++ < 5000) { + val seat = EuchreRules.currentSeat(state) + if (seat == null) { + state = EuchreRules.nextHand(state) + continue + } + val legal = EuchreRules.legalMoves(state, seat) + assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty()) + state = EuchreRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("game $iteration did not finish", EuchreRules.isFinished(state)) + assertTrue(state.scores.max() >= state.options.pointsToWin) + } + } + + @Test + fun `ai play always reaches a winner`() { + val ai = EuchreAi() + repeat(10) { iteration -> + var state = EuchreRules.initialState(config(seed = 500L + iteration)) + var guard = 0 + while (!EuchreRules.isFinished(state) && guard++ < 5000) { + val seat = EuchreRules.currentSeat(state) + if (seat == null) { + state = EuchreRules.nextHand(state) + continue + } + val legal = EuchreRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = EuchreRules.applyMove(state, seat, move) + } + assertTrue("ai game $iteration did not finish", EuchreRules.isFinished(state)) + } + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a seat view hides every other hand but keeps the counts`() { + val state = EuchreRules.initialState(config()) + val view = EuchreRules.viewFor(state, seat = 2) + assertEquals(state.hands[2], view.hands[2]) + assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() }) + assertEquals(listOf(5, 5, 5, 5), view.handCounts) + } + + @Test + fun `state and moves survive a round trip`() { + val state = EuchreRules.initialState(config()) + val decoded = EuchreRules.decodeState(EuchreRules.encodeState(state)) + assertEquals(state, decoded) + + val moves = listOf( + Pass, + OrderUp(alone = true), + CallTrump(Suit.HEARTS, alone = false), + Discard(Card(Rank.NINE, Suit.CLUBS)), + PlayCard(Card(Rank.ACE, Suit.SPADES)), + ) + moves.forEach { move -> + assertEquals(move, EuchreRules.decodeMove(EuchreRules.encodeMove(move))) + } + } + + @Test + fun `illegal moves are rejected`() { + val state = EuchreRules.initialState(config()) + val wrongSeat = (state.turn + 1) % 4 + var threw = false + try { + EuchreRules.applyMove(state, wrongSeat, Pass) + } catch (expected: IllegalArgumentException) { + threw = true + } + assertTrue("playing out of turn must be rejected", threw) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt new file mode 100644 index 0000000..844e5cb --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt @@ -0,0 +1,374 @@ +package org.prolibertate.games.game.sequence + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class SequenceRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: SequenceOptions = SequenceOptions(), + seed: Long = 7L, + ) = TableConfig( + gameId = "sequence", + seats = (0 until options.playerCount).map { + PlayerSlot( + seat = it, + name = "P$it", + kind = PlayerKind.AI, + team = it % options.teamCount, + ) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + // -- Board -------------------------------------------------------------- + + @Test + fun `board holds every non-jack card twice plus four free corners`() { + assertEquals(BOARD_CELLS, SequenceBoard.cells.size) + val cards = SequenceBoard.cells.filterNotNull() + assertEquals(96, cards.size) + assertEquals(4, SequenceBoard.cells.count { it == null }) + + val counts = cards.groupingBy { it }.eachCount() + assertEquals("48 distinct cards", 48, counts.size) + assertTrue("each appears exactly twice", counts.values.all { it == 2 }) + assertTrue("no jacks on the board", cards.none { it.rank == Rank.JACK }) + } + + @Test + fun `the four corners are the free squares`() { + listOf( + SequenceBoard.cellAt(0, 0), + SequenceBoard.cellAt(0, 9), + SequenceBoard.cellAt(9, 0), + SequenceBoard.cellAt(9, 9), + ).forEach { assertTrue(SequenceBoard.isCorner(it)) } + } + + @Test + fun `every non-jack card maps to exactly two squares`() { + val nonJacks = Suit.entries.flatMap { suit -> + Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) } + } + nonJacks.forEach { card -> + assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size) + } + } + + @Test + fun `jack classification splits one-eyed from two-eyed`() { + assertTrue(isTwoEyedJack(Card(Rank.JACK, Suit.DIAMONDS))) + assertTrue(isTwoEyedJack(Card(Rank.JACK, Suit.CLUBS))) + assertTrue(isOneEyedJack(Card(Rank.JACK, Suit.HEARTS))) + assertTrue(isOneEyedJack(Card(Rank.JACK, Suit.SPADES))) + assertFalse(isOneEyedJack(Card(Rank.ACE, Suit.SPADES))) + } + + // -- Dealing ------------------------------------------------------------ + + @Test + fun `hand sizes follow the player count`() { + assertEquals(7, handSizeFor(2)) + assertEquals(6, handSizeFor(3)) + assertEquals(6, handSizeFor(4)) + assertEquals(5, handSizeFor(6)) + assertEquals(4, handSizeFor(8)) + assertEquals(3, handSizeFor(12)) + } + + @Test + fun `initial deal gives everyone a hand from a 104 card deck`() { + val options = SequenceOptions(teamCount = 2, playersPerTeam = 2) + val state = SequenceRules.initialState(config(options)) + assertEquals(4, state.playerCount) + state.hands.forEach { assertEquals(6, it.size) } + assertEquals(104 - 4 * 6, state.drawPile.size) + assertTrue(state.chips.all { it == NO_TEAM }) + } + + // -- Placement ---------------------------------------------------------- + + @Test + fun `placing a chip claims the square and passes the turn`() { + var state = SequenceRules.initialState(config()) + val move = SequenceRules.legalMoves(state, 0).filterIsInstance().first() + state = SequenceRules.applyMove(state, 0, move) + assertEquals(state.teams[0], state.chips[move.cell]) + assertEquals(1, state.turn) + } + + @Test + fun `a card whose squares are both taken becomes a dead card`() { + val card = Card(Rank.ACE, Suit.HEARTS) + val squares = SequenceBoard.squaresByCard.getValue(card) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + squares.forEach { chips[it] = 1 } + + val state = baseState(chips = chips, hand0 = listOf(card)) + val moves = SequenceRules.legalMoves(state, 0) + assertTrue("no placement is possible", moves.none { it is PlaceChip }) + assertEquals(listOf(ExchangeDeadCard(card)), moves) + } + + @Test + fun `exchanging a dead card keeps the turn`() { + val card = Card(Rank.ACE, Suit.HEARTS) + val squares = SequenceBoard.squaresByCard.getValue(card) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + squares.forEach { chips[it] = 1 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, ExchangeDeadCard(card)) + assertEquals("turn does not pass", 0, state.turn) + assertTrue(state.exchangedThisTurn) + assertFalse("the dead card is gone", state.hands[0].contains(card)) + } + + // -- Jacks -------------------------------------------------------------- + + @Test + fun `a two-eyed jack can be placed on any empty square`() { + val jack = Card(Rank.JACK, Suit.DIAMONDS) + val state = baseState(hand0 = listOf(jack)) + val placements = SequenceRules.legalMoves(state, 0).filterIsInstance() + // Every square except the four corners is available. + assertEquals(BOARD_CELLS - 4, placements.size) + } + + @Test + fun `a one-eyed jack removes an opposing chip but not a locked one`() { + val jack = Card(Rank.JACK, Suit.SPADES) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + val target = SequenceBoard.cellAt(5, 5) + val protectedCell = SequenceBoard.cellAt(6, 6) + chips[target] = 1 + chips[protectedCell] = 1 + val locked = MutableList(BOARD_CELLS) { false } + locked[protectedCell] = true + + val state = baseState(chips = chips, locked = locked, hand0 = listOf(jack)) + val removals = SequenceRules.legalMoves(state, 0).filterIsInstance() + assertEquals(listOf(target), removals.map { it.cell }) + } + + @Test + fun `a one-eyed jack cannot remove your own teams chip`() { + val jack = Card(Rank.JACK, Suit.SPADES) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + chips[SequenceBoard.cellAt(4, 4)] = 0 // seat 0 is on team 0 + + val state = baseState(chips = chips, hand0 = listOf(jack)) + assertTrue(SequenceRules.legalMoves(state, 0).none { it is RemoveChip }) + } + + // -- Sequences ---------------------------------------------------------- + + @Test + fun `five in a row completes a sequence and locks the squares`() { + // Four chips already down in row 4; the card for the fifth is in hand. + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + + assertEquals(1, state.sequencesByTeam[0]) + (0..4).forEach { col -> + assertTrue("(4,$col) locked", state.locked[SequenceBoard.cellAt(4, col)]) + } + } + + @Test + fun `a corner counts as a free square for the run`() { + // Corner (0,0) plus (0,1)..(0,3) held, needing only (0,4). + val fifth = SequenceBoard.cellAt(0, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(1, 2, 3).forEach { chips[SequenceBoard.cellAt(0, it)] = 0 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertEquals("the free corner completed the five", 1, state.sequencesByTeam[0]) + } + + @Test + fun `a diagonal run counts`() { + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(it, it)] = 0 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertEquals(1, state.sequencesByTeam[0]) + } + + @Test + fun `an opposing chip breaks the run`() { + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 } + chips[SequenceBoard.cellAt(4, 2)] = 1 + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertEquals(0, state.sequencesByTeam[0]) + } + + @Test + fun `two sequences may share at most one chip`() { + // A full row of nine for team 0. Placing the tenth cannot manufacture + // an extra sequence out of heavily overlapping windows. + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + val locked = MutableList(BOARD_CELLS) { false } + (0..4).forEach { + chips[SequenceBoard.cellAt(6, it)] = 0 + locked[SequenceBoard.cellAt(6, it)] = true + } + (5..8).forEach { chips[SequenceBoard.cellAt(6, it)] = 0 } + + val fifth = SequenceBoard.cellAt(6, 9) + val card = SequenceBoard.cells[fifth]!! + var state = baseState(chips = chips, locked = locked, hand0 = listOf(card)) + .copy(sequencesByTeam = listOf(1, 0)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + + // Squares 5..9 contain no already-locked chip, so this is a clean second. + assertEquals(2, state.sequencesByTeam[0]) + } + + @Test + fun `reaching the sequence target wins the game`() { + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 } + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + + var state = baseState( + chips = chips, + hand0 = listOf(card), + options = SequenceOptions(teamCount = 2, sequencesToWin = 1), + ) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertTrue(SequenceRules.isFinished(state)) + assertEquals(0, state.winner) + assertEquals(SequencePhase.GAME_OVER, state.phase) + } + + // -- Full games --------------------------------------------------------- + + @Test + fun `random legal play terminates without an illegal state`() { + repeat(10) { iteration -> + val random = Random(iteration.toLong()) + var state = SequenceRules.initialState(config(seed = iteration.toLong())) + var guard = 0 + while (!SequenceRules.isFinished(state) && guard++ < 4000) { + val seat = SequenceRules.currentSeat(state) ?: break + val legal = SequenceRules.legalMoves(state, seat) + if (legal.isEmpty()) break // a blocked table is a legitimate draw + state = SequenceRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("ran away", guard < 4000) + } + } + + @Test + fun `ai play produces only legal moves and reaches a result`() { + val ai = SequenceAi() + repeat(5) { iteration -> + var state = SequenceRules.initialState( + config(SequenceOptions(teamCount = 2, playersPerTeam = 1), seed = 900L + iteration) + ) + var guard = 0 + while (!SequenceRules.isFinished(state) && guard++ < 4000) { + val seat = SequenceRules.currentSeat(state) ?: break + val legal = SequenceRules.legalMoves(state, seat) + if (legal.isEmpty()) break + val move = ai.chooseMove(state, seat, legal) + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = SequenceRules.applyMove(state, seat, move) + } + assertTrue("ai game ran away", guard < 4000) + } + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a seat view hides other hands and the deck order`() { + val state = SequenceRules.initialState(config(SequenceOptions(playersPerTeam = 2))) + val view = SequenceRules.viewFor(state, seat = 1) + assertEquals(state.hands[1], view.hands[1]) + assertTrue(view.hands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() }) + assertTrue("deck order is hidden", view.drawPile.isEmpty()) + assertEquals(state.handCounts, view.handCounts) + } + + @Test + fun `state and moves survive a round trip`() { + val state = SequenceRules.initialState(config()) + assertEquals(state, SequenceRules.decodeState(SequenceRules.encodeState(state))) + + val moves = listOf( + PlaceChip(Card(Rank.ACE, Suit.HEARTS), 12), + RemoveChip(Card(Rank.JACK, Suit.SPADES), 40), + ExchangeDeadCard(Card(Rank.TWO, Suit.CLUBS)), + ) + moves.forEach { assertEquals(it, SequenceRules.decodeMove(SequenceRules.encodeMove(it))) } + } + + @Test + fun `summary reports the running sequence count`() { + val state = SequenceRules.initialState(config()) + assertNotNull(SequenceRules.summary(state)) + } + + // -- Helpers ------------------------------------------------------------ + + private fun baseState( + chips: List = List(BOARD_CELLS) { NO_TEAM }, + locked: List = List(BOARD_CELLS) { false }, + hand0: List, + options: SequenceOptions = SequenceOptions(), + ): SequenceState { + val hands = listOf(hand0) + List(options.playerCount - 1) { emptyList() } + return SequenceState( + options = options, + seed = 1L, + teams = (0 until options.playerCount).map { it % options.teamCount }, + chips = chips, + locked = locked, + hands = hands, + handCounts = hands.map { it.size }, + drawPile = emptyList(), + discardPile = emptyList(), + turn = 0, + sequencesByTeam = List(options.teamCount) { 0 }, + winner = null, + phase = SequencePhase.PLAYING, + exchangedThisTurn = false, + lastPlacedCell = null, + log = emptyList(), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6c2cdd5 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file. Configuration common to all sub-projects goes here. +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false +} diff --git a/docs/RULES-euchre.md b/docs/RULES-euchre.md new file mode 100644 index 0000000..6abdf6e --- /dev/null +++ b/docs/RULES-euchre.md @@ -0,0 +1,81 @@ +# Euchre — implemented rules + +This is the ruleset the engine enforces, so you can check it against how you +actually play. Anything marked **toggle** is exposed in the game setup screen; +anything else is currently fixed and would need a code change. + +## Table + +- Four players, two fixed partnerships. Seats 0 and 2 are one team, seats 1 and + 3 are the other, so partners sit opposite each other. +- The deal rotates one seat clockwise after every hand. + +## Deck — *toggle* + +- **24 cards** (9, 10, J, Q, K, A in each suit). The standard game. +- **32 cards** (7 through A). Five cards are still dealt to each player and one + is still turned up, so the extra cards simply deepen the undealt remainder. + Some 32-card variants deal eight cards and play eight tricks; that is *not* + what this does. + +## Ranking + +With trump named, the order from the top is: + +1. **Right bower** — the jack of trump. +2. **Left bower** — the jack of the *same colour* as trump. It counts as a trump + card, not as a card of its printed suit. If spades are trump, the jack of + clubs is a spade for the whole hand: it must be played when spades are led, + and it does *not* satisfy following clubs. +3. A, K, Q, 10, 9 of trump. + +Non-trump suits rank A, K, Q, J, 10, 9. A card that is neither trump nor of the +led suit cannot win a trick. + +## Bidding + +**Round one.** Starting to the dealer's left, each player may order up the +turned card or pass. Ordering up makes that card's suit trump; the dealer takes +the card into hand and discards one card face down. + +**Round two.** If all four pass, the turn card is turned down and its suit may +not be named. Starting again to the dealer's left, each player may name any +other suit or pass. + +**Stick the dealer — *toggle*, on by default.** If the first three players pass +in round two, the dealer must name a suit. With the toggle off, a fourth pass +throws the hand in and it is redealt by the next dealer. + +**Going alone — *toggle*, on by default.** A maker may declare going alone when +ordering up or naming trump. Their partner sits the hand out entirely and plays +no cards; the hand is played three-handed. + +## Play + +- The player to the dealer's left leads to the first trick; after that the + winner of each trick leads to the next. +- You must follow the led suit if you hold it, remembering that the left bower + belongs to trump. Otherwise you may play anything. +- Five tricks are played. + +## Scoring + +Let the makers be the team that named trump. + +| Outcome | Points | +| --- | --- | +| Makers take 3 or 4 tricks | 1 to the makers | +| Makers take all 5 | 2 to the makers | +| Makers take all 5, playing alone | 4 to the makers | +| Makers take fewer than 3 (euchred) | 2 to the defenders | + +A lone hand that takes 3 or 4 tricks scores 1, the same as an ordinary make. + +**Game — *toggle*.** First team to **10** points (default), or 11, or 15. + +## Known simplifications + +- Cards are dealt five at a time rather than in the traditional 3–2 / 2–3 + packets. With a shuffled deck this changes nothing about the odds, but it does + mean the deal does not *look* like a real one. +- No defending alone, no farmer's hand, no notrump or misère bids. diff --git a/docs/RULES-sequence.md b/docs/RULES-sequence.md new file mode 100644 index 0000000..b09c36d --- /dev/null +++ b/docs/RULES-sequence.md @@ -0,0 +1,77 @@ +# Sequence — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is exposed +in the game setup screen. + +## ⚠️ The board layout is not the commercial one + +`SequenceBoard.LAYOUT` in `SequenceModel.kt` is a **generated** 10×10 layout, not +a reproduction of the printed Sequence board. It was generated because the real +arrangement could not be verified from here, and shipping a guessed one labelled +as authentic would have been worse than shipping an honest substitute. + +It satisfies every property the rules depend on: + +- each of the 48 non-jack cards appears exactly twice, +- the four corners are free squares, +- no card is adjacent to its own twin (including diagonally). + +So the game plays correctly — but square for square it will not match a physical +board, and two players using this app plus one physical board would disagree. +To fix that, replace the ten strings in `LAYOUT` with the real arrangement. +Nothing else needs to change; the parser, the tests and the AI all read from it. + +## Table + +- **Teams — *toggle*.** Two or three. Seats are dealt round-robin into teams, so + team-mates are never seated adjacently. +- **Players per team — *toggle*.** 1 to 4. +- **Sequences to win — *toggle*.** Defaults to 2 for two teams and 1 for three + teams, matching the usual rule, but can be set explicitly. + +## Cards + +Two full 52-card decks, shuffled together — 104 cards. + +Hand size shrinks as the table grows: + +| Players | Cards | +| --- | --- | +| 2 | 7 | +| 3–4 | 6 | +| 5–6 | 5 | +| 7–9 | 4 | +| 10+ | 3 | + +After playing a card you draw a replacement. If the draw pile runs out, the +discards are shuffled to form a new one. + +## Turn + +Play one card and place a chip on a matching empty square, then draw. + +- **Two-eyed jacks** (♦ and ♣) are wild: place a chip on any empty square. +- **One-eyed jacks** (♥ and ♠) remove one opposing chip — but never a chip that + is part of a completed sequence, and never your own team's chip. +- **Dead card — *toggle*, on by default.** If both squares printed with your card + are already occupied, you may discard it and draw a replacement. This does not + cost your turn, and you may do it once per turn. + +## Sequences + +A sequence is five chips in a row — horizontally, vertically or diagonally. + +- The **four corners are free squares** and count towards any team's sequence. +- Two sequences may **share at most one chip**. The engine enforces this by + refusing to count a run of five that contains more than one already-locked + square. +- Chips in a completed sequence are locked and cannot be removed by a one-eyed + jack. + +The first team to reach the sequence target wins immediately. + +## Known simplifications + +- A completely blocked table (no legal move for anyone) ends the game without a + winner rather than triggering a formal draw. +- The AI does not count cards or track which squares opponents can still reach. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..23ff864 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX +android.useAndroidX=true + +# Kotlin code style +kotlin.code.style=official + +# Only build the resources needed for the configuration being built. +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..86d7148 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ProLibertateGames" +include(":app") From 4ad0f5233167351b124044d160ef5d73301af455 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:50:15 +0000 Subject: [PATCH 3/5] Fix serialization import and quieten CI build logs - Transport.kt used the reified Json.encodeToString extension without importing kotlinx.serialization.encodeToString, so it resolved to the two-argument member overload and failed to compile. - Opt in to ExperimentalMaterial3Api for the clickable game tile. - Drop --stacktrace from the CI build. A one-line Kotlin error was being buried under 150 lines of Gradle internals, which made the actual message hard to find in the log. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .github/workflows/ci.yml | 6 +++--- app/src/main/java/org/prolibertate/games/net/Transport.kt | 1 + app/src/main/java/org/prolibertate/games/ui/AppRoot.kt | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3d235..2aa346c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,13 +38,13 @@ jobs: uses: android-actions/setup-android@v3 - name: Build debug APK - run: ./gradlew assembleDebug --no-daemon --stacktrace + run: ./gradlew assembleDebug --no-daemon - name: Run unit tests - run: ./gradlew testDebugUnitTest --no-daemon --stacktrace + run: ./gradlew testDebugUnitTest --no-daemon - name: Run Android lint - run: ./gradlew lintDebug --no-daemon --stacktrace + run: ./gradlew lintDebug --no-daemon - name: Upload debug APK uses: actions/upload-artifact@v4 diff --git a/app/src/main/java/org/prolibertate/games/net/Transport.kt b/app/src/main/java/org/prolibertate/games/net/Transport.kt index 8bf2523..6ba84d4 100644 --- a/app/src/main/java/org/prolibertate/games/net/Transport.kt +++ b/app/src/main/java/org/prolibertate/games/net/Transport.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString import java.io.BufferedReader import java.io.BufferedWriter import java.io.Closeable diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 4abbe14..dc17eff 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -192,6 +192,7 @@ fun MainMenuScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { Card( From 85ec10836b519551a0d16f0c9bbfd818946111aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:56:27 +0000 Subject: [PATCH 4/5] Request only the Bluetooth permission the app actually uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint flagged ACCESS_FINE_LOCATION declared without ACCESS_COARSE_LOCATION, which Android 12 requires to be requested together. Rather than add the missing one, drop location entirely: the Bluetooth transport only talks to already-paired devices, enumerating bonded devices and opening an RFCOMM socket. Neither is a scan, so no location access is needed. BLUETOOTH_SCAN and BLUETOOTH_ADVERTISE go for the same reason — nothing was using them. Discovery of unpaired devices would bring all of these back; the manifest notes what that would require. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- app/src/main/AndroidManifest.xml | 23 ++++++++----------- .../org/prolibertate/games/MainActivity.kt | 10 +++----- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d1e9b20..4e8e6f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,25 +10,22 @@ - + - - - - diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt index eb041c1..7d13af8 100644 --- a/app/src/main/java/org/prolibertate/games/MainActivity.kt +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -47,14 +47,10 @@ class MainActivity : ComponentActivity() { } private fun requestBluetoothPermissions() { + // Only CONNECT: the transport reaches paired devices and never scans. + // See the manifest for what discovery of unpaired devices would add. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - bluetoothPermissions.launch( - arrayOf( - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_ADVERTISE, - ) - ) + bluetoothPermissions.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT)) } } From 80ff1784376d86db8bb6d2d1c5b8c70fb85241de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:47:20 +0000 Subject: [PATCH 5/5] Pin OTA releases to main and stop dev builds outranking them Two ways the updater could have served or blocked the wrong build: - release.yml also triggers on workflow_dispatch, which can be aimed at any branch from the Actions UI. That would have built dev code and published it as a real Release, which every install would then offer to download. The job now refuses to run off anything but main. - versionCode came from GITHUB_RUN_NUMBER, but that counter is per workflow, so the dev pipeline had its own independent sequence. A debug APK installed from a CI artifact already carried a higher versionCode than the first several releases would, and the updater's check is latest <= installed, so those users would have been told they were up to date indefinitely. Only release.yml now sets PLG_RELEASE, and only that stamps a real version; everything else stays at 1.0.0-dev. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .github/workflows/release.yml | 9 +++++++++ README.md | 11 ++++++++++- app/build.gradle.kts | 26 ++++++++++++++++++++------ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2976f9..fed4e80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,16 @@ concurrency: jobs: build: name: Build APK and publish release + # workflow_dispatch can be aimed at any branch from the Actions UI. Releases + # are what the in-app updater installs, so only main may produce one — + # anything else is a no-op rather than shipping unreviewed code to everyone. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + env: + # Tells the build this is a real release, so the APK is stamped with the + # run number that matches the tag below. Absent everywhere else, which + # keeps dev and local builds at 1.0.0-dev. + PLG_RELEASE: "1" steps: - name: Checkout uses: actions/checkout@v4 diff --git a/README.md b/README.md index a85fc6a..8f68e4f 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,20 @@ 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`. + 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.`. The in-app updater reads that tag, which is why the APK's `versionCode` is the same run number. +**OTA updates follow `main`, and only `main`.** The updater asks GitHub for the +latest published Release; the release workflow is the only thing that creates +one, and it refuses to run off any branch but `main` — so a manual dispatch +aimed at `dev` does nothing rather than shipping unreviewed code to every +install. Only that workflow sets `PLG_RELEASE`, which is what stamps a real +`versionCode`; dev builds stay at `1.0.0-dev` so a side-loaded CI artifact can +never outrank a genuine release and suppress future updates. + 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d97dd7c..de1c796 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,10 +4,20 @@ plugins { id("org.jetbrains.kotlin.plugin.serialization") } -// On CI, GITHUB_RUN_NUMBER is set automatically and matches the release tag -// (v1.0.), so the shipped APK knows its own version and the -// in-app updater can tell whether a newer release exists. Locally it's a dev build. -val ciRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 +// Only the release workflow stamps a real version. It sets PLG_RELEASE, and its +// GITHUB_RUN_NUMBER is the same number as the release tag it publishes +// (v1.0.), so the shipped APK knows its own version and the in-app +// updater can compare against the latest published release. +// +// Everything else — a local build, or the dev CI pipeline — stays at 1.0.0-dev. +// GITHUB_RUN_NUMBER alone is NOT enough to key off: it counts per workflow, so +// the dev pipeline has its own independent sequence. A debug APK installed from +// a CI artifact would otherwise carry that counter as its versionCode, could +// outrank a genuine release, and would leave the updater reporting "you're on +// the latest version" indefinitely. +val isReleaseBuild = System.getenv("PLG_RELEASE") == "1" +val releaseRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 +val stampReleaseVersion = isReleaseBuild && releaseRunNumber > 0 android { namespace = "org.prolibertate.games" @@ -17,8 +27,12 @@ android { applicationId = "org.prolibertate.games" minSdk = 24 targetSdk = 34 - versionCode = if (ciRunNumber > 0) ciRunNumber else 1 - versionName = if (ciRunNumber > 0) "1.0.$ciRunNumber" else "1.0.0-dev" + // Dev builds sit at 1, below every release except the very first — + // a v1.0.1 release ties with a dev build and so is not offered as an + // update. Harmless, and preferable to versionCode 0, which Android + // documents as out of range. + versionCode = if (stampReleaseVersion) releaseRunNumber else 1 + versionName = if (stampReleaseVersion) "1.0.$releaseRunNumber" else "1.0.0-dev" } signingConfigs {