# The Last Train to Hoshikawa — Offline Android VN Architecture

## 1. Decision summary

- **Native shell:** one Kotlin `MainActivity` containing one hardened Android `WebView`.
- **Content/runtime:** dependency-free HTML/CSS/ES modules and versioned JSON under Android assets.
- **Offline origin:** serve bundled files through `WebViewAssetLoader` at `https://appassets.androidplatform.net/assets/web/index.html`; never load with `file://`.
- **Persistence:** namespaced, versioned `localStorage`; manual slots and autosave are snapshots of the same engine state. Global seen/unlock/settings data is separate from slot data.
- **Engine:** pure reducer (`state + command -> state + effects`) shared by the UI and Node route simulator. Story nodes have stable IDs; never use array offsets as save identifiers.
- **Display:** landscape-first for the Pixel 9a because 16:9 backgrounds, character staging, and train cinematography fit it best. Still support portrait and rotation; do not hard-lock orientation. At roughly 924×412 CSS px in landscape, use a 16:9 scene crop with a bottom dialogue panel. Portrait stacks a 16:9 scene above a taller text panel.
- **Release:** Gradle release build signed from environment/local properties, `zipalign`/`apksigner` verified. No network permissions and no remote content.

## 2. Exact project tree

```text
/root/projects/the-last-train-to-hoshikawa/
├── ARCHITECTURE.md
├── README.md
├── .gitignore
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradlew
├── gradlew.bat
├── gradle/wrapper/...
├── app/
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/com/hoshikawa/lasttrain/MainActivity.kt
│       │   ├── res/
│       │   │   ├── drawable/splash_background.xml
│       │   │   ├── mipmap-*/ic_launcher.png
│       │   │   ├── mipmap-*/ic_launcher_round.png
│       │   │   ├── values/{colors.xml,strings.xml,themes.xml}
│       │   │   └── xml/{backup_rules.xml,data_extraction_rules.xml}
│       │   └── assets/web/
│       │       ├── index.html
│       │       ├── css/{base.css,stage.css,ui.css,responsive.css}
│       │       ├── js/
│       │       │   ├── main.js
│       │       │   ├── engine/{reducer.js,commands.js,conditions.js,schema.js}
│       │       │   ├── data/{story-loader.js,asset-manifest.js}
│       │       │   ├── persistence/{storage.js,migrations.js,autosave.js}
│       │       │   ├── ui/{renderer.js,input.js,menus.js,backlog.js,gallery.js}
│       │       │   └── util/{assert.js,debounce.js,hash.js}
│       │       ├── story/
│       │       │   ├── manifest.json
│       │       │   ├── prologue.json
│       │       │   ├── common-01.json
│       │       │   ├── route-akari.json
│       │       │   ├── route-ren.json
│       │       │   └── endings.json
│       │       ├── assets/
│       │       │   ├── bg/*.jpg
│       │       │   ├── characters/<character>/*.png
│       │       │   ├── cg/*.jpg
│       │       │   ├── ui/*.png
│       │       │   ├── audio/{bgm,sfx,voice}/...
│       │       │   └── asset-manifest.json
│       │       └── fonts/*.woff2
│       ├── test/java/com/hoshikawa/lasttrain/...
│       └── androidTest/java/com/hoshikawa/lasttrain/WebViewSmokeTest.kt
├── story-src/                 # Authoring source; not packaged directly
│   ├── chapters/*.json
│   ├── endings.json
│   └── schema/story.schema.json
├── art-src/                   # Prompts, source generations, licenses/metadata
│   ├── prompts.yml
│   └── raw/...
├── tools/
│   ├── package-story.mjs      # validate, split, normalize, copy to assets/web/story
│   ├── validate-story.mjs
│   ├── simulate-routes.mjs
│   ├── optimize-assets.py
│   ├── verify-assets.py
│   └── release-check.sh
├── tests/
│   ├── engine.test.mjs
│   ├── fixtures/*.json
│   └── route-expectations.json
└── release/                   # ignored except checksums/release notes
```

Keep authoring files and raw generations out of the APK. Only optimized, referenced runtime assets belong in `app/src/main/assets/web`.

## 3. Story format and engine contract

Split the long story by chapter/route to keep parsing fast. `manifest.json` maps node IDs to chapter files, lists the start node, schema/content versions, and SHA-256 content hash. A packaged chapter is an object keyed by stable node ID:

```json
{
  "schemaVersion": 1,
  "nodes": {
    "p0001": {"type":"line","speaker":"Narrator","text":"The final train...","next":"p0002","bg":"bg/platform_rain.jpg","bgm":"bgm/last_departure.ogg"},
    "p0002": {"type":"choice","choices":[
      {"id":"wait","text":"Wait on the platform","if":"trust.akari >= 1","set":{"flags.waited":true},"next":"a0100"},
      {"id":"board","text":"Board the train","next":"c0100"}
    ]},
    "a0999": {"type":"ending","endingId":"akari_true","title":"Where the Rails Meet","next":null}
  }
}
```

Allowed node types should be a small closed set: `line`, `choice`, `set`, `jump`, `scene`, `ending`. Conditions are **data**, not JavaScript: parse a tiny allow-listed expression grammar or use structured predicates such as `{"gte":[{"var":"trust.akari"},1]}`. Never `eval` story content.

Canonical engine state:

```js
{
  format: 1, contentVersion: "1.0.0", nodeId: "p0001",
  vars: {}, history: [], choiceTrail: [],
  scene: { bg: null, sprites: [], bgm: null },
  playSeconds: 0, savedAt: 0
}
```

`reducer.js` must have no DOM, Android, clock, randomness, or storage access. Commands (`ADVANCE`, `CHOOSE`, `LOAD`) return deterministic state plus effects (`render`, `audio`, `persist`). Inject seeded randomness only if the story truly needs it and store the seed/cursor in state.

### Packaging validation (release-blocking)

`validate-story.mjs` must fail on duplicate/missing IDs, missing targets/assets, unknown node fields/types, invalid predicates, unreachable nodes (unless allow-listed), choice nodes with no reachable option, endings with duplicate IDs, illegal variable writes, and cycles that contain no player input/line/ending. Emit a route graph summary and orphan report.

`simulate-routes.mjs` imports the same reducer and performs bounded DFS/BFS over choices. Hash `(nodeId, canonical vars, unlocked endings)` to deduplicate states; cap steps and report loops. Assert:

- every declared ending is reachable;
- expected route lock points cannot be bypassed;
- no state dead-ends or has zero visible choices;
- every node/choice ID intended for release is visitable;
- save/load serialization at sampled states resumes identically;
- migrations load fixtures from every prior shipped save format.

Node is preferred because it imports the production ES modules directly. Python is useful for independent graph/schema checks, but should not reimplement branching semantics. Commands:

```bash
node --test tests/*.test.mjs
node tools/validate-story.mjs story-src
node tools/simulate-routes.mjs --all-endings --max-states 250000
python3 tools/verify-assets.py
./gradlew test lintRelease assembleRelease
./gradlew connectedReleaseAndroidTest
```

## 4. Save, seen text, skip, and menus

Use explicit namespaced records:

```text
hoshikawa:v1:slot:0 ... slot:11   manual save snapshots + metadata/thumbnail key
hoshikawa:v1:autosave             latest safe snapshot
hoshikawa:v1:global               seen IDs, ending/CG unlocks, achievements
hoshikawa:v1:settings             text speed, auto delay, volumes, skip mode, UI scale
hoshikawa:v1:journal              optional previous autosave for crash recovery
```

- **Save slots:** 12 slots. Store `{format, contentVersion, checksum, snapshot, summary}`. Checksum detects truncation/corruption; validate shape before applying. A failed load must leave current state untouched.
- **Autosave:** debounce writes, but force one after every choice, chapter transition, ending, app background, and before navigation into menus. Write journal first, then primary, then delete/retain journal. `localStorage` writes are synchronous; keep snapshots compact and do not store images/base64/audio.
- **Seen text:** global set of stable line IDs, not text hashes. Add a line only when fully revealed or advanced according to the chosen product rule. Compact to sorted arrays for storage; for very large scripts use chapter bitsets generated from stable packaged line indexes.
- **Skip:** default `read-only`; it advances only lines marked seen and stops immediately at an unseen line, visible choice, ending, or menu. Optional “skip all” lives behind settings plus a confirmation. Use a cancellable timer/token; never a tight loop.
- **Auto mode:** waits for reveal completion plus configurable delay; cancels on choice/menu/background.
- **Backlog:** derive from the current run’s persisted `history`; cap at a documented value such as 500 entries. Include speaker/text and choice selections. Backlog is display-only—no arbitrary rollback unless rollback state snapshots are deliberately implemented and tested.
- **Gallery:** ending and CG unlocks belong to global data so loading an old slot cannot relock them. Gallery entries come from a data manifest and reveal title/date/thumbnail when unlocked.
- **Settings:** persist immediately; CSS custom properties apply text speed/size/UI scale, and audio buses apply BGM/SFX/voice volume separately.

Handle `localStorage` quota errors visibly. Export/import saves as a later feature; uninstall/clear-data removes localStorage. Android backup should be explicitly disabled (`android:allowBackup="false"`) unless cloud/device backup behavior is designed and tested.

## 5. Android WebView shell

Use current stable Android Gradle Plugin/Kotlin and pin versions. Suggested baseline: `minSdk 26`, current installed `compileSdk`/`targetSdk`, Java/Kotlin 17 bytecode, and `androidx.webkit:webkit` for `WebViewAssetLoader`.

Manifest must contain **no** `INTERNET`, `ACCESS_NETWORK_STATE`, storage, media, or notification permission. Only the launcher activity is exported. Set `usesCleartextTraffic="false"`, `allowBackup="false"`, and a non-network security config if desired. Do not include ad/analytics SDKs that merge network permissions.

Core Kotlin pattern:

```kotlin
private val assetLoader by lazy {
    WebViewAssetLoader.Builder()
        .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this))
        .build()
}

@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
    val web = WebView(this)
    setContentView(web)
    with(web.settings) {
        javaScriptEnabled = true
        domStorageEnabled = true
        allowFileAccess = false
        allowContentAccess = false
        javaScriptCanOpenWindowsAutomatically = false
        setSupportMultipleWindows(false)
        mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
        mediaPlaybackRequiresUserGesture = false
    }
    web.webViewClient = object : WebViewClient() {
        override fun shouldInterceptRequest(v: WebView, r: WebResourceRequest): WebResourceResponse? {
            val u = r.url
            if (u.scheme == "https" && u.host == "appassets.androidplatform.net")
                return assetLoader.shouldInterceptRequest(u)
            return WebResourceResponse("text/plain", "UTF-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
        }
        override fun shouldOverrideUrlLoading(v: WebView, r: WebResourceRequest): Boolean =
            r.url.host != "appassets.androidplatform.net"
    }
    web.loadUrl("https://appassets.androidplatform.net/assets/web/index.html")
}
```

Also:

- Handle Android back via `OnBackPressedDispatcher`: close VN overlay/menu first, then ask to quit; do not expose arbitrary WebView history navigation.
- On `onPause`, call a JS autosave hook using `evaluateJavascript`; JS must already autosave at choices so process death is survivable.
- Avoid `addJavascriptInterface`; it is unnecessary for this architecture. If later added, expose a tiny allow-listed API and never enable remote navigation.
- Set `WebChromeClient` for console diagnostics in debug; turn console errors/unhandled promise rejections into test failures.
- Apply `WindowInsets` to the WebView/root or pass safe-area values as CSS vars. Test gesture and 3-button navigation, status bar, display cutout, and keyboard.
- Keep one stable HTTPS origin across versions. Changing from `file://` or changing host/path can orphan existing localStorage.
- `fetch("story/manifest.json")` is relative to the appassets URL. Verify MIME types for `.json`, `.js`, `.woff2`, `.ogg`; module scripts and fetch are unreliable under `file://`, which is why asset loading is mandatory.
- Do not enable universal access from file URLs. Do not use `setAllowUniversalAccessFromFileURLs(true)`.

## 6. Responsive UI and asset budget

Landscape-first breakpoints:

```css
:root { --safe-t: env(safe-area-inset-top); --safe-r: env(safe-area-inset-right);
        --safe-b: env(safe-area-inset-bottom); --safe-l: env(safe-area-inset-left); }
#app { width:100dvw; height:100dvh; padding:var(--safe-t) var(--safe-r) var(--safe-b) var(--safe-l); }
@media (orientation: landscape) { .stage { aspect-ratio:16/9; } .dialogue { min-height:34%; } }
@media (orientation: portrait)  { .layout { grid-template-rows:minmax(0,56%) minmax(0,44%); } }
```

Use `100dvh`, not only `100vh`; CSS Grid/Flex with `min-height:0`; touch targets at least 48 CSS px; max dialogue line length around 40–55 characters; user-scalable text; and no hover-only controls. Respect `prefers-reduced-motion`.

Asset recommendations:

- Backgrounds/CG: generated master retained in `art-src`, runtime JPG 1600×900 or 1920×1080, quality ~82–88, progressive stripped metadata.
- Sprites/UI with alpha: optimized PNG, trimmed transparent bounds, consistent anchor/eye-line metadata. Use atlases only if measurement proves many-request overhead; appassets requests are local.
- Generate an `asset-manifest.json` with path, byte size, dimensions, and digest. Validate case-sensitive paths on Linux; Android asset paths are case-sensitive.
- Preload current and next scene only; decode/caching too many large PNGs can kill the WebView. Explicitly release obsolete image references.
- Establish an APK size budget early (for example 150–250 MB). Voice and music, not code, will dominate size. A single APK over store/device limits may require future Play Asset Delivery, which conflicts with strict first-launch offline simplicity.

## 7. Release signing and verification

Never commit the release keystore or passwords. Read these from `~/.gradle/gradle.properties` or CI secrets:

```properties
HOSHIKAWA_STORE_FILE=/absolute/private/path/hoshikawa-release.jks
HOSHIKAWA_STORE_PASSWORD=...
HOSHIKAWA_KEY_ALIAS=hoshikawa
HOSHIKAWA_KEY_PASSWORD=...
```

Configure `signingConfigs.release` in `app/build.gradle.kts`, enable R8/resource shrinking only after testing asset paths (assets are not resource-shrunk), and produce a versioned artifact such as `TheLastTrainToHoshikawa-1.0.0-release.apk`. Preserve the same package ID and certificate forever for upgrade installs.

Release gates:

```bash
./gradlew clean test lintRelease assembleRelease
$ANDROID_HOME/build-tools/<ver>/zipalign -c -P 16 -v 4 app/build/outputs/apk/release/app-release.apk
$ANDROID_HOME/build-tools/<ver>/apksigner verify --verbose --print-certs app/build/outputs/apk/release/app-release.apk
$ANDROID_HOME/build-tools/<ver>/aapt2 dump permissions app/build/outputs/apk/release/app-release.apk
unzip -t app/build/outputs/apk/release/app-release.apk
sha256sum app/build/outputs/apk/release/app-release.apk > release/SHA256SUMS
```

`aapt2 dump permissions` must show no network/storage permissions. Inspect the APK archive for representative story/assets and ensure raw authoring files/keystore are absent. On a Pixel 9a or API-equivalent emulator test clean install, upgrade from previous signed build, airplane-mode cold launch, process kill/relaunch from autosave, all slot operations, rotate at line/choice/menu, skip stopping at unseen text, all ending simulations, audio focus/interruption, and 30–60 minutes of memory use. Capture `adb logcat` for WebView crashes and JS console errors.

## 8. High-risk implementation pitfalls

1. **Using `file:///android_asset`:** breaks or complicates ES modules/fetch/CORS and creates fragile storage origins. Use `WebViewAssetLoader` HTTPS origin.
2. **Story logic in UI callbacks:** makes route coverage impossible. Keep all branching in the pure reducer.
3. **Mutable/positional IDs:** edits corrupt saves and seen flags. Stable node/choice/line/ending IDs are content API contracts.
4. **One giant JSON parse:** delays startup and increases memory. Split by chapter and lazily load/prefetch adjacent chapters.
5. **Save migration omitted:** any schema/story refactor can strand users. Ship fixtures and migrations before release 1.1.
6. **Autosave only on timers:** Android may kill the process before the timer. Save synchronously after choices/transitions and on background.
7. **Skip as rapid synthetic taps:** races animations/audio and skips choices. Implement a serialized engine command loop with cancellation.
8. **Gallery unlocks stored in slots:** loading an old slot relocks content. Keep global progress separate and merge monotonically.
9. **Rotation treated as reload:** can duplicate audio/advance or lose transient state. Make rendering idempotent and autosave before lifecycle changes.
10. **Assuming “no permission” means no network:** WebView can still attempt links if navigation is not blocked. Omit permissions *and* enforce appassets-only request/navigation policy.
11. **Back button exiting immediately:** first close backlog/settings/save/choice overlays; confirm exit from the game screen.
12. **Signing with debug or changing keys:** prevents safe upgrades and can delete saves if users must uninstall. Verify signer SHA-256 against the prior release.
