From 08b12f135a98179004be879cb5f256872f2e992a Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:09:22 -0700 Subject: [PATCH 01/11] feat(android): scaffold compatible build --- mobile/android/settings.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 mobile/android/settings.gradle.kts diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..f366dd2 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ScaredSacred" +include(":app") From 9e4e95132bb1a686707c8042047b3f5c1fc732e3 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:09:29 -0700 Subject: [PATCH 02/11] feat(android): pin AGP and Chaquopy --- mobile/android/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 mobile/android/build.gradle.kts diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..d43e545 --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.2.1" apply false + id("com.chaquo.python") version "17.0.0" apply false +} From 915530785155f4a3472afb9c8b407443936f7cc6 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:09:35 -0700 Subject: [PATCH 03/11] feat(android): add lean Gradle defaults --- mobile/android/gradle.properties | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 mobile/android/gradle.properties diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..bebd99f --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +android.nonTransitiveRClass=true From fe2f6ce85a8923d80ec3b4e88c7ef323eebc2ade Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:09:48 -0700 Subject: [PATCH 04/11] feat(android): package tested Python engine and RevenueCat SDK --- mobile/android/app/build.gradle.kts | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 mobile/android/app/build.gradle.kts diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..1fc36c4 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("com.android.application") + id("com.chaquo.python") +} + +fun quoted(value: String): String = "\"" + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + "\"" + +val revenueCatApiKey = providers.gradleProperty("REVENUECAT_API_KEY") + .orElse(providers.environmentVariable("REVENUECAT_API_KEY")) + .getOrElse("") +val revenueCatEntitlement = providers.gradleProperty("REVENUECAT_ENTITLEMENT_ID") + .orElse(providers.environmentVariable("REVENUECAT_ENTITLEMENT_ID")) + .getOrElse("sacred_table") + +android { + namespace = "org.theinterdependency.scaredsacred" + compileSdk = 36 + + defaultConfig { + applicationId = "org.theinterdependency.scaredsacred" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + + buildConfigField("String", "REVENUECAT_API_KEY", quoted(revenueCatApiKey)) + buildConfigField("String", "REVENUECAT_ENTITLEMENT_ID", quoted(revenueCatEntitlement)) + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +chaquopy { + defaultConfig { + version = "3.12" + } + sourceSets { + getByName("main") { + srcDir("src/main/python") + srcDir("../../../engine") + srcDir("../../../render") + } + } +} + +dependencies { + implementation("com.revenuecat.purchases:purchases:10.15.1") +} From 9496e6188aa19316ef6d8933c0de318f5d17e255 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:10:00 -0700 Subject: [PATCH 05/11] feat(android): declare local game shell --- .../android/app/src/main/AndroidManifest.xml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 mobile/android/app/src/main/AndroidManifest.xml diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a8e9e31 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + From 6a0459f57f3a2056748f422a15342e83c24e5d64 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:10:09 -0700 Subject: [PATCH 06/11] feat(android): bridge WebView to existing Python table --- .../app/src/main/python/mobile_bridge.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 mobile/android/app/src/main/python/mobile_bridge.py diff --git a/mobile/android/app/src/main/python/mobile_bridge.py b/mobile/android/app/src/main/python/mobile_bridge.py new file mode 100644 index 0000000..1b893cc --- /dev/null +++ b/mobile/android/app/src/main/python/mobile_bridge.py @@ -0,0 +1,33 @@ +"""Android bridge for the existing POLITICS table server. + +The mobile shell does not fork game logic. It starts the same stdlib HTTP +table on loopback and lets Android own only lifecycle and store purchase UI. +""" + +import threading + +import serve + +_SERVER = None +_THREAD = None + + +def start_server(port=5300, seats=3): + global _SERVER, _THREAD + if _SERVER is not None: + return int(port) + + serve.TABLE = serve.Table(seats=int(seats)) + _SERVER = serve.ThreadingHTTPServer(("127.0.0.1", int(port)), serve.H) + _THREAD = threading.Thread(target=_SERVER.serve_forever, daemon=True) + _THREAD.start() + return int(port) + + +def stop_server(): + global _SERVER, _THREAD + if _SERVER is not None: + _SERVER.shutdown() + _SERVER.server_close() + _SERVER = None + _THREAD = None From 85cab01a9338944ab36a001344774fa82e82cdb8 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:11:04 -0700 Subject: [PATCH 07/11] feat(android): add native WebView and RevenueCat cosmetic entitlement --- .../scaredsacred/MainActivity.java | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java diff --git a/mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java b/mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java new file mode 100644 index 0000000..6be5044 --- /dev/null +++ b/mobile/android/app/src/main/java/org/theinterdependency/scaredsacred/MainActivity.java @@ -0,0 +1,247 @@ +package org.theinterdependency.scaredsacred; + +import android.app.Activity; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import com.revenuecat.purchases.CustomerInfo; +import com.revenuecat.purchases.EntitlementInfo; +import com.revenuecat.purchases.Offering; +import com.revenuecat.purchases.Offerings; +import com.revenuecat.purchases.Package; +import com.revenuecat.purchases.PurchaseParams; +import com.revenuecat.purchases.Purchases; +import com.revenuecat.purchases.PurchasesConfiguration; +import com.revenuecat.purchases.PurchasesError; +import com.revenuecat.purchases.interfaces.PurchaseCallback; +import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback; +import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback; +import com.revenuecat.purchases.models.StoreTransaction; + +import java.util.List; + +public final class MainActivity extends Activity { + private static final int GAME_PORT = 5300; + + private WebView webView; + private TextView purchaseStatus; + private Button purchaseButton; + private Button restoreButton; + private Package sacredTablePackage; + private boolean sacredTableOwned = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(buildUi()); + startGameServer(); + configureRevenueCat(); + } + + private View buildUi() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.BLACK); + + webView = new WebView(this); + webView.setBackgroundColor(Color.BLACK); + webView.getSettings().setJavaScriptEnabled(true); + webView.getSettings().setDomStorageEnabled(false); + webView.setWebViewClient(new WebViewClient() { + @Override + public void onPageFinished(WebView view, String url) { + applySacredTableSkin(); + } + }); + root.addView(webView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + + LinearLayout storeBar = new LinearLayout(this); + storeBar.setOrientation(LinearLayout.VERTICAL); + storeBar.setPadding(12, 8, 12, 12); + + purchaseStatus = new TextView(this); + purchaseStatus.setTextColor(Color.LTGRAY); + purchaseStatus.setText("SACRED TABLE: checking ownership"); + storeBar.addView(purchaseStatus); + + LinearLayout buttons = new LinearLayout(this); + buttons.setOrientation(LinearLayout.HORIZONTAL); + + purchaseButton = new Button(this); + purchaseButton.setText("UNLOCK SACRED TABLE"); + purchaseButton.setEnabled(false); + purchaseButton.setOnClickListener(v -> purchaseSacredTable()); + buttons.addView(purchaseButton, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + restoreButton = new Button(this); + restoreButton.setText("RESTORE"); + restoreButton.setEnabled(false); + restoreButton.setOnClickListener(v -> restorePurchases()); + buttons.addView(restoreButton, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + storeBar.addView(buttons); + root.addView(storeBar); + return root; + } + + private void startGameServer() { + new Thread(() -> { + try { + Python py = Python.getInstance(); + PyObject bridge = py.getModule("mobile_bridge"); + bridge.callAttr("start_server", GAME_PORT, 3); + runOnUiThread(() -> webView.loadUrl( + "http://127.0.0.1:" + GAME_PORT + "/")); + } catch (Exception error) { + runOnUiThread(() -> purchaseStatus.setText( + "GAME START FAILED: " + error.getClass().getSimpleName())); + } + }, "politics-python-start").start(); + } + + private void configureRevenueCat() { + String apiKey = BuildConfig.REVENUECAT_API_KEY; + if (apiKey == null || apiKey.isEmpty()) { + purchaseStatus.setText("SACRED TABLE: RevenueCat key not configured"); + return; + } + + Purchases.configure(new PurchasesConfiguration.Builder( + getApplicationContext(), apiKey).build()); + restoreButton.setEnabled(true); + refreshCustomerInfo(); + loadOffering(); + } + + private void refreshCustomerInfo() { + Purchases.getSharedInstance().getCustomerInfo(new ReceiveCustomerInfoCallback() { + @Override + public void onReceived(CustomerInfo customerInfo) { + setOwnership(customerInfo); + } + + @Override + public void onError(PurchasesError error) { + purchaseStatus.setText("SACRED TABLE: ownership unavailable"); + } + }); + } + + private void loadOffering() { + Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() { + @Override + public void onReceived(Offerings offerings) { + Offering current = offerings.getCurrent(); + List packages = current == null + ? null : current.getAvailablePackages(); + sacredTablePackage = (packages == null || packages.isEmpty()) + ? null : packages.get(0); + purchaseButton.setEnabled(!sacredTableOwned && sacredTablePackage != null); + if (!sacredTableOwned && sacredTablePackage == null) { + purchaseStatus.setText("SACRED TABLE: configure a RevenueCat offering"); + } + } + + @Override + public void onError(PurchasesError error) { + purchaseStatus.setText("SACRED TABLE: offering unavailable"); + } + }); + } + + private void purchaseSacredTable() { + if (sacredTablePackage == null || sacredTableOwned) { + return; + } + purchaseButton.setEnabled(false); + PurchaseParams params = new PurchaseParams.Builder( + this, sacredTablePackage).build(); + Purchases.getSharedInstance().purchase(params, new PurchaseCallback() { + @Override + public void onCompleted(StoreTransaction storeTransaction, + CustomerInfo customerInfo) { + setOwnership(customerInfo); + } + + @Override + public void onError(PurchasesError error, boolean userCancelled) { + purchaseButton.setEnabled(!userCancelled && sacredTablePackage != null); + if (userCancelled) { + purchaseStatus.setText("SACRED TABLE: purchase cancelled"); + purchaseButton.setEnabled(true); + } else { + purchaseStatus.setText("SACRED TABLE: purchase failed"); + purchaseButton.setEnabled(true); + } + } + }); + } + + private void restorePurchases() { + restoreButton.setEnabled(false); + Purchases.getSharedInstance().restorePurchases(new ReceiveCustomerInfoCallback() { + @Override + public void onReceived(CustomerInfo customerInfo) { + restoreButton.setEnabled(true); + setOwnership(customerInfo); + } + + @Override + public void onError(PurchasesError error) { + restoreButton.setEnabled(true); + purchaseStatus.setText("SACRED TABLE: restore failed"); + } + }); + } + + private void setOwnership(CustomerInfo customerInfo) { + EntitlementInfo entitlement = customerInfo.getEntitlements() + .get(BuildConfig.REVENUECAT_ENTITLEMENT_ID); + sacredTableOwned = entitlement != null && entitlement.isActive(); + if (sacredTableOwned) { + purchaseStatus.setText("SACRED TABLE: owned permanently"); + purchaseButton.setText("SACRED TABLE OWNED"); + purchaseButton.setEnabled(false); + } else { + purchaseStatus.setText("SACRED TABLE: optional cosmetic unlock"); + purchaseButton.setText("UNLOCK SACRED TABLE"); + purchaseButton.setEnabled(sacredTablePackage != null); + } + applySacredTableSkin(); + } + + private void applySacredTableSkin() { + if (webView == null || !sacredTableOwned) { + return; + } + String js = "(() => {" + + "document.body.style.background='#160d18';" + + "document.body.style.color='#f1e8d2';" + + "document.querySelectorAll('.card,.st').forEach(e=>{" + + "e.style.borderColor='#b99b63';e.style.background='#201522';});" + + "document.querySelectorAll('#tracks,#identity,#reaction').forEach(e=>" + + "e.style.letterSpacing='0.04em');" + + "})()"; + webView.evaluateJavascript(js, null); + } + + @Override + protected void onDestroy() { + if (webView != null) { + webView.destroy(); + } + super.onDestroy(); + } +} From 99f0c4ac2dd8e5ca274c4339475a9e8711db5e73 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:11:33 -0700 Subject: [PATCH 08/11] docs(android): define Shipaton store boundary --- mobile/android/README.md | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 mobile/android/README.md diff --git a/mobile/android/README.md b/mobile/android/README.md new file mode 100644 index 0000000..ffb07f5 --- /dev/null +++ b/mobile/android/README.md @@ -0,0 +1,56 @@ +# SCARED SACRED Android shell + +This module wraps the existing tested Python game rather than rewriting it. +Chaquopy packages `engine/` and `render/` directly; `mobile_bridge.py` starts +the same table server on `127.0.0.1`, and the Android WebView is only a shell. + +## Store boundary + +RevenueCat exists here to satisfy a real product boundary without selling game +power, Witness Accounts, or access to the ruleset. + +- entitlement: `sacred_table` by default +- product: configure one Google Play one-time product as **non-consumable** +- offering: place that product in the current RevenueCat offering +- effect: permanent cosmetic Sacred Table skin only +- restore: exposed in the native shell + +Witness Accounts remain giver-owned, table-bound, and unsellable per base canon. +All playable mechanics remain available without purchase. + +## Build + +The build intentionally pins the newest combination currently claimed compatible +by both Android and Chaquopy rather than using incompatible latest versions: + +- Android Gradle Plugin 9.2.1 +- Gradle 9.4.1 +- Chaquopy 17.0.0 +- Python 3.12 +- compile/target API 36 +- RevenueCat Android SDK 10.15.1 + +From `mobile/android`: + +```sh +gradle :app:assembleDebug +``` + +The public RevenueCat SDK key is injected at build time and is not committed: + +```sh +gradle :app:assembleDebug \ + -PREVENUECAT_API_KEY=goog_xxx \ + -PREVENUECAT_ENTITLEMENT_ID=sacred_table +``` + +With no key, the game still builds and runs; the purchase controls remain +visibly unconfigured rather than pretending monetization works. + +## Store release boundary + +A Shipaton-eligible release still requires the external store configuration: +Google Play app, one-time product, RevenueCat project/offering/entitlement, +signed AAB, store listing, judge access, icon, screenshot, and demo video. + +hmmm — commerce belongs around the table, not between a witness and the room. From 03462beac0fafc4036a3fd620b89c390ad72f085 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:12:00 -0700 Subject: [PATCH 09/11] ci: move repository gate to checkout v6 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c562ec8..f9a860b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.12' From b0bd1564cfc3c83535cf45728faa51c9927787f4 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:12:11 -0700 Subject: [PATCH 10/11] ci(android): build Shipaton shell on PR --- .github/workflows/android.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..b694b2f --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,40 @@ +name: android-build + +on: + pull_request: + paths: + - 'mobile/android/**' + - 'engine/**' + - 'render/**' + - '.github/workflows/android.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + assemble-debug: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: gradle/actions/setup-gradle@v6 + with: + gradle-version: '9.4.1' + - name: Install Android 16 SDK + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager 'platforms;android-36' 'build-tools;36.0.0' + - name: Assemble debug APK + working-directory: mobile/android + run: gradle --no-daemon :app:assembleDebug From fdab4f567584ebd4b750c442a9783bc57c2bd02e Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Sun, 6 Sep 2026 12:13:43 -0700 Subject: [PATCH 11/11] ci(android): provision SDK with Node 24 setup action --- .github/workflows/android.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b694b2f..5d38745 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,13 +28,12 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.12' + - uses: android-actions/setup-android@v4 + with: + packages: 'platform-tools platforms;android-36 build-tools;36.0.0' - uses: gradle/actions/setup-gradle@v6 with: gradle-version: '9.4.1' - - name: Install Android 16 SDK - run: | - yes | sdkmanager --licenses >/dev/null - sdkmanager 'platforms;android-36' 'build-tools;36.0.0' - name: Assemble debug APK working-directory: mobile/android run: gradle --no-daemon :app:assembleDebug