Add app shell with Euchre and Sequence, LAN/Bluetooth play

First vertical slice: the whole shell plus two games playable end to end,
so every layer is exercised before the other nine games are built on it.

Games
- Euchre: bowers, ordering up, dealer discard, going alone, stick the
  dealer, and full scoring. Rule variants (deck size, points to win,
  stick the dealer, going alone) are toggles in game setup.
- Sequence: two-eyed and one-eyed jacks, free corners, locked sequences,
  the shared-chip limit, and dead-card exchange. Teams, players per team
  and the sequence target are toggles.
- The board layout in SequenceBoard is generated, not the commercial
  board — it holds every structural property the rules need, but does not
  match a physical board. Documented in docs/RULES-sequence.md.

Multiplayer
- One transport abstraction over newline-delimited JSON, implemented
  twice: LAN via NSD discovery and TCP, Bluetooth via Classic RFCOMM.
  A lobby advertises on both at once, so a table can mix them.
- Host-authoritative: clients send move intents, the host re-validates
  every one against the rules, and each client receives a state redacted
  for its own seat so hidden information never crosses the wire.
- Seats start as AI and are handed to people as they join, which is what
  makes any mix of humans and AI work without a separate code path.

Shell
- Main menu lists all eleven games; the nine without engines are marked
  coming soon and are driven entirely by GameCatalog.
- Settings: sound, animation speed (one multiplier drives card movement
  and AI thinking time), and OTA updates from the latest GitHub release.
- Layouts measure available space rather than assuming an orientation.

The game package is free of Android types, so the rules engines and AI
run under plain JUnit; 43 tests cover ranking, following suit, scoring,
sequence detection, redaction, wire round-trips and full random and AI
playthroughs.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-05 22:37:05 +00:00
parent 4312616efa
commit 487c68419e
54 changed files with 6487 additions and 0 deletions
@@ -0,0 +1,77 @@
package org.prolibertate.games
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
import org.prolibertate.games.update.Updater
import java.util.UUID
class MainActivity : ComponentActivity() {
private lateinit var env: AppEnv
/**
* Bluetooth needs runtime consent from Android 12 onwards. The result is
* ignored deliberately: refusing simply means the Bluetooth transport
* reports itself unavailable and the lobby falls back to Wi-Fi.
*/
private val bluetoothPermissions =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
env = AppEnv(
settingsRepository = SettingsRepository(applicationContext),
updater = Updater(this),
lobby = LobbyController(applicationContext, lifecycleScope),
peerId = stablePeerId(),
requestBluetoothPermissions = ::requestBluetoothPermissions,
)
setContent {
ProLibertateTheme {
AppRoot(env)
}
}
}
private fun requestBluetoothPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
bluetoothPermissions.launch(
arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
)
)
}
}
/**
* Identifies this device to a host for the length of an install. ANDROID_ID
* is per-app-signing-key and survives restarts, which is all a lobby needs
* to tell two phones apart.
*/
private fun stablePeerId(): String {
val androidId = runCatching {
AndroidSettings.Secure.getString(contentResolver, AndroidSettings.Secure.ANDROID_ID)
}.getOrNull()
return androidId ?: UUID.randomUUID().toString()
}
override fun onDestroy() {
super.onDestroy()
if (::env.isInitialized) env.lobby.stop()
}
}