Merge dev: app shell with Euchre and Sequence, plus CI/release pipelines
Brings the first playable slice to main and, with it, the first run of the release pipeline: an APK published as v1.0.<run-number> for the in-app updater to pick up. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -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
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew testDebugUnitTest --no-daemon
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lintDebug --no-daemon
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
# 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
|
||||
|
||||
- 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."
|
||||
+44
@@ -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
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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`. Its APK artifact is a dev build, versioned
|
||||
`1.0.0-dev`.
|
||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||||
publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater
|
||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||
|
||||
**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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,110 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
// 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.<run-number>), 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"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.prolibertate.games"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
// 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 {
|
||||
// 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")
|
||||
}
|
||||
Binary file not shown.
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# The game has no reflection-based code, so the defaults are sufficient.
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<!-- Lets the app hand a downloaded APK to the system installer. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- Bluetooth. Android 12 (API 31) replaced the catch-all BLUETOOTH
|
||||
permission with explicit ones, so the legacy pair is capped below 31
|
||||
and BLUETOOTH_CONNECT is requested at runtime above it.
|
||||
|
||||
Only CONNECT is declared because the transport talks exclusively to
|
||||
devices that are already paired: it enumerates bonded devices and
|
||||
opens an RFCOMM socket, neither of which is a scan. Adding discovery
|
||||
of unpaired devices later would mean declaring BLUETOOTH_SCAN here
|
||||
(and, below API 31, both ACCESS_FINE_LOCATION and
|
||||
ACCESS_COARSE_LOCATION, which Android requires together). -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
tools:targetApi="s" />
|
||||
|
||||
<!-- Both radios are optional: the app is fully playable against AI without them. -->
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ProLibertateGames">
|
||||
|
||||
<!-- No fixed orientation: the UI adapts to portrait, landscape and
|
||||
tablet widths. configChanges keeps Compose state across rotation
|
||||
instead of tearing the activity down mid-hand. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Serves the downloaded APK to the package installer via a content URI. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,73 @@
|
||||
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() {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
@@ -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<GameDescriptor> = 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<GameDescriptor> =
|
||||
all.filter { it.category == category }
|
||||
}
|
||||
@@ -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<Card> =
|
||||
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<Card> {
|
||||
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<Card> = 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 <T> List<T>.shuffledWith(random: Random): List<T> = toMutableList().apply {
|
||||
for (i in indices.reversed()) {
|
||||
val j = random.nextInt(i + 1)
|
||||
val tmp = this[i]
|
||||
this[i] = this[j]
|
||||
this[j] = tmp
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerSlot>,
|
||||
/** 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<S : Any, M : Any> {
|
||||
|
||||
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<M>
|
||||
|
||||
/**
|
||||
* 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<S : Any, M : Any> {
|
||||
fun chooseMove(state: S, seat: Int, legal: List<M>): M
|
||||
}
|
||||
@@ -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<EuchreState, EuchreMove> {
|
||||
|
||||
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>): 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<Card>, 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>,
|
||||
): 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>,
|
||||
): EuchreMove {
|
||||
val hand = state.hands[seat]
|
||||
val callable = legal.filterIsInstance<CallTrump>()
|
||||
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>): EuchreMove {
|
||||
val trump = requireNotNull(state.trump)
|
||||
val discards = legal.filterIsInstance<Discard>()
|
||||
// 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>,
|
||||
): EuchreMove {
|
||||
val trump = requireNotNull(state.trump)
|
||||
val cards = legal.filterIsInstance<PlayCard>().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<Card>, 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<Card>, trump: Suit, ledSuit: Suit): Card =
|
||||
cards.minBy { trickStrength(it, trump, ledSuit).takeIf { s -> s > 0 } ?: it.rank.order }
|
||||
}
|
||||
@@ -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<List<Card>>,
|
||||
/** Card counts survive redaction so the table can still be drawn. */
|
||||
val handCounts: List<Int>,
|
||||
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<PlayedCard>,
|
||||
val leader: Int,
|
||||
val tricksWon: List<Int>,
|
||||
/** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */
|
||||
val scores: List<Int>,
|
||||
val lastTrickWinner: Int?,
|
||||
val log: List<String>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
@@ -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<EuchreState, EuchreMove> {
|
||||
|
||||
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<EuchreOptions>(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<Int>,
|
||||
log: List<String>,
|
||||
): 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<EuchreMove> {
|
||||
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<Card> {
|
||||
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)
|
||||
}
|
||||
@@ -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<SequenceState, SequenceMove> {
|
||||
|
||||
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>,
|
||||
): 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<ExchangeDeadCard>().firstOrNull()
|
||||
if (exchange != null && legal.none { it is PlaceChip }) return exchange
|
||||
|
||||
val best = legal
|
||||
.filterIsInstance<PlaceChip>()
|
||||
.maxByOrNull { scorePlacement(state, it, team) }
|
||||
|
||||
val removal = legal
|
||||
.filterIsInstance<RemoveChip>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<Card?> = 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<Card, List<Int>> = 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<Int>,
|
||||
/** Occupying team per square, or [NO_TEAM]. */
|
||||
val chips: List<Int>,
|
||||
/** Squares belonging to a completed sequence: safe from one-eyed jacks. */
|
||||
val locked: List<Boolean>,
|
||||
val hands: List<List<Card>>,
|
||||
val handCounts: List<Int>,
|
||||
val drawPile: List<Card>,
|
||||
val discardPile: List<Card>,
|
||||
val turn: Int,
|
||||
val sequencesByTeam: List<Int>,
|
||||
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<String>,
|
||||
) {
|
||||
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
|
||||
@@ -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<SequenceState, SequenceMove> {
|
||||
|
||||
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<SequenceOptions>(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<List<Card>>()
|
||||
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<SequenceMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
val team = state.teamOf(seat)
|
||||
val moves = mutableListOf<SequenceMove>()
|
||||
|
||||
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<Int>,
|
||||
locked: MutableList<Boolean>,
|
||||
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)
|
||||
}
|
||||
@@ -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<Connection> = 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<List<DiscoveredHost>> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<Connection> = 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<List<DiscoveredHost>> = callbackFlow {
|
||||
val found = Collections.synchronizedMap(linkedMapOf<String, DiscoveredHost>())
|
||||
|
||||
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<NsdServiceInfo>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerSlot> = emptyList(),
|
||||
val discovered: List<DiscoveredHost> = emptyList(),
|
||||
val message: String? = null,
|
||||
val started: TableConfig? = null,
|
||||
)
|
||||
|
||||
private val lan = LanTransport(context)
|
||||
private val bluetooth = BluetoothTransport(context)
|
||||
|
||||
val transports: List<Transport> = listOf(lan, bluetooth)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
/** Peer id -> live connection, for every seated remote player. */
|
||||
private val connections = linkedMapOf<String, Connection>()
|
||||
private var clientLink: Connection? = null
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
||||
val hostConnections: List<Connection> get() = connections.values.toList()
|
||||
val clientConnection: Connection? get() = clientLink
|
||||
|
||||
fun availableTransports(): List<Transport> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<S : Any, M : Any>(
|
||||
private val rules: GameRules<S, M>,
|
||||
private val ai: GameAi<S, M>,
|
||||
private val config: TableConfig,
|
||||
private val scope: CoroutineScope,
|
||||
val role: Role,
|
||||
/** Seats played by a person on this device. */
|
||||
private val localSeats: Set<Int>,
|
||||
/** 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<S?>(null)
|
||||
|
||||
/** What the local player should see: redacted on the host, as-sent on a client. */
|
||||
val state: StateFlow<S?> = _state.asStateFlow()
|
||||
|
||||
private val _legalMoves = MutableStateFlow<List<M>>(emptyList())
|
||||
val legalMoves: StateFlow<List<M>> = _legalMoves.asStateFlow()
|
||||
|
||||
private val _finished = MutableStateFlow(false)
|
||||
val finished: StateFlow<Boolean> = _finished.asStateFlow()
|
||||
|
||||
private val _notice = MutableStateFlow<String?>(null)
|
||||
val notice: StateFlow<String?> = _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<String, Connection>()
|
||||
private var hostLink: Connection? = null
|
||||
|
||||
private val seatKinds: Map<Int, PlayerKind> =
|
||||
config.seats.associate { it.seat to it.kind }
|
||||
private val seatPeers: Map<Int, String?> =
|
||||
config.seats.associate { it.seat to it.peerId }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Start-up
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Host: begin the match and start pushing state to [clients]. */
|
||||
fun startAsHost(clients: List<Connection>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerSlot>,
|
||||
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<String>,
|
||||
) : 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
|
||||
@@ -0,0 +1,138 @@
|
||||
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 kotlinx.serialization.encodeToString
|
||||
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<NetMessage>
|
||||
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<NetMessage>(replay = 0, extraBufferCapacity = 64)
|
||||
override val incoming: SharedFlow<NetMessage> = _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<NetMessage>(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<NetMessage>(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<Connection>
|
||||
|
||||
/** Emits the current list of visible hosts as it changes. */
|
||||
fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>>
|
||||
|
||||
suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection
|
||||
|
||||
fun stop()
|
||||
}
|
||||
@@ -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<Preferences> 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<Settings> = 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" } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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>(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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -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 <T> ChipRow(
|
||||
label: String,
|
||||
values: List<T>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<DiscoveredHost>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EuchreState, EuchreMove>(
|
||||
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<SequenceState, SequenceMove>(
|
||||
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 <S : Any, M : Any> StartMatch(
|
||||
env: AppEnv,
|
||||
controller: MatchController<S, M>,
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(null) }
|
||||
var pendingRelease by remember { mutableStateOf<Updater.Release?>(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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<EuchreState, EuchreMove>,
|
||||
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<EuchreMove>,
|
||||
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<EuchreMove>,
|
||||
cardWidth: androidx.compose.ui.unit.Dp,
|
||||
onPlay: (EuchreMove) -> Unit,
|
||||
) {
|
||||
val hand = state.hands.getOrNull(seat).orEmpty()
|
||||
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||
val discardable = legal.filterIsInstance<Discard>().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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SequenceState, SequenceMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
var selected by remember { mutableStateOf<Card?>(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<PlaceChip>().filter { it.card == selected }
|
||||
val removals = legal.filterIsInstance<RemoveChip>().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<Int>,
|
||||
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<SequenceMove>,
|
||||
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<ExchangeDeadCard>().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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Color> = 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,
|
||||
)
|
||||
}
|
||||
@@ -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.<run-number> 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Solid grass-green field. -->
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- A black hole in the center of the icon's safe zone. -->
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fallback round launcher icon for API < 26. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1B5E20</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Pro Libertate Games</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Compose supplies the real theme; this only needs to be a Material 3
|
||||
base with no action bar so the app draws its own top bars. -->
|
||||
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- The updater downloads the APK into the app's cache dir. -->
|
||||
<cache-path name="updates" path="." />
|
||||
</paths>
|
||||
@@ -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<List<Card>>,
|
||||
trump: Suit,
|
||||
trick: List<PlayedCard> = 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<CallTrump>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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<PlaceChip>().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<PlaceChip>()
|
||||
// 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<RemoveChip>()
|
||||
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<Int> = List(BOARD_CELLS) { NO_TEAM },
|
||||
locked: List<Boolean> = List(BOARD_CELLS) { false },
|
||||
hand0: List<Card>,
|
||||
options: SequenceOptions = SequenceOptions(),
|
||||
): SequenceState {
|
||||
val hands = listOf(hand0) + List(options.playerCount - 1) { emptyList<Card>() }
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user