diff --git a/.agents/skills/ship-release/SKILL.md b/.agents/skills/ship-release/SKILL.md index 3d32eb0..2ad4a43 100644 --- a/.agents/skills/ship-release/SKILL.md +++ b/.agents/skills/ship-release/SKILL.md @@ -126,6 +126,16 @@ committed versionCode. ### App Store +**Default release option: automatic immediately after approval.** The user +asked on 2026-10-08 to use “Automatically release this version” for future +iOS releases. Set and verify App Store version `releaseType=AFTER_APPROVAL` +before review submission; never rely on an inherited manual setting. Do +not select the scheduled-date option. Record the remote readback in the +release evidence. This is a standing release preference, not authorization +for an otherwise unrequested submission, cancellation or immediate release +of an older approved build. Existing task authorization still governs +which version is submitted. + ```bash export MOONLIT_ASC_KEY_ID=<10 characters> export MOONLIT_ASC_ISSUER_ID= diff --git a/.claude/skills/ship-release/SKILL.md b/.claude/skills/ship-release/SKILL.md index 3d32eb0..2ad4a43 100644 --- a/.claude/skills/ship-release/SKILL.md +++ b/.claude/skills/ship-release/SKILL.md @@ -126,6 +126,16 @@ committed versionCode. ### App Store +**Default release option: automatic immediately after approval.** The user +asked on 2026-10-08 to use “Automatically release this version” for future +iOS releases. Set and verify App Store version `releaseType=AFTER_APPROVAL` +before review submission; never rely on an inherited manual setting. Do +not select the scheduled-date option. Record the remote readback in the +release evidence. This is a standing release preference, not authorization +for an otherwise unrequested submission, cancellation or immediate release +of an older approved build. Existing task authorization still governs +which version is submitted. + ```bash export MOONLIT_ASC_KEY_ID=<10 characters> export MOONLIT_ASC_ISSUER_ID= diff --git a/.github/scripts/check-locale.mjs b/.github/scripts/check-locale.mjs index 429f098..4e0dd4a 100644 --- a/.github/scripts/check-locale.mjs +++ b/.github/scripts/check-locale.mjs @@ -65,6 +65,18 @@ const KEY = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g; // Looks like a key but is not. Engine constants and node names. const NOT_KEYS = /^(?:[A-Z]+_[A-Z0-9_]*(?:MODE|FILTER|PRESET|DIRECTION|ALIGNMENT|MASK|LAYER)|SUB_RESOURCE|EXT_RESOURCE|GD_SCENE|PACKED[A-Z0-9]*ARRAY|STYLE_BOX[A-Z_]*|NODE_PATH)$/; +// Firestore's server-timestamp transform writes its wire enum as a plain +// string (`"setToServerValue": "REQUEST_TIME"`), which the scan below would +// read as a UI key. It is protocol, never displayed copy, so that exact +// occurrence is skipped: the direct value of the field, or the expected +// value in a wrapped `.get("setToServerValue", default)` assertion, whose +// tail lands on the same line. Merely naming the field earlier on a line +// exempts nothing: any other REQUEST_TIME on the line — a stored label, a +// warning literal, a tr() argument — stays checked, as does any other key. +const SERVER_VALUE_DIRECT = /"setToServerValue"\s*:\s*$/; +const SERVER_VALUE_ASSERTED = /"setToServerValue"\s*,\s*"[^"]*"\s*\)\)\s*,\s*$/; +const SERVER_VALUE_REQUEST_TIME = 'REQUEST_TIME'; + // The vendored godot-iap addon never uses the game's keys; its env-var names look like keys. const VENDORED_IAP = join(GAME, 'addons', 'godot-iap'); @@ -90,7 +102,17 @@ for (const file of walk(GAME)) { // through. Strip comments before scanning. const code = /\.gd$/.test(file) ? line.split('#')[0] : line; const scene = line.match(/^text = "([A-Z][A-Z0-9_]*)"$/); - const literals = [...code.matchAll(/"([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)"/g)].map((m) => m[1]); + const literals = []; + for (const match of code.matchAll(/"([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)"/g)) { + const before = code.slice(0, match.index); + if ( + match[1] === SERVER_VALUE_REQUEST_TIME + && (SERVER_VALUE_DIRECT.test(before) || SERVER_VALUE_ASSERTED.test(before)) + ) { + continue; + } + literals.push(match[1]); + } const found = scene ? [scene[1], ...literals] : literals; for (const key of found) { if (NOT_KEYS.test(key) || !KEY.test(key)) continue; diff --git a/apps/docs/docs/assets/manifest.md b/apps/docs/docs/assets/manifest.md index cfb2520..88ceba9 100644 --- a/apps/docs/docs/assets/manifest.md +++ b/apps/docs/docs/assets/manifest.md @@ -1440,3 +1440,46 @@ Modification: none to the bytes. SHA-256 font Author note: the variable face keeps its original bracketed filename so the shipped bytes map to the donor record with no renaming step. + +--- + +## Gate lodge room plates (first-login refuge) + +```text +Source: painted art generated for Moonlit Beacon (gate chamber set) +Creator: Moonlit Beacon · built-in image generation +License: follows the project license +Original path: notes/workflow/muse/art/gate-chamber/gate-chamber-room.png + (1881x836 RGB, SHA-256 + 44df1b68919508caf1c6cfb57fc8596320765ec9271ee81bd28c22e2a80cb540) + notes/workflow/muse/art/gate-chamber/gate-chamber-room-tablet.png + (1448x1086 RGB, SHA-256 + fc73cc31c4ff5c2fc521226b3c49f22f62aeda280d213c87cfe0965eaeb7a0a8) +Project path: res://assets/custom/world/lodge/room_wide.png, res://assets/custom/world/lodge/room_tablet.png +Used: gate lodge environment plate; the wide plate serves + 808x360 and wider landscape, the tablet plate serves + taller 4:3 landscape such as 808x606 +Modification: deterministic RGBA re-encode at native size via + apps/game/tools/build_lodge_assets.py; no paint changes +``` + +--- + +## Lumi the Lantern Keeper (gate lodge guide) + +```text +Source: painted art generated for Moonlit Beacon (gate chamber set) +Creator: Moonlit Beacon · built-in image generation +License: follows the project license +Original path: notes/workflow/muse/art/gate-chamber/lumi-turnaround.png + (1774x887 RGBA, SHA-256 + f80263c3d8da6d48f735a7088fc4ccc48e60390cce4f7593bbb66817687b4fa9) +Project path: res://assets/custom/actors/lumi/front.png, res://assets/custom/actors/lumi/rear.png, res://assets/custom/actors/lumi/left.png, res://assets/custom/actors/lumi/right.png, res://assets/custom/actors/lumi/bust.png +Used: four-facing grounded lodge guide sprite plus the small + name-form portrait; never a full-sheet billboard +Modification: per-facing crop with an 8px margin on one common + top/bottom line (front 335x869, rear 336x869, + left 262x869, right 247x869), bust head crop 260x320, + via apps/game/tools/build_lodge_assets.py; visible + figure heights 853/850/851/852 source px (0.35% spread) +``` diff --git a/apps/docs/docs/game.md b/apps/docs/docs/game.md index 8b2af88..93abba1 100644 --- a/apps/docs/docs/game.md +++ b/apps/docs/docs/game.md @@ -19,7 +19,7 @@ title: The game we are making | Controls | full-screen floating move stick / bottom-right dash button / auto-attack | | Engine | Godot 4.7.1 Standard, GDScript | | Run length | you can cash out after each guardian — keep going after an 8-cycle official win | -| Project version | 4.0.0 (iOS build 12 / Android versionCode 17) | +| Project version | 4.0.1 (iOS build 15 / Android versionCode 20) | ### One-line pitch @@ -39,7 +39,12 @@ screen as it looked when that lesson was finished. 1. Tap the title and choose how to enter: continue as a guest on this installation, or with Google / Apple sign-in where configured. Starting means you accept the in-app Terms and have read the Privacy - Policy, both linked on the same card. Pick a character at the shrine, + Policy, both linked on the same card. A cloud account entering for + the first time meets Lumi in the gate lodge before anything else: + claim one unique adventurer name, practice movement and dash on the + open floor, then step through the moon gate. A completed guide + lesson skips the lodge afterwards, while interrupted practice + returns to it. Pick a character at the shrine, and spend collected moon shards on permanent boons. If a journey is saved, the gate offers Resume back through the last gate with the saved hero; starting over needs a confirmation first so the saved @@ -77,11 +82,25 @@ screen as it looked when that lesson was finished. gate, so Continue resumes at the next cycle with the build intact. 9. Returning shows cycle, beacons, survival time, level, kill score, and rank. Moon shards and records carry into the next run. Hitting 0 - health keeps the banked journey and offers Retry from the last gate - with the growth earned before that segment; the lost segment's kills, - score and rewards grant nothing, so no number of retries can farm - them. A paid continue coin still revives in place instead, and is - never spent just to resume the saved journey. + health seals the journey: the final score settles once into shards + and records, and the save is marked ended, so neither the title, a + relaunch, nor a cloud restore can resume the defeated run. The seal is + mirrored into the backup copy, so even a damaged save file still reads + ended rather than reviving an older checkpoint. The loss screen offers + three honest roads: spend one continue coin to revive where you fell + with hero, Wave, score and growth intact; start over from the + beginning with a new journey; or return to the title. Coins are never + spent just to resume a saved journey. The coin is charged to the purse + before the revive exists: a crash mid-continue either keeps the sealed + defeat with the coin untouched, or recovers the paid revive on the + next launch without charging again. If the paid revive cannot open, + the title says so and asks before anything starts. A paid revive + belongs to the account that paid: switching accounts never moves it + into another save — it waits until its own account returns. When + signing in folds a guest save into an existing account, a waiting paid + revive folds with it and still costs nothing more. With no coins left + but a paid revive waiting, the loss screen offers a save retry instead + of the shop. ## 3. Exact shipping-version scope @@ -481,7 +500,7 @@ return, and stays hidden on defeat. | `analytics.json` | allowed events not yet sent after consent. Max 200 events / 14 days; revoked consent deletes immediately | | `player_identity.cfg` | durable player ID (`MB-` + 32 hex), minted before the first play and shown in full on the moon gate | | `player_bindings.cfg` | which player ID each signed-in account plays under, keyed by hashed account reference only | -| `journey..json` | one saved gate per player ID: cycle, hero, relics, growth and score counters, plus backup, revision and rejected-copy files | +| `journey..json` | one saved gate per player ID: cycle, hero, relics, growth and score counters, plus backup, revision and rejected-copy files; a run that ended in defeat keeps an ended marker instead of a resumable gate | A missing or corrupt save still launches with defaults. @@ -516,12 +535,39 @@ result. fresh guest ID; the previous account's ID, binding, and save stay on the device. - **Continue where you stopped.** The gate resumes the last saved gate with - its hero, relics, and growth. Starting over with a save in place asks - first. Retrying a gate is free; continue coins only resume where you fell. + its hero, relics, and growth, but only while the run is still alive: a + journey that ended in defeat is marked ended and offers a fresh start + instead of a free continuation. Starting over with a live save in place + asks first. Continue coins only revive where you fell, one coin per + revive, and quitting mid-run always keeps the live gate resumable. +- **Coming back pays two coins.** A signed-in account that returns after + twelve hours gets two continue coins, at most once per twelve hours + since the last claim; days away still pay two, never a stockpile. + Claiming needs a connection, and the game plays on without the grant + when offline. On phones, Settings can also switch on a local reminder + when the next two coins are ready: one notice at eligibility, then + roughly every twelve hours while away, up to 24 days of scheduled + notices that refresh whenever the game is opened. Notices stay + silent while the game is open, never grant coins by themselves, and + turning them off in Settings cancels everything scheduled. +- **First login opens the gate lodge.** A cloud account that never chose + its public handle or finished the lesson meets Lumi, the Lantern + Keeper, in the gate refuge after sign-in: pick the 2–12 character + handle the register and Hall will remember, walk and dash once on the + open floor, then step through the moon gate. The lesson touches no + saved run and spends no coins; a living journey still offers Resume + against a confirmed fresh start afterwards, and finished accounts + skip the lodge entirely. Local guests play straight through as before. - **The Hall shows real ranks.** Rows carry the actual hero, score, and full player ID, each labeled live, cached, or offline. Equal scores share one standing, and the rank chip in the Arena agrees with the board. Local bests stay on the device ladder and are never mixed into the cloud board. + A signed-in account can also claim one permanent public game handle of + 2–12 letters, digits, or Chinese/Japanese/Korean characters; claiming + needs a connection, and afterwards the handle shows on that account's + Hall row on every device, including the Hall panel headline next to the + rank and score. Handles cannot be renamed or moved, and rows + claimed before handles existed still render honestly without one. - **Conflicts and offline states are explicit.** When the device save and the cloud save differ, the gate shows both and waits for a pick; the rejected side is preserved for recovery first. Nothing is overwritten silently, and @@ -538,8 +584,9 @@ it upload name, character, app version, score, rank, and cycle, and fetch global top records. That name/rank upload is not the current Hall. The active 4.0.0 Hall (`mb_hall_v1`) keeps one row per public ID carrying the public ID, the canonical hero path, score, cycles, -release, schema, and update time — never the legacy name or a rank -field. Standing is derived from the scores, so equal scores share one +release, schema, and update time — never the legacy typed name or a rank +field — plus the account's claimed public handle when it has one. +Standing is derived from the scores, so equal scores share one rank. Missing connection or a failed request leaves play and local records working. Android export has the internet permission on for this optional feature. @@ -711,15 +758,17 @@ The current 4.0.0 entry instead identifies each player with the durable player ID — as a local guest, optionally registered in the background, or through Google/Apple sign-in where genuinely configured — and the owned Hall keeps one row per public ID with -no typed name. Unconnected play still shows only local records. +no typed name, only the account's claimed public handle when it has +one. Unconnected play still shows only local records. Seven non-consumables are implemented: Moonlit Supporter, five companion heroes, and the Lantern Colors pack, plus three consumable continue-coin SKUs (1, 5, and 10). The free Moonlit Warden alone can play every region and cycle. Paid heroes are balanced options with different starting relics and visuals, not a power ladder by price. Continue coins are an -optional convenience that resumes a run where you fell; you can start a -new run immediately without them. Past Hero Bundle buyers still restore +optional convenience that resumes a run where you fell; signed-in +accounts also earn two free coins per twelve-hour return, and you can +start a new run immediately without them. Past Hero Bundle buyers still restore Shadow Dancer and Beacon Keeper after that SKU left the new-sale list. All 10 sale products are registered on both stores. Product and verification boundaries are in [Monetization design](./monetize.md). @@ -732,6 +781,7 @@ GATE ──→ PLAYING ⇄ PAUSED ├─ Shrine ├─ guardian → loot → return → RESULT → record → restart ├─ Hall │ └─ continue → next cycle → PLAYING └─ Account └─ health 0 → RESULT ──→ coin continue → PLAYING + └─ start over → fresh journey ``` Android **Back** sends `PLAYING` to `PAUSED`. diff --git a/apps/game/addons/moonlit-identity/README.md b/apps/game/addons/moonlit-identity/README.md index 851f98f..6b7c96a 100644 --- a/apps/game/addons/moonlit-identity/README.md +++ b/apps/game/addons/moonlit-identity/README.md @@ -122,6 +122,73 @@ Firebase initializes from the staged public client config carried in each call's payload. Without it, calls answer `not_configured` instead of throwing — for guests as well as providers. +## Attendance reminders (same natives, separate API) + +Local notifications only — no Firebase, no provider gating, no cloud. +The game side is `scripts/gameplay/attendance_reminders.gd` (owned by +the production host) plus a Settings row; both natives expose the same +seven methods on the same `(request_id, args_json)` receipt contract: + +- `moonlitReminderStatus` — live OS state: `permission` + (`granted`/`denied`/`unknown`), whether this install asked the OS to + hold a delivery (persisted intent, never a token probe), and + (Android) whether this process started from the reminder's launch + action. `permission` weighs the app-global switch, the 33+ runtime + grant, and the attendance channel together. Async on iOS (`pending` + + signal) with a fresh OS read, because `getNotificationSettings` + is async-only. +- `moonlitReminderRequestPermission` — the OS sheet; `pending`, then one + terminal outcome with the resulting `permission`. Uses the + interactive (human) timeout like the provider sheets. +- `moonlitReminderSchedule` — first fire at `eligible_utc_millis` + (absolute, server-derived) plus the twelve-hour repeat; replaces any + earlier attendance schedule. Identical inputs skip without shifting + the repeat anchor, unless the held window runs low (iOS refills 48 + future slots from the same anchor and reports `base_slot` / + `horizon_end_unix`; Android reports `-1` for its unbounded alarm). +- `moonlitReminderCancel` — cancels the scheduled delivery and dismisses + a delivered one. Names exactly this app's attendance identifiers. +- `moonlitReminderOpenSettings` — opens the app's OS notification + settings page. +- `moonlitReminderPending` — trigger facts plus the persisted + schedule mirror for QA (Android separates intent from token + existence; iOS reports the held horizon window). Async on iOS + (`pending` + signal). +- `moonlitReminderDebugSchedule` — QA only: one short (5–600 s) + non-repeating delivery. Never touches the production twelve-hour + rule or any wallet. + +When the Android activity is between lifecycles every entry point +answers retryable `error` (`no_activity`) instead of guessing: the +game keeps its owned delivery and retries on the next refresh. + +Android holds one inexact `RTC_WAKEUP` alarm (`setInexactRepeating`, +no exact-alarm privilege, no foreground service) re-armed after reboot +from a SharedPreferences mirror; delivery re-checks the persisted +opt-out, the runtime permission, the localized channel state, and +whether the game is foregrounded (a fresh resume record or a +foreground-ranked process — a stale record never suppresses alone). +The small icon is a monochrome beacon vector; the channel name ships +in the game's five languages. iOS holds a bounded horizon of 48 +non-repeating slot requests at eligibility plus N * 43200 seconds (24 +days, under the 64-request OS budget): every delivery is anchored to +eligibility, elapsed slots are skipped rather than burst, identical +refreshes skip while the horizon is live, and a nearly consumed +horizon refills from the same anchor. Cancellation names the whole +horizon plus the legacy one-shot/repeat/debug ids. Foreground +presentation is suppressed through a delegate installed only when +none owns the center; returning none matches the no-delegate default. +Scheduling metadata (account, eligible time, locale) lives in +app-only `NSUserDefaults` keys declared by the app's own +`ios/PrivacyInfo.xcprivacy` (reason CA92.1). Godot 4.7.1 emits its +own app-root manifest, so the export plugin registers no loose copy +(a second same-basename output fails Xcode); instead the owned +export pipeline (`node scripts/ios.mjs`, every export path) merges +this file's UserDefaults declaration into Godot's generated root +manifest and verifies the single registration, while the SDK +bundles keep their untouched manifests. No token, email, name, or +wallet value crosses this API. + ## Build commands (director runs these) ```bash diff --git a/apps/game/addons/moonlit-identity/android/AndroidManifest.xml.tmpl b/apps/game/addons/moonlit-identity/android/AndroidManifest.xml.tmpl index 8afa74f..efd22a9 100644 --- a/apps/game/addons/moonlit-identity/android/AndroidManifest.xml.tmpl +++ b/apps/game/addons/moonlit-identity/android/AndroidManifest.xml.tmpl @@ -9,9 +9,24 @@ + + + + + + + + + diff --git a/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitIdentityPlugin.kt b/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitIdentityPlugin.kt index f2ca733..27a9471 100644 --- a/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitIdentityPlugin.kt +++ b/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitIdentityPlugin.kt @@ -1,7 +1,13 @@ package dev.moonlitbeacon.identity +import android.Manifest import android.app.Activity +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings import android.util.Log import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential @@ -114,6 +120,15 @@ class MoonlitIdentityPlugin(godot: Godot) : GodotPlugin(godot) { private const val CODE_NO_GOOGLE_ACCOUNT = "no_google_account" private const val CODE_ALREADY_PENDING = "request_already_pending" private const val CODE_MUTATION_IN_PROGRESS = "mutation_in_progress" + private const val CODE_PERMISSION_DENIED = "permission_denied" + private const val CODE_NO_ACTIVITY = "no_activity" + private const val CODE_INVALID_ARGS = "invalid_args" + private const val CODE_REQUEST_BUSY = "request_busy" + + private const val STATUS_DENIED = "denied" + private const val REMINDER_PERMISSION_CODE = 4102 + private const val DEBUG_DELAY_MIN_SECONDS = 5.0 + private const val DEBUG_DELAY_MAX_SECONDS = 600.0 private const val PLAY_APP_ID_KEY = "com.google.android.gms.games.APP_ID" @@ -162,6 +177,14 @@ class MoonlitIdentityPlugin(godot: Godot) : GodotPlugin(godot) { private val lock = Any() + /** + * Owner of the single in-flight notification-permission request. + * Separate from the identity mutation slot: permission is not a + * Firebase mutation and must never block (or be blocked by) + * sign-in work. Guarded by [lock]. + */ + private var reminderPermissionOwner: String? = null + override fun getPluginName(): String = "MoonlitIdentity" override fun getPluginSignals(): MutableSet { @@ -986,6 +1009,9 @@ class MoonlitIdentityPlugin(godot: Godot) : GodotPlugin(godot) { pickerJob = credentialJobs.remove(requestId) pending.remove(requestId) if (mutationOwner == requestId) mutationOwner = null + if (reminderPermissionOwner == requestId) { + reminderPermissionOwner = null + } rememberSettledLocked(requestId) } // Outside the lock: cancelling never calls back synchronously. @@ -1191,6 +1217,341 @@ class MoonlitIdentityPlugin(godot: Godot) : GodotPlugin(godot) { } } + // ------------------------------------------------------------------ + // Attendance reminders (local notifications; no Firebase, no provider). + // + // One inexact twelve-hour alarm whose first fire targets the + // server-confirmed eligibility the game passes in. Delivery guards + // live in MoonlitReminderAlarm/MoonlitReminderReceiver; this class + // only answers status, asks permission, and writes the schedule the + // game requested. Nothing here grants or spends coins. + // ------------------------------------------------------------------ + + override fun onMainResume() { + val host = activity ?: return + val store = MoonlitReminderStore.prefs(host) + store.edit() + .putBoolean(MoonlitReminderStore.KEY_RESUMED, true) + .putLong(MoonlitReminderStore.KEY_RESUMED_AT_MILLIS, + System.currentTimeMillis()) + .apply() + val launch = host.intent + if (launch != null && launch.getBooleanExtra( + MoonlitReminderAlarm.EXTRA_LAUNCHED, false + ) + ) { + launch.removeExtra(MoonlitReminderAlarm.EXTRA_LAUNCHED) + store.edit() + .putBoolean(MoonlitReminderStore.KEY_LAUNCHED, true) + .apply() + } + } + + override fun onMainPause() { + val host = activity ?: return + MoonlitReminderStore.prefs(host).edit() + .putBoolean(MoonlitReminderStore.KEY_RESUMED, false) + .apply() + } + + override fun onMainRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + if (requestCode != REMINDER_PERMISSION_CODE) return + val owner = synchronized(lock) { + val current = reminderPermissionOwner + reminderPermissionOwner = null + current + } + if (owner == null) return + var granted = false + for (index in permissions.indices) { + if (permissions[index] == + Manifest.permission.POST_NOTIFICATIONS && + index < grantResults.size && + grantResults[index] == PackageManager.PERMISSION_GRANTED + ) { + granted = true + } + } + if (granted) { + finish(owner, reminderPermissionOutcome(owner, "granted")) + } else { + finish(owner, reminderPermissionOutcome(owner, "denied")) + } + } + + @UsedByGodot + fun moonlitReminderStatus(requestId: String, argsJson: String): String { + val context = activity ?: return error(requestId, + CODE_NO_ACTIVITY, retryable = true).toString() + return reminderStatusReceipt(requestId, context).toString() + } + + @UsedByGodot + fun moonlitReminderRequestPermission( + requestId: String, + argsJson: String + ): String { + val host = activity + if (host == null) { + return error(requestId, CODE_NO_ACTIVITY, retryable = true) + .toString() + } + if (!MoonlitReminderAlarm.runtimePermissionMissing(host)) { + // No sheet to show (below 33, or already granted): + // answer the effective verdict instead. A global or + // channel block reports denied so the row offers OS + // settings rather than a sheet that cannot fire. + val permission = + if (MoonlitReminderAlarm.notificationsAllowed(host)) { + "granted" + } else { + "denied" + } + return reminderPermissionOutcome(requestId, permission) + .toString() + } + synchronized(lock) { + if (pending.contains(requestId) || + settled.contains(requestId) + ) { + return alreadyPending(requestId) + } + if (reminderPermissionOwner != null) { + return error(requestId, CODE_REQUEST_BUSY, + retryable = true).toString() + } + pending.add(requestId) + reminderPermissionOwner = requestId + } + MoonlitReminderStore.prefs(host).edit() + .putBoolean( + MoonlitReminderStore.KEY_PERMISSION_ASKED, true + ) + .apply() + host.runOnUiThread { + host.requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REMINDER_PERMISSION_CODE + ) + } + return pendingReceipt(requestId) + } + + @UsedByGodot + fun moonlitReminderSchedule( + requestId: String, + argsJson: String + ): String { + val context = activity ?: return error(requestId, + CODE_NO_ACTIVITY, retryable = true).toString() + val args = JSONObject(argsJson) + val eligible = args.optLong("eligible_utc_millis", 0L) + val title = args.optString("title", "").trim() + val body = args.optString("body", "").trim() + val account = args.optString("account", "").trim() + val locale = args.optString("locale", "").trim() + if (eligible <= 0L || title.isEmpty() || body.isEmpty() || + account.isEmpty() + ) { + return error(requestId, CODE_INVALID_ARGS, + retryable = false).toString() + } + if (!MoonlitReminderAlarm.notificationsAllowed(context)) { + return JSONObject() + .put("status", STATUS_DENIED) + .put("permission", "denied") + .put("code", CODE_PERMISSION_DENIED) + .put("request_id", requestId) + .toString() + } + MoonlitReminderAlarm.ensureChannel(context) + MoonlitReminderStore.prefs(context).edit() + .putBoolean(MoonlitReminderStore.KEY_ENABLED, true) + .putString(MoonlitReminderStore.KEY_ACCOUNT, account) + .putLong( + MoonlitReminderStore.KEY_ELIGIBLE_MILLIS, eligible + ) + .putString(MoonlitReminderStore.KEY_TITLE, title) + .putString(MoonlitReminderStore.KEY_BODY, body) + .putString(MoonlitReminderStore.KEY_LOCALE, locale) + .apply() + MoonlitReminderAlarm.schedule(context, eligible) + return JSONObject() + .put("status", STATUS_OK) + .put("eligible_millis", eligible) + .put("horizon_end_unix", -1) + .put("request_id", requestId) + .toString() + } + + @UsedByGodot + fun moonlitReminderCancel( + requestId: String, + argsJson: String + ): String { + val context = activity ?: return error(requestId, + CODE_NO_ACTIVITY, retryable = true).toString() + MoonlitReminderAlarm.cancel(context) + MoonlitReminderStore.prefs(context).edit() + .putBoolean(MoonlitReminderStore.KEY_ENABLED, false) + .apply() + return JSONObject() + .put("status", STATUS_OK) + .put("request_id", requestId) + .toString() + } + + @UsedByGodot + fun moonlitReminderOpenSettings( + requestId: String, + argsJson: String + ): String { + val host = activity + if (host == null) { + return error(requestId, CODE_NO_ACTIVITY, retryable = true) + .toString() + } + val target = if (Build.VERSION.SDK_INT >= 26) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, + host.packageName) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.parse("package:${host.packageName}")) + } + return try { + host.runOnUiThread { host.startActivity(target) } + JSONObject() + .put("status", STATUS_OK) + .put("request_id", requestId) + .toString() + } catch (error: Exception) { + Log.d(TAG, "reminder-settings $requestId failed: " + + error.javaClass.simpleName) + error(requestId, CODE_NETWORK, retryable = true) + .toString() + } + } + + @UsedByGodot + fun moonlitReminderPending( + requestId: String, + argsJson: String + ): String { + val context = activity ?: return error(requestId, + CODE_NO_ACTIVITY, retryable = true).toString() + val store = MoonlitReminderStore.prefs(context) + return JSONObject() + .put("status", STATUS_OK) + .put("schedule_intent_persisted", + MoonlitReminderAlarm.scheduleIntentPersisted( + context)) + .put("alarm_token_present", + MoonlitReminderAlarm.alarmTokenPresent(context)) + .put("stored", JSONObject() + .put("enabled", store.getBoolean( + MoonlitReminderStore.KEY_ENABLED, false)) + .put("account", store.getString( + MoonlitReminderStore.KEY_ACCOUNT, "").orEmpty()) + .put("eligible_millis", store.getLong( + MoonlitReminderStore.KEY_ELIGIBLE_MILLIS, 0L)) + .put("locale", store.getString( + MoonlitReminderStore.KEY_LOCALE, "").orEmpty())) + .put("request_id", requestId) + .toString() + } + + @UsedByGodot + fun moonlitReminderDebugSchedule( + requestId: String, + argsJson: String + ): String { + val context = activity ?: return error(requestId, + CODE_NO_ACTIVITY, retryable = true).toString() + if (!MoonlitReminderAlarm.notificationsAllowed(context)) { + return JSONObject() + .put("status", STATUS_DENIED) + .put("permission", "denied") + .put("code", CODE_PERMISSION_DENIED) + .put("request_id", requestId) + .toString() + } + val args = JSONObject(argsJson) + val delay = args.optDouble("delay_seconds", 0.0) + .coerceIn(DEBUG_DELAY_MIN_SECONDS, + DEBUG_DELAY_MAX_SECONDS) + val store = MoonlitReminderStore.prefs(context) + val title = store.getString( + MoonlitReminderStore.KEY_TITLE, "" + ).orEmpty().ifEmpty { + context.getString(R.string.moonlit_reminder_channel_name) + } + val body = store.getString( + MoonlitReminderStore.KEY_BODY, "" + ).orEmpty().ifEmpty { "QA reminder" } + MoonlitReminderAlarm.ensureChannel(context) + MoonlitReminderAlarm.scheduleDebug( + context, + System.currentTimeMillis() + (delay * 1000L).toLong(), + title, + body + ) + return JSONObject() + .put("status", STATUS_OK) + .put("fire_in_seconds", delay) + .put("request_id", requestId) + .toString() + } + + private fun reminderPermissionOutcome( + requestId: String, + permission: String + ): JSONObject { + return JSONObject() + .put("status", STATUS_OK) + .put("permission", permission) + .put("request_id", requestId) + } + + private fun reminderStatusReceipt( + requestId: String, + context: Context + ): JSONObject { + val allowed = + MoonlitReminderAlarm.notificationsAllowed(context) + val asked = MoonlitReminderStore.prefs(context).getBoolean( + MoonlitReminderStore.KEY_PERMISSION_ASKED, false + ) + val permission = if (allowed) { + "granted" + } else if (Build.VERSION.SDK_INT >= 33 && !asked) { + "unknown" + } else { + "denied" + } + val store = MoonlitReminderStore.prefs(context) + val launched = store.getBoolean( + MoonlitReminderStore.KEY_LAUNCHED, false + ) + if (launched) { + store.edit() + .putBoolean(MoonlitReminderStore.KEY_LAUNCHED, false) + .apply() + } + return JSONObject() + .put("status", STATUS_OK) + .put("permission", permission) + .put("scheduled", + MoonlitReminderAlarm.scheduleIntentPersisted( + context)) + .put("launched_from_reminder", launched) + .put("request_id", requestId) + } + /** * Emit one terminal outcome for a request still in flight. Late or double * completions are dropped. Emission runs on the UI thread; the engine diff --git a/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitReminderAlarm.kt b/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitReminderAlarm.kt new file mode 100644 index 0000000..ce5f2b0 --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/kotlin/dev/moonlitbeacon/identity/MoonlitReminderAlarm.kt @@ -0,0 +1,405 @@ +package dev.moonlitbeacon.identity + +import android.Manifest +import android.app.ActivityManager +import android.app.AlarmManager +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build + +/** + * SharedPreferences mirror of the reminder delivery this install asked + * the OS to hold. The game keeps the authoritative intent in its own + * settings; this mirror lets the alarm receiver and the boot receiver + * enforce the opt-out and re-arm after reboot without waking Godot. + */ +object MoonlitReminderStore { + const val PREFS = "moonlit_reminder" + const val KEY_ENABLED = "enabled" + const val KEY_ACCOUNT = "account" + const val KEY_ELIGIBLE_MILLIS = "eligible_millis" + const val KEY_TITLE = "title" + const val KEY_BODY = "body" + const val KEY_LOCALE = "locale" + const val KEY_PERMISSION_ASKED = "permission_asked" + const val KEY_RESUMED = "resumed" + const val KEY_RESUMED_AT_MILLIS = "resumed_at_millis" + const val KEY_LAUNCHED = "launched_from_reminder" + + fun prefs(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + } +} + +/** + * Battery-conscious twelve-hour attendance alarm. One inexact repeating + * `RTC_WAKEUP` alarm (batched with the system's other alarms under Doze; + * no exact-alarm privilege, no foreground service) plus a boot receiver + * that re-arms it from the persisted mirror. Delivery re-checks the + * persisted opt-out, the OS permission, the channel state, and whether + * the game is foregrounded; reminders are for a player who is away, so + * a foregrounded fire is skipped while the repeat cadence continues. + * + * All intents are explicit and immutable. The launch intent opens the + * ordinary game entry with a `moonlit_reminder` extra the plugin reads + * on resume; tapping never grants or spends anything by itself. + */ +object MoonlitReminderAlarm { + const val ACTION_ALARM = "dev.moonlitbeacon.identity.REMINDER_ALARM" + const val ACTION_DEBUG = "dev.moonlitbeacon.identity.REMINDER_DEBUG" + const val CHANNEL_ID = "moonlit_attendance" + const val NOTIFICATION_ID = 4102 + const val REPEAT_MILLIS = 43200000L + const val REQUEST_ALARM = 4102 + const val REQUEST_DEBUG = 4103 + const val REQUEST_LAUNCH = 4104 + const val EXTRA_LAUNCHED = "moonlit_reminder" + const val EXTRA_TITLE = "debug_title" + const val EXTRA_BODY = "debug_body" + + /** A stuck resumed flag expires: a crash without `onPause` must not + * silence reminders forever. */ + const val RESUMED_FRESH_MILLIS = 600000L + + /** Effective posting verdict: the app-global switch, the runtime + * permission on 33+, and the attendance channel must all allow. + * A bare POST_NOTIFICATIONS check is not enough on any version: + * a global block or a closed channel refuses delivery the same + * way a missing runtime permission does. */ + fun notificationsAllowed(context: Context): Boolean { + if (!appNotificationsEnabled(context)) return false + if (runtimePermissionMissing(context)) return false + return channelOpen(context) + } + + /** True only on 33+ while POST_NOTIFICATIONS is not granted. On + * older versions no runtime permission exists to be missing. */ + fun runtimePermissionMissing(context: Context): Boolean { + if (Build.VERSION.SDK_INT < 33) return false + return context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + } + + /** The app-global notification switch. `areNotificationsEnabled` + * starts at 24; below that the same reflective int-op check the + * support library uses applies, with no new dependency: the op id + * resolves by name at runtime because the string op constant is + * not in this compile SDK. */ + fun appNotificationsEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= 24) { + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + return manager.areNotificationsEnabled() + } + return try { + val ops = context.getSystemService( + Context.APP_OPS_SERVICE + ) as android.app.AppOpsManager + val opsClass = Class.forName(ops.javaClass.name) + val checkOp = opsClass.getMethod("checkOpNoThrow", + Integer.TYPE, Integer.TYPE, String::class.java) + val opField = + opsClass.getDeclaredField("OP_POST_NOTIFICATION") + val op = opField.getInt(null) + val mode = checkOp.invoke(ops, op, + android.os.Process.myUid(), + context.packageName) as Int + mode == android.app.AppOpsManager.MODE_ALLOWED + } catch (error: Exception) { + // An exotic ROM that cannot answer must not newly deny. + true + } + } + + fun channelOpen(context: Context): Boolean { + if (Build.VERSION.SDK_INT < 26) return true + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + val channel = manager.getNotificationChannel(CHANNEL_ID) + ?: return true + return channel.importance != NotificationManager.IMPORTANCE_NONE + } + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < 26) return + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.moonlit_reminder_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + channel.description = context.getString( + R.string.moonlit_reminder_channel_desc + ) + manager.createNotificationChannel(channel) + } + + /** Next trigger on the anchor's twelve-hour grid at or after now: + * a future anchor fires at itself, an overdue one at its next + * future slot. A past trigger would fire its backlog immediately, + * so callers never pass a raw overdue anchor to the alarm. */ + fun firstFutureTrigger(anchorMillis: Long, nowMillis: Long): Long { + if (nowMillis <= anchorMillis) return anchorMillis + val elapsed = nowMillis - anchorMillis + val steps = (elapsed + REPEAT_MILLIS - 1) / REPEAT_MILLIS + return anchorMillis + steps * REPEAT_MILLIS + } + + fun schedule(context: Context, eligibleMillis: Long) { + val alarms = context.getSystemService( + Context.ALARM_SERVICE + ) as AlarmManager + alarms.setInexactRepeating( + AlarmManager.RTC_WAKEUP, + firstFutureTrigger( + eligibleMillis, System.currentTimeMillis() + ), + REPEAT_MILLIS, + alarmIntent(context) + ) + } + + fun scheduleDebug(context: Context, fireMillis: Long, + title: String, body: String + ) { + val alarms = context.getSystemService( + Context.ALARM_SERVICE + ) as AlarmManager + alarms.set( + AlarmManager.RTC_WAKEUP, + fireMillis, + debugIntent(context, title, body) + ) + } + + fun cancel(context: Context) { + val alarms = context.getSystemService( + Context.ALARM_SERVICE + ) as AlarmManager + cancelToken(alarms, context, ACTION_ALARM, REQUEST_ALARM) + cancelToken(alarms, context, ACTION_DEBUG, REQUEST_DEBUG) + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + manager.cancel(NOTIFICATION_ID) + } + + /** Retire one delivery token without creating it: look it up with + * NO_CREATE (extras never distinguish tokens) and, only when one + * exists, drop its alarms and cancel the token itself so later + * probes stop seeing it. */ + private fun cancelToken(alarms: AlarmManager, context: Context, + action: String, requestCode: Int + ) { + val probe = Intent( + context, MoonlitReminderReceiver::class.java + ).setAction(action) + val existing = PendingIntent.getBroadcast( + context, + requestCode, + probe, + PendingIntent.FLAG_NO_CREATE or + PendingIntent.FLAG_IMMUTABLE + ) ?: return + alarms.cancel(existing) + existing.cancel() + } + + /** What this install asked the OS to hold, from the persisted + * mirror. Intent, not an AlarmManager query: no public API + * reports a held alarm. */ + fun scheduleIntentPersisted(context: Context): Boolean { + val store = MoonlitReminderStore.prefs(context) + return store.getBoolean( + MoonlitReminderStore.KEY_ENABLED, false + ) && store.getLong( + MoonlitReminderStore.KEY_ELIGIBLE_MILLIS, 0L + ) > 0L + } + + /** Whether a matching alarm token exists right now. Token + * existence only: scheduling, firing, and retiring all shape + * the token table, so this never claims the OS holds an alarm. */ + fun alarmTokenPresent(context: Context): Boolean { + val intent = Intent(context, + MoonlitReminderReceiver::class.java).setAction(ACTION_ALARM) + val existing = PendingIntent.getBroadcast( + context, + REQUEST_ALARM, + intent, + PendingIntent.FLAG_NO_CREATE or + PendingIntent.FLAG_IMMUTABLE + ) + return existing != null + } + + fun deliver(context: Context, title: String, body: String) { + ensureChannel(context) + val builder = if (Build.VERSION.SDK_INT >= 26) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + builder + .setSmallIcon(R.drawable.ic_moonlit_beacon) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + val launch = context.packageManager + .getLaunchIntentForPackage(context.packageName) + if (launch != null) { + launch.putExtra(EXTRA_LAUNCHED, true) + launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + builder.setContentIntent( + PendingIntent.getActivity( + context, + REQUEST_LAUNCH, + launch, + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE + ) + ) + } + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + manager.notify(NOTIFICATION_ID, builder.build()) + } + + /** + * True when the game is genuinely foregrounded right now: either the + * OS still ranks this process foreground, or the plugin recorded a + * fresh resume. A stale resume record (crash without pause) never + * suppresses on its own. + */ + fun appForegrounded(context: Context): Boolean { + val store = MoonlitReminderStore.prefs(context) + if (store.getBoolean(MoonlitReminderStore.KEY_RESUMED, false)) { + val at = store.getLong( + MoonlitReminderStore.KEY_RESUMED_AT_MILLIS, 0L + ) + if (System.currentTimeMillis() - at + < RESUMED_FRESH_MILLIS + ) { + return true + } + } + val manager = context.getSystemService( + Context.ACTIVITY_SERVICE + ) as ActivityManager + val processes = manager.runningAppProcesses ?: return false + val pid = android.os.Process.myPid() + for (info in processes) { + if (info.pid == pid) { + return info.importance == ActivityManager + .RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + } + return false + } + + private fun alarmIntent(context: Context): PendingIntent { + val intent = Intent( + context, MoonlitReminderReceiver::class.java + ).setAction(ACTION_ALARM) + return PendingIntent.getBroadcast( + context, + REQUEST_ALARM, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun debugIntent(context: Context, title: String, + body: String + ): PendingIntent { + val intent = Intent( + context, MoonlitReminderReceiver::class.java + ).setAction(ACTION_DEBUG) + intent.putExtra(EXTRA_TITLE, title) + intent.putExtra(EXTRA_BODY, body) + return PendingIntent.getBroadcast( + context, + REQUEST_DEBUG, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE + ) + } +} + +/** + * Alarm, debug, and boot receiver for attendance reminders. Every + * delivery path re-checks the persisted opt-out, the OS permission, the + * channel state, and the foreground state before posting. + */ +class MoonlitReminderReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED -> onBoot(context) + MoonlitReminderAlarm.ACTION_DEBUG -> onDebug(context, intent) + else -> onAlarm(context) + } + } + + private fun onBoot(context: Context) { + val store = MoonlitReminderStore.prefs(context) + if (!store.getBoolean(MoonlitReminderStore.KEY_ENABLED, false)) { + return + } + val eligible = store.getLong( + MoonlitReminderStore.KEY_ELIGIBLE_MILLIS, 0L + ) + if (eligible <= 0L) return + // Raw anchor in: schedule() moves an overdue trigger onto the + // anchor grid's next future slot instead of firing now. + MoonlitReminderAlarm.schedule(context, eligible) + } + + private fun onAlarm(context: Context) { + val store = MoonlitReminderStore.prefs(context) + if (!store.getBoolean(MoonlitReminderStore.KEY_ENABLED, false)) { + return + } + if (!MoonlitReminderAlarm.notificationsAllowed(context)) return + if (!MoonlitReminderAlarm.channelOpen(context)) return + if (MoonlitReminderAlarm.appForegrounded(context)) return + MoonlitReminderAlarm.deliver( + context, + store.getString(MoonlitReminderStore.KEY_TITLE, "") + .orEmpty(), + store.getString(MoonlitReminderStore.KEY_BODY, "") + .orEmpty() + ) + } + + private fun onDebug(context: Context, intent: Intent) { + // QA path: same OS and foreground guards as production, but no + // Settings intent or twelve-hour deadline is required. + if (!MoonlitReminderAlarm.notificationsAllowed(context)) return + if (!MoonlitReminderAlarm.channelOpen(context)) return + if (MoonlitReminderAlarm.appForegrounded(context)) return + MoonlitReminderAlarm.deliver( + context, + intent.getStringExtra(MoonlitReminderAlarm.EXTRA_TITLE) + .orEmpty(), + intent.getStringExtra(MoonlitReminderAlarm.EXTRA_BODY) + .orEmpty() + ) + } +} diff --git a/apps/game/addons/moonlit-identity/android/src/main/res/drawable/ic_moonlit_beacon.xml b/apps/game/addons/moonlit-identity/android/src/main/res/drawable/ic_moonlit_beacon.xml new file mode 100644 index 0000000..79a8365 --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/res/drawable/ic_moonlit_beacon.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/apps/game/addons/moonlit-identity/android/src/main/res/values-ja/strings.xml b/apps/game/addons/moonlit-identity/android/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..1705d6c --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/res/values-ja/strings.xml @@ -0,0 +1,5 @@ + + + 出席報酬 + 出席コインが受け取れるときにお知らせします + diff --git a/apps/game/addons/moonlit-identity/android/src/main/res/values-ko/strings.xml b/apps/game/addons/moonlit-identity/android/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..375e36b --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/res/values-ko/strings.xml @@ -0,0 +1,5 @@ + + + 출석 보상 + 출석 코인을 받을 수 있을 때 알려 드립니다 + diff --git a/apps/game/addons/moonlit-identity/android/src/main/res/values-zh-rCN/strings.xml b/apps/game/addons/moonlit-identity/android/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..4063a3f --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,5 @@ + + + 签到奖励 + 可在领取签到币时提醒您 + diff --git a/apps/game/addons/moonlit-identity/android/src/main/res/values-zh-rTW/strings.xml b/apps/game/addons/moonlit-identity/android/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..9e1d843 --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,5 @@ + + + 簽到獎勵 + 可在領取簽到幣時提醒您 + diff --git a/apps/game/addons/moonlit-identity/android/src/main/res/values/strings.xml b/apps/game/addons/moonlit-identity/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..6d70465 --- /dev/null +++ b/apps/game/addons/moonlit-identity/android/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Attendance rewards + Reminds you when attendance coins are ready + diff --git a/apps/game/addons/moonlit-identity/ios/PrivacyInfo.xcprivacy b/apps/game/addons/moonlit-identity/ios/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..0e3bf79 --- /dev/null +++ b/apps/game/addons/moonlit-identity/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,19 @@ + + + + + NSPrivacyTracking + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/apps/game/addons/moonlit-identity/ios/include/MoonlitIdentityIos.h b/apps/game/addons/moonlit-identity/ios/include/MoonlitIdentityIos.h index dffbf9e..5d73223 100644 --- a/apps/game/addons/moonlit-identity/ios/include/MoonlitIdentityIos.h +++ b/apps/game/addons/moonlit-identity/ios/include/MoonlitIdentityIos.h @@ -35,6 +35,23 @@ class MoonlitIdentityIos : public godot::Object { const godot::String &p_args_json); // Answers `cancelled` while no mutation began, `draining` once one did. godot::String moonlitCancelRequest(const godot::String &p_request_id); + // Local attendance reminders (UserNotifications; no Firebase). Status + // answers synchronously from a cached authorization state; permission + // and pending diagnostics answer `pending` and settle on the signal. + godot::String moonlitReminderStatus(const godot::String &p_request_id, + const godot::String &p_args_json); + godot::String moonlitReminderRequestPermission( + const godot::String &p_request_id, const godot::String &p_args_json); + godot::String moonlitReminderSchedule(const godot::String &p_request_id, + const godot::String &p_args_json); + godot::String moonlitReminderCancel(const godot::String &p_request_id, + const godot::String &p_args_json); + godot::String moonlitReminderOpenSettings( + const godot::String &p_request_id, const godot::String &p_args_json); + godot::String moonlitReminderPending(const godot::String &p_request_id, + const godot::String &p_args_json); + godot::String moonlitReminderDebugSchedule( + const godot::String &p_request_id, const godot::String &p_args_json); // Deferred emission point. Native SDK callbacks (main queue) never emit // the Godot signal directly; they call_deferred into this method, which diff --git a/apps/game/addons/moonlit-identity/ios/src/MoonlitIdentityIos.mm b/apps/game/addons/moonlit-identity/ios/src/MoonlitIdentityIos.mm index 8c44bc6..e05ec44 100644 --- a/apps/game/addons/moonlit-identity/ios/src/MoonlitIdentityIos.mm +++ b/apps/game/addons/moonlit-identity/ios/src/MoonlitIdentityIos.mm @@ -51,8 +51,10 @@ #import #import #import +#import #include "MoonlitIdentityIos.h" +#include "MoonlitReminderPlanner.h" #include #include @@ -82,6 +84,50 @@ static NSString *const kCodeAppleUnavailable = @"apple_sign_in_unavailable"; static NSString *const kCodeGoogleUnavailable = @"google_sign_in_unavailable"; static NSString *const kCodeSignOutFailed = @"sign_out_failed"; +static NSString *const kCodePermissionDenied = @"permission_denied"; +static NSString *const kCodeInvalidArgs = @"invalid_args"; +static NSString *const kCodeRequestBusy = @"request_busy"; +static NSString *const kStatusDenied = @"denied"; + +// Attendance-reminder request identifiers. Deliveries are a bounded +// horizon of owned non-repeating slots at the server eligibility plus +// N * 43200 seconds; cancellation names exactly these plus the legacy +// ids below and never touches unrelated pending requests. +static NSString *const kReminderSlotPrefix = + @"dev.moonlitbeacon.attendance.slot-"; +// The horizon refills from the same anchor on a genuine foreground +// visit and resets on the next attendance claim. Slot counts and the +// refill math live in MoonlitReminderPlanner.h, shared with the +// host-side numeric test so the shipped computation is the proven one. +// Legacy one-shot/repeat shape plus the QA one-shot: scheduling +// replaces their pending copies (no double delivery across the +// migration) and cancellation removes them with the horizon. +static NSString *const kReminderFirstId = + @"dev.moonlitbeacon.attendance.first"; +static NSString *const kReminderRepeatId = + @"dev.moonlitbeacon.attendance.repeat"; +static NSString *const kReminderDebugId = + @"dev.moonlitbeacon.attendance.debug"; +// App-only scheduling metadata in standardUserDefaults (declared in the +// app PrivacyInfo.xcprivacy as CA92.1): the requested account, eligible +// time, and locale, so identical re-schedules skip without shifting the +// repeat anchor, plus the held window's base slot and end time and the +// diagnostics baseline. No token, email, name, or wallet value is +// stored here. +static NSString *const kReminderDefaultsAccount = + @"dev.moonlitbeacon.reminder.account"; +static NSString *const kReminderDefaultsEligibleMillis = + @"dev.moonlitbeacon.reminder.eligible_millis"; +static NSString *const kReminderDefaultsLocale = + @"dev.moonlitbeacon.reminder.locale"; +static NSString *const kReminderDefaultsScheduledAt = + @"dev.moonlitbeacon.reminder.scheduled_at"; +static NSString *const kReminderDefaultsBaseSlot = + @"dev.moonlitbeacon.reminder.base_slot"; +static NSString *const kReminderDefaultsHorizonEnd = + @"dev.moonlitbeacon.reminder.horizon_end"; +static const NSTimeInterval kReminderDebugMinSeconds = 5.0; +static const NSTimeInterval kReminderDebugMaxSeconds = 600.0; // GoogleSignIn dismissal, from GIDSignInError (the canceled code in the // com.google.GIDSignIn domain). Spelled as literals because the SDK's ObjC @@ -171,7 +217,8 @@ + (void)installOnce { // --------------------------------------------------------------------------- @interface MoonlitIdentityWorker : NSObject + ASAuthorizationControllerPresentationContextProviding, + UNUserNotificationCenterDelegate> @property(nonatomic, assign) MoonlitIdentityIos *owner; @property(nonatomic, strong) NSMutableSet *pending; @@ -186,6 +233,15 @@ @interface MoonlitIdentityWorker : NSObject 0.0 && + MoonlitReminderWindowHolds(recordedBase, firstFuture)) { + // Identical inputs with an adequate horizon: the center + // already holds these absolute slots, so touching them + // would only churn. A nearly consumed or exhausted window + // falls through to the refill below, still anchored to + // the same deadline. The receipt carries the held window + // so the game side keeps its known metadata instead of + // recording an unknown zero and re-asking forever. + return [self jsonString:@{ + @"status" : kStatusOk, + @"eligible_millis" : @(eligibleMillis), + @"duplicate" : @YES, + @"scheduled_slots" : @(MoonlitReminderRemaining( + recordedBase, firstFuture)), + @"base_slot" : @(recordedBase), + @"horizon_end_unix" : @(recordedEnd), + @"request_id" : requestId + }]; + } + UNUserNotificationCenter *center = + [UNUserNotificationCenter currentNotificationCenter]; + // Replace the whole owned horizon plus any legacy pending copies, + // so a device migrating from the one-shot/repeat shape cannot + // double-deliver. Identifiers are stable ordinals 0..47 while + // timestamps stay eligibility + N * 43200: a refill never shifts + // the anchor, and re-added slots keep absolute times. + NSMutableArray *replaced = + [[self reminderSlotIdentifiers] mutableCopy]; + [replaced addObjectsFromArray:@[ + kReminderFirstId, kReminderRepeatId + ]]; + [center removePendingNotificationRequestsWithIdentifiers:replaced]; + UNMutableNotificationContent *content = + [[UNMutableNotificationContent alloc] init]; + content.title = title; + content.body = body; + content.sound = [UNNotificationSound defaultSound]; + long long added = 0; + double lastFire = 0.0; + for (long long pos = 0; pos < kReminderHorizonSlots; pos++) { + long long slot = firstFuture + pos; + long long fireMs = MoonlitReminderSlotFireMs( + eligibleMillis, slot); + if (fireMs < nowMillis) { + // Strictly elapsed slots stay unscheduled: the past + // must never burst as immediate deliveries, even if + // the clock jumped between the window computation and + // this loop. A slot due exactly now schedules once; + // the next computation already counts it as past. + continue; + } + NSTimeInterval delay = + (NSTimeInterval)(fireMs - nowMillis) / 1000.0; + if (delay < 1.0) { + delay = 1.0; + } + UNTimeIntervalNotificationTrigger *trigger = + [UNTimeIntervalNotificationTrigger + triggerWithTimeInterval:delay + repeats:NO]; + NSString *identifier = [kReminderSlotPrefix + stringByAppendingFormat:@"%02lld", pos]; + UNNotificationRequest *request = + [UNNotificationRequest requestWithIdentifier:identifier + content:content + trigger:trigger]; + [center addNotificationRequest:request + withCompletionHandler:nil]; + added++; + lastFire = (double)fireMs / 1000.0; + } + [defaults setObject:account forKey:kReminderDefaultsAccount]; + [defaults setObject:@(eligibleMillis) + forKey:kReminderDefaultsEligibleMillis]; + [defaults setObject:locale forKey:kReminderDefaultsLocale]; + [defaults setDouble:now forKey:kReminderDefaultsScheduledAt]; + [defaults setObject:@(firstFuture) forKey:kReminderDefaultsBaseSlot]; + [defaults setDouble:lastFire forKey:kReminderDefaultsHorizonEnd]; + return [self jsonString:@{ + @"status" : kStatusOk, + @"eligible_millis" : @(eligibleMillis), + @"scheduled_slots" : @(added), + @"base_slot" : @(firstFuture), + @"horizon_end_unix" : @(lastFire), + @"request_id" : requestId + }]; +} + +- (NSArray *)reminderSlotIdentifiers { + NSMutableArray *ids = [NSMutableArray + arrayWithCapacity:(NSUInteger)kReminderHorizonSlots]; + for (long long slot = 0; slot < kReminderHorizonSlots; slot++) { + [ids addObject:[kReminderSlotPrefix + stringByAppendingFormat:@"%02lld", slot]]; + } + return ids; +} + +- (NSString *)reminderCancel:(NSString *)requestId { + UNUserNotificationCenter *center = + [UNUserNotificationCenter currentNotificationCenter]; + // The whole owned horizon plus every legacy identifier. Other + // app notifications are never named here. + NSMutableArray *owned = + [[self reminderSlotIdentifiers] mutableCopy]; + [owned addObjectsFromArray:@[ + kReminderFirstId, kReminderRepeatId, kReminderDebugId + ]]; + [center removePendingNotificationRequestsWithIdentifiers:owned]; + [center removeDeliveredNotificationsWithIdentifiers:owned]; + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults removeObjectForKey:kReminderDefaultsAccount]; + [defaults removeObjectForKey:kReminderDefaultsEligibleMillis]; + [defaults removeObjectForKey:kReminderDefaultsLocale]; + [defaults removeObjectForKey:kReminderDefaultsScheduledAt]; + [defaults removeObjectForKey:kReminderDefaultsBaseSlot]; + [defaults removeObjectForKey:kReminderDefaultsHorizonEnd]; + return [self jsonString:@{ + @"status" : kStatusOk, + @"request_id" : requestId + }]; +} + +- (NSString *)reminderOpenSettings:(NSString *)requestId { + dispatch_async(dispatch_get_main_queue(), ^{ + NSURL *url = [NSURL + URLWithString:UIApplicationOpenSettingsURLString]; + if (url != nil) { + [[UIApplication sharedApplication] openURL:url + options:@{} + completionHandler:nil]; + } + }); + return [self jsonString:@{ + @"status" : kStatusOk, + @"request_id" : requestId + }]; +} + +- (NSString *)reminderPending:(NSString *)requestId { + if (![self beginRequest:requestId]) { + return [self jsonString:@{ + @"status" : kStatusError, + @"code" : kCodeAlreadyPending, + @"request_id" : requestId + }]; + } + UNUserNotificationCenter *center = + [UNUserNotificationCenter currentNotificationCenter]; + [center getPendingNotificationRequestsWithCompletionHandler:^( + NSArray *requests) { + NSMutableArray *held = [NSMutableArray array]; + long long heldSlots = 0; + for (UNNotificationRequest *request in requests) { + NSString *identifier = request.identifier; + BOOL owned = [identifier + hasPrefix:kReminderSlotPrefix] || + [identifier isEqualToString:kReminderFirstId] || + [identifier isEqualToString:kReminderRepeatId] || + [identifier isEqualToString:kReminderDebugId]; + if (!owned) { + continue; + } + if ([identifier hasPrefix:kReminderSlotPrefix]) { + heldSlots++; + } + UNNotificationTrigger *trigger = request.trigger; + NSTimeInterval interval = -1; + BOOL repeats = NO; + NSDate *next = nil; + if ([trigger + isKindOfClass:[UNTimeIntervalNotificationTrigger class]]) { + UNTimeIntervalNotificationTrigger *timed = + (UNTimeIntervalNotificationTrigger *)trigger; + interval = timed.timeInterval; + repeats = timed.repeats; + next = timed.nextTriggerDate; + } else if ([trigger + isKindOfClass:[UNCalendarNotificationTrigger class]]) { + next = ((UNCalendarNotificationTrigger *)trigger) + .nextTriggerDate; + } + [held addObject:@{ + @"identifier" : identifier, + @"interval_seconds" : @(interval), + @"repeats" : @(repeats), + @"next_trigger_unix" : next != nil + ? @([next timeIntervalSince1970]) + : @(-1) + }]; + } + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + NSNumber *anchorNumber = + [defaults objectForKey:kReminderDefaultsEligibleMillis]; + long long anchor = + [anchorNumber isKindOfClass:[NSNumber class]] + ? [anchorNumber longLongValue] + : -1; + NSNumber *baseNumber = + [defaults objectForKey:kReminderDefaultsBaseSlot]; + long long base = + [baseNumber isKindOfClass:[NSNumber class]] + ? [baseNumber longLongValue] + : 0; + double windowEnd = + [defaults objectForKey:kReminderDefaultsHorizonEnd] + != nil + ? [defaults doubleForKey:kReminderDefaultsHorizonEnd] + : 0.0; + [self finishRequest:requestId outcome:@{ + @"status" : kStatusOk, + @"held" : held, + @"recorded" : @{ + @"account" : [defaults + stringForKey:kReminderDefaultsAccount] ?: @"", + @"eligible_millis" : @(anchor), + @"locale" : [defaults + stringForKey:kReminderDefaultsLocale] ?: @"" + }, + @"horizon" : @{ + @"anchor_eligible_millis" : @(anchor), + @"slot_count" : @(kReminderHorizonSlots), + @"base_slot" : @(base), + @"held_slots" : @(heldSlots), + @"horizon_end_unix" : @(windowEnd) + }, + @"request_id" : requestId + }]; + }]; + return [self jsonString:@{ + @"status" : kStatusPending, + @"request_id" : requestId + }]; +} + +- (NSString *)reminderDebugSchedule:(NSString *)requestId + args:(NSDictionary *)args { + NSInteger auth = UNAuthorizationStatusNotDetermined; + @synchronized(self) { + auth = _reminderAuthStatus; + } + if (![self reminderAuthGrantsDelivery:auth]) { + [self refreshReminderAuthStatus]; + return [self jsonString:@{ + @"status" : kStatusDenied, + @"permission" : [self reminderPermissionName:auth], + @"code" : kCodePermissionDenied, + @"request_id" : requestId + }]; + } + NSTimeInterval delay = [args[@"delay_seconds"] doubleValue]; + if (delay < kReminderDebugMinSeconds) { + delay = kReminderDebugMinSeconds; + } + if (delay > kReminderDebugMaxSeconds) { + delay = kReminderDebugMaxSeconds; + } + UNUserNotificationCenter *center = + [UNUserNotificationCenter currentNotificationCenter]; + [center removePendingNotificationRequestsWithIdentifiers:@[ + kReminderDebugId + ]]; + UNMutableNotificationContent *content = + [[UNMutableNotificationContent alloc] init]; + content.title = @"Moonlit Beacon QA"; + content.body = @"QA reminder"; + content.sound = [UNNotificationSound defaultSound]; + UNTimeIntervalNotificationTrigger *trigger = + [UNTimeIntervalNotificationTrigger + triggerWithTimeInterval:delay + repeats:NO]; + UNNotificationRequest *request = + [UNNotificationRequest requestWithIdentifier:kReminderDebugId + content:content + trigger:trigger]; + [center addNotificationRequest:request withCompletionHandler:nil]; + return [self jsonString:@{ + @"status" : kStatusOk, + @"fire_in_seconds" : @(delay), + @"request_id" : requestId + }]; +} @end -// --------------------------------------------------------------------------- // GDExtension binding. // --------------------------------------------------------------------------- @@ -1331,6 +1902,24 @@ - (void)deleteFirebaseUserAfterReauth:(NSString *)requestId user:(FIRUser *)user "request_id", "args_json"), &MoonlitIdentityIos::moonlitDeleteAccount); godot::ClassDB::bind_method(godot::D_METHOD("moonlitCancelRequest", "request_id"), &MoonlitIdentityIos::moonlitCancelRequest); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderStatus", + "request_id", "args_json"), &MoonlitIdentityIos::moonlitReminderStatus); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderRequestPermission", + "request_id", "args_json"), + &MoonlitIdentityIos::moonlitReminderRequestPermission); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderSchedule", + "request_id", "args_json"), + &MoonlitIdentityIos::moonlitReminderSchedule); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderCancel", + "request_id", "args_json"), &MoonlitIdentityIos::moonlitReminderCancel); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderOpenSettings", + "request_id", "args_json"), + &MoonlitIdentityIos::moonlitReminderOpenSettings); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderPending", + "request_id", "args_json"), &MoonlitIdentityIos::moonlitReminderPending); + godot::ClassDB::bind_method(godot::D_METHOD("moonlitReminderDebugSchedule", + "request_id", "args_json"), + &MoonlitIdentityIos::moonlitReminderDebugSchedule); godot::ClassDB::bind_method(godot::D_METHOD("_emit_outcome", "outcome_json"), &MoonlitIdentityIos::_emit_outcome); ADD_SIGNAL(godot::MethodInfo("moonlit_identity_event", @@ -1433,6 +2022,58 @@ - (void)deleteFirebaseUserAfterReauth:(NSString *)requestId user:(FIRUser *)user return ns_to_godot([worker cancelRequest:godot_to_ns(p_request_id)]); } +godot::String MoonlitIdentityIos::moonlitReminderStatus( + const godot::String &p_request_id, const godot::String &p_args_json) { + (void)p_args_json; + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot([worker reminderStatus:godot_to_ns(p_request_id)]); +} + +godot::String MoonlitIdentityIos::moonlitReminderRequestPermission( + const godot::String &p_request_id, const godot::String &p_args_json) { + (void)p_args_json; + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot( + [worker reminderRequestPermission:godot_to_ns(p_request_id)]); +} + +godot::String MoonlitIdentityIos::moonlitReminderSchedule( + const godot::String &p_request_id, const godot::String &p_args_json) { + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot([worker reminderSchedule:godot_to_ns(p_request_id) + args:[worker parseArgs:godot_to_ns(p_args_json)]]); +} + +godot::String MoonlitIdentityIos::moonlitReminderCancel( + const godot::String &p_request_id, const godot::String &p_args_json) { + (void)p_args_json; + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot([worker reminderCancel:godot_to_ns(p_request_id)]); +} + +godot::String MoonlitIdentityIos::moonlitReminderOpenSettings( + const godot::String &p_request_id, const godot::String &p_args_json) { + (void)p_args_json; + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot( + [worker reminderOpenSettings:godot_to_ns(p_request_id)]); +} + +godot::String MoonlitIdentityIos::moonlitReminderPending( + const godot::String &p_request_id, const godot::String &p_args_json) { + (void)p_args_json; + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot([worker reminderPending:godot_to_ns(p_request_id)]); +} + +godot::String MoonlitIdentityIos::moonlitReminderDebugSchedule( + const godot::String &p_request_id, const godot::String &p_args_json) { + MoonlitIdentityWorker *worker = (__bridge MoonlitIdentityWorker *)_worker; + return ns_to_godot( + [worker reminderDebugSchedule:godot_to_ns(p_request_id) + args:[worker parseArgs:godot_to_ns(p_args_json)]]); +} + void MoonlitIdentityIos::_emit_outcome( const godot::String &p_outcome_json) { emit_signal("moonlit_identity_event", p_outcome_json); diff --git a/apps/game/addons/moonlit-identity/ios/src/MoonlitReminderPlanner.h b/apps/game/addons/moonlit-identity/ios/src/MoonlitReminderPlanner.h new file mode 100644 index 0000000..0a34f60 --- /dev/null +++ b/apps/game/addons/moonlit-identity/ios/src/MoonlitReminderPlanner.h @@ -0,0 +1,73 @@ +// Pure attendance-reminder horizon planner, shared by the iOS native +// schedule path and the host-side numeric test. All times are integer +// UTC milliseconds and all math is exact: the equal-time boundary and +// the duplicate gate must decide identically on every run, with no +// floating-point epsilon between "due now" and "elapsed". +// +// A horizon is 48 twelve-hour slots on one server-owned anchor. Slot k +// fires at anchorMs + k * 43200000. A schedule plans the 48 slots from +// the first not-yet-elapsed position; an identical re-schedule while at +// least 8 of the recorded window are still future is a duplicate that +// touches nothing. The game-side refill margin mirrors this rule from +// the recorded window metadata so both sides agree when a refill is +// due. A slot firing exactly now is due, not elapsed: it schedules +// once (the caller clamps its trigger to a valid delay) and never +// replays as a burst, because the next computation already counts it +// as past. +#ifndef MOONLIT_REMINDER_PLANNER_H +#define MOONLIT_REMINDER_PLANNER_H + +// 48 deliveries cover 24 days away and stay under the 64-request OS +// pending budget, leaving room for other app requests plus the QA +// one-shot. +static const long long kReminderHorizonSlots = 48; +// A held horizon with at least this many future slots is adequate: +// identical re-schedules skip without touching the center. +static const long long kReminderMinFutureSlots = 8; +// Twelve hours in milliseconds. +static const long long kReminderRepeatMillis = 43200000LL; + +// First slot not yet elapsed: the smallest k with +// anchorMs + k * REPEAT >= nowMs. Zero while the anchor is still +// future; exact at every grid multiple (no float division). +static inline long long MoonlitReminderFirstFuture( + long long anchorMs, long long nowMs) { + if (nowMs <= anchorMs) { + return 0; + } + return (nowMs - anchorMs + kReminderRepeatMillis - 1) + / kReminderRepeatMillis; +} + +// Absolute fire time of an ordinal slot on its anchor. +static inline long long MoonlitReminderSlotFireMs( + long long anchorMs, long long slot) { + return anchorMs + slot * kReminderRepeatMillis; +} + +// Future slots still held from a recorded window base: the 48 +// scheduled ordinals minus the elapsed prefix. A base entirely ahead +// of now (the clock regressed) counts whole; the gate below still +// refills that corner rather than holding it. +static inline long long MoonlitReminderRemaining( + long long recordedBase, long long firstFuture) { + if (firstFuture < recordedBase) { + return kReminderHorizonSlots; + } + long long remaining = + recordedBase + kReminderHorizonSlots - firstFuture; + return remaining < 0 ? 0 : remaining; +} + +// The native duplicate gate: identical inputs hold while at least 8 +// of the recorded window's slots are still future. +static inline int MoonlitReminderWindowHolds( + long long recordedBase, long long firstFuture) { + if (firstFuture < recordedBase) { + return 0; + } + return (firstFuture - recordedBase) + kReminderMinFutureSlots + <= kReminderHorizonSlots; +} + +#endif // MOONLIT_REMINDER_PLANNER_H diff --git a/apps/game/addons/moonlit-identity/ios_export_manifest.gd b/apps/game/addons/moonlit-identity/ios_export_manifest.gd index fb089fd..f46d864 100644 --- a/apps/game/addons/moonlit-identity/ios_export_manifest.gd +++ b/apps/game/addons/moonlit-identity/ios_export_manifest.gd @@ -41,6 +41,7 @@ const SYSTEM_FRAMEWORKS: Array[String] = [ "SafariServices", "Security", "SystemConfiguration", + "UserNotifications", "WebKit", ] const LINKER_FLAGS: String = "-ObjC -lz" @@ -232,6 +233,35 @@ static func bundle_has_manifest(bundle_dir: String) -> bool: bundle_dir + "/" + PRIVACY_MANIFEST_FILE) +## App-owned privacy declaration for the reminder worker's own +## `NSUserDefaults` keys (scheduled account/eligible/locale plus the +## diagnostics baseline, `dev.moonlitbeacon.reminder.*`). Reason CA92.1: +## reading and writing values the app itself stored. This file is the +## merge source: the owned export pipeline merges it into Godot's +## generated app-root manifest (a loose registration would duplicate +## Godot's own file and fail Xcode); the Firebase/Google SDK manifests +## travel untouched inside their own bundles above. +const APP_PRIVACY_MANIFEST_PATH: String = \ + "res://addons/moonlit-identity/ios/PrivacyInfo.xcprivacy" +const APP_PRIVACY_API: String = \ + "NSPrivacyAccessedAPICategoryUserDefaults" +const APP_PRIVACY_REASON: String = "CA92.1" + + +static func app_privacy_manifest_path() -> String: + return APP_PRIVACY_MANIFEST_PATH + + +## True when the app declaration exists and names exactly the accessed +## API category plus its required reason. +static func app_privacy_manifest_ready( + path: String = APP_PRIVACY_MANIFEST_PATH) -> bool: + if path.is_empty() or not FileAccess.file_exists(path): + return false + var text: String = FileAccess.get_file_as_string(path) + return APP_PRIVACY_API in text and APP_PRIVACY_REASON in text + + static func _child_paths(parent_dir: String, suffix: String) -> PackedStringArray: var found: PackedStringArray = PackedStringArray() if parent_dir.is_empty() or not DirAccess.dir_exists_absolute( diff --git a/apps/game/addons/moonlit-identity/moonlit_identity.gd b/apps/game/addons/moonlit-identity/moonlit_identity.gd index b07aef4..f00a368 100644 --- a/apps/game/addons/moonlit-identity/moonlit_identity.gd +++ b/apps/game/addons/moonlit-identity/moonlit_identity.gd @@ -340,6 +340,77 @@ func delete_account(options: Dictionary = {}) -> Dictionary: return _native_call("moonlitDeleteAccount", options) +## Local attendance reminders. A separate API on the same natives, with +## no Firebase or provider gating: a local notification needs no cloud. +## Every method answers synchronously with a terminal receipt, except +## `reminder_request_permission`, which answers `pending` and settles on +## `request_completed` like an identity call, and `reminder_status`, +## which answers `pending` on iOS (the OS read is async-only) and +## settles the same way. Where no native side exists +## every method answers `unsupported` and the game stays playable. +## +## Reminder payloads carry only scheduling facts (`permission`, +## `eligible_utc`, `remaining_seconds`, `locale`, `account`): no token, +## email, name, or wallet value crosses this layer. + +## Live OS notification state plus what this install scheduled. +## `permission` is `granted`, `denied`, or `unknown` (never asked); +## `scheduled` tells whether this install asked the OS to hold an +## attendance delivery (persisted intent: no platform exposes a live +## OS-held query, and token probes cannot stand in for one); +## `launched_from_reminder` is true once when this process started from +## the reminder's launch action. A retryable `error` (for example the +## activity is between lifecycles) keeps the owned delivery: it is a +## reason to retry, never to cancel. +func reminder_status() -> Dictionary: + return _native_call("moonlitReminderStatus", {}) + + +## Ask the OS for notification permission. The system sheet waits on a +## human, so this uses the interactive window; the terminal receipt +## carries the resulting `permission`. +func reminder_request_permission() -> Dictionary: + return _native_call("moonlitReminderRequestPermission", {}) + + +## Schedule the first fire at the server-confirmed eligibility and a +## twelve-hour repeat after it. `args` carries `eligible_utc_millis` +## (absolute, server-derived), `title`, `body`, `locale`, and `account`. +## Replaces any earlier attendance schedule; answers `ok` with the +## stored `eligible_utc` plus the held window (`base_slot`, +## `horizon_end_unix`, `scheduled_slots`, also on duplicates) or an +## honest `error`/`denied` receipt. +func reminder_schedule(args: Dictionary) -> Dictionary: + return _native_call("moonlitReminderSchedule", args) + + +## Cancel any scheduled attendance delivery and dismiss a delivered +## attendance notification. Never touches unrelated requests. +func reminder_cancel() -> Dictionary: + return _native_call("moonlitReminderCancel", {}) + + +## Open the app's OS notification settings so a denied player can grant +## access by hand. Answers `ok` when the settings page opened. +func reminder_open_settings() -> Dictionary: + return _native_call("moonlitReminderOpenSettings", {}) + + +## Pending-delivery diagnostics for QA: the native side reports what it +## actually holds (identifiers plus trigger times), never a wish. +func reminder_pending_diagnostics() -> Dictionary: + return _native_call("moonlitReminderPending", {}) + + +## QA only: schedule one short non-repeating test delivery in +## `delay_seconds`, without touching the production twelve-hour rule or +## any wallet. The isolated native harness drives this; production play +## never calls it. +func reminder_debug_schedule(delay_seconds: float) -> Dictionary: + return _native_call("moonlitReminderDebugSchedule", + {"delay_seconds": delay_seconds}) + + func cancel_request(request_id: String) -> Dictionary: if not _pending.has(request_id): return {"status": STATUS_ERROR, "code": CODE_NO_PENDING_REQUEST} @@ -465,7 +536,8 @@ func _missing_provider_keys_for(provider: String) -> Array: ## and delete calls keep the ordinary network window. func _timeout_for_method(method: String) -> float: if method == "moonlitSignInProvider" \ - or method == "moonlitLinkProvider": + or method == "moonlitLinkProvider" \ + or method == "moonlitReminderRequestPermission": return _interactive_timeout_seconds return _request_timeout_seconds diff --git a/apps/game/addons/moonlit-identity/moonlit_identity_plugin.gd b/apps/game/addons/moonlit-identity/moonlit_identity_plugin.gd index 555e794..f0ac3d0 100644 --- a/apps/game/addons/moonlit-identity/moonlit_identity_plugin.gd +++ b/apps/game/addons/moonlit-identity/moonlit_identity_plugin.gd @@ -125,6 +125,7 @@ class MoonlitIdentityExportPlugin extends EditorExportPlugin: _add_ios_plist_content( IosExportManifest.plist_url_types_content(IDENTITY_CONFIG_PATH)) _export_ios_frameworks(is_debug) + _export_app_privacy_manifest() func _export_ios_frameworks(is_debug: bool) -> void: # Static Firebase frameworks, staged per variant by --build-ios @@ -174,6 +175,24 @@ class MoonlitIdentityExportPlugin extends EditorExportPlugin: "[MoonlitIdentity] Staged bundle lacks its manifest, " + "skipping: %s." % bundle) + func _export_app_privacy_manifest() -> void: + # The app's own UserDefaults declaration (reminder scheduling + # metadata, CA92.1) is only validated here, never registered: + # Godot 4.7.1 emits its own app-root PrivacyInfo.xcprivacy, and + # a loose same-basename registration fails Xcode with + # "Multiple commands produce". The owned export pipeline + # (scripts/ios.mjs) merges this file into Godot's generated + # manifest after the export. The SDK bundles above keep their + # own untouched manifests. + var path: String = \ + IosExportManifest.app_privacy_manifest_path() + if not IosExportManifest.app_privacy_manifest_ready(path): + push_warning( + "[MoonlitIdentity] App privacy manifest missing or " + + "invalid: %s. " % path + "The export pipeline cannot " + + "merge the required-reason declaration for reminder " + + "storage.") + func _add_ios_project_static_lib(path: String) -> void: if has_method("add_apple_embedded_platform_project_static_lib"): call("add_apple_embedded_platform_project_static_lib", path) diff --git a/apps/game/assets/custom/actors/lumi/bust.png b/apps/game/assets/custom/actors/lumi/bust.png new file mode 100644 index 0000000..6e66cd7 Binary files /dev/null and b/apps/game/assets/custom/actors/lumi/bust.png differ diff --git a/apps/game/assets/custom/actors/lumi/bust.png.import b/apps/game/assets/custom/actors/lumi/bust.png.import new file mode 100644 index 0000000..eb0a2a0 --- /dev/null +++ b/apps/game/assets/custom/actors/lumi/bust.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1hvto74m6v5u" +path="res://.godot/imported/bust.png-d735418fd416869fe968ddd93f63ef90.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/actors/lumi/bust.png" +dest_files=["res://.godot/imported/bust.png-d735418fd416869fe968ddd93f63ef90.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/assets/custom/actors/lumi/front.png b/apps/game/assets/custom/actors/lumi/front.png new file mode 100644 index 0000000..5c7e4b2 Binary files /dev/null and b/apps/game/assets/custom/actors/lumi/front.png differ diff --git a/apps/game/assets/custom/actors/lumi/front.png.import b/apps/game/assets/custom/actors/lumi/front.png.import new file mode 100644 index 0000000..acd9317 --- /dev/null +++ b/apps/game/assets/custom/actors/lumi/front.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdid8kd6ei0rl" +path="res://.godot/imported/front.png-f9fb5fc65d84aa58e79c94d653914090.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/actors/lumi/front.png" +dest_files=["res://.godot/imported/front.png-f9fb5fc65d84aa58e79c94d653914090.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/assets/custom/actors/lumi/left.png b/apps/game/assets/custom/actors/lumi/left.png new file mode 100644 index 0000000..7be6d03 Binary files /dev/null and b/apps/game/assets/custom/actors/lumi/left.png differ diff --git a/apps/game/assets/custom/actors/lumi/left.png.import b/apps/game/assets/custom/actors/lumi/left.png.import new file mode 100644 index 0000000..e8dd6b7 --- /dev/null +++ b/apps/game/assets/custom/actors/lumi/left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c83sidggmxoa7" +path="res://.godot/imported/left.png-8f23b3a293ffec08c257acb3747874ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/actors/lumi/left.png" +dest_files=["res://.godot/imported/left.png-8f23b3a293ffec08c257acb3747874ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/assets/custom/actors/lumi/rear.png b/apps/game/assets/custom/actors/lumi/rear.png new file mode 100644 index 0000000..1037cd2 Binary files /dev/null and b/apps/game/assets/custom/actors/lumi/rear.png differ diff --git a/apps/game/assets/custom/actors/lumi/rear.png.import b/apps/game/assets/custom/actors/lumi/rear.png.import new file mode 100644 index 0000000..0bba79f --- /dev/null +++ b/apps/game/assets/custom/actors/lumi/rear.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0eqqmow5hdar" +path="res://.godot/imported/rear.png-02155e4ae265ca14f13d81a1b305c43e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/actors/lumi/rear.png" +dest_files=["res://.godot/imported/rear.png-02155e4ae265ca14f13d81a1b305c43e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/assets/custom/actors/lumi/right.png b/apps/game/assets/custom/actors/lumi/right.png new file mode 100644 index 0000000..bbb10ac Binary files /dev/null and b/apps/game/assets/custom/actors/lumi/right.png differ diff --git a/apps/game/assets/custom/actors/lumi/right.png.import b/apps/game/assets/custom/actors/lumi/right.png.import new file mode 100644 index 0000000..a05d0e9 --- /dev/null +++ b/apps/game/assets/custom/actors/lumi/right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ce6dxptibxykx" +path="res://.godot/imported/right.png-2a6f7f2e9d1c86bd65d1eead64f0ba3b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/actors/lumi/right.png" +dest_files=["res://.godot/imported/right.png-2a6f7f2e9d1c86bd65d1eead64f0ba3b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/assets/custom/world/lodge/room_tablet.png b/apps/game/assets/custom/world/lodge/room_tablet.png new file mode 100644 index 0000000..3d01801 Binary files /dev/null and b/apps/game/assets/custom/world/lodge/room_tablet.png differ diff --git a/apps/game/assets/custom/world/lodge/room_tablet.png.import b/apps/game/assets/custom/world/lodge/room_tablet.png.import new file mode 100644 index 0000000..72d07fe --- /dev/null +++ b/apps/game/assets/custom/world/lodge/room_tablet.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://hpxb4o631dwm" +path="res://.godot/imported/room_tablet.png-72ee1887964e487d791ec7abbe5c1bcf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/world/lodge/room_tablet.png" +dest_files=["res://.godot/imported/room_tablet.png-72ee1887964e487d791ec7abbe5c1bcf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/assets/custom/world/lodge/room_wide.png b/apps/game/assets/custom/world/lodge/room_wide.png new file mode 100644 index 0000000..b4d4acd Binary files /dev/null and b/apps/game/assets/custom/world/lodge/room_wide.png differ diff --git a/apps/game/assets/custom/world/lodge/room_wide.png.import b/apps/game/assets/custom/world/lodge/room_wide.png.import new file mode 100644 index 0000000..d27c0aa --- /dev/null +++ b/apps/game/assets/custom/world/lodge/room_wide.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bprtytfbqmcn1" +path="res://.godot/imported/room_wide.png-5634cd6838db5e820be228a40d1fe179.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/custom/world/lodge/room_wide.png" +dest_files=["res://.godot/imported/room_wide.png-5634cd6838db5e820be228a40d1fe179.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/apps/game/export_presets.cfg b/apps/game/export_presets.cfg index b963918..c9d20df 100644 --- a/apps/game/export_presets.cfg +++ b/apps/game/export_presets.cfg @@ -31,8 +31,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=19 -version/name="4.0.0" +version/code=20 +version/name="4.0.1" launcher_icons/main_192x192="res://assets/custom/ui/app_icon_main.png" launcher_icons/adaptive_foreground_432x432="res://assets/custom/ui/app_icon_foreground.png" launcher_icons/adaptive_background_432x432="res://assets/custom/ui/app_icon_background.png" @@ -87,8 +87,8 @@ plugins/MoonlitIdentity=true application/export_project_only=true application/bundle_identifier="com.crossplatformkorea.moonlitbeacon" application/signature="" -application/short_version="4.0.0" -application/version="14" +application/short_version="4.0.1" +application/version="15" application/app_store_team_id="PRDQGB267K" application/code_sign_identity_debug="" application/code_sign_identity_release="" @@ -136,8 +136,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=19 -version/name="4.0.0" +version/code=20 +version/name="4.0.1" launcher_icons/main_192x192="res://assets/custom/ui/app_icon_main.png" launcher_icons/adaptive_foreground_432x432="res://assets/custom/ui/app_icon_foreground.png" launcher_icons/adaptive_background_432x432="res://assets/custom/ui/app_icon_background.png" diff --git a/apps/game/localization/gate_entry.csv b/apps/game/localization/gate_entry.csv index f3a0794..39c140c 100644 --- a/apps/game/localization/gate_entry.csv +++ b/apps/game/localization/gate_entry.csv @@ -50,6 +50,7 @@ gate.conflict.cloud,클라우드 저장 쓰기,Keep the cloud,クラウドを残 gate.conflict.cancel,나중에 정하기,Decide later,あとで決める,稍后再定,稍後再定 gate.conflict.local_tag,이 기기,This device,この端末,本设备,本裝置 gate.conflict.cloud_tag,클라우드,Cloud,クラウド,云端,雲端 +gate.conflict.ended,종료된 여정,Journey ended,終わった旅,已结束的旅程,已結束的旅程 gate.hall.title,전당,Hall,殿堂,殿堂,殿堂 gate.hall.empty,아직 기록이 없다 · 한 번 다녀오면 전당에 오른다,No ranks yet · finish an expedition to enter the Hall,まだ記録がありません · 出陣を終えると殿堂入りします,暂无排行 · 完成一次出征即可入选殿堂,暫無排行 · 完成一次出征即可入選殿堂 gate.hall.cached,저장됨,Saved,保存済み,已保存,已儲存 @@ -99,3 +100,36 @@ gate.save.check_offline,오프라인이라 저장된 관문을 확인하지 못 gate.save.play_offline,오프라인으로 출전,Play offline,オフラインで出陣,离线出征,離線出征 gate.save.offline_unknown,저장 확인 안 됨 · 오프라인 출전,Save unchecked · playing offline,未確認 · オフラインで出陣,未检查存档 · 离线出征,未檢查存檔 · 離線出征 gate.save.offline_body,오프라인 출전은 이 기기의 여정을 쓰고 없으면 새로 시작한다。,Offline start uses this device's saved journey or starts fresh.,オフライン出陣はこの端末の旅を使うか新しく始める。,离线出征使用本机旅程或重新开始。,離線出征使用本機旅程或重新開始。 +gate.lodge.lumi_name,등불지기 루미,Lumi · Lantern Keeper,灯守りのルミ,守灯人露米,守燈人露米 +gate.lodge.greet_1,어서 와 여행자 · 여긴 관문 등불 방이야,Welcome in traveller · this is the gate lodge,ようこそ旅人さん · ここは門の灯の部屋だよ,欢迎旅人 · 这里是关口灯室,歡迎旅人 · 這裡是關口燈室 +gate.lodge.greet_2,나리는 봉화길을 고치러 떠났다가 아직 돌아오지 못했어,Nari went to mend the beacon road and has not come home yet,ナリは灯の道を直しに出て まだ帰らないんだ,娜莉去修灯路 还没有回来,娜莉去修燈路 還沒有回來 +gate.lodge.greet_3,주전자는 올려두었으니 식지 않게 · 네가 집으로 가는 길을 밝혀주렴,The kettle is on so keep it warm · you keep the road home lit,やかんを火にかけたから 冷まさないで · 家への道を灯してね,水壶在炉上 别让它凉了 · 点亮回家的路吧,水壺在爐上 別讓它涼了 · 點亮回家的路吧 +gate.lodge.ask_name,대장에 오를 이름을 알려주렴 · 전당도 그 이름으로 기억할 거야,Tell me the name for the register · the Hall will remember it too,台帳に載せる名を教えてね · 広間もその名で覚えるよ,告诉我登记的名字吧 · 殿堂也会记住这个名字,告訴我登記的名字吧 · 殿堂也會記住這個名字 +gate.lodge.name_title,모험가 이름,Adventurer name,冒険者の名,冒险者名字,冒險者名字 +gate.lodge.name_hint,2~12자 · 한글 영문 숫자 밑줄,2–12 letters · Hangul Latin digits _,2〜12文字 · 英数かな漢字 _,2~12字 · 韩文英文数字下划线,2~12字 · 韓文英文數字底線 +gate.lodge.name_public,전당에 공개되는 이름이야 · 본명은 쓰지 마,This name shows in the Hall · never your real name,広間に公開される名だよ · 本名は使わないで,这个名字会公开在殿堂里 · 不要用真名,這個名字會公開在殿堂裡 · 不要用真名 +gate.lodge.name_confirm,이 이름으로 등록,Register this name,この名で登録,登记这个名字,登記這個名字 +gate.lodge.name_retry,다시 시도,Try again,もう一度,重试,重試 +gate.lodge.name_checking,대장 확인 중…,Checking the register…,台帳を確認中…,正在核对登记册…,正在核對登記冊… +gate.lodge.name_invalid,그 이름은 안 돼 · 2~12자로 다시 지어보렴,That name will not do · try 2–12 letters again,その名は使えないよ · 2〜12文字で考え直してね,这个名字不行 · 用2~12个字再想一个吧,這個名字不行 · 用2~12個字再想一個吧 +gate.lodge.name_taken,이미 대장에 있는 이름이야 · 다른 이름을 지어보렴,That name is already in the register · try another,その名はもう台帳にあるよ · 別の名を考えてね,登记册里已经有这个名字了 · 换一个吧,登記冊裡已經有這個名字了 · 換一個吧 +gate.lodge.name_offline,연결이 끊겼어 · 연결되면 다시 시도해주렴,The link is down · try again when you are back,つながりが切れたみたい · つながったらもう一度ね,连接断开了 · 恢复连接后再试吧,連接斷開了 · 恢復連接後再試吧 +gate.lodge.named_ok,%s… 좋은 이름이야 · 대장과 전당이 기억할 거야,%s… a fine name · the register and Hall will remember it,%s… いい名だね · 台帳も広間も覚えるよ,%s… 好名字 · 登记册和殿堂都会记住,%s… 好名字 · 登記冊和殿堂都會記住 +gate.lodge.move_lesson,걸어보렴 · 화면을 누른 채 끌면 그쪽으로 가,Walk for me · press and drag anywhere to go that way,歩いてみてね · どこでも押して引けばそちらへ,走走看 · 按住屏幕拖动就往那边走,走走看 · 按住螢幕拖動就往那邊走 +gate.lodge.move_done,잘했어 · 이번엔 몸을 날려보렴,Well walked · now fling yourself forward,うまく歩けたね · 今度は体を翻してみて,走得好 · 这次试试飞身向前,走得好 · 這次試試飛身向前 +gate.lodge.dash_lesson,오른쪽 아래 단추를 눌러 재빨리 몸을 날려보렴,Tap the lower-right button to dash,右下のボタンを押して 素早く駆けてみて,点右下角的按钮快速冲刺吧,點右下角的按鈕快速衝刺吧 +gate.lodge.dash_done,멋져 · 봉화길이 널 기다려,Splendid · the beacon road waits for you,見事だよ · 灯の道が待っている,太棒了 · 灯路在等你,太棒了 · 燈路在等你 +gate.lodge.gate_lesson,%s · 오른쪽 문으로 가보렴 · 봉화길이 열려 있어,%s · walk through the gate on the right · the beacon road stands open,%s · 右の門をくぐってね · 灯の道が開いているよ,%s · 从右边的门出去吧 · 灯路已经敞开,%s · 從右邊的門出去吧 · 燈路已經敞開 +gate.lodge.gate_wait,대장에 도장 찍는 중…,Sealing the register…,台帳に判を押しているよ…,正在登记册上盖印…,正在登記冊上蓋印… +gate.lodge.gate_retry,대장이 못 들었대 · 문 앞에서 다시 기다려보렴,The ledger did not hear · wait at the gate and try again,台帳が聞き取れなかったみたい · 門の前でもう一度待ってね,登记册没听清 · 在门前稍等再试一次吧,登記冊沒聽清 · 在門前稍等再試一次吧 +gate.lodge.skip,넘기기,Skip,すすむ,跳过,跳過 +gate.lodge.tap_next,계속하려면 누르기,Tap to continue,タップでつづく,点按继续,點按繼續 +gate.lodge.depart_title,출전,Depart,出発,出征,出征 +gate.lodge.depart_saved,저장된 여정 · %s,Saved journey · %s,旅の記録 · %s,已存旅程 · %s,已存旅程 · %s +gate.lodge.depart_where,%s주기 %s구역,cycle %s zone %s,第%s期 %s区,第%s周期 %s区域,第%s週期 %s區域 +gate.lodge.depart_resume,이어하기,Resume,つづける,继续,繼續 +gate.lodge.depart_fresh,새로 떠나기,Fresh departure,新たに出発,重新出发,重新出發 +gate.lodge.depart_fresh_confirm,정말 새로 떠나겠어 · 저장된 여정은 사라져,Truly depart fresh · the saved journey is gone,本当に新しく出発するの · 旅の記録は消えるよ,真的要重新出发吗 · 已存旅程会消失,真的要重新出發嗎 · 已存旅程會消失 +gate.lodge.depart_back,돌아가기,Return,もどる,返回,返回 +gate.lodge.account_lost,계정이 바뀌었어 · 처음으로 돌아갈게,The account changed · returning to the title,持ち主が変わったみたい · はじめに戻るね,账号变了 · 先回到开头吧,帳號變了 · 先回到開頭吧 +gate.lodge.return_title,처음으로,To title,はじめへ,回开头,回開頭 diff --git a/apps/game/localization/moonlit.csv b/apps/game/localization/moonlit.csv index 0f6f6c2..596ed6e 100644 --- a/apps/game/localization/moonlit.csv +++ b/apps/game/localization/moonlit.csv @@ -21,6 +21,7 @@ RESULT_GOAL_COMPLETE,제단의 모든 축복을 완성했습니다,Shrine collec RESULT_GOAL_READY,%s 구매 가능 · 제단에서 강화하세요,%s is ready · upgrade at the Shrine,%s 購入可能 · 祭壇で強化しよう,%s 可购买 · 前往祭坛强化,%s 可購買 · 前往祭壇強化 RESULT_GOAL_PROGRESS,%s · 앞으로 %d조각,%s · %d shards to go,%s · あと%d個,%s · 还需%d枚碎片,%s · 還需%d枚碎片 RESULT_RETRY,다시 출전,Retry,再挑戦,再次出战,再次出戰 +RESULT_START_OVER,처음부터 시작,Start over,はじめから始める,重新开始,重新開始 RESULT_SHRINE,제단 강화,Upgrade,祭壇で強化,祭坛强化,祭壇強化 RESULT_RECORD,기록 남기기,Record,記録を残す,留下记录,留下紀錄 SCORE_BEACONS,봉화 %d,Beacons %d,烽火 %d,烽火 %d,烽火 %d @@ -41,6 +42,16 @@ SETTINGS_ANALYTICS,익명 분석,Analytics,匿名分析,匿名分析,匿名分 SETTINGS_ANALYTICS_UNKNOWN,선택 안 함,Not chosen,未選択,未选择,未選擇 SETTINGS_ANALYTICS_ON,공유 켜짐,Sharing on,共有オン,已开启共享,已開啟分享 SETTINGS_ANALYTICS_OFF,공유 꺼짐,Sharing off,共有オフ,已关闭共享,已關閉分享 +SETTINGS_REMINDERS,출석 알림,Attendance reminders,出席リマインダー,签到提醒,簽到提醒 +SETTINGS_REMINDERS_ON,알림 켜짐,Reminders on,リマインダーオン,提醒已开启,提醒已開啟 +SETTINGS_REMINDERS_OFF,알림 꺼짐,Reminders off,リマインダーオフ,提醒已关闭,提醒已關閉 +SETTINGS_REMINDERS_DENIED,알림 권한 필요,Permission needed,許可が必要,需要通知权限,需要通知權限 +SETTINGS_REMINDERS_UNKNOWN,확인 필요,Check needed,確認が必要,需要检查,需要檢查 +SETTINGS_REMINDERS_UNSUPPORTED,이 기기 미지원,Not on this device,この端末は未対応,此设备不支持,此裝置不支援 +SETTINGS_REMINDERS_OPEN,알림 설정 열기,Open notification settings,通知設定を開く,打开通知设置,開啟通知設定 +REMINDER_TITLE,달빛 봉화,Moonlit Beacon,月明かりの烽火,月光烽火,月光烽火 +REMINDER_BODY,지금 출석하면 2코인을 받을 수 있어요,Check in now for 2 continue coins,今出席すると2コインがもらえます,现在签到可领取2枚续关币,現在簽到可領取2枚續關幣 +REMINDER_OFFER,출석 알림 받기,Get attendance reminders,出席リマインダーを受け取る,接收签到提醒,接收簽到提醒 ANALYTICS_CONSENT_TITLE,익명 플레이 지표를 공유할까요?,Share anonymous gameplay metrics?,匿名のプレイ指標を共有しますか?,要分享匿名游玩指标吗?,要分享匿名遊玩指標嗎? ANALYTICS_CONSENT_BODY,익명 플레이 지표를 공유해 게임 개선을 돕습니다. 공유 지표에는 이름 · 구매 내역 · 기기 식별자가 포함되지 않습니다. 구매는 앱 스토어가 별도로 처리하며 공유 여부는 설정에서 언제든 바꿀 수 있습니다.,Share anonymous gameplay metrics to help improve the game. Shared metrics exclude your name · purchase history · and device ID. Purchases are processed separately by the app store. You can change sharing anytime in Settings.,ゲーム改善のため匿名のプレイ指標を共有します。共有される指標に名前・購入履歴・端末識別子は含まれません。購入はアプリストアが別途処理します。共有は設定でいつでも変更できます。,分享匿名游玩指标以帮助改进游戏。分享的指标不包含姓名・购买记录或设备标识符。购买由应用商店单独处理。你可以随时在设置中更改分享选择。,分享匿名遊玩指標以協助改善遊戲。分享的指標不包含姓名・購買紀錄或裝置識別碼。購買由應用程式商店另行處理。你可以隨時在設定中更改分享選擇。 ANALYTICS_CONSENT_PRIVACY,개인정보 처리방침,Privacy policy,プライバシーポリシー,隐私政策,隱私權政策 @@ -349,7 +360,9 @@ HERO_MOTION_ORBIT,한쪽으로 감기는 궤도,Orbit that curls,片側へ巻く HERO_MOTION_STEP,툭툭 꺾이는 각진 계단,Sharp zigzag steps,カクカク折れる階段,棱角分明的折线,稜角分明的折線 RESULT_CONTINUE,이어하기,Continue,コンティニュー,继续游戏,繼續遊戲 RESULT_CONTINUE_COINS,이어하기 (코인 %d),Continue (%d coins),コンティニュー (コイン %d),继续游戏(金币 %d),繼續遊戲(金幣 %d) +ATTENDANCE_RECEIPT,출석 보상 · 이어하기 코인 +%d,Attendance reward · Continue coins +%d,出席報酬 · コンティニューコイン +%d,出勤奖励 · 继续游戏金币 +%d,出勤獎勵 · 繼續遊戲金幣 +%d RESULT_CONTINUE_BUY,판 끝내고 상점으로,End run & open Store,ランを終了してストアへ,结束本局并前往商店,結束本局並前往商店 +RESULT_CONTINUE_RETRY,저장 다시 시도,Retry Save,セーブを再試行,重试保存,重試保存 CONTINUE_RESUMED,다시 일어섰다,Back on your feet,再び立ち上がった,重新站起来了,重新站起來了 IAP_CONTINUE_TITLE,이어하기 코인,Continue Coin,コンティニューコイン,继续游戏金币,繼續遊戲金幣 IAP_CONTINUE_DESC,코인 한 개 · 쓰러진 자리에서 이어간다,One coin · resume where you fell,コイン1枚 · 倒れた場所から再開,金币一枚 · 在倒下之处继续,金幣一枚 · 在倒下之處繼續 @@ -565,4 +578,3 @@ JOURNEY_CONFIRM_KEEP,여정 유지,Keep journey,旅をつづける,保留旅程, JOURNEY_SAVE_FAILED,여정을 저장하지 못했습니다 · 마지막 문 이후의 기록이 위험합니다,Could not save the journey · progress past the last gate is at risk,旅を保存できなかった · 最後の門からの記録が危険だ,无法保存旅程 · 上一道门之后的进度面临风险,無法儲存旅程 · 上一道門之後的進度面臨風險 JOURNEY_RESUME_FAILED,저장된 여정을 읽지 못했습니다 · 새로 시작합니다,The saved journey could not be read · starting over,保存された旅を読めなかった · はじめから始める,无法读取已保存的旅程 · 重新开始,無法讀取已儲存的旅程 · 重新開始 JOURNEY_RESUMED,마지막 문으로 · %d순환,Back at the last gate · Wave %d,最後の門へ · 第%d巡,回到上一道门 · 第%d轮,回到上一道門 · 第%d輪 -RESULT_GATE_RETRY,문에서 재시작,Retry from gate,門から再挑戦,从上一个门重试,從上一個門重試 diff --git a/apps/game/project.godot b/apps/game/project.godot index 12876e6..c53bb33 100644 --- a/apps/game/project.godot +++ b/apps/game/project.godot @@ -19,7 +19,7 @@ config/name_localized={ "zh_TW": "月光烽火" } config/description="봉화를 밝히고 정령의 불씨와 유물로 무기를 키워 끝없는 순환에 도전하는 2D 탑다운 서바이버 액션 게임." -config/version="4.0.0" +config/version="4.0.1" run/main_scene="res://scenes/menus/production_entry.tscn" config/quit_on_go_back=false config/features=PackedStringArray("4.7", "GL Compatibility") diff --git a/apps/game/scenes/gameplay/gate_lodge.tscn b/apps/game/scenes/gameplay/gate_lodge.tscn new file mode 100644 index 0000000..cfa9d9d --- /dev/null +++ b/apps/game/scenes/gameplay/gate_lodge.tscn @@ -0,0 +1,36 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://scripts/gameplay/gate_lodge.gd" id="lodge"] +[ext_resource type="Script" path="res://scripts/actors/lumi_guide.gd" id="lumi"] +[ext_resource type="PackedScene" path="res://scenes/actors/player.tscn" id="player"] +[ext_resource type="PackedScene" path="res://scenes/ui/virtual_stick.tscn" id="stick"] +[ext_resource type="PackedScene" path="res://scenes/ui/dash_button.tscn" id="dashbtn"] + +[node name="GateLodge" type="Node2D"] +y_sort_enabled = true +script = ExtResource("lodge") + +[node name="Room" type="Sprite2D" parent="."] +z_index = -100 +texture_filter = 2 + +[node name="Lumi" type="Node2D" parent="."] +script = ExtResource("lumi") + +[node name="Player" parent="." instance=ExtResource("player")] + +[node name="Camera2D" type="Camera2D" parent="."] +; Physics callback (0): the project interpolates physics and the idle +; default warns on every instantiation. +process_callback = 0 + +[node name="Ui" type="CanvasLayer" parent="."] + +[node name="MoveStick" parent="Ui" instance=ExtResource("stick")] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = 0.0 +grow_horizontal = 2 + +[node name="Dash" parent="Ui" instance=ExtResource("dashbtn")] diff --git a/apps/game/scenes/ui/settings_panel.tscn b/apps/game/scenes/ui/settings_panel.tscn index cd838dd..350ecb5 100644 --- a/apps/game/scenes/ui/settings_panel.tscn +++ b/apps/game/scenes/ui/settings_panel.tscn @@ -42,7 +42,7 @@ anchor_bottom = 0.5 offset_left = -256.0 offset_top = -140.0 offset_right = 256.0 -offset_bottom = 174.0 +offset_bottom = 172.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -78,9 +78,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -224.0 -offset_top = -70.0 +offset_top = -103.0 offset_right = -148.0 -offset_bottom = -46.0 +offset_bottom = -79.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -101,9 +101,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -138.0 -offset_top = -72.0 +offset_top = -105.0 offset_right = -86.0 -offset_bottom = -44.0 +offset_bottom = -77.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -122,9 +122,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -82.0 -offset_top = -72.0 +offset_top = -105.0 offset_right = -30.0 -offset_bottom = -44.0 +offset_bottom = -77.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -143,9 +143,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -26.0 -offset_top = -72.0 +offset_top = -105.0 offset_right = 26.0 -offset_bottom = -44.0 +offset_bottom = -77.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -164,9 +164,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = 30.0 -offset_top = -72.0 +offset_top = -105.0 offset_right = 82.0 -offset_bottom = -44.0 +offset_bottom = -77.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -185,9 +185,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = 86.0 -offset_top = -72.0 +offset_top = -105.0 offset_right = 138.0 -offset_bottom = -44.0 +offset_bottom = -77.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -204,9 +204,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -172.0 -offset_top = -26.0 +offset_top = -59.0 offset_right = -92.0 -offset_bottom = -2.0 +offset_bottom = -35.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -227,9 +227,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -76.0 -offset_top = -28.0 +offset_top = -61.0 offset_right = -50.0 -offset_bottom = 0.0 +offset_bottom = -33.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -246,9 +246,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -42.0 -offset_top = -21.0 +offset_top = -54.0 offset_right = 42.0 -offset_bottom = -7.0 +offset_bottom = -40.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -264,9 +264,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = 50.0 -offset_top = -28.0 +offset_top = -61.0 offset_right = 76.0 -offset_bottom = 0.0 +offset_bottom = -33.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -283,9 +283,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -172.0 -offset_top = 18.0 +offset_top = -15.0 offset_right = -92.0 -offset_bottom = 42.0 +offset_bottom = 9.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -306,9 +306,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -76.0 -offset_top = 16.0 +offset_top = -17.0 offset_right = -50.0 -offset_bottom = 44.0 +offset_bottom = 11.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -325,9 +325,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -42.0 -offset_top = 23.0 +offset_top = -10.0 offset_right = 42.0 -offset_bottom = 37.0 +offset_bottom = 4.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -343,9 +343,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = 50.0 -offset_top = 16.0 +offset_top = -17.0 offset_right = 76.0 -offset_bottom = 44.0 +offset_bottom = 11.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -362,9 +362,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -172.0 -offset_top = 46.0 +offset_top = 13.0 offset_right = -44.0 -offset_bottom = 80.0 +offset_bottom = 47.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -385,9 +385,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -36.0 -offset_top = 46.0 +offset_top = 13.0 offset_right = 172.0 -offset_bottom = 80.0 +offset_bottom = 47.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(0.92, 0.9, 0.98, 1) @@ -405,9 +405,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -110.0 -offset_top = 84.0 +offset_top = 85.0 offset_right = -6.0 -offset_bottom = 112.0 +offset_bottom = 113.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(0.92, 0.9, 0.98, 1) @@ -425,9 +425,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = 6.0 -offset_top = 84.0 +offset_top = 85.0 offset_right = 110.0 -offset_bottom = 112.0 +offset_bottom = 113.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(1, 0.92, 0.78, 1) @@ -480,7 +480,7 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -250.0 -offset_top = 150.0 +offset_top = 151.0 offset_right = 250.0 offset_bottom = 168.0 grow_horizontal = 2 diff --git a/apps/game/scripts/actors/lumi_guide.gd b/apps/game/scripts/actors/lumi_guide.gd new file mode 100644 index 0000000..0c77c49 --- /dev/null +++ b/apps/game/scripts/actors/lumi_guide.gd @@ -0,0 +1,145 @@ +class_name LumiGuide +extends Node2D + +## Lumi, the Lantern Keeper, standing by the lodge registry desk. +## +## One sprite, one common scale, one foot anchor: every facing frame is +## packed at the same height on the same ground line, so swapping the +## texture never moves her feet or resizes her face. The node converts +## packed source pixels to world units itself; the room only multiplies +## its layout zoom on top. She turns toward the hero (front/left/right; +## the rear frame exists for the contact sheet) and breathes in place +## with a still body and a breathing floor shadow. No walk cycle: she +## tends the desk and does not escort. Nothing here translates the +## sprite: the painted boots stay planted on the foot line. + +const FACING_FRONT: String = "front" +const FACING_REAR: String = "rear" +const FACING_LEFT: String = "left" +const FACING_RIGHT: String = "right" + +const TEX_FRONT: Texture2D = preload( + "res://assets/custom/actors/lumi/front.png") +const TEX_REAR: Texture2D = preload( + "res://assets/custom/actors/lumi/rear.png") +const TEX_LEFT: Texture2D = preload( + "res://assets/custom/actors/lumi/left.png") +const TEX_RIGHT: Texture2D = preload( + "res://assets/custom/actors/lumi/right.png") +const TEX_SHADOW: Texture2D = preload("res://assets/derived/player/shadow.png") + +## Packed frame height every facing shares. +const FRAME_HEIGHT: float = 869.0 +## World height at layout zoom 1.0: 1.5x the tallest playable hero +## opaque body (28.05 world px, measured across all five heroes), so +## the adult keeper reads beside the chibi arrival without towering. +const WORLD_HEIGHT: float = 42.0 +## Packed-source-to-world conversion every facing shares. +const BASE_SCALE: float = WORLD_HEIGHT / FRAME_HEIGHT +## Floor shadow in world units: a touch wider than her ~16px body, the +## same soft ellipse language as the hero's own shadow. +const SHADOW_WORLD: Vector2 = Vector2(19.0, 8.0) +## Idle breath period. Only the floor shadow pulses; the body never +## translates, so the painted foot line stays fixed at every point of the breath. +const IDLE_PERIOD: float = 2.6 +## Shadow pulse depth around its resting alpha. +const IDLE_SHADOW_PULSE: float = 0.04 +## Face front while the hero stands this close to her center line. +const FRONT_DEAD_ZONE: float = 12.0 + +var _sprite: Sprite2D = null +var _shadow: Sprite2D = null +var _facing: String = FACING_FRONT +var _actor_scale: float = 1.0 +var _idle_time: float = 0.0 + + +func _ready() -> void: + _shadow = Sprite2D.new() + _shadow.name = &"Shadow" + _shadow.light_mask = 0 + _shadow.modulate = Color(0.05, 0.06, 0.12, 0.85) + _shadow.texture = TEX_SHADOW + add_child(_shadow) + _sprite = Sprite2D.new() + _sprite.name = &"Sprite" + _sprite.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + _sprite.centered = false + add_child(_sprite) + set_facing(FACING_FRONT) + _update_scale() + + +## Room layout zoom on top of the source-to-world conversion. The scene +## calls this on every relayout; the contact sheet leaves the default. +func set_actor_scale(actor_scale: float) -> void: + _actor_scale = actor_scale + if is_inside_tree(): + _update_scale() + + +## Total node scale: source conversion times layout zoom. +func total_scale() -> float: + return BASE_SCALE * _actor_scale + + +func _update_scale() -> void: + var total: float = total_scale() + scale = Vector2.ONE * total + if _shadow != null and _shadow.texture != null: + var shadow_size: Vector2 = _shadow.texture.get_size() + _shadow.scale = Vector2(SHADOW_WORLD.x / (shadow_size.x * total), + SHADOW_WORLD.y / (shadow_size.y * total)) + + +## Swap the facing texture. The foot anchor stays: every frame shares +## one height, so bottom-left offset math is identical per facing. +func set_facing(facing: String) -> void: + if facing != FACING_FRONT and facing != FACING_REAR \ + and facing != FACING_LEFT and facing != FACING_RIGHT: + return + _facing = facing + if _sprite == null: + return + var texture: Texture2D = TEX_FRONT + if facing == FACING_REAR: + texture = TEX_REAR + elif facing == FACING_LEFT: + texture = TEX_LEFT + elif facing == FACING_RIGHT: + texture = TEX_RIGHT + _sprite.texture = texture + var size: Vector2 = texture.get_size() + _sprite.offset = Vector2(-size.x * 0.5, -size.y) + _sprite.position = Vector2.ZERO + + +## Current facing key: front, rear, left, or right. +func facing() -> String: + return _facing + + +## Turn toward the hero's horizontal position: left past the dead zone, +## right past it, front while near. The keeper faces the room, never +## away from her guest. +func face_toward(hero_x: float) -> void: + var delta: float = hero_x - position.x + if delta < -FRONT_DEAD_ZONE: + set_facing(FACING_LEFT) + elif delta > FRONT_DEAD_ZONE: + set_facing(FACING_RIGHT) + else: + set_facing(FACING_FRONT) + + +## Foot point in local space: the node origin by construction. +func foot_local() -> Vector2: + return Vector2.ZERO + + +func _process(delta: float) -> void: + if _sprite == null: + return + _idle_time += delta + var pulse_angle: float = _idle_time * TAU / IDLE_PERIOD + _shadow.modulate.a = 0.85 + sin(pulse_angle) * IDLE_SHADOW_PULSE diff --git a/apps/game/scripts/actors/lumi_guide.gd.uid b/apps/game/scripts/actors/lumi_guide.gd.uid new file mode 100644 index 0000000..774c63a --- /dev/null +++ b/apps/game/scripts/actors/lumi_guide.gd.uid @@ -0,0 +1 @@ +uid://bclkc6qpwkeus diff --git a/apps/game/scripts/cloud/cloud_account.gd b/apps/game/scripts/cloud/cloud_account.gd index 2d04056..26983c9 100644 --- a/apps/game/scripts/cloud/cloud_account.gd +++ b/apps/game/scripts/cloud/cloud_account.gd @@ -3,59 +3,86 @@ extends RefCounted ## Account-data deletion in ONE atomic commit. Runs before Firebase Auth ## deletion so no cloud row is orphaned. ## -## The commit deletes Hall, checkpoint, reservation, and profile together. -## Atomicity is the crash guarantee: a kill mid-run leaves either all four -## rows or none of them, never a living profile claiming an ID whose -## reservation is gone (which another account could then re-register). The -## rules enforce the same pairing server-side: living pair halves go only -## together with the Hall row already gone. Missing rows are server no-ops, -## so repeating the commit after a crash or a failure is safe. +## The commit deletes Hall, name claim, adventurer, checkpoint, attendance, +## reservation, and profile together. Atomicity is the crash guarantee: a +## kill mid-run leaves either all seven rows or none of them, never a +## living profile claiming an ID whose reservation is gone (which another +## account could then re-register) and never a freed name under a living +## score. The rules enforce the same pairing server-side: living pair +## halves go only together with the Hall row already gone, and the +## attendance row goes with the canonical halves. Missing rows are server +## no-ops, so repeating the commit after a crash or a failure is safe. +## Unnamed accounts commit the five legacy steps unchanged. ## ## Only the native identity layer deletes the Firebase Auth user, and only ## after this commit is acknowledged. This helper reports ok solely on that ## acknowledgement, with explicit failure codes otherwise. const CloudSchema: Script = preload("res://scripts/cloud/cloud_schema.gd") +const CloudName: Script = preload("res://scripts/cloud/cloud_name.gd") const STEP_HALL: String = "hall" +const STEP_NAME: String = "name" +const STEP_ADVENTURER: String = "adventurer" const STEP_CHECKPOINT: String = "checkpoint" +const STEP_ATTENDANCE: String = "attendance" const STEP_RESERVATION: String = "reservation" const STEP_PROFILE: String = "profile" ## Ordered deletion steps for one account. Used by the commit builder below -## and by the author runbook so the two cannot drift apart. -static func deletion_plan(uid: String, public_id: String) -> Array: - return [ +## and by the author runbook so the two cannot drift apart. The name key +## rides along when the account claimed one; empty omits the pair. +static func deletion_plan(uid: String, public_id: String, + name_key: String = "") -> Array: + var steps: Array = [ { "step": STEP_HALL, "path": "documents/%s/%s" % [ CloudSchema.HALL_COLLECTION, public_id], }, - { - "step": STEP_CHECKPOINT, - "path": "documents/%s/%s" % [ - CloudSchema.CHECKPOINT_COLLECTION, uid], - }, - { - "step": STEP_RESERVATION, + ] + if not name_key.is_empty(): + steps.append({ + "step": STEP_NAME, "path": "documents/%s/%s" % [ - CloudSchema.RESERVATION_COLLECTION, public_id], - }, - { - "step": STEP_PROFILE, + CloudSchema.NAME_COLLECTION, name_key], + }) + steps.append({ + "step": STEP_ADVENTURER, "path": "documents/%s/%s" % [ - CloudSchema.PROFILE_COLLECTION, uid], - }, - ] + CloudSchema.ADVENTURER_COLLECTION, public_id], + }) + steps.append({ + "step": STEP_CHECKPOINT, + "path": "documents/%s/%s" % [ + CloudSchema.CHECKPOINT_COLLECTION, uid], + }) + steps.append({ + "step": STEP_ATTENDANCE, + "path": "documents/%s/%s" % [ + CloudSchema.ATTENDANCE_COLLECTION, public_id], + }) + steps.append({ + "step": STEP_RESERVATION, + "path": "documents/%s/%s" % [ + CloudSchema.RESERVATION_COLLECTION, public_id], + }) + steps.append({ + "step": STEP_PROFILE, + "path": "documents/%s/%s" % [ + CloudSchema.PROFILE_COLLECTION, uid], + }) + return steps ## Commit body deleting every plan step at once. No preconditions: missing ## rows delete as no-ops on the server and the rules allow them, which keeps ## repeats idempotent. -static func deletion_commit_body(uid: String, public_id: String) -> Dictionary: +static func deletion_commit_body(uid: String, public_id: String, + name_key: String = "") -> Dictionary: var writes: Array = [] - for step in deletion_plan(uid, public_id): + for step in deletion_plan(uid, public_id, name_key): writes.append({ "delete": "projects/%s/databases/%s/%s" % [ CloudSchema.PROJECT_ID, CloudSchema.DATABASE_ID, @@ -64,12 +91,20 @@ static func deletion_commit_body(uid: String, public_id: String) -> Dictionary: return {"writes": writes} -static func _step_names() -> Array[String]: - return [STEP_HALL, STEP_CHECKPOINT, STEP_RESERVATION, STEP_PROFILE] +static func _step_names(name_key: String = "") -> Array[String]: + if name_key.is_empty(): + return [STEP_HALL, STEP_CHECKPOINT, STEP_ATTENDANCE, + STEP_RESERVATION, STEP_PROFILE] + return [STEP_HALL, STEP_NAME, STEP_ADVENTURER, STEP_CHECKPOINT, + STEP_ATTENDANCE, STEP_RESERVATION, STEP_PROFILE] +## Delete one account's owned rows. An empty key resolves the claimed name +## first: found rows join the same commit, a missing adventurer keeps the +## legacy five-step commit, and a resolution failure stops before any +## delete so a wrong key can never strand the real name. func delete_account_data(transport: RefCounted, uid: String, - public_id: String) -> Dictionary: + public_id: String, name_key: String = "") -> Dictionary: if not CloudSchema.is_valid_uid(uid): return {"status": "failure", "code": "invalid-uid", "retryable": false} @@ -79,14 +114,30 @@ func delete_account_data(transport: RefCounted, uid: String, if transport == null or not transport.has_method("post"): return {"status": "unconfigured", "code": "missing-transport", "retryable": false} + var key: String = name_key + if key.is_empty(): + var names: RefCounted = CloudName.new() + var resolved: Dictionary = await names.call( + "fetch_adventurer", transport, uid, public_id) + if str(resolved.get("status", "")) == "ok": + key = str(resolved.get("key", "")) + elif str(resolved.get("code", "")) != "adventurer-not-found": + return { + "status": str(resolved.get("status", "failure")), + "code": str(resolved.get("code", "delete-failed")), + "retryable": bool(resolved.get("retryable", false)), + "completed": [], + "remaining": _step_names(), + } var reply: Dictionary = await transport.call("post", "documents:commit", - JSON.stringify(deletion_commit_body(uid, public_id))) + JSON.stringify(deletion_commit_body(uid, public_id, key))) if str(reply.get("status", "")) == "ok": - return {"status": "ok", "completed": _step_names(), "remaining": []} + return {"status": "ok", "completed": _step_names(key), + "remaining": []} return { "status": str(reply.get("status", "failure")), "code": str(reply.get("code", "delete-failed")), "retryable": bool(reply.get("retryable", false)), "completed": [], - "remaining": _step_names(), + "remaining": _step_names(key), } diff --git a/apps/game/scripts/cloud/cloud_attendance.gd b/apps/game/scripts/cloud/cloud_attendance.gd new file mode 100644 index 0000000..f3f22a1 --- /dev/null +++ b/apps/game/scripts/cloud/cloud_attendance.gd @@ -0,0 +1,324 @@ +extends RefCounted + +## Account-owned attendance claims: two continue coins per rolling twelve +## hours, enforced by server time. +## +## Each registered account owns one private row +## (`mb_attendance_v1/{public_id}`) holding the last claim's server +## timestamp plus the receiving install. A first read uses `batchGet` so +## the single response carries both the row (or its authoritative absence +## for the proven owner) and the server `readTime` the cooldown compares +## against. Eligibility and remaining time derive ONLY from server +## timestamps — device wall clocks, time zones, and launches never enter. +## +## A first claim is one conditional commit (`exists:false`); an eligible +## advance commits separately through `commit_advance`, after the caller +## backfills any owned still-unapplied reward, compare-and-swapped on the +## read row's `updateTime`. Concurrent claims for one account yield +## exactly one new receipt; the loser re-reads and converges on +## already-claimed. The claim stamp is a server timestamp transform, so +## forged times die in the rules. Every advance carries the overwritten +## row's stamp and install (`prev_*`, copied opaquely), so another +## install's later advance never erases this install's recoverable +## receipt; the rules require exactly that carry. The wallet receipt key +## derives from `{public_id, claim seconds, install}`: the claimer and +## any later recovery derive the same key, while another install's key +## never matches this wallet. +## +## Reads and writes pass transport replies through untouched: 403 stays +## `permission-denied` (a configuration/ownership error, never a taken +## period), offline stays offline. No row here grants coins by itself; +## the coordinator applies the acknowledged claim to the Vault exactly +## once. + +const CloudSchema: Script = preload("res://scripts/cloud/cloud_schema.gd") + + +## Single-document batchGet body. The response carries `readTime` (server +## now) alongside `found` or `missing`, which plain documents.get omits. +static func batch_get_body(public_id: String) -> String: + var doc_name: String = CloudSchema.document_name( + CloudSchema.ATTENDANCE_COLLECTION, public_id) + return JSON.stringify({"documents": [doc_name]}) + + +## Atomic claim commit: create-on-first-claim or updateTime +## compare-and-swap afterwards. The stamp is server-set; the payload +## binds the canonical halves and the receiving install. Advances also +## carry the overwritten row's stamp and install opaquely (`prev_*`), +## copied byte for byte so sub-second precision survives; first claims +## omit them. +static func claim_commit_body(public_id: String, uid: String, + install_id: String, update_time: String, + prev_claim_rfc: String = "", + prev_install: String = "") -> String: + var doc_name: String = CloudSchema.document_name( + CloudSchema.ATTENDANCE_COLLECTION, public_id) + var fields: Dictionary = { + "public_id": CloudSchema.encode_string(public_id), + "uid": CloudSchema.encode_string(uid), + "install_id": CloudSchema.encode_string(install_id), + "schema": CloudSchema.encode_int(CloudSchema.SCHEMA_VERSION), + } + if not update_time.is_empty(): + fields[CloudSchema.ATTENDANCE_PREV_CLAIM_FIELD] = \ + CloudSchema.encode_timestamp_rfc3339(prev_claim_rfc) + fields[CloudSchema.ATTENDANCE_PREV_INSTALL_FIELD] = \ + CloudSchema.encode_string(prev_install) + var write: Dictionary = { + "update": {"name": doc_name, "fields": fields}, + "updateTransforms": [{ + "fieldPath": "last_claim_at", + "setToServerValue": "REQUEST_TIME", + }], + } + if update_time.is_empty(): + write["currentDocument"] = {"exists": false} + else: + write["currentDocument"] = {"updateTime": update_time} + return JSON.stringify({"writes": [write]}) + + +## Read the account's attendance row with its server time. Returns `ok` +## with `{last_claim_at, last_claim_rfc, install_id, prev_claim_at, +## prev_install_id, update_time, server_now}` (stamps in seconds plus the +## raw current stamp for the opaque advance carry; `prev_*` are zero and +## empty on rows without a carried claim), +## `attendance-not-found` with `{server_now}` for a first claim, or the +## transport reply untouched (offline/denied stay errors, never reads). +## A row bound to another UID or public ID fails closed: never adopt it. +func fetch_attendance(transport: RefCounted, uid: String, + public_id: String) -> Dictionary: + var checked: Dictionary = CloudSchema.validate_profile( + uid, public_id) + if not bool(checked.get("ok", false)): + return {"status": "failure", + "code": str(checked.get("error", "invalid-uid")), + "retryable": false} + var reply: Dictionary = await transport.call("post", + "documents:batchGet", batch_get_body(public_id)) + if str(reply.get("status", "")) != "ok": + return reply + var body: Dictionary = CloudSchema.parse_json_value( + str(reply.get("body", ""))) + if not bool(body.get("ok", false)) \ + or typeof(body.get("value")) != TYPE_ARRAY: + return {"status": "failure", "code": "attendance-bad-read", + "retryable": false} + var entries: Array = body.get("value", []) + if entries.is_empty() or typeof(entries[0]) != TYPE_DICTIONARY: + return {"status": "failure", "code": "attendance-bad-read", + "retryable": false} + var entry: Dictionary = entries[0] + var server_now: int = CloudSchema.unix_from_rfc3339( + str(entry.get("readTime", ""))) + if server_now <= 0: + return {"status": "failure", "code": "attendance-no-server-time", + "retryable": false} + if entry.has("missing"): + return {"status": "attendance-not-found", + "server_now": server_now} + var found: Dictionary = entry.get("found", {}) + if typeof(found) != TYPE_DICTIONARY or found.is_empty(): + return {"status": "failure", "code": "attendance-bad-read", + "retryable": false} + var fields: Dictionary = found.get("fields", {}) + if typeof(fields) != TYPE_DICTIONARY: + return {"status": "failure", "code": "attendance-bad-read", + "retryable": false} + if CloudSchema.decode_string(fields, "uid") != uid \ + or CloudSchema.decode_string(fields, "public_id") \ + != public_id: + return {"status": "failure", "code": "attendance-row-mismatch", + "retryable": false} + var last_claim_rfc: String = CloudSchema.decode_timestamp( + fields, "last_claim_at") + var last_claim_at: int = CloudSchema.unix_from_rfc3339( + last_claim_rfc) + if last_claim_at <= 0: + return {"status": "failure", "code": "attendance-bad-stamp", + "retryable": false} + var prev_claim_at: int = 0 + var prev_install_id: String = "" + if fields.has(CloudSchema.ATTENDANCE_PREV_CLAIM_FIELD) \ + or fields.has(CloudSchema.ATTENDANCE_PREV_INSTALL_FIELD): + prev_install_id = CloudSchema.decode_string(fields, + CloudSchema.ATTENDANCE_PREV_INSTALL_FIELD) + prev_claim_at = CloudSchema.unix_from_rfc3339( + CloudSchema.decode_timestamp(fields, + CloudSchema.ATTENDANCE_PREV_CLAIM_FIELD)) + if prev_claim_at <= 0 or prev_install_id.is_empty() \ + or prev_install_id.length() \ + > CloudSchema.MAX_INSTALL_ID_LENGTH: + return {"status": "failure", + "code": "attendance-bad-stamp", + "retryable": false} + return {"status": "ok", + "last_claim_at": last_claim_at, + "last_claim_rfc": last_claim_rfc, + "install_id": CloudSchema.decode_string(fields, "install_id"), + "prev_claim_at": prev_claim_at, + "prev_install_id": prev_install_id, + "update_time": str(found.get("updateTime", "")), + "server_now": server_now} + + +## Claim the current period: read the server row, compare server +## timestamps, and commit a first claim conditionally. An eligible +## advance is NOT committed here: the caller must first backfill any +## owned still-unapplied reward from the returned row, then commit +## through `commit_advance`, so no advance erases a recoverable receipt. +## +## Returns `ok` with `{claim_seconds, next_eligible_utc, +## remaining_seconds, server_now}` on a landed first claim (the caller +## still owes the local wallet grant), `eligible` with the read row when +## the next period can advance, `already-claimed` with the same +## server-derived deadline fields when the period belongs to any install +## (plus `mine`, whether this install received it, and any carried +## `prev_*` for backfill), or an honest failure/offline/conflict that +## grants nothing. +func claim_attendance(transport: RefCounted, uid: String, + public_id: String, install_id: String) -> Dictionary: + if not CloudSchema.is_valid_install_id(install_id): + return {"status": "failure", "code": "invalid-install-id", + "retryable": false} + var current: Dictionary = await fetch_attendance( + transport, uid, public_id) + var state: String = str(current.get("status", "")) + if state != "ok" and state != "attendance-not-found": + return current + if state == "ok": + var held: Dictionary = _cooldown_result(current, install_id) + if not bool(held.get("eligible", false)): + return held + if str(current.get("update_time", "")).is_empty(): + return {"status": "failure", + "code": "attendance-no-update-time", + "retryable": false} + held.erase("eligible") + held["status"] = "eligible" + return held + var commit: Dictionary = await transport.call("post", + "documents:commit", claim_commit_body( + public_id, uid, install_id, "")) + var commit_status: String = str(commit.get("status", "")) + if commit_status == "ok": + return _granted_result(commit, install_id) + if commit_status != "conflict": + return commit + return await _settle_conflict( + transport, uid, public_id, install_id) + + +## Commit one eligible advance after the caller backfilled every owned +## still-unapplied reward on the read row. Carries the overwritten row's +## stamp and install opaquely, compare-and-swapped on its update time; +## the rules require exactly that carry, so a racing advance that changed +## the row fails the swap and settles by re-reading. Returns `ok` with +## the new claim, an uncertain ack without a stamp, or the conflict +## verdict, exactly like a first claim. +func commit_advance(transport: RefCounted, uid: String, + public_id: String, install_id: String, update_time: String, + prev_claim_rfc: String, prev_install: String) -> Dictionary: + if not CloudSchema.is_valid_install_id(install_id): + return {"status": "failure", "code": "invalid-install-id", + "retryable": false} + if update_time.is_empty() or prev_claim_rfc.is_empty() \ + or prev_install.is_empty() \ + or CloudSchema.unix_from_rfc3339(prev_claim_rfc) <= 0: + return {"status": "failure", "code": "attendance-bad-advance", + "retryable": false} + var commit: Dictionary = await transport.call("post", + "documents:commit", claim_commit_body(public_id, uid, + install_id, update_time, prev_claim_rfc, prev_install)) + var commit_status: String = str(commit.get("status", "")) + if commit_status == "ok": + return _granted_result(commit, install_id) + if commit_status != "conflict": + return commit + return await _settle_conflict( + transport, uid, public_id, install_id) + + +## Cooldown verdict for a row already read: an eligible row returns its +## full read shape for the caller's backfill-then-commit advance, a held +## period returns `already-claimed` with server-derived deadline fields. +## `mine` tells the caller whether this install received the held period; +## both shapes carry any `prev_*` so owned unapplied rewards backfill. +func _cooldown_result(current: Dictionary, install_id: String) -> Dictionary: + var window: Dictionary = CloudSchema.attendance_cooldown( + int(current.get("server_now", 0)), + int(current.get("last_claim_at", 0))) + if bool(window.get("eligible", false)): + return { + "eligible": true, + "last_claim_at": int(current.get("last_claim_at", 0)), + "last_claim_rfc": str(current.get("last_claim_rfc", "")), + "install_id": str(current.get("install_id", "")), + "prev_claim_at": int(current.get("prev_claim_at", 0)), + "prev_install_id": str(current.get( + "prev_install_id", "")), + "update_time": str(current.get("update_time", "")), + "server_now": int(current.get("server_now", 0)), + } + return { + "status": "already-claimed", + "mine": str(current.get("install_id", "")) == install_id + and not install_id.is_empty(), + "last_claim_at": int(current.get("last_claim_at", 0)), + "last_claim_rfc": str(current.get("last_claim_rfc", "")), + "install_id": str(current.get("install_id", "")), + "prev_claim_at": int(current.get("prev_claim_at", 0)), + "prev_install_id": str(current.get("prev_install_id", "")), + "next_eligible_utc": CloudSchema.rfc3339_from_unix( + int(window.get("next_at", 0))), + "remaining_seconds": int(window.get("remaining", 0)), + "server_now": int(current.get("server_now", 0)), + } + + +## Granted-claim result from the commit's server `commitTime`: the exact +## claim seconds plus the confirmed next deadline. A missing commitTime +## is an uncertain ack the caller must resolve by re-reading, never a +## grant on an assumed clock. +func _granted_result(commit: Dictionary, install_id: String) -> Dictionary: + var body: Dictionary = CloudSchema.parse_json_value( + str(commit.get("body", ""))) + var commit_time: String = "" + if bool(body.get("ok", false)) \ + and typeof(body.get("value")) == TYPE_DICTIONARY: + commit_time = str((body.get("value") as Dictionary).get( + "commitTime", "")) + var claim_seconds: int = CloudSchema.unix_from_rfc3339(commit_time) + if claim_seconds <= 0: + return {"status": "failure", "code": "attendance-uncertain-ack", + "retryable": true} + return { + "status": "ok", "claim_seconds": claim_seconds, + "install_id": install_id, + "next_eligible_utc": CloudSchema.rfc3339_from_unix( + claim_seconds + CloudSchema.ATTENDANCE_COOLDOWN_SECONDS), + "remaining_seconds": CloudSchema.ATTENDANCE_COOLDOWN_SECONDS, + "server_now": claim_seconds, + } + + +## One bounded re-read after a lost compare-and-swap: the winner's row +## decides. A held period converges on `already-claimed` (granting +## locally when this install won); anything else passes through honestly +## for the next trigger to resolve. +func _settle_conflict(transport: RefCounted, uid: String, + public_id: String, install_id: String) -> Dictionary: + var recheck: Dictionary = await fetch_attendance( + transport, uid, public_id) + var state: String = str(recheck.get("status", "")) + if state == "ok": + var held: Dictionary = _cooldown_result(recheck, install_id) + if not bool(held.get("eligible", false)): + return held + return {"status": "failure", "code": "attendance-claim-race", + "retryable": true} + if state == "attendance-not-found": + return {"status": "failure", "code": "attendance-claim-race", + "retryable": true} + return recheck diff --git a/apps/game/scripts/cloud/cloud_attendance.gd.uid b/apps/game/scripts/cloud/cloud_attendance.gd.uid new file mode 100644 index 0000000..fdd6cd8 --- /dev/null +++ b/apps/game/scripts/cloud/cloud_attendance.gd.uid @@ -0,0 +1 @@ +uid://c8rn2ao2by6xt diff --git a/apps/game/scripts/cloud/cloud_coordinator.gd b/apps/game/scripts/cloud/cloud_coordinator.gd index 361f317..2f5a8d4 100644 --- a/apps/game/scripts/cloud/cloud_coordinator.gd +++ b/apps/game/scripts/cloud/cloud_coordinator.gd @@ -27,7 +27,11 @@ extends Node ## remote summaries (sizes and digests, never payloads or credentials). ## The host picks local or remote explicitly, and the rejected version is ## preserved to a recovery file first — the actually overwritten remote -## bytes when it advanced again mid-choice. +## bytes when it advanced again mid-choice. The one automatic rule is +## same-journey defeat precedence: a sealed defeat outranks a stale alive +## payload of the same journey without a dialog, reported explicitly as +## `defeat-kept` or `defeat-adopted`, so no sync path silently resumes a +## run that already ended. Different journeys still choose explicitly. ## - Downloads validate completely through `Journey.validate()` before any ## install. Malformed, oversized, or ledger-carrying payloads are refused ## with the local files untouched. @@ -53,6 +57,7 @@ signal save_changed(snapshot: Dictionary) signal conflict_found(info: Dictionary) signal hall_changed(snapshot: Dictionary) signal rank_changed(snapshot: Dictionary) +signal attendance_changed(snapshot: Dictionary) const CloudSchema: Script = preload("res://scripts/cloud/cloud_schema.gd") const TransportScript: Script = preload( @@ -62,6 +67,9 @@ const IdentityScript: Script = preload( const CheckpointScript: Script = preload( "res://scripts/cloud/cloud_checkpoint.gd") const HallScript: Script = preload("res://scripts/cloud/cloud_hall.gd") +const NameScript: Script = preload("res://scripts/cloud/cloud_name.gd") +const AttendanceScript: Script = preload( + "res://scripts/cloud/cloud_attendance.gd") const VaultScript: Script = preload("res://scripts/gameplay/vault.gd") ## Upload attempts per flush for retryable failures. Conflicts, offline, @@ -75,6 +83,8 @@ var _transport: RefCounted var _identity: RefCounted var _checkpoint: RefCounted var _hall: RefCounted +var _names: RefCounted +var _attendance: RefCounted var _generation: int = 0 var _closed: bool = false @@ -119,6 +129,7 @@ var _floor_code: String = "" var _revision_error: String = "" var _hall_snapshot: Dictionary = {} var _rank_snapshot: Dictionary = {} +var _attendance_snapshot: Dictionary = {} func _init() -> void: @@ -126,6 +137,8 @@ func _init() -> void: _identity = IdentityScript.new() _checkpoint = CheckpointScript.new() _hall = HallScript.new() + _names = NameScript.new() + _attendance = AttendanceScript.new() _hall_snapshot = { "state": "unregistered", "source": "unregistered", "rows": [], "last_submit": {}, @@ -241,7 +254,7 @@ func configure_host(config: Dictionary) -> Dictionary: } _partition_key = guest_id Journey.use_account(guest_id) - _last_migration = Journey.migrate_legacy_to_account(guest_id) + _last_migration = _vault.call("adopt_legacy_slot", guest_id) as Dictionary _load_revision_file() _reconcile_active_receipt_floor() _subscribe_hook() @@ -349,8 +362,11 @@ func flush() -> Dictionary: ## validates the complete download through `Journey`, preserves ours, ## installs theirs — remapping an imported integer journey id into this ## device's receipt namespace — and floors its receipt with zero granted. -## Anything else is refused and the conflict stays open. Reports busy -## while a flush owns the upload. +## Anything else is refused and the conflict stays open. A choice that would +## silently resume a sealed same-journey run is likewise refused (as +## `defeat-kept`), as is a choice committing or installing a death its +## journey already revived past (as `stale-ended-retired`), with the +## conflict left open. Reports busy while a flush owns the upload. func resolve_conflict(choice: String) -> Dictionary: var captured: int = _generation if _closed: @@ -385,12 +401,15 @@ func cancel_pending_restore() -> void: ## Pull the cloud checkpoint down safely. Installs only into an empty or ## install-identical slot; a differing local journey becomes an explicit -## conflict instead of a silent overwrite. A fresh install floors its -## receipt with zero granted — remapping an imported integer journey id -## first, so history never mints and never aliases a local receipt — and -## adopts the remote as the new sync baseline. Reports busy while a flush -## owns the upload. Each call claims the next restore epoch at start, so -## a newer read preempts any older attempt still in flight. +## conflict instead of a silent overwrite — except a same-journey defeat, +## which wins outright (`defeat-kept` locally, `defeat-adopted` from the +## server) so a stale payload can never resurrect a sealed run. A fresh +## install floors its receipt with zero granted — remapping an imported +## integer journey id first, so history never mints and never aliases a +## local receipt — and adopts the remote as the new sync baseline. Reports +## busy while a flush owns the upload. Each call claims the next restore +## epoch at start, so a newer read preempts any older attempt still in +## flight. func restore_from_cloud() -> Dictionary: var captured: int = _generation if _closed: @@ -437,6 +456,11 @@ func restore_from_cloud() -> Dictionary: return {"status": "ok", "code": "already-in-sync", "revision": int(remote.get("revision", 0))} if not effective_text.is_empty(): + var settled: Dictionary = _settle_defeat_download( + captured, restore_epoch, effective_text, remote, + remote_text, install_text, install_data) + if not settled.is_empty(): + return settled _open_download_conflict(effective_text, remote) return {"status": "conflict", "code": "restore-differs", "retryable": false, @@ -486,6 +510,203 @@ func restore_from_cloud() -> Dictionary: "remapped": bool(prepared.get("remapped", false))} +## Same-journey defeat precedence for a download that differs from the local +## journey. Returns `{}` when the normal explicit conflict must open (both +## sides alive, both sealed, different journeys, or a seal strictly newer +## than the other side's defeat without an acknowledged baseline — genuine +## concurrent progress). Otherwise the terminal state wins without a dialog: +## a local seal keeps `defeat-kept` with the files untouched, a remote seal +## installs as `defeat-adopted` with the superseded alive bytes preserved +## to recovery first, and a stale remote seal against our acknowledged +## newer alive seal retires as `stale-ended-retired` with the files +## untouched. Journey identity compares the downloaded bytes before +## receipt-namespace remapping, so one device's own lineage always matches; +## a cross-device sequence collision resolves to the defeat and the +## surviving journey stays on the server for an explicit fresh-start +## conflict afterwards. +func _settle_defeat_download(captured: int, restore_epoch: int, + effective_text: String, remote: Dictionary, remote_text: String, + install_text: String, install_data: Dictionary) -> Dictionary: + var local_meta: Dictionary = _defeat_meta(effective_text) + var remote_meta: Dictionary = _defeat_meta(remote_text) + if local_meta.is_empty() or remote_meta.is_empty(): + return {} + if str(local_meta.get("journey", "")) != str(remote_meta.get("journey", "")): + return {} + var local_ended: bool = bool(local_meta.get("ended", false)) + var remote_ended: bool = bool(remote_meta.get("ended", false)) + if local_ended == remote_ended: + return {} + var local_cid: int = int(local_meta.get("checkpoint_id", 0)) + var remote_cid: int = int(remote_meta.get("checkpoint_id", 0)) + if local_ended and remote_cid > local_cid: + return {} + if remote_ended and local_cid > remote_cid: + # Our alive seal is strictly newer than the downloaded death. Only + # an acknowledged seal retires it outright: an unflushed revive + # still opens the explicit dialog, where the guarded resolve + # refuses the stale death and rebases the live seal instead. + if _baseline_revision >= 1 \ + and _baseline_digest == effective_text.sha256_text(): + return {"status": "ok", "code": "stale-ended-retired", + "remote_revision": int(remote.get("revision", 0))} + return {} + if local_ended: + return {"status": "ok", "code": "defeat-kept", + "remote_revision": int(remote.get("revision", 0))} + return _adopt_remote_defeat(captured, restore_epoch, effective_text, + remote, remote_text, install_text, install_data) + + +## Install a same-journey remote defeat over stale local alive bytes. Mirrors +## the empty-slot install: the superseded bytes are preserved first, a +## retired read installs nothing, and the receipt floors with zero granted. +func _adopt_remote_defeat(captured: int, restore_epoch: int, + effective_text: String, remote: Dictionary, remote_text: String, + install_text: String, install_data: Dictionary) -> Dictionary: + if not effective_text.is_empty() and not _write_rejected( + "local", effective_text): + return {"status": "failure", "code": "recovery-save-failed", + "retryable": false} + if install_text != remote_text and not _write_rejected( + "remote", remote_text): + return {"status": "failure", "code": "recovery-save-failed", + "retryable": false} + if _stale(captured) or restore_epoch != _restore_epoch: + return _cancelled_stale() + _installing_remote = true + var installed: Error = Journey.write_checkpoint_text(install_text) + _installing_remote = false + if _stale(captured) or restore_epoch != _restore_epoch: + return _cancelled_stale() + if installed != OK: + _save_state = "error" + _save_code = "restore-install-failed" + save_changed.emit(save_snapshot()) + return {"status": "failure", "code": "restore-install-failed", + "retryable": false} + var floored: Dictionary = _floor_from_checkpoint(install_data, "restore") + if str(floored.get("code", "")) == "floor-save-failed": + _save_state = "error" + _save_code = "floor-save-failed" + save_changed.emit(save_snapshot()) + return {"status": "failure", "code": "floor-save-failed", + "retryable": true} + _adopt_baseline(int(remote.get("revision", 0)), remote_text) + _save_state = "acked" + _save_code = "" + save_changed.emit(save_snapshot()) + return {"status": "ok", "code": "defeat-adopted", + "revision": _known_remote_revision, + "remapped": install_text != remote_text, + "local_preserved": not effective_text.is_empty()} + + +## Journey identity, checkpoint id and defeat marker of one checkpoint +## payload, or `{}` when the bytes are not a checkpoint at all. Parsed +## numbers arrive as floats, so whole floats count as integers exactly like +## the Journey validator counts them; the journey key keeps ints and strings +## in separate namespaces so id `5` never aliases id `"5"`. +func _defeat_meta(text: String) -> Dictionary: + var decoded: Dictionary = CloudSchema.parse_json_value(text) + if not bool(decoded.get("ok", false)) \ + or typeof(decoded.get("value")) != TYPE_DICTIONARY: + return {} + var data: Dictionary = decoded.get("value") + var journey: Variant = data.get("journey_id", null) + var key: String = "" + if journey is int: + key = "i%d" % int(journey) + elif journey is float and journey == floor(journey): + key = "i%d" % int(journey) + elif journey is String and not str(journey).is_empty(): + key = "s%s" % str(journey) + else: + return {} + var cid: Variant = data.get("checkpoint_id", null) + var checkpoint_id: int = -1 + if cid is int: + checkpoint_id = int(cid) + elif cid is float and cid == floor(cid): + checkpoint_id = int(cid) + else: + return {} + var marker: Variant = data.get("ended", false) + return { + "journey": key, + "checkpoint_id": checkpoint_id, + "ended": marker is bool and bool(marker), + } + + +## True when installing the download would silently resume a sealed run: the +## current local bytes hold the same journey's defeat and the download is +## that journey alive at no newer seal. +func _download_resurrects(local_text: String, remote_text: String) -> bool: + var local_meta: Dictionary = _defeat_meta(local_text) + var remote_meta: Dictionary = _defeat_meta(remote_text) + if local_meta.is_empty() or remote_meta.is_empty(): + return false + if str(local_meta.get("journey", "")) != str(remote_meta.get("journey", "")): + return false + return bool(local_meta.get("ended", false)) \ + and not bool(remote_meta.get("ended", false)) \ + and int(remote_meta.get("checkpoint_id", 0)) \ + <= int(local_meta.get("checkpoint_id", 0)) + + +## True when uploading the local bytes would silently resurrect a sealed run +## on the server: the fresh remote holds the same journey's defeat and the +## local bytes are that journey alive at no newer seal. +func _upload_resurrects(local_text: String, remote_text: String) -> bool: + var local_meta: Dictionary = _defeat_meta(local_text) + var remote_meta: Dictionary = _defeat_meta(remote_text) + if local_meta.is_empty() or remote_meta.is_empty(): + return false + if str(local_meta.get("journey", "")) != str(remote_meta.get("journey", "")): + return false + return not bool(local_meta.get("ended", false)) \ + and bool(remote_meta.get("ended", false)) \ + and int(local_meta.get("checkpoint_id", 0)) \ + <= int(remote_meta.get("checkpoint_id", 0)) + + +## True when uploading the local bytes would commit a stale death over the +## journey's acknowledged newer seal: the payload is ended, the fresh +## remote is that journey alive at a strictly newer seal (the paid revive +## already landed there), so the ended candidate retires instead. +func _upload_commits_stale_death(payload: String, + fresh_text: String) -> bool: + var local_meta: Dictionary = _defeat_meta(payload) + var remote_meta: Dictionary = _defeat_meta(fresh_text) + if local_meta.is_empty() or remote_meta.is_empty(): + return false + if str(local_meta.get("journey", "")) != str(remote_meta.get("journey", "")): + return false + return bool(local_meta.get("ended", false)) \ + and not bool(remote_meta.get("ended", false)) \ + and int(remote_meta.get("checkpoint_id", 0)) \ + > int(local_meta.get("checkpoint_id", 0)) + + +## True when installing the download would lay a stale death over the +## journey's newer alive seal: the download is ended, the current local +## bytes are that journey alive at a strictly newer seal, so the ended +## candidate retires instead. +func _download_installs_stale_death(local_text: String, + remote_text: String) -> bool: + var local_meta: Dictionary = _defeat_meta(local_text) + var remote_meta: Dictionary = _defeat_meta(remote_text) + if local_meta.is_empty() or remote_meta.is_empty(): + return false + if str(local_meta.get("journey", "")) != str(remote_meta.get("journey", "")): + return false + return not bool(local_meta.get("ended", false)) \ + and bool(remote_meta.get("ended", false)) \ + and int(local_meta.get("checkpoint_id", 0)) \ + > int(remote_meta.get("checkpoint_id", 0)) + + ## Submit the active checkpoint's actual hero, score, and cycles as our ## single best Hall row. Reads the local checkpoint only; the row is the ## one document our public ID owns, so retries overwrite it idempotently. @@ -512,10 +733,14 @@ func submit_current_best() -> Dictionary: return {"status": "failure", "code": str(row_check.get("error", "invalid-row")), "retryable": false} + var display: String = "" + if _vault != null: + display = str((_vault.call("verified_name_for_account", + _canonical_id) as Dictionary).get("display", "")) var result: Dictionary = await _hall.call("submit_best", _transport, _canonical_id, str(derived.get("hero", "")), int(derived.get("score", 0)), int(derived.get("cycles", 0)), - _release) + _release, display) if _stale(captured): return _cancelled_stale() if str(result.get("status", "")) == "ok": @@ -542,6 +767,453 @@ func submit_current_best() -> Dictionary: return result +## Claim an adventurer name for the ready account, or restore the one it +## already owns. A verified claim is cached for offline use and attached +## to the existing best row at its own score; the claim stands even when +## the cache write or the backfill fails, and the result says which leg +## did not land (`cached`, `backfilled`, `backfill_code`) so the lodge +## can retry exactly that leg. +func claim_adventurer_name(raw_display: String) -> Dictionary: + var captured: int = _generation + if _closed: + return {"status": "cancelled", "code": "coordinator-closed", + "retryable": false} + if not account_ready(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + var claimed: Dictionary = await _names.call("claim_name", _transport, + _uid, _canonical_id, raw_display) + if _stale(captured): + return _cancelled_stale() + if str(claimed.get("status", "")) != "ok": + return claimed + claimed["cached"] = _cache_verified_name(claimed) + var backfilled: Dictionary = await _hall.call("backfill_display", + _transport, _canonical_id, str(claimed.get("display", ""))) + if _stale(captured): + return _cancelled_stale() + claimed["backfilled"] = bool(backfilled.get("backfilled", false)) + claimed["backfill_code"] = str(backfilled.get("code", + backfilled.get("status", ""))) + return claimed + + +## Reload the ready account's claimed name and refresh its offline cache. +## A recovered row never adopts another account: fetching names the exact +## UID and public ID, and a mismatched row reports instead of applying. +func fetch_adventurer_name() -> Dictionary: + var captured: int = _generation + if _closed: + return {"status": "cancelled", "code": "coordinator-closed", + "retryable": false} + if not account_ready(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + var loaded: Dictionary = await _names.call("fetch_adventurer", + _transport, _uid, _canonical_id) + if _stale(captured): + return _cancelled_stale() + if str(loaded.get("status", "")) != "ok": + return loaded + loaded["cached"] = _cache_verified_name(loaded) + return loaded + + +## Flip the tutorial bit on the ready account's claimed name. Idempotent: +## double taps and uncertain acknowledgements converge on one row. +func complete_intro() -> Dictionary: + var captured: int = _generation + if _closed: + return {"status": "cancelled", "code": "coordinator-closed", + "retryable": false} + if not account_ready(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + var done: Dictionary = await _names.call("mark_intro_complete", + _transport, _uid, _canonical_id) + if _stale(captured): + return _cancelled_stale() + if str(done.get("status", "")) != "ok": + return done + done["cached"] = _cache_verified_name(done) + return done + + +## Attach the ready account's verified handle to its existing best row at +## the row's own score. Resolves a cold cache from the server once; writes +## nothing when no best row exists yet. +func backfill_hall_name() -> Dictionary: + var captured: int = _generation + if _closed: + return {"status": "cancelled", "code": "coordinator-closed", + "retryable": false} + if not account_ready(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + var display: String = "" + if _vault != null: + display = str((_vault.call("verified_name_for_account", + _canonical_id) as Dictionary).get("display", "")) + if display.is_empty(): + var loaded: Dictionary = await fetch_adventurer_name() + if _stale(captured): + return _cancelled_stale() + if str(loaded.get("status", "")) != "ok": + return loaded + display = str(loaded.get("display", "")) + return await _hall.call("backfill_display", _transport, + _canonical_id, display) + + +## Claim this attendance period: one server-conditional claim, then exactly +## one local grant of two coins under the stable derived receipt key. +## +## Returns `granted` with `{receipt, coins, next_eligible_utc, +## remaining_seconds}` for a new claim, `already-claimed` when this install +## already received the current period (topping up a missing local grant +## after an uncertain ack or a failed wallet write), or `cooldown` when +## another install holds the period. An eligible advance first backfills +## every owned still-unapplied reward named by the read row, then commits +## carrying the row; results name any backfill in `backfilled` plus +## `backfilled_receipt`. Offline, denied, and conflict-race replies pass +## through honestly and grant nothing; a failed local wallet write after +## a server ack returns `local-grant-failed` with `server_claimed` so the +## next trigger recovers without loss or replay. +func claim_attendance() -> Dictionary: + var captured: int = _generation + if _closed: + return {"status": "cancelled", "code": "coordinator-closed", + "retryable": false} + if not account_ready(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + var install_id: String = "" + if _vault != null: + install_id = str(_vault.call("ensure_install_id")) + if install_id.is_empty(): + return {"status": "failure", "code": "attendance-no-install", + "retryable": true} + var claimed: Dictionary = await _attendance.call("claim_attendance", + _transport, _uid, _canonical_id, install_id) + if _stale(captured): + return _cancelled_stale() + var state: String = str(claimed.get("status", "")) + if state == "ok": + return _grant_attendance_claim(claimed, install_id, captured) + if state == "already-claimed": + return _settle_attendance_hold(claimed, install_id, captured) + if state == "eligible": + return await _advance_attendance_claim( + claimed, install_id, captured) + if _stale(captured): + return _cancelled_stale() + _serve_attendance_offline() + return claimed + + +## Advance one eligible period: backfill every owned still-unapplied +## reward on the read row first, then commit carrying the row. A failed +## wallet save stops before the commit so the server row keeps the +## recoverable receipt; an ambiguous forgotten receipt is skipped (named, +## never repaid) while the genuine new claim still progresses. The wallet +## already holds every backfilled coin. +func _advance_attendance_claim(row: Dictionary, install_id: String, + captured: int) -> Dictionary: + var backfill: Dictionary = _backfill_attendance_claims( + row, install_id, true) + if _stale(captured): + return _cancelled_stale() + if str(backfill.get("status", "")) == "failure": + return _pending_attendance_receipt(row, backfill) + var advanced: Dictionary = await _attendance.call("commit_advance", + _transport, _uid, _canonical_id, install_id, + str(row.get("update_time", "")), + str(row.get("last_claim_rfc", "")), + str(row.get("install_id", ""))) + if _stale(captured): + return _cancelled_stale() + var settled: Dictionary = advanced + if str(advanced.get("status", "")) != "ok": + _serve_attendance_offline() + else: + settled = _grant_attendance_claim( + advanced, install_id, captured) + if str(settled.get("status", "")) != "cancelled": + settled["backfilled"] = bool(backfill.get("backfilled", false)) + settled["backfilled_receipt"] = str( + backfill.get("receipt", "")) + settled["backfill_skipped"] = bool( + backfill.get("skipped", false)) + settled["skipped_receipt"] = str( + backfill.get("skipped_receipt", "")) + return settled + + +## Apply owned still-unapplied rewards named by a read row: its current +## stamp when this install received it (`include_last`), plus any carried +## previous stamp from this install. Each grant is strict (a re-read +## carries no fresh proof), so a replay past the eviction floor refuses +## instead of paying twice. A refused old receipt is SKIPPED, never +## repaid, and the caller still proceeds: bounded retired history must +## not freeze genuinely new rewards. Only a failed wallet save stops the +## caller (the row keeps the recoverable receipt). Returns `{backfilled, +## receipt, skipped, skipped_receipt}` naming the newest landed backfill +## and the newest skipped old receipt, or `{status: failure, code, +## receipt, retryable}` when a save fails. +func _backfill_attendance_claims(row: Dictionary, install_id: String, + include_last: bool) -> Dictionary: + var summary: Dictionary = {"backfilled": false, "receipt": "", + "skipped": false, "skipped_receipt": ""} + var candidates: Array = [] + if include_last \ + and str(row.get("install_id", "")) == install_id \ + and not install_id.is_empty(): + candidates.append(int(row.get("last_claim_at", 0))) + if str(row.get("prev_install_id", "")) == install_id \ + and not install_id.is_empty(): + candidates.append(int(row.get("prev_claim_at", 0))) + var pending: Array = [] + for seconds in candidates: + if int(seconds) <= 0: + continue + if _vault != null and bool(_vault.call( + "attendance_receipt_applied", _canonical_id, + int(seconds), install_id)): + continue + pending.append(int(seconds)) + if pending.is_empty(): + return summary + if _vault == null: + return {"status": "failure", "code": "attendance-no-vault", + "receipt": "", "retryable": false} + for seconds in pending: + var receipt: String = CloudSchema.attendance_receipt_key( + _canonical_id, seconds, install_id) + var granted: Dictionary = _vault.call("grant_attendance_coins", + _canonical_id, seconds, install_id, false) + if str(granted.get("status", "")) != "ok": + if str(granted.get("code", "")) \ + == "attendance-replay-ambiguous": + summary["skipped"] = true + summary["skipped_receipt"] = receipt + continue + var mapped: Dictionary = _vault_grant_failure(granted) + return {"status": "failure", + "code": str(mapped.get("code", "")), + "receipt": receipt, + "retryable": bool(mapped.get("retryable", false))} + if bool(granted.get("granted", false)): + summary["backfilled"] = true + summary["receipt"] = receipt + return summary + + +## Publish the live snapshot for a pending earned receipt the wallet +## could not take yet, and report it honestly: the server row already +## acknowledged these coins, the deadline is eligible now, and retry may +## recover them (a failed save) or never can (an eviction ambiguity). +func _pending_attendance_receipt(row: Dictionary, + backfill: Dictionary) -> Dictionary: + var last_seconds: int = int(row.get("last_claim_at", 0)) + var receipt: String = str(backfill.get("receipt", "")) + var pending: Dictionary = { + "next_eligible_utc": CloudSchema.rfc3339_from_unix( + last_seconds + CloudSchema.ATTENDANCE_COOLDOWN_SECONDS), + "remaining_seconds": 0, + "server_now": int(row.get("server_now", last_seconds)), + } + _cache_attendance_deadline(pending) + _publish_attendance_snapshot(pending, receipt, last_seconds) + return {"status": "failure", "code": str(backfill.get("code", "")), + "server_claimed": true, "receipt": receipt, + "next_eligible_utc": str(pending.get("next_eligible_utc", "")), + "remaining_seconds": 0, + "retryable": bool(backfill.get("retryable", false))} + + +## Map a vault grant failure to its honest caller code. An eviction-floor +## ambiguity is terminal (no retry can resolve forgotten history); a +## failed save stays retryable under the established code. +static func _vault_grant_failure(granted: Dictionary) -> Dictionary: + if str(granted.get("code", "")) == "attendance-replay-ambiguous": + return {"code": "attendance-replay-ambiguous", + "retryable": false} + return {"code": "local-grant-failed", "retryable": true} + + +## Apply a landed server claim to the local wallet exactly once, then +## publish the confirmed deadline. The just-landed commit is fresh proof, +## so same-second cross-owner ties still grant; a failed wallet write +## keeps the server truth and reports recovery-by-retry, never a loss. +func _grant_attendance_claim(claimed: Dictionary, install_id: String, + captured: int) -> Dictionary: + var claim_seconds: int = int(claimed.get("claim_seconds", 0)) + var receipt: String = CloudSchema.attendance_receipt_key( + _canonical_id, claim_seconds, install_id) + var granted: Dictionary = {"status": "failure", + "code": "attendance-no-vault", "granted": false, + "duplicate": false} + if _vault != null: + granted = _vault.call("grant_attendance_coins", _canonical_id, + claim_seconds, install_id, true) + if _stale(captured): + return _cancelled_stale() + _cache_attendance_deadline(claimed) + _publish_attendance_snapshot(claimed, receipt, claim_seconds) + if str(granted.get("status", "")) != "ok": + var mapped: Dictionary = _vault_grant_failure(granted) + return {"status": "failure", + "code": str(mapped.get("code", "")), + "server_claimed": true, "receipt": receipt, + "claim_seconds": claim_seconds, + "next_eligible_utc": str(claimed.get( + "next_eligible_utc", "")), + "remaining_seconds": int(claimed.get( + "remaining_seconds", 0)), + "retryable": bool(mapped.get("retryable", false))} + return {"status": "granted", "receipt": receipt, + "coins": CloudSchema.ATTENDANCE_GRANT_COINS, + "granted": bool(granted.get("granted", false)), + "duplicate": bool(granted.get("duplicate", false)), + "next_eligible_utc": str(claimed.get("next_eligible_utc", "")), + "remaining_seconds": int(claimed.get("remaining_seconds", 0))} + + +## Settle a held period. Ours (double tap, uncertain ack, recovery): +## ensure the local grant under the row's own key, idempotently. +## Another install's: deadline only, never a grant into this wallet. +## Either way, a carried previous stamp from this install backfills +## first, so another install's advance never strands our reward. +func _settle_attendance_hold(claimed: Dictionary, install_id: String, + captured: int) -> Dictionary: + _cache_attendance_deadline(claimed) + var backfill: Dictionary = _backfill_attendance_claims( + claimed, install_id, false) + if _stale(captured): + return _cancelled_stale() + if str(backfill.get("status", "")) == "failure": + claimed["status"] = "failure" + claimed["code"] = str(backfill.get("code", "")) + claimed["server_claimed"] = true + claimed["receipt"] = str(backfill.get("receipt", "")) + claimed["retryable"] = bool(backfill.get("retryable", false)) + _publish_attendance_snapshot(claimed, + str(backfill.get("receipt", "")), + int(claimed.get("last_claim_at", 0))) + return claimed + claimed["backfilled"] = bool(backfill.get("backfilled", false)) + claimed["backfilled_receipt"] = str(backfill.get("receipt", "")) + claimed["backfill_skipped"] = bool(backfill.get("skipped", false)) + claimed["skipped_receipt"] = str( + backfill.get("skipped_receipt", "")) + if not bool(claimed.get("mine", false)): + _publish_attendance_snapshot(claimed, + str(backfill.get("receipt", "")), + int(claimed.get("last_claim_at", 0))) + claimed["status"] = "cooldown" + return claimed + var claim_seconds: int = int(claimed.get("last_claim_at", 0)) + var receipt: String = CloudSchema.attendance_receipt_key( + _canonical_id, claim_seconds, install_id) + var granted: Dictionary = {"status": "failure", + "code": "attendance-no-vault", "granted": false, + "duplicate": false} + if _vault != null: + granted = _vault.call("grant_attendance_coins", _canonical_id, + claim_seconds, install_id) + if _stale(captured): + return _cancelled_stale() + _publish_attendance_snapshot(claimed, receipt, claim_seconds) + if str(granted.get("status", "")) != "ok": + var mapped: Dictionary = _vault_grant_failure(granted) + claimed["status"] = "failure" + claimed["code"] = str(mapped.get("code", "")) + claimed["server_claimed"] = true + claimed["receipt"] = receipt + claimed["retryable"] = bool(mapped.get("retryable", false)) + return claimed + claimed["receipt"] = receipt + claimed["coins"] = CloudSchema.ATTENDANCE_GRANT_COINS + claimed["granted"] = bool(granted.get("granted", false)) + return claimed + + +## Best-effort deadline cache for display and reminder scheduling. The +## claim path never reads it back: only a live server read grants. +func _cache_attendance_deadline(claimed: Dictionary) -> void: + if _vault == null: + return + _vault.call("cache_attendance_next", _canonical_id, + str(claimed.get("next_eligible_utc", "")), + int(claimed.get("remaining_seconds", 0)), + int(claimed.get("server_now", + claimed.get("claim_seconds", + claimed.get("last_claim_at", 0))))) + + +## Publish the live attendance snapshot for the reminder controller and +## any deadline display. Absolute server-confirmed UTC plus bounded +## server-derived remaining: no grant capability rides along. +func _publish_attendance_snapshot(claimed: Dictionary, receipt: String, + claim_seconds: int) -> void: + var last_utc: String = "" + if claim_seconds > 0: + last_utc = CloudSchema.rfc3339_from_unix(claim_seconds) + _attendance_snapshot = {"state": "ready", "source": "live", + "public_id": _canonical_id, "receipt": receipt, + "last_claim_utc": last_utc, + "next_eligible_utc": str(claimed.get("next_eligible_utc", "")), + "remaining_seconds": int(claimed.get("remaining_seconds", -1))} + attendance_changed.emit(_attendance_snapshot.duplicate()) + + +## Offline or errored reads keep the last live snapshot when one exists; +## otherwise they serve the cached deadline labeled as cache, which can +## schedule a reminder but never mint coins. +func _serve_attendance_offline() -> void: + if str(_attendance_snapshot.get("source", "")) == "live": + return + var cached: Dictionary = {} + if _vault != null: + cached = _vault.call("attendance_next_for_account", + _canonical_id) + if cached.is_empty(): + _attendance_snapshot = {"state": "offline", "source": "none", + "public_id": _canonical_id, "receipt": "", + "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + else: + _attendance_snapshot = {"state": "offline", "source": "cache", + "public_id": _canonical_id, "receipt": "", + "last_claim_utc": "", + "next_eligible_utc": str(cached.get("next_utc", "")), + "remaining_seconds": int(cached.get("remaining", -1))} + attendance_changed.emit(_attendance_snapshot.duplicate()) + + +## Current attendance view for the reminder controller. Live values win; +## cached deadlines arrive labeled and grant nothing. +func attendance_snapshot() -> Dictionary: + if _attendance_snapshot.is_empty(): + return {"state": "unregistered", "source": "none", + "public_id": _canonical_id, "receipt": "", + "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + return _attendance_snapshot.duplicate() + + +## Remember one verified claim result in the account-partitioned durable +## cache. Best effort: the server already acknowledged the name, so a +## failed local write only skips the offline copy, which the next load +## restores from the server. +func _cache_verified_name(claimed: Dictionary) -> bool: + if _vault == null: + return false + return bool(_vault.call("cache_verified_name", _canonical_id, + str(claimed.get("display", "")), str(claimed.get("key", "")), + bool(claimed.get("intro_complete", false)))) + + ## Refresh the public top board. Results label their source; failures keep ## the last known rows labeled instead of inventing new ones. func refresh_board(limit: int = 20) -> Dictionary: @@ -773,7 +1445,15 @@ func _reserve_async(captured: int) -> void: if status == "ok": _canonical_id = str(result.get("public_id", "")) if _canonical_id != _partition_key: - _move_slot_to_canonical() + var migration: Dictionary = _move_slot_to_canonical() + if not bool(migration.get("ok", false)): + _account_state = "error" + _account_code = "slot-move-failed" + _save_state = "error" + _save_code = "slot-move-failed" + account_changed.emit(account_snapshot()) + save_changed.emit(save_snapshot()) + return _hall.call("set_account", _canonical_id) _reconcile_active_receipt_floor() _account_state = "ready" @@ -808,22 +1488,38 @@ func _reserve_async(captured: int) -> void: save_changed.emit(save_snapshot()) -## Move an offline guest slot under its canonical id once known. Files only -## move into an empty canonical slot; a canonical slot that already holds a -## journey keeps it and the guest slot stays in place, preserved but unused, -## so nothing is ever overwritten or lost. -func _move_slot_to_canonical() -> void: +## Move an offline guest slot under its canonical id once known, +## rekey-first: the pending receipt rekeys onto the canonical slot BEFORE +## any file moves, so a crash between the two leaves the receipt ahead of +## its bytes and the retry completes the move. A failure holds the move — +## guest files and receipt stay jointly in place — and reports `ok: false` +## instead of adopting over a stranded receipt. Files only move into an +## empty canonical slot; a canonical slot that already holds a journey +## keeps it and the guest slot stays in place, preserved but deferred. +func _move_slot_to_canonical() -> Dictionary: if _canonical_id.is_empty() or _partition_key.is_empty(): - return + return {"ok": true, "moved": []} if _canonical_id == _partition_key: - return + return {"ok": true, "moved": []} + var readiness: String = str(_vault.call("prepare_receipt_move", + _partition_key, _canonical_id)) + if readiness == "failed": + _save_code = "slot-move-failed" + return {"ok": false, "moved": [], "code": "slot-move-failed"} + if readiness == "kept": + _partition_key = _canonical_id + Journey.use_account(_canonical_id) + _load_revision_file() + return {"ok": true, "moved": [], "kept": true} var moved: Dictionary = Journey.move_account_slot( _partition_key, _canonical_id) if not bool(moved.get("ok", false)): _save_code = "slot-move-failed" + return {"ok": false, "moved": [], "code": "slot-move-failed"} _partition_key = _canonical_id Journey.use_account(_canonical_id) _load_revision_file() + return {"ok": true, "moved": moved.get("moved", []), "kept": false} ## Queue the current local checkpoint once the account is ready, so offline @@ -1163,6 +1859,27 @@ func _resolve_keep_local(captured: int) -> Dictionary: if first_status == "ok" and not _write_rejected("remote", first_text): return {"status": "failure", "code": "recovery-save-failed", "retryable": false} + if first_status == "ok" and _upload_resurrects(payload, first_text): + # The fresh remote sealed this journey's defeat after the dialog + # opened (or the dialog predates the death). Rebasing our stale + # alive bytes over it would resurrect the run on the server, so + # the local choice is refused and the conflict stays open. + _save_state = "conflict" + _save_code = "defeat-kept" + save_changed.emit(save_snapshot()) + return {"status": "failure", "code": "defeat-kept", + "retryable": false} + if first_status == "ok" \ + and _upload_commits_stale_death(payload, first_text): + # The dialog's ended candidate predates the acknowledged revive + # the fresh remote already holds. Committing the stale death over + # the newer alive seal would resurrect it remotely, so the local + # choice retires and the conflict stays open for the live side. + _save_state = "conflict" + _save_code = "stale-ended-retired" + save_changed.emit(save_snapshot()) + return {"status": "failure", "code": "stale-ended-retired", + "retryable": false} var result: Dictionary = await _checkpoint.call( "choose_local", _transport, _uid, payload) if _stale(captured): @@ -1217,6 +1934,26 @@ func _resolve_keep_remote(captured: int) -> Dictionary: return {"status": "failure", "code": str(checked.get("error", "invalid-remote-payload")), "retryable": false} + if _download_resurrects(_read_effective_bytes(), remote_text): + # The local file sealed this journey's defeat after the dialog + # opened (or the dialog predates the death). Installing the stale + # alive download would resume the sealed run, so the remote choice + # is refused and the conflict stays open. + _save_state = "conflict" + _save_code = "defeat-kept" + save_changed.emit(save_snapshot()) + return {"status": "failure", "code": "defeat-kept", + "retryable": false} + if _download_installs_stale_death(_read_effective_bytes(), remote_text): + # The download's ended candidate predates our newer alive seal of + # the same journey. Installing the stale death over the live seal + # would end a run that already revived past it, so the remote + # choice retires and the conflict stays open for the live side. + _save_state = "conflict" + _save_code = "stale-ended-retired" + save_changed.emit(save_snapshot()) + return {"status": "failure", "code": "stale-ended-retired", + "retryable": false} var prepared: Dictionary = _prepare_install( checked.get("data", {}) as Dictionary, remote_text) if not bool(prepared.get("ok", false)): diff --git a/apps/game/scripts/cloud/cloud_hall.gd b/apps/game/scripts/cloud/cloud_hall.gd index 66a8093..c0e7ff9 100644 --- a/apps/game/scripts/cloud/cloud_hall.gd +++ b/apps/game/scripts/cloud/cloud_hall.gd @@ -83,20 +83,22 @@ func hall_relative_path(public_id: String) -> String: func hall_body(public_id: String, hero: String, score: int, cycles: int, - release: String) -> Dictionary: - return { - "fields": { - "public_id": CloudSchema.encode_string(public_id), - "hero": CloudSchema.encode_string( - CloudSchema.canonical_hero(hero)), - "score": CloudSchema.encode_int(score), - "cycles": CloudSchema.encode_int(cycles), - "release": CloudSchema.encode_string(release), - "schema": CloudSchema.encode_int(CloudSchema.SCHEMA_VERSION), - "updated_at": CloudSchema.encode_timestamp_rfc3339( - CloudSchema.now_rfc3339()), - } + release: String, display: String = "") -> Dictionary: + var fields: Dictionary = { + "public_id": CloudSchema.encode_string(public_id), + "hero": CloudSchema.encode_string( + CloudSchema.canonical_hero(hero)), + "score": CloudSchema.encode_int(score), + "cycles": CloudSchema.encode_int(cycles), + "release": CloudSchema.encode_string(release), + "schema": CloudSchema.encode_int(CloudSchema.SCHEMA_VERSION), + "updated_at": CloudSchema.encode_timestamp_rfc3339( + CloudSchema.now_rfc3339()), } + if not display.is_empty(): + fields[CloudSchema.HALL_DISPLAY_FIELD] = \ + CloudSchema.encode_string(display) + return {"fields": fields} func top_query_body(limit: int) -> Dictionary: @@ -172,6 +174,8 @@ func parse_row(body: String) -> Dictionary: "release": CloudSchema.decode_string(fields, "release"), "schema": CloudSchema.decode_int(fields, "schema"), "updated_at": CloudSchema.decode_timestamp(fields, "updated_at"), + "display": CloudSchema.decode_string( + fields, CloudSchema.HALL_DISPLAY_FIELD), } if not CloudSchema.is_valid_public_id(str(row.get("public_id", ""))): return {"status": "failure", "code": "bad-hall-row", @@ -181,9 +185,13 @@ func parse_row(body: String) -> Dictionary: ## Submits a candidate best. Reads the current row first so a lower score is ## reported as not-best without a doomed write. Equal scores are idempotent: -## the retry overwrites the same document and succeeds. +## the retry overwrites the same document and succeeds. `display` carries +## the caller's verified handle when one is cached, else stays empty for an +## unnamed row; anything else fails before any request, and the server +## re-binds a display to the claimed name pair before it lands. func submit_best(transport: RefCounted, public_id: String, hero: String, - score: int, cycles: int, release: String) -> Dictionary: + score: int, cycles: int, release: String, + display: String = "") -> Dictionary: if not CloudSchema.is_valid_public_id(public_id): return {"status": "failure", "code": "invalid-public-id", "retryable": false} @@ -195,13 +203,19 @@ func submit_best(transport: RefCounted, public_id: String, hero: String, if not bool(row_check.get("ok", false)): return {"status": "failure", "code": str(row_check.get("error", "")), "retryable": false} + if not display.is_empty() and not bool( + CloudSchema.normalize_adventurer_name(display).get( + "ok", false)): + return {"status": "failure", "code": "invalid-name", + "retryable": false} if transport == null or not transport.has_method("post"): return {"status": "unconfigured", "code": "missing-transport", "retryable": false} var current: Dictionary = await fetch_own(transport, public_id) + var effective_display: String = display if str(current.get("status", "")) == "ok": - var best: int = int((current.get("row", {}) as Dictionary).get( - "score", 0)) + var stored: Dictionary = current.get("row", {}) as Dictionary + var best: int = int(stored.get("score", 0)) if score < best: # The server holds a better row (ours or another # device's): drop every cache so the next rank @@ -210,6 +224,11 @@ func submit_best(transport: RefCounted, public_id: String, hero: String, clear_cache() return {"status": "failure", "code": "not-best", "retryable": false, "best": best} + if effective_display.is_empty(): + # Our own row already carries our verified handle + # (claimed on another device): keep it rather than + # overwrite it away from a cold cache. + effective_display = str(stored.get("display", "")) elif str(current.get("code", "")) != "not-found": return current var commit_body: Dictionary = { @@ -219,7 +238,8 @@ func submit_best(transport: RefCounted, public_id: String, hero: String, "name": CloudSchema.document_name( CloudSchema.HALL_COLLECTION, public_id), "fields": (hall_body(public_id, hero, score, cycles, - release) as Dictionary)["fields"], + release, + effective_display) as Dictionary)["fields"], }, } ] @@ -242,6 +262,42 @@ func submit_best(transport: RefCounted, public_id: String, hero: String, return {"status": "ok", "score": score, "public_id": public_id} +## Attach a verified handle to the account's existing best row at its own +## score. Uses the row's saved hero and cycles — never the currently +## selected hero — and writes nothing when no row exists, so backfill +## invents no zero-score record and can never downgrade a newer best: a +## higher server score reports `not-best` and the retry re-reads it. +func backfill_display(transport: RefCounted, public_id: String, + display: String) -> Dictionary: + if not CloudSchema.is_valid_public_id(public_id): + return {"status": "failure", "code": "invalid-public-id", + "retryable": false} + if not bool(CloudSchema.normalize_adventurer_name( + display).get("ok", false)): + return {"status": "failure", "code": "invalid-name", + "retryable": false} + if not _account_allows(public_id): + return {"status": "cancelled", "code": "account-changed", + "retryable": false} + var current: Dictionary = await fetch_own(transport, public_id) + if str(current.get("status", "")) != "ok": + if str(current.get("code", "")) == "not-found": + return {"status": "ok", "backfilled": false, + "code": "no-row"} + return current + var stored: Dictionary = current.get("row", {}) as Dictionary + if str(stored.get("display", "")) == display: + return {"status": "ok", "backfilled": false, + "code": "already-named"} + var submitted: Dictionary = await submit_best(transport, public_id, + str(stored.get("hero", "")), int(stored.get("score", 0)), + int(stored.get("cycles", 0)), + str(stored.get("release", "")), display) + if str(submitted.get("status", "")) == "ok": + submitted["backfilled"] = true + return submitted + + ## Top-board read with cache and throttle. `now_msec` is injected so tests ## control time; pass Time.get_ticks_msec() in production. func fetch_top(transport: RefCounted, limit: int, @@ -304,6 +360,8 @@ func parse_top_rows(body: String) -> Dictionary: "cycles": CloudSchema.decode_int(fields, "cycles"), "release": CloudSchema.decode_string(fields, "release"), "updated_at": CloudSchema.decode_timestamp(fields, "updated_at"), + "display": CloudSchema.decode_string( + fields, CloudSchema.HALL_DISPLAY_FIELD), }) return {"status": "ok", "rows": rows, "source": "live"} diff --git a/apps/game/scripts/cloud/cloud_name.gd b/apps/game/scripts/cloud/cloud_name.gd new file mode 100644 index 0000000..eeddc50 --- /dev/null +++ b/apps/game/scripts/cloud/cloud_name.gd @@ -0,0 +1,256 @@ +extends RefCounted + +## Account-owned adventurer names and their global reservation pair. +## +## Each registered account owns one private adventurer row +## (`mb_adventurers_v1/{public_id}`) and one matching global name claim +## (`mb_names_v1/{name_key}`), created together in one atomic Firestore +## commit. The claim is immutable: no rename, no reassignment, no lone +## halves. Two owners racing for one name yield exactly one winner; the +## loser reads `name-taken` and keeps its own ID, journey, and best score. +## +## The collision key lowercases the display, so `Luna` and `LUNA` are one +## name. Commit JSON carries raw Unicode document names; only HTTP request +## URLs percent-encode the name segment. Offline work never certifies a +## global claim: without a server acknowledgement there is no name. + +const CloudSchema: Script = preload("res://scripts/cloud/cloud_schema.gd") + + +func adventurer_relative_path(public_id: String) -> String: + return "documents/%s/%s" % [CloudSchema.ADVENTURER_COLLECTION, public_id] + + +func name_relative_path(name_key: String) -> String: + return "documents/%s/%s" % [ + CloudSchema.NAME_COLLECTION, name_key.uri_encode()] + + +func adventurer_body(uid: String, public_id: String, name_key: String, + display: String, intro_complete: bool) -> Dictionary: + var stamp: String = CloudSchema.now_rfc3339() + return { + "fields": { + "public_id": CloudSchema.encode_string(public_id), + "uid": CloudSchema.encode_string(uid), + "name_key": CloudSchema.encode_string(name_key), + "display": CloudSchema.encode_string(display), + "intro_complete": CloudSchema.encode_bool(intro_complete), + "schema": CloudSchema.encode_int(CloudSchema.SCHEMA_VERSION), + "created_at": CloudSchema.encode_timestamp_rfc3339(stamp), + "updated_at": CloudSchema.encode_timestamp_rfc3339(stamp), + } + } + + +func name_body(name_key: String, display: String, + public_id: String) -> Dictionary: + return { + "fields": { + "name_key": CloudSchema.encode_string(name_key), + "display": CloudSchema.encode_string(display), + "public_id": CloudSchema.encode_string(public_id), + "schema": CloudSchema.encode_int(CloudSchema.SCHEMA_VERSION), + "created_at": CloudSchema.encode_timestamp_rfc3339( + CloudSchema.now_rfc3339()), + } + } + + +## Atomic two-write claim body. Both writes require `exists: false`, so a +## taken name fails the whole commit without half state. Document names +## carry the raw Unicode key; URL encoding applies to request URLs only. +func claim_commit_body(uid: String, public_id: String, name_key: String, + display: String) -> Dictionary: + return { + "writes": [ + { + "update": { + "name": CloudSchema.document_name( + CloudSchema.ADVENTURER_COLLECTION, public_id), + "fields": (adventurer_body(uid, public_id, + name_key, display, false) as Dictionary)["fields"], + }, + "currentDocument": {"exists": false}, + }, + { + "update": { + "name": CloudSchema.document_name( + CloudSchema.NAME_COLLECTION, name_key), + "fields": (name_body(name_key, display, + public_id) as Dictionary)["fields"], + }, + "currentDocument": {"exists": false}, + }, + ] + } + + +## Reads the account's claimed name. Returns ok/not-found plus offline, +## unconfigured, cancelled, and failure without inventing a name. A row +## bound to another UID or public ID reports `adventurer-mismatch` and is +## never adopted. +func fetch_adventurer(transport: RefCounted, uid: String, + public_id: String) -> Dictionary: + var validation: Dictionary = _validate_ids(transport, uid, public_id) + if not bool(validation.get("ok", false)): + return validation.get("result", {}) + var reply: Dictionary = await transport.call("get_document", + adventurer_relative_path(public_id)) + var status: String = str(reply.get("status", "failure")) + if status != "ok": + if str(reply.get("code", "")) == "not-found": + return { + "status": "failure", "code": "adventurer-not-found", + "retryable": false, + } + return reply + var decoded: Dictionary = CloudSchema.parse_json_value( + str(reply.get("body", ""))) + if not bool(decoded.get("ok", false)) \ + or typeof(decoded.get("value")) != TYPE_DICTIONARY: + return {"status": "failure", "code": "bad-adventurer-body", + "retryable": false} + var fields: Dictionary = (decoded.get("value") as Dictionary).get( + "fields", {}) + var stored_uid: String = CloudSchema.decode_string(fields, "uid") + var stored_id: String = CloudSchema.decode_string(fields, "public_id") + if stored_uid != uid or stored_id != public_id: + return {"status": "failure", "code": "adventurer-mismatch", + "retryable": false} + var normalized: Dictionary = CloudSchema.normalize_adventurer_name( + CloudSchema.decode_string(fields, "display")) + if not bool(normalized.get("ok", false)) \ + or str(normalized.get("key", "")) \ + != CloudSchema.decode_string(fields, "name_key"): + return {"status": "failure", "code": "adventurer-mismatch", + "retryable": false} + return { + "status": "ok", + "display": str(normalized.get("display", "")), + "key": str(normalized.get("key", "")), + "intro_complete": CloudSchema.decode_bool( + fields, "intro_complete"), + "source": "cloud", + } + + +## Claims a display name for a registered account, or restores the one it +## already owns. A taken name reports `name-taken` after one recheck rules +## out our own landed row; a permission denial is never renamed to taken, +## so missing backend rules surface as a configuration failure instead. +## Invalid names fail before any request; offline callers get no name. +func claim_name(transport: RefCounted, uid: String, public_id: String, + raw_display: String) -> Dictionary: + var normalized: Dictionary = CloudSchema.normalize_adventurer_name( + raw_display) + if not bool(normalized.get("ok", false)): + return {"status": "failure", "code": "invalid-name", + "retryable": false} + var validation: Dictionary = _validate_ids(transport, uid, public_id) + if not bool(validation.get("ok", false)): + return validation.get("result", {}) + var display: String = str(normalized.get("display", "")) + var key: String = str(normalized.get("key", "")) + var existing: Dictionary = await fetch_adventurer( + transport, uid, public_id) + if str(existing.get("status", "")) == "ok": + return { + "status": "ok", "display": str(existing.get("display", "")), + "key": str(existing.get("key", "")), + "intro_complete": bool( + existing.get("intro_complete", false)), + "restored": true, "source": "cloud", + } + if str(existing.get("code", "")) != "adventurer-not-found": + return existing + var commit: Dictionary = await transport.call("post", + "documents:commit", JSON.stringify(claim_commit_body( + uid, public_id, key, display))) + var commit_status: String = str(commit.get("status", "failure")) + if commit_status == "ok": + return { + "status": "ok", "display": display, "key": key, + "intro_complete": false, "restored": false, + "source": "cloud", + } + if commit_status == "conflict": + # Either the name belongs to another owner, or our own pair + # landed first (another device won, or an uncertain + # acknowledgement hid our own success). Re-read once: a row + # means our canonical name wins explicitly; no row means the + # name itself is taken and no silent replacement follows. + var recheck: Dictionary = await fetch_adventurer( + transport, uid, public_id) + if str(recheck.get("status", "")) == "ok": + return { + "status": "ok", + "display": str(recheck.get("display", "")), + "key": str(recheck.get("key", "")), + "intro_complete": bool( + recheck.get("intro_complete", false)), + "restored": true, "source": "cloud", + } + if str(recheck.get("code", "")) != "adventurer-not-found": + return recheck + return {"status": "conflict", "code": "name-taken", + "retryable": false, "key": key} + return commit + + +## Flips the tutorial bit false to true. Idempotent: an already complete +## row reports ok without a write, so uncertain acknowledgements and +## double taps converge instead of duplicating. +func mark_intro_complete(transport: RefCounted, uid: String, + public_id: String) -> Dictionary: + var validation: Dictionary = _validate_ids(transport, uid, public_id) + if not bool(validation.get("ok", false)): + return validation.get("result", {}) + var current: Dictionary = await fetch_adventurer( + transport, uid, public_id) + if str(current.get("status", "")) != "ok": + return current + if bool(current.get("intro_complete", false)): + return { + "status": "ok", "display": str(current.get("display", "")), + "key": str(current.get("key", "")), "intro_complete": true, + "source": "cloud", + } + var body: Dictionary = { + "writes": [ + { + "update": { + "name": CloudSchema.document_name( + CloudSchema.ADVENTURER_COLLECTION, public_id), + "fields": (adventurer_body(uid, public_id, + str(current.get("key", "")), + str(current.get("display", "")), + true) as Dictionary)["fields"], + }, + "currentDocument": {"exists": true}, + } + ] + } + var reply: Dictionary = await transport.call("post", + "documents:commit", JSON.stringify(body)) + if str(reply.get("status", "")) != "ok": + return reply + return { + "status": "ok", "display": str(current.get("display", "")), + "key": str(current.get("key", "")), "intro_complete": true, + "source": "cloud", + } + + +func _validate_ids(transport: RefCounted, uid: String, + public_id: String) -> Dictionary: + if transport == null or not transport.has_method("get_document"): + return {"ok": false, "result": {"status": "unconfigured", + "code": "missing-transport", "retryable": false}} + if not CloudSchema.is_valid_uid(uid): + return {"ok": false, "result": {"status": "failure", + "code": "invalid-uid", "retryable": false}} + if not CloudSchema.is_valid_public_id(public_id): + return {"ok": false, "result": {"status": "failure", + "code": "invalid-public-id", "retryable": false}} + return {"ok": true} diff --git a/apps/game/scripts/cloud/cloud_name.gd.uid b/apps/game/scripts/cloud/cloud_name.gd.uid new file mode 100644 index 0000000..cc98ac8 --- /dev/null +++ b/apps/game/scripts/cloud/cloud_name.gd.uid @@ -0,0 +1 @@ +uid://iy2g0plvj3hy diff --git a/apps/game/scripts/cloud/cloud_schema.gd b/apps/game/scripts/cloud/cloud_schema.gd index 21fadbe..15bda7c 100644 --- a/apps/game/scripts/cloud/cloud_schema.gd +++ b/apps/game/scripts/cloud/cloud_schema.gd @@ -15,6 +15,9 @@ const PROFILE_COLLECTION: String = "mb_profiles_v1" const RESERVATION_COLLECTION: String = "mb_reservations_v1" const CHECKPOINT_COLLECTION: String = "mb_checkpoints_v1" const HALL_COLLECTION: String = "mb_hall_v1" +const ADVENTURER_COLLECTION: String = "mb_adventurers_v1" +const NAME_COLLECTION: String = "mb_names_v1" +const ATTENDANCE_COLLECTION: String = "mb_attendance_v1" const SCHEMA_VERSION: int = 1 const PUBLIC_ID_PREFIX: String = "MB-" @@ -42,6 +45,52 @@ const HALL_FIELDS: Array[String] = [ "public_id", "hero", "score", "cycles", "release", "schema", "updated_at", ] +## Hall rows may also carry `display`: the owner's verified adventurer +## name, bound server-side to the claimed name pair. Absent on legacy and +## unnamed rows, which keep rendering honestly. +const HALL_DISPLAY_FIELD: String = "display" +const ADVENTURER_FIELDS: Array[String] = [ + "public_id", "uid", "name_key", "display", "intro_complete", + "schema", "created_at", "updated_at", +] +const NAME_FIELDS: Array[String] = [ + "name_key", "display", "public_id", "schema", "created_at", +] +## One private attendance row per public ID: the last claim's server +## timestamp plus the receiving install. The ONLY timestamp is server-set +## (`last_claim_at == request.time` in the rules); no client-writable +## created/updated stamp exists to forge. +const ATTENDANCE_FIELDS: Array[String] = [ + "public_id", "uid", "install_id", "last_claim_at", "schema", + "prev_claim_at", "prev_install_id", +] +## Previous-claim carry on attendance advances: the overwritten row's +## stamp and install, copied opaquely so another install's later advance +## never erases this install's still-unapplied reward. Absent on first +## claims and pre-chain rows. +const ATTENDANCE_PREV_CLAIM_FIELD: String = "prev_claim_at" +const ATTENDANCE_PREV_INSTALL_FIELD: String = "prev_install_id" +## Rolling attendance cooldown in seconds: one grant per twelve hours, +## enforced by server time on both sides. Client wall clocks never enter. +const ATTENDANCE_COOLDOWN_SECONDS: int = 43200 +## Coins per granted attendance claim. +const ATTENDANCE_GRANT_COINS: int = 2 +const MAX_INSTALL_ID_LENGTH: int = 64 +## Attendance receipt keys share the wallet's grant ledger with purchased +## coins. This prefix separates them so bounded pruning never evicts a +## purchase; store transaction keys never carry it. +const ATTENDANCE_KEY_PREFIX: String = "attendance:" +## Newest attendance receipts kept in the wallet ledger per install. Older +## ones prune once the owner-bound watermark passes them; the watermark +## (not the pruned key) stops them from ever granting again. +const MAX_ATTENDANCE_KEYS: int = 8 +## Adventurer display bounds in visible characters. The allow-list below is +## BMP-only, so this count equals the rules `size()` UTF-16 count exactly. +const MIN_NAME_LENGTH: int = 2 +const MAX_NAME_LENGTH: int = 12 +## Verified names remembered per public ID on this device. Bounded: every +## entry is one small verified handle, never a token or identity profile. +const MAX_VERIFIED_NAME_CACHE: int = 8 ## Full hero resource paths accepted in Hall rows. Short ids are resolved to ## these before upload so the rules list stays in one vocabulary. @@ -92,6 +141,53 @@ static func is_valid_public_id(public_id: String) -> bool: return true +## Normalize a raw adventurer name into its display form and collision key. +## Trims boundary whitespace, then requires 2-12 visible characters from +## the explicit allow-list: Latin letters, digits, underscore, precomposed +## Hangul syllables (U+AC00-U+D7A3), Hiragana (U+3041-U+3096), Katakana +## (U+30A1-U+30FA plus the prolonged mark U+30FC), CJK ideographs +## (U+4E00-U+9FFF), and single interior ASCII spaces. Path separators, +## controls, invisible/combining characters, Jamo, astral emoji, doubled +## or boundary spaces, and anything else are invalid. The key lowercases +## the display, so Latin variants collide and CJK passes through byte +## identical. The security rules mirror this list exactly; both sides +## count the same BMP-only characters. +## Returns `{ok, display, key}` or `{ok: false, error: "invalid-name"}`. +static func normalize_adventurer_name(raw: String) -> Dictionary: + var display: String = raw.strip_edges() + if display.length() < MIN_NAME_LENGTH \ + or display.length() > MAX_NAME_LENGTH: + return {"ok": false, "error": "invalid-name"} + var previous_space: bool = false + for index in display.length(): + var code: int = display.unicode_at(index) + if code == 32: + if previous_space: + return {"ok": false, "error": "invalid-name"} + previous_space = true + continue + previous_space = false + if _is_name_word(code) or _is_name_cjk(code): + continue + return {"ok": false, "error": "invalid-name"} + return {"ok": true, "display": display, "key": display.to_lower()} + + +static func _is_name_word(code: int) -> bool: + return (code >= 48 and code <= 57) \ + or (code >= 65 and code <= 90) \ + or (code >= 97 and code <= 122) \ + or code == 95 + + +static func _is_name_cjk(code: int) -> bool: + return (code >= 0xAC00 and code <= 0xD7A3) \ + or (code >= 0x3041 and code <= 0x3096) \ + or (code >= 0x30A1 and code <= 0x30FA) \ + or code == 0x30FC \ + or (code >= 0x4E00 and code <= 0x9FFF) + + ## Accepts a full hero path or a short id and returns the canonical full path. ## Returns "" when the hero is outside the allow-list. static func canonical_hero(hero: String) -> String: @@ -141,10 +237,146 @@ static func decode_timestamp(fields: Dictionary, key: String) -> String: return str((entry as Dictionary).get("timestampValue", "")) +static func encode_bool(value: bool) -> Dictionary: + return {"booleanValue": value} + + +static func decode_bool(fields: Dictionary, key: String) -> bool: + var entry: Variant = fields.get(key, {}) + if typeof(entry) != TYPE_DICTIONARY: + return false + return bool((entry as Dictionary).get("booleanValue", false)) + + static func now_rfc3339() -> String: return Time.get_datetime_string_from_system(true) + "Z" +## Server timestamps arrive RFC3339 Zulu with optional fractional seconds. +## Truncation to whole seconds is deliberate: cooldown math compares +## seconds. Returns 0 for garbage; callers fail closed on unparseable +## server time rather than granting on an unknown clock. The engine logs +## an error for any malformed or impossible date, so every input passes +## the strict shape gate first and the log stays clean. +static func unix_from_rfc3339(text: String) -> int: + var clean: String = text.strip_edges() + if clean.ends_with("Z") or clean.ends_with("z"): + clean = clean.left(clean.length() - 1) + var dot: int = clean.find(".") + if dot >= 0: + clean = clean.left(dot) + if not _is_datetime_string(clean): + return 0 + return maxi(int(Time.get_unix_time_from_datetime_string(clean)), 0) + + +## Strict datetime shape: `DDDD-DD-DD` plus `T` (or one space) plus +## `DD:DD:DD`, a real calendar day with leap years counted, year +## 1970..9999, hour 00..23, minute and second 00..59. Anything else is +## not a stamp the engine may see. +static func _is_datetime_string(clean: String) -> bool: + if clean.length() != 19: + return false + var join: String = clean.substr(10, 1) + if join != "T" and join != " ": + return false + if clean.substr(4, 1) != "-" or clean.substr(7, 1) != "-" \ + or clean.substr(13, 1) != ":" \ + or clean.substr(16, 1) != ":": + return false + var digits: String = clean.left(4) + clean.substr(5, 2) \ + + clean.substr(8, 2) + clean.substr(11, 2) \ + + clean.substr(14, 2) + clean.substr(17, 2) + for index in digits.length(): + var code: int = int(digits.unicode_at(index)) + if code < 48 or code > 57: + return false + var year: int = int(clean.left(4)) + if year < 1970 or year > 9999: + return false + var month: int = int(clean.substr(5, 2)) + if month < 1 or month > 12: + return false + var leap: bool = year % 4 == 0 \ + and (year % 100 != 0 or year % 400 == 0) + var longest: Array = [31, 29 if leap else 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31] + if int(clean.substr(8, 2)) < 1 \ + or int(clean.substr(8, 2)) > int(longest[month - 1]): + return false + if int(clean.substr(11, 2)) > 23 \ + or int(clean.substr(14, 2)) > 59 \ + or int(clean.substr(17, 2)) > 59: + return false + return true + + +## Absolute UTC deadline for the reminder API. Never a device clock: the +## input seconds always come from a server read or commit timestamp. +static func rfc3339_from_unix(seconds: int) -> String: + var text: String = Time.get_datetime_string_from_unix_time( + maxi(seconds, 0)) + return text.replace(" ", "T") + "Z" + + +## Whole-second cooldown math over server timestamps only. `server_now` +## is a batchGet readTime or commitTime, never the device clock. +## Returns `{eligible, remaining (0..cooldown), next_at}` in seconds. A +## backwards or forged-future stamp fails closed with the full wait, +## never an instant grant. +static func attendance_cooldown(server_now: int, + last_claim_at: int) -> Dictionary: + var elapsed: int = server_now - last_claim_at + if elapsed >= ATTENDANCE_COOLDOWN_SECONDS: + return {"eligible": true, "remaining": 0, + "next_at": server_now + ATTENDANCE_COOLDOWN_SECONDS} + if elapsed < 0: + return {"eligible": false, + "remaining": ATTENDANCE_COOLDOWN_SECONDS, + "next_at": last_claim_at + ATTENDANCE_COOLDOWN_SECONDS} + return {"eligible": false, + "remaining": ATTENDANCE_COOLDOWN_SECONDS - elapsed, + "next_at": last_claim_at + ATTENDANCE_COOLDOWN_SECONDS} + + +## Stable wallet receipt key for one attendance grant. Derived, never +## random: the claimer and any later recovery read the same server row +## and derive the same key, so double taps and uncertain acks converge +## on one idempotent grant, while another install's key never matches +## this wallet's. +static func attendance_receipt_key(public_id: String, claim_seconds: int, + install_id: String) -> String: + return "%s%s:%d:%s" % [ATTENDANCE_KEY_PREFIX, public_id, + claim_seconds, install_id] + + +## Claim seconds embedded in a receipt key, or -1 unless the shape is +## exactly `attendance:{public_id}:{seconds}:{install}`. +static func attendance_key_seconds(key: String) -> int: + var parts: PackedStringArray = key.split(":") + if parts.size() != 4 or parts[0] != "attendance": + return -1 + if parts[1].is_empty() or parts[3].is_empty(): + return -1 + if not parts[2].is_valid_int(): + return -1 + var seconds: int = int(parts[2]) + if seconds <= 0: + return -1 + return seconds + + +## Our own install binding for a claim write. Fail closed on empty or +## overlong IDs: no claim without a receiving-install binding. +static func is_valid_install_id(install_id: String) -> bool: + if install_id.is_empty(): + return false + if install_id.length() > MAX_INSTALL_ID_LENGTH: + return false + return not (install_id.contains("/") or install_id.contains(" ") + or install_id.contains(":")) + + ## Silent JSON parse for untrusted text. The static `JSON.parse_string` ## prints an engine ERROR on malformed input, which fails the regression ## runner's error gate; the instance `parse` only returns its error code. @@ -225,4 +457,9 @@ static func summarize_payload(payload: String, revision: int) -> Dictionary: for key in ["gate", "cycle", "checkpoint", "act"]: if parsed.has(key): summary[key] = parsed[key] + # The defeat marker travels with the summary so a conflict dialog + # can name a sealed run. Only a present bool rides along; payloads + # sealed before the defeat rules carry no key at all. + if parsed.has("ended") and parsed.get("ended") is bool: + summary["ended"] = bool(parsed["ended"]) return summary diff --git a/apps/game/scripts/gameplay/arena.gd b/apps/game/scripts/gameplay/arena.gd index 8720546..3daf214 100644 --- a/apps/game/scripts/gameplay/arena.gd +++ b/apps/game/scripts/gameplay/arena.gd @@ -735,14 +735,21 @@ var _journey_id: Variant = "" var _journey_checkpoint_id: int = 0 var _journey_settled_score: int = 0 ## A receipt whose Vault save failed stays pending (sealed unsettled); any -## later successful receipt supersedes it, which keeps retries available. +## later successful receipt supersedes it. +## A defeat whose terminal seal failed to write stays pending too: the run +## cannot be left until the marker lands, or the older alive file behind it +## would offer a free continuation. +var _journey_defeat_pending: bool = false var _journey_receipt_pending: bool = false var _journey_receipt_pending_cid: int = 0 var _journey_receipt_pending_score: int = 0 var _journey_resuming: bool = false var _journey_resume_failed: bool = false +## A stranded paid continue whose seal cannot land. The run never opens: +## booting fresh would abandon the journaled revive, so `_ready` leaves +## for the title and the next entry retries the materialize. +var _journey_recovery_failed: bool = false var _journey_opening_played: bool = false -var _journey_last_finish_won: bool = false var _journey_opening_strip: Array[String] = [] ## If result save fails briefly, retry before leaving the run. The side that saved drops out of ## pending, so a stuck other side cannot duplicate shards or a new record. @@ -789,16 +796,31 @@ var _combat_hud_queued: bool = false func _ready() -> void: + # A crash may have stranded a paid continue: debit journaled, seal not + # yet written. Settle it before reading, so the paid revive — not a + # fresh fallback — greets the relaunch. + var recovery: String = Vault.recover_paid_continue() # The title's journey action is consumed first so the restored hero, seed, # cycle and route are in place before the room, art and boons build below. _journey_action = Journey.consume_pending() if _journey_action == Journey.Pending.RESUME: _journey_snapshot = Journey.read_checkpoint() - if _journey_snapshot.is_empty(): - # Resume asked for a journey that is gone or refused. Fall back - # to a fresh run, visibly: the banner says so below. - _journey_action = Journey.Pending.FRESH - _journey_resume_failed = true + if _journey_snapshot.is_empty() \ + or Journey.is_ended(_journey_snapshot): + if recovery == "failed": + # The paid seal cannot land (its write keeps failing) and + # the files still hold the defeat. Booting a fresh run + # here would abandon — and clear — the paid revive, so + # leave for the title instead; the journal stays and the + # next entry retries it. + _journey_recovery_failed = true + else: + # Resume asked for a journey that is gone, refused, or already + # sealed its defeat. Fall back to a fresh run, visibly: the + # banner says so below. + _journey_snapshot = {} + _journey_action = Journey.Pending.FRESH + _journey_resume_failed = true else: _journey_resuming = true _camera.zoom = Vector2.ONE * WORLD_CAMERA_ZOOM @@ -928,6 +950,13 @@ func _ready() -> void: _apply_boons() _apply_test_boost() _refresh_evolution_hud() + if _journey_recovery_failed: + Journey.disarm() + # Only the scene root swaps scenes: an embedded arena (tests, the + # review harness) idles unopened instead of redirecting its host. + if get_tree().current_scene == self: + get_tree().call_deferred("change_scene_to_file", TITLE_SCENE) + return _journey_open_run() _tutorial_origin = _player.position _begin_tutorial.call_deferred() @@ -979,7 +1008,7 @@ func _hero_path_for_run() -> String: ## settles nothing so starting alone never pays. func _journey_open_run() -> void: if _journey_resuming: - _journey_restore_segment(true) + _journey_restore_segment() _hud.announce( tr("JOURNEY_RESUMED") % _cycle, Color(0.72, 0.94, 1.0, 1), ONBOARD_HOLD) @@ -1002,8 +1031,8 @@ func _journey_open_run() -> void: ## Restore the checkpoint's plain values before the room, art and analytics -## build from them. Called in `_ready` right after the Vault pick, and again -## by an in-place gate retry. +## build from them. Called in `_ready` right after the Vault pick for a run +## that is still alive; a sealed defeat never reaches here. func _journey_apply_early(snapshot: Dictionary) -> void: _run_seed = int(snapshot["run_seed"]) _cycle = int(snapshot["cycle"]) @@ -1064,29 +1093,11 @@ func _journey_apply_early(snapshot: Dictionary) -> void: ## Rebuild the saved segment through production methods: reset growth to base, ## feed saved relic stacks through the real pick path, rebuild the ## deterministic world, quietly relight completed beacons, and reset transient -## combat state. `world_ready` is true when `_ready` already built the room -## from restored values. -func _journey_restore_segment(world_ready: bool) -> void: - if not world_ready: - _journey_apply_early(_journey_snapshot) - _raid_queue.clear() - _clear_hostile_projectiles() - _clear_friendly_projectiles() - for spirit in _spirits: - if is_instance_valid(spirit): - spirit.retreat() - _spirits.clear() - _guardian = null - _guardian_title = "" - _guardian_detail = "" - for group in [&"moon_embers", &"power_orbs", &"missile_cores", &"moon_dews"]: - for pickup in get_tree().get_nodes_in_group(group): - if is_instance_valid(pickup): - pickup.queue_free() - if _skills != null: - _skills.queue_free() - _skills = null - # The defeated segment grants nothing: banking its embers here would pay twice. +## combat state. Runs once at resume, after `_ready` already built the room +## from the restored values. +func _journey_restore_segment() -> void: + # A resumed segment starts clean: banking carried-over embers here would + # pay for progress the checkpoint never sealed. _ember_positions.clear() _ember_elites.clear() _pending_embers = 0 @@ -1101,13 +1112,11 @@ func _journey_restore_segment(world_ready: bool) -> void: _on_relic_picked(item, false, "opening") _settle_rates() _refresh_aux_weapons() - # The gate restores you. Health is not stored: retrying a gate saved at - # one heart with one heart would be a death loop. + # The gate restores you. Health is not stored: resuming a gate saved + # at one heart with one heart would be a death loop. _set_health(_max_health) _moonfire_charge = 0.0 _end_moonfire() - if not world_ready: - _change_world(false) # Reset quietly: `reset()` emits `lit_changed(false)` for a lit beacon, # which would uncount the restored beacons below. for beacon in _beacons: @@ -1232,12 +1241,16 @@ func _vault_settle_journey_receipt( return Vault.settle_journey_receipt(journey, checkpoint_id, target) -func _journey_snapshot_now(total: int) -> Dictionary: +## Seal the current live state as a checkpoint dict. Gate and cycle seals +## tick the checkpoint id; the defeat seal passes `false` so the terminal +## marker keeps the last alive seal's id and the coin revive seals the next. +func _journey_snapshot_now(total: int, bump_checkpoint: bool = true) -> Dictionary: # Fresh starts and resumes always set the id first; this is last resort. if str(_journey_id).is_empty(): _journey_id = "r%x" % abs(randi()) _journey_checkpoint_id = 0 - _journey_checkpoint_id += 1 + if bump_checkpoint: + _journey_checkpoint_id += 1 _journey_settled_score = total var stacks: Dictionary = {} for item in _taken: @@ -1296,51 +1309,18 @@ func _journey_score_total() -> int: return score.total() -## A gate retry is possible: a human run holding a checkpoint. -func _journey_retry_available() -> bool: - return Journey.armed and not _journey_snapshot.is_empty() - - -## Count the result from the checkpoint, not the defeated segment. The lost -## segment's kills, score and rewards grant nothing, so no number of retries, -## restarts or relaunches can multiply them. -func _journey_fill_score_from_checkpoint(score: Score) -> void: - score.cycles = maxi(int(_journey_snapshot["cycle"]) - 1, 0) - score.beacons = int(_journey_snapshot["lit_count"]) - score.survived = float(_journey_snapshot["survived"]) - score.level = int(_journey_snapshot["level"]) - score.kills = int(_journey_snapshot["kill_score"]) - - -## Retry the saved segment after death: restore the checkpoint in place and -## resume the run. Free — no continue coin is spent — and the checkpoint is -## preserved, so dying again retries the same gate. -func _retry_from_gate() -> void: - if not _over or not _journey_retry_available(): - return - if not _retry_pending_result_persistence(): - _result.reopen_after_failed_continue() - return - _over = false - _journey_last_finish_won = false - _analytics_run_end_reason = "" - _analytics_track("gate_retry", { - "cycle": int(_journey_snapshot.get("cycle", 1)), - "elapsed_ms": _analytics_elapsed_ms(), - }) - _pending_result_action = ResultAction.NONE - _result.visible = false - _journey_restore_segment(false) - _player.resume_after_continue(CONTINUE_CLEAR_RADIUS) - _player.set_move_input(Vector2.ZERO) - set_process(true) - _stick.set_active(true) - _pause.set_available(true) - _spawn_timer = maxf(_spawn_timer, CONTINUE_CALM) - _raid_left = maxf(_raid_left, CONTINUE_CALM) - _hud.announce( - tr("JOURNEY_RESUMED") % _cycle, Color(0.72, 0.94, 1.0, 1), - ONBOARD_HOLD) +## Seal the defeat durably: the journey file carries the final counters with +## the terminal marker, so the title, a relaunch, a backup or a cloud restore +## can never resume this run for free. The previous alive seal rotates into +## the backup behind it. When the write fails the marker stays pending and +## the run cannot be left until the seal lands (see +## `_retry_pending_result_persistence`); wallets and records are untouched. +func _seal_defeat() -> void: + var tombstone: Dictionary = _journey_snapshot_now( + _journey_score_total(), false) + tombstone["ended"] = true + _journey_snapshot = tombstone + _journey_defeat_pending = Journey.write_checkpoint(tombstone) != OK ## Fresh human journey, first seconds: the opening plays as a nonmodal voice @@ -1724,6 +1704,8 @@ func _on_result_dismissed(action: ResultAction) -> void: ## Result screen's record button. Opens the ladder panel; closing it returns to results. +## A verified named player records under the account's handle with no +## re-ask; everyone else gets the classic typed name. func _on_result_record() -> void: if not _retry_pending_result_persistence(): _result.reopen_after_failed_continue() @@ -1731,7 +1713,19 @@ func _on_result_record() -> void: # **Always hide the result screen.** The ladder panel's dim is 0.82, so the score breakdown and # name field stack on one screen behind it. That is how it actually captured. _result.visible = false - _ladder.ask( + var handle: String = str(Vault.verified_name_for_account( + Journey.active_account).get("display", "")) + if handle.is_empty(): + _ladder.ask( + _board_score, + _board_rank, + _board_cycles, + _hero_path_for_run(), + _board_run_id(), + ) + return + _ladder.ask_named( + handle, _board_score, _board_rank, _board_cycles, @@ -1773,11 +1767,9 @@ func _finish_result_action() -> void: ResultAction.SHRINE: _return_to_title(true) _: - # After a lost run with a checkpoint, Retry means the saved gate, - # not a fresh run. The button says so on screen. - if _over and not _journey_last_finish_won and _journey_retry_available(): - _retry_from_gate() - return + # Win or lose, Retry starts over: the sealed defeat stays sealed + # and the reloaded scene begins a new journey. The button says + # so on screen. _restart() @@ -1844,6 +1836,9 @@ func _submit_run_record(total_score: int, rank: String) -> int: ## Shared retry door for result buttons, recording, continue, and shop travel. ## Attempt both saves so one failure cannot block the other side's recovery. +## A defeat whose terminal seal failed lands last, after a recovered +## settlement refreshes its echo: leaving with the older alive file still in +## place would offer a free continuation. func _retry_pending_result_persistence() -> bool: var awarded_before: int = _run_shards_awarded var settlement_ok: bool = true @@ -1862,11 +1857,24 @@ func _retry_pending_result_persistence() -> bool: record_ok = record_result != Records.SubmitResult.SAVE_FAILED record_saved = record_result == Records.SubmitResult.SAVED + var defeat_ok: bool = true + if _journey_defeat_pending: + if Journey.armed and not _journey_snapshot.is_empty(): + _journey_snapshot["shards_awarded"] = _run_shards_awarded + _journey_snapshot["settled_score"] = maxi( + int(_journey_snapshot.get("settled_score", 0)), + _journey_score_total()) + if Journey.write_checkpoint(_journey_snapshot) == OK: + _journey_defeat_pending = false + else: + _journey_defeat_pending = false + defeat_ok = not _journey_defeat_pending + if _result != null and _result.visible \ and (record_saved or awarded_before != _run_shards_awarded): # Do not replay an already-revealed result; only correct the stored values. _result.refresh_persistence(record_saved, _run_shards_awarded) - return settlement_ok and record_ok + return settlement_ok and record_ok and defeat_ok ## Small boundary where result-persistence regression tests inject save failure and recovery. @@ -3307,33 +3315,23 @@ func _finish(won: bool) -> void: _ripple.queue_free() _ripple = null - _journey_last_finish_won = won + # The result always counts the final live state. Death is terminal now, + # so there is no checkpoint to fall back to and no retry to farm: the + # once-only receipt ledger keeps the single settlement exact. var score: Score = Score.new() - if _journey_retry_available(): - # Count the checkpoint, never the defeated segment: the lost segment - # grants nothing, so retries cannot farm it. A cashout wrote its own - # checkpoint just above, so this matches the live state there. - _journey_fill_score_from_checkpoint(score) - else: - score.cycles = maxi(_cycle - 1, 0) - score.beacons = _lit_count - score.survived = _survived - score.level = _level - score.kills = _kill_score + score.cycles = maxi(_cycle - 1, 0) + score.beacons = _lit_count + score.survived = _survived + score.level = _level + score.kills = _kill_score # Both new records and shards confirm a real save. A briefly failed side retries when a result # button is pressed, and until then the run cannot be left. var is_best: bool = _persist_finished_result(score.total(), score.rank()) - if _journey_retry_available(): - # The death may have banked the checkpoint's remainder (the run-start - # checkpoint settles nothing until now). Rewrite the bookkeeping so a - # relaunch cannot grant it again. The gate itself is preserved. - _journey_snapshot["shards_awarded"] = _run_shards_awarded - _journey_snapshot["settled_score"] = maxi( - int(_journey_snapshot.get("settled_score", 0)), score.total()) - _journey_snapshot["opening_played"] = _journey_opening_played - # Best effort: the in-memory checkpoint stays valid for the retry even - # if this rewrite fails, and `Journey.last_error` carries the reason. - Journey.write_checkpoint(_journey_snapshot) + if not won and Journey.armed: + # The death may have banked the run-start remainder (the first + # checkpoint settles nothing until now). Seal the defeat with the + # final bookkeeping so no exit, restore or relaunch grants it again. + _seal_defeat() # The result line's "this run" is the cumulative shards actually received in this run, not the # last settlement delta. Pre-continue grants still show as this run's reward. score.shards = _run_shards_awarded @@ -3351,15 +3349,15 @@ func _finish(won: bool) -> void: _board_cycles = score.cycles _result.show_result( won, score, is_best, Ladder.makes_board(_board_score), - _places_seen_run.size(), not won and _journey_retry_available(), - _run_hero_path) + _places_seen_run.size(), _run_hero_path) func _on_continue_requested() -> void: if continue_run(): return # Balance gone or save failed. Restore the result screen as-is so the player can choose again. - # Coins were not deducted. + # A debit that already journaled stays journaled: the panel offers a + # save retry for it instead of the shop, and the retry never recharges. _result.reopen_after_failed_continue() @@ -3405,16 +3403,37 @@ const CONTINUE_CLEAR_RADIUS: float = 132.0 ## continue buys a life, not progress, and that is what separates its value from ## "from scratch." ## -## With no balance, change nothing and return false. Coin is spent before revive, so keep that -## order and a mid-revive failure cannot burn a coin alone (a failed save undoes the -## debit itself). +## One recoverable protocol, never best-effort rollback across two saves. +## On a sealed journey run the coin debit journals the exact revive seal +## first (`begin_continue_txn`); only then is the alive checkpoint written +## (no receipt settles here; the next gate seal carries new value), and the +## journal clears once the seal lands. A failed debit writes nothing; a +## failed seal keeps the terminal marker with the debit journaled, so a +## retry reuses it without charging again and a relaunch recovers the paid +## revive — instead of stranding an unpaid alive checkpoint — through the +## entry points. Runs without a sealed journey keep the plain spend. func continue_run() -> bool: if not _over: return false if not _retry_pending_result_persistence(): return false - if not Vault.spend_continue_coin(): - return false + if Journey.armed and Journey.is_ended(_journey_snapshot): + var revived: Dictionary = _journey_snapshot_now( + _journey_score_total()) + if not Vault.begin_continue_txn(_journey_id, + int(revived["checkpoint_id"]), JSON.stringify(revived)): + _journey_checkpoint_id -= 1 + return false + if Journey.write_checkpoint(revived) != OK: + _journey_checkpoint_id -= 1 + return false + _journey_snapshot = revived + Vault.ack_continue_txn() + else: + if Vault.continue_coins <= 0: + return false + if not Vault.spend_continue_coin(): + return false _over = false _analytics_run_end_reason = "" @@ -3519,8 +3538,7 @@ func _open_credits() -> void: func _restart() -> void: # A restart is an explicit fresh start: the reloaded scene begins a new - # journey as a human run. Gate retry (which preserves the checkpoint) - # routes around this through `_retry_from_gate`. + # journey as a human run, clearing whatever the old one sealed. Journey.begin_fresh() RunEntry.mark_from_title() _analytics_track_run_end( diff --git a/apps/game/scripts/gameplay/attendance_reminders.gd b/apps/game/scripts/gameplay/attendance_reminders.gd new file mode 100644 index 0000000..66da77d --- /dev/null +++ b/apps/game/scripts/gameplay/attendance_reminders.gd @@ -0,0 +1,491 @@ +class_name AttendanceReminders +extends Node + +## Local attendance-reminder controller. One per install, owned by the +## production host. +## +## After an acknowledged attendance reward the server-confirmed deadline +## (`next_eligible_utc` + bounded `remaining_seconds` from the host's +## attendance view) is the single scheduling authority: there is no +## second reward timer, and a notification never grants coins by itself. +## The controller schedules one first fire at eligibility plus the +## native twelve-hour cadence. Every refresh re-checks the live OS +## permission before skipping unchanged inputs (a revocation on the +## same deadline surfaces instead of hiding), and refills a bounded +## native horizon from the same anchor when its held window runs low; +## a title refresh never shifts the deadline or stacks requests. It +## retires the schedule on disable, sign-out, account switch, or +## deletion. +## +## Scheduling decisions always query the live native permission; the +## `reminder_os_state` in Settings is display cache only. Desktop and +## other builds without a native side stay playable with an honest +## `unsupported` status and no fake scheduled success. + +const SCHEMA_SCRIPT: Script = preload( + "res://scripts/cloud/cloud_schema.gd") + +## Twelve-hour repeat, matching the server's rolling cooldown. +const REPEAT_SECONDS: int = 43200 +## Native window shape, mirroring the iOS planner exactly: 48 slots on +## the anchor grid, adequate while 8 are still future. Millisecond +## math so the equal-time boundary agrees bit-for-bit, like the +## native side. +const REPEAT_MILLIS: int = 43200000 +const HORIZON_SLOTS: int = 48 +const MIN_FUTURE_SLOTS: int = 8 + +var _host: Node = null +var _bridge: Node = null +var _generation: int = 0 +var _pending_permission_id: String = "" +var _pending_generation: int = -1 +var _pending_status_id: String = "" +var _status_generation: int = -1 +## Reentrancy guard: reconciling writes `reminder_os_state`, which emits +## `Settings.changed`, which calls back into `refresh`. Nested passes +## are redundant (the outer pass already sees the latest view), and on +## the never-recorded refusal path they would oscillate forever. +var _reconciling: bool = false + + +func _ready() -> void: + if _host == null and has_node("/root/ProductionHost"): + configure(get_node("/root/ProductionHost"), + get_node_or_null("/root/MoonlitIdentity")) + + +## Tests and the host inject fakes here. Production passes the real +## host plus the MoonlitIdentity autoload (or null on desktop builds +## without the bridge, which stays honestly unsupported). +func configure(host: Node, bridge: Node) -> void: + if _host != null and is_instance_valid(_host): + if _host.is_connected("production_attendance", + _on_attendance_snapshot): + _host.disconnect("production_attendance", + _on_attendance_snapshot) + if _host.is_connected("production_changed", + _on_production_changed): + _host.disconnect("production_changed", + _on_production_changed) + if _bridge != null and is_instance_valid(_bridge): + if _bridge.is_connected("request_completed", + _on_bridge_outcome): + _bridge.disconnect("request_completed", + _on_bridge_outcome) + _generation += 1 + _pending_permission_id = "" + _pending_generation = -1 + _pending_status_id = "" + _status_generation = -1 + _host = host + _bridge = bridge + if _host != null and is_instance_valid(_host): + if _host.has_signal("production_attendance"): + _host.connect("production_attendance", + _on_attendance_snapshot) + if _host.has_signal("production_changed"): + _host.connect("production_changed", + _on_production_changed) + if _bridge != null and is_instance_valid(_bridge): + if _bridge.has_signal("request_completed"): + _bridge.connect("request_completed", _on_bridge_outcome) + if Settings.is_connected("changed", _on_settings_changed): + Settings.disconnect("changed", _on_settings_changed) + Settings.connect("changed", _on_settings_changed) + + +## Re-evaluate the schedule from the live view. Called on every +## attendance snapshot, settings change, and explicit refresh. +func refresh() -> void: + if _reconciling: + return + if _host == null or not is_instance_valid(_host): + return + if not _host.has_method("attendance_view"): + return + _reconciling = true + _reconcile(_host.call("attendance_view")) + _reconciling = false + + +## Settings-row tap: off enables (or opens OS settings when denied), +## on disables and cancels immediately. +func user_toggle() -> void: + if not Settings.reminders_enabled: + if Settings.reminder_os_state == "denied": + _open_os_settings() + return + user_enable() + return + user_disable() + + +## Persist the intent, ask the OS for permission, and schedule on +## grant. The receipt offer's accept path calls this too. Never +## auto-requests: only explicit taps arrive here. +func user_enable() -> void: + if _bridge == null or not is_instance_valid(_bridge): + Settings.set_reminder_os_state("unsupported") + return + if not _bridge.has_method("reminder_request_permission"): + Settings.set_reminder_os_state("unsupported") + return + if not Settings.set_reminders_enabled(true): + return + var receipt: Dictionary = _bridge.call( + "reminder_request_permission") + if str(receipt.get("status", "")) == "pending": + _pending_permission_id = str( + receipt.get("request_id", "")) + _pending_generation = _generation + return + _apply_permission_receipt(receipt, _generation) + + +## Persist off, cancel any native delivery, and forget the schedule. +## The Settings sentinel lands first, so a crash cannot resurrect it. +func user_disable() -> void: + _cancel_native() + Settings.clear_reminder_schedule() + Settings.set_reminders_enabled(false) + + +## Refresh the display-cache OS state without scheduling. When the +## native side answers pending (iOS reads the OS asynchronously), the +## cached state stands until the bounded outcome lands and corrects +## the open panel through Settings.changed: no second reopen, no +## prompt, and never a flash of ignorance on every open. +func refresh_permission() -> void: + var status: Dictionary = _native_status() + if status.is_empty(): + Settings.set_reminder_os_state("unsupported") + return + if str(status.get("status", "")) == "error": + # Transient native outage: leave the cached display state + # alone rather than overwriting it with ignorance. + return + if str(status.get("status", "")) == "pending": + _pending_status_id = str(status.get("request_id", "")) + _status_generation = _generation + return + Settings.set_reminder_os_state(str( + status.get("permission", "unknown"))) + + +## The first receipt's offer text, or "" when no offer applies. Marks +## the offer shown when it returns text, so repeat receipts never nag. +## Offered only where a native side exists: desktop never dangles one. +func offer_for_receipt() -> String: + if Settings.reminder_receipt_offered: + return "" + if Settings.reminders_enabled: + return "" + if _bridge == null or not is_instance_valid(_bridge): + return "" + if not _bridge.has_method("reminder_status"): + return "" + var status: Dictionary = _bridge.call("reminder_status") + var code: String = str(status.get("status", "")) + if code == "pending": + _pending_status_id = str(status.get("request_id", "")) + _status_generation = _generation + elif code != "ok": + return "" + Settings.mark_reminder_receipt_offered() + return tr("REMINDER_OFFER") + + +func _on_attendance_snapshot(_snapshot: Dictionary) -> void: + refresh() + + +func _on_production_changed(_state: Dictionary) -> void: + # Account, deletion, and restore transitions all publish here; the + # live view below retires a schedule whose owner is gone. + refresh() + + +func _on_settings_changed() -> void: + refresh() + + +func _on_bridge_outcome(outcome: Dictionary) -> void: + var request_id: String = str(outcome.get("request_id", "")) + if request_id == _pending_permission_id \ + and _pending_generation == _generation \ + and not _pending_permission_id.is_empty(): + _pending_permission_id = "" + _pending_generation = -1 + _apply_permission_receipt(outcome, _generation) + return + if request_id == _pending_status_id \ + and _status_generation == _generation \ + and not _pending_status_id.is_empty(): + _pending_status_id = "" + _status_generation = -1 + _apply_status_outcome(outcome, _generation) + + +func _apply_permission_receipt(receipt: Dictionary, + generation: int) -> void: + if generation != _generation: + return + var status: String = str(receipt.get("status", "")) + if status == "unsupported" or status == "not_configured": + Settings.set_reminder_os_state("unsupported") + return + var permission: String = str( + receipt.get("permission", "unknown")) + if permission == "granted": + Settings.set_reminder_os_state("granted") + refresh() + elif permission == "denied": + # Denial is not enabled: the intent stays on so the settings + # row can offer OS settings, but nothing schedules. + Settings.set_reminder_os_state("denied") + else: + Settings.set_reminder_os_state("unknown") + + +## Settle one asynchronous native status read. A fresh grant may +## unblock a schedule this pass skipped for lack of a verdict, so a +## granted outcome runs the schedule tail once against a fresh view. +## Bounded: the tail never queries status, failed outcomes schedule +## nothing and refresh nothing, and a state change converges through +## the normal Settings.changed refresh instead of a loop here. +func _apply_status_outcome(outcome: Dictionary, + generation: int) -> void: + if generation != _generation: + return + if str(outcome.get("status", "")) != "ok": + return + var permission: String = str( + outcome.get("permission", "unknown")) + Settings.set_reminder_os_state(permission) + if permission != "granted": + return + if _host == null or not is_instance_valid(_host): + return + if not _host.has_method("attendance_view"): + return + var view: Variant = _host.call("attendance_view") + if typeof(view) != TYPE_DICTIONARY: + return + _schedule_for_view(view) + + +## Whether the recorded native horizon still holds its deliveries. +## Negative means an unbounded repeating native (Android): always +## held. Zero means a legacy schedule that never reported a window +## (or none at all): only iOS absolute slots must replan once, since +## a repeating native still holds without any window. A real window +## holds under the exact native duplicate rule: the first +## not-yet-elapsed slot derives from the recorded anchor in integer +## milliseconds, and the window holds while 8 of its 48 ordinals are +## still future. A base the native side never reported derives from +## the recorded end once; an underivable one replans once and then +## carries full metadata. Whole-second fixtures agree with the native +## planner bit-for-bit (see the shared agreement vectors); live +## sub-second sampling can differ for under a second and self-heals. +static func _horizon_holds(horizon_end: float, base_slot: int, + eligible_unix: int, now_unix: float, + platform: String) -> bool: + if horizon_end < 0.0: + return true + if horizon_end == 0.0: + return platform != "iOS" + var base: int = base_slot + if base < 0: + base = _legacy_base_slot(horizon_end, eligible_unix) + if base < 0: + return false + var anchor_ms: int = eligible_unix * 1000 + var now_ms: int = int(now_unix * 1000.0) + var first_future: int = 0 + if now_ms > anchor_ms: + first_future = int((now_ms - anchor_ms + REPEAT_MILLIS \ + - 1) / REPEAT_MILLIS) + if first_future < base: + return false + return (first_future - base) + MIN_FUTURE_SLOTS \ + <= HORIZON_SLOTS + + +## First ordinal of a window the old native receipts never named: the +## recorded end is the last fire, 47 repeats past the base. -1 when +## the end is not on the anchor grid or names no window at all. +static func _legacy_base_slot(horizon_end: float, + eligible_unix: int) -> int: + if eligible_unix <= 0: + return -1 + var end_sec: int = int(floor(horizon_end + 0.5)) + var span: int = end_sec - eligible_unix + if span < (HORIZON_SLOTS - 1) * REPEAT_SECONDS: + return -1 + if span % REPEAT_SECONDS != 0: + return -1 + return span / REPEAT_SECONDS - (HORIZON_SLOTS - 1) + + +func _reconcile(view: Dictionary) -> void: + if typeof(view) != TYPE_DICTIONARY: + return + var account: String = str(view.get("public_id", "")) + if not Settings.reminder_sched_account.is_empty() \ + and account != Settings.reminder_sched_account: + # Sign-out, switch, or deletion retired this owner: cancel the + # stale delivery and forget it. Never reschedules here; the new + # account's own snapshot drives its own schedule below. + _cancel_native() + Settings.clear_reminder_schedule() + if not Settings.reminders_enabled: + return + if typeof(view) != TYPE_DICTIONARY or view.is_empty(): + return + var source: String = str(view.get("source", "none")) + if source != "live" and source != "cache": + # No deadline known: leave any existing delivery alone rather + # than churning on ignorance. Cached deadlines may schedule + # (reminder only, never a grant); absence schedules nothing. + return + var eligible_utc: String = str( + view.get("next_eligible_utc", "")) + if eligible_utc.is_empty(): + return + var eligible_unix: int = SCHEMA_SCRIPT.unix_from_rfc3339( + eligible_utc) + if eligible_unix <= 0: + return + # Live permission first, identical-input skipping after: an OS + # revocation on the same deadline must surface as denied instead + # of hiding behind the skip. Denial keeps the intent and the + # record (a re-grant resumes delivery with no reschedule) but + # schedules nothing; only a lost native clears defensively. + var status: Dictionary = _native_status() + if status.is_empty(): + Settings.set_reminder_os_state("unsupported") + _cancel_native() + Settings.clear_reminder_schedule() + return + if str(status.get("status", "")) == "error": + # Transient native outage (for example the activity is + # between lifecycles): keep the owned delivery and its + # record, and let the next refresh converge. Never cancel + # or clear on ignorance. + return + if str(status.get("status", "")) == "pending": + # Asynchronous read (iOS): the bounded outcome converges + # this pass through _apply_status_outcome. + _pending_status_id = str(status.get("request_id", "")) + _status_generation = _generation + return + var permission: String = str(status.get("permission", "unknown")) + if permission != "granted": + Settings.set_reminder_os_state(permission) + return + Settings.set_reminder_os_state("granted") + _schedule_for_view(view) + + +## Schedule the view's deadline unless it is already held. Pure of +## status reads: both the synchronous reconcile path and the granted +## async-status outcome share it, so neither can stack deliveries or +## loop back into a status wait. The user-intent and ownership gates +## live here (not only in the reconcile head) because a late async +## verdict arrives after taps and scope changes: a grant may update +## the OS cache while off, but it must never re-enable intent or arm +## a delivery the current settings and source do not own. Never moves +## the deadline: a refill re-asks the native side for the same anchor, +## and the native side keeps its absolute times. An expired known +## anchor still schedules: the native side plans future-only slots +## from it (never a burst), so an offline return that never claims +## still re-arms instead of starving on an exhausted horizon. +## Scheduling grants no coins. +func _schedule_for_view(view: Dictionary) -> void: + if not Settings.reminders_enabled: + return + var source: String = str(view.get("source", "none")) + if source != "live" and source != "cache": + # No owned deadline: stale fields a dead scope retains must + # not arm anything, however parseable they still are. + return + var account: String = str(view.get("public_id", "")) + var eligible_utc: String = str( + view.get("next_eligible_utc", "")) + var eligible_unix: int = SCHEMA_SCRIPT.unix_from_rfc3339( + eligible_utc) + if eligible_unix <= 0: + return + if account == Settings.reminder_sched_account \ + and eligible_utc == Settings.reminder_sched_eligible_utc \ + and Settings.locale == Settings.reminder_sched_locale \ + and _horizon_holds( + Settings.reminder_sched_horizon_end, + Settings.reminder_sched_base_slot, + eligible_unix, + Time.get_unix_time_from_system(), + OS.get_name()): + return + var scheduled: Dictionary = _bridge.call("reminder_schedule", { + "eligible_utc_millis": eligible_unix * 1000, + "title": tr("REMINDER_TITLE"), + "body": tr("REMINDER_BODY"), + "locale": Settings.locale, + "account": account, + }) + if str(scheduled.get("status", "")) != "ok": + # Not recorded: the next refresh converges instead of + # claiming a delivery the OS refused. + if str(scheduled.get("status", "")) == "denied" \ + or str(scheduled.get("permission", "")) == "denied": + Settings.set_reminder_os_state("denied") + return + var end: float = float( + scheduled.get("horizon_end_unix", 0.0)) + var base: int = int(scheduled.get("base_slot", -1)) + if end == 0.0 and bool(scheduled.get("duplicate", false)) \ + and account == Settings.reminder_sched_account \ + and eligible_utc \ + == Settings.reminder_sched_eligible_utc \ + and Settings.locale == Settings.reminder_sched_locale: + # A duplicate that names no window must not wipe the known + # one: keep the recorded metadata so the next refresh + # skips instead of re-asking forever. + end = Settings.reminder_sched_horizon_end + base = Settings.reminder_sched_base_slot + if not Settings.record_reminder_schedule(account, eligible_utc, + Settings.locale, end, base): + # The OS holds a delivery this install can no longer name: + # cancel it rather than orphan it, and converge next time. + _cancel_native() + + +func _native_status() -> Dictionary: + if _bridge == null or not is_instance_valid(_bridge): + return {} + if not _bridge.has_method("reminder_status"): + return {} + var status: Variant = _bridge.call("reminder_status") + if typeof(status) != TYPE_DICTIONARY: + return {} + var code: String = str(status.get("status", "")) + if code == "ok" or code == "error" or code == "pending": + return status + return {} + + +func _cancel_native() -> void: + if _bridge == null or not is_instance_valid(_bridge): + return + if not _bridge.has_method("reminder_cancel"): + return + _bridge.call("reminder_cancel") + + +func _open_os_settings() -> void: + if _bridge == null or not is_instance_valid(_bridge): + return + if not _bridge.has_method("reminder_open_settings"): + return + _bridge.call("reminder_open_settings") diff --git a/apps/game/scripts/gameplay/attendance_reminders.gd.uid b/apps/game/scripts/gameplay/attendance_reminders.gd.uid new file mode 100644 index 0000000..9ad2124 --- /dev/null +++ b/apps/game/scripts/gameplay/attendance_reminders.gd.uid @@ -0,0 +1 @@ +uid://oet0nm6yqa8f diff --git a/apps/game/scripts/gameplay/gate_lodge.gd b/apps/game/scripts/gameplay/gate_lodge.gd new file mode 100644 index 0000000..cbf5c91 --- /dev/null +++ b/apps/game/scripts/gameplay/gate_lodge.gd @@ -0,0 +1,1332 @@ +class_name GateLodge +extends Node2D + +## First-login refuge where Lumi registers the arriving traveller. +## +## Cloud-linked accounts whose durable cache holds no verified handle or +## no acknowledged lesson bit land here instead of the Arena (see +## ProductionHost.needs_lodge_lesson). Lumi greets the hero, the account +## claims one immutable adventurer name through the real atomic service, +## the player walks and dashes for real on the open floor, and the gate +## seals the account-owned lesson before departure. Completed accounts +## pass straight to the departure choice; local-only guests never enter. +## +## The lesson is not an expedition: nothing here arms a journey, spends +## a continue coin, spawns combat, or touches the saved run until the +## departure choice plans exactly like the title would. Name/move/dash +## gates complete only from actual performance, and the intro bit flips +## only from the server acknowledgement. The scene joins +## `moonlit_dialogue` for its whole visit, so attendance receipts wait +## for the lesson instead of covering the name keyboard or speeches. +## +## Harness hook: `host_override` substitutes the production host, and +## `layout_for` is pure so placement math is testable without a window. + +enum State { BOOT, GREET, NAME, MOVE, DASH, GATE, SEALING, DEPART, EXITING, LOST, LINES } + +## Room art this layout was measured against. The desk and gate centers +## come from a color probe of the raw plates: pale-blue gate mass at +## (0.826, 0.196) wide / (0.857, 0.163) tablet, warm desk mass at +## (0.261, 0.300) wide / (0.239, 0.263) tablet, open floor below ~0.34 +## wide / ~0.30 tablet. +const WIDE_PLATE: Texture2D = preload( + "res://assets/custom/world/lodge/room_wide.png") +const TABLET_PLATE: Texture2D = preload( + "res://assets/custom/world/lodge/room_tablet.png") +const WIDE_PLATE_SIZE: Vector2 = Vector2(1881.0, 836.0) +const TABLET_PLATE_SIZE: Vector2 = Vector2(1448.0, 1086.0) +const WIDE_BASE: Vector2 = Vector2(808.0, 360.0) +const TABLET_BASE: Vector2 = Vector2(808.0, 606.0) +## Viewport aspect at/above this uses the wide plate, below the tablet. +const PLATE_ASPECT_SPLIT: float = 1.7 + +## Plate-fraction layout per plate: walk clamp, desk blocker, gate +## trigger, Lumi feet, hero start. Fractions keep collisions glued to +## the art at any window size. +const WIDE_WALK: Rect2 = Rect2(0.03, 0.34, 0.94, 0.62) +const WIDE_DESK: Rect2 = Rect2(0.06, 0.08, 0.26, 0.30) +const WIDE_GATE: Rect2 = Rect2(0.771, 0.35, 0.11, 0.10) +const WIDE_LUMI: Vector2 = Vector2(0.35, 0.46) +## Seated so the hero's opaque feet clear the tallest greeting card +## by 4px at both wide viewports (880x360 is the worst case: the card +## top sits at y231 while the spawn's plate fraction stretches with +## the wider room). Still deep inside the walk band, clear of the +## desk, Lumi's personal space, and the gate trigger. +const WIDE_HERO: Vector2 = Vector2(0.52, 0.634) +const TABLET_WALK: Rect2 = Rect2(0.03, 0.28, 0.94, 0.68) +const TABLET_DESK: Rect2 = Rect2(0.05, 0.06, 0.25, 0.26) +const TABLET_GATE: Rect2 = Rect2(0.802, 0.30, 0.11, 0.09) +const TABLET_LUMI: Vector2 = Vector2(0.33, 0.40) +const TABLET_HERO: Vector2 = Vector2(0.52, 0.62) + +const TITLE_SCENE: String = "res://scenes/menus/production_entry.tscn" + +## Desk-edge tie margin, in pixels: exits this close count as equally +## near, so the fixed edge order breaks the tie instead of float +## rounding picking a different edge at each framing. +const DESK_TIE_EPSILON: float = 0.001 +## Cumulative walked distance that teaches the move gate, base units. +const MOVE_GATE_DISTANCE: float = 60.0 +## Lumi's personal space: the hero slides around her, never through. +const LUMI_BODY_RADIUS: float = 26.0 +## Name field headroom above the 12 visible characters for IME composition. +const NAME_FIELD_MAX: int = 24 +## Viewport-space margin the confirm action keeps above the OS keyboard. +const KEYBOARD_ACTION_MARGIN: float = 8.0 + +## Substitute production host for the QA harness. Production leaves null. +var host_override: Node = null + +## The harness embeds the lodge instead of swapping to it. Embedded, the +## lodge emits its exit intents instead of changing the scene itself. +var embedded: bool = false + +## Emitted instead of a scene swap when embedded: return or depart. +signal request_title +signal request_arena(arena_path: String) + +## Scripted movement for the harness clip. While active, the frame step +## feeds this to the player instead of the stick. Production never sets it. +var move_override_active: bool = false +var move_override: Vector2 = Vector2.ZERO + +var _state: int = State.BOOT +var _host: Node = null +var _account_id: String = "" +var _display: String = "" +var _actor_scale: float = 1.0 +var _walk_rect: Rect2 = Rect2() +var _desk_rect: Rect2 = Rect2() +var _gate_rect: Rect2 = Rect2() +var _move_accum: float = 0.0 +var _gate_armed: bool = false +var _gate_cooldown: bool = false +var _fresh_armed: bool = false +var _exiting: bool = false +var _claim_busy: bool = false +var _claim_failed: bool = false +var _speech_lines: Array = [] +var _speech_index: int = 0 +var _speech_done: Callable = Callable() +var _speech_locked: bool = false +var _last_pos: Vector2 = Vector2.ZERO + +@onready var _room: Sprite2D = $Room +@onready var _lumi: LumiGuide = $Lumi +@onready var _player: Player = $Player +@onready var _camera: Camera2D = $Camera2D +@onready var _stick: Control = $Ui/MoveStick +@onready var _dash_button: Control = $Ui/Dash + +var _ui: CanvasLayer = null +var _top_bar: HBoxContainer = null +var _speech_panel: PanelContainer = null +var _speech_speaker: Label = null +var _speech_body: Label = null +var _speech_hint: Label = null +var _speech_skip: Button = null +var _name_panel: PanelContainer = null +var _name_bust: TextureRect = null +var _name_head: HBoxContainer = null +var _name_hint: Label = null +var _name_field: LineEdit = null +var _name_status: Label = null +var _name_confirm: Button = null +## Keyboard-aware form seating: the unshifted panel top plus whether a +## visible OS keyboard currently lifts the form. Restored when the +## keyboard hides, so desktop framing never drifts. +var _name_base_y: float = 0.0 +var _name_shifted: bool = false +## Tall-keyboard adaptation: the full card height while uncompacted +## (the mode decision measures against it even after compacting, so +## the compact rect cannot feed back and oscillate), whether the +## optional bust/header/copy currently hides, and which viewport +## occlusion already asked for one keyboard dismissal. +var _name_full_h: float = 0.0 +var _name_compact: bool = false +var _dismissed_occlusion: float = -1.0 +var _depart_panel: PanelContainer = null +var _depart_saved: Label = null +var _depart_status: Label = null +var _depart_resume: Button = null +var _depart_fresh: Button = null + + +## Placement for one viewport size, in viewport/world pixels. Pure, so +## tests judge every framing without opening a window. The room covers +## the viewport (never bands), actors scale with the room from their +## plate's base size, and every rect stays glued to the painted art. +static func layout_for(viewport_size: Vector2) -> Dictionary: + var wide: bool = viewport_size.x / maxf(viewport_size.y, 1.0) \ + >= PLATE_ASPECT_SPLIT + var plate: Vector2 = WIDE_PLATE_SIZE if wide else TABLET_PLATE_SIZE + var base: Vector2 = WIDE_BASE if wide else TABLET_BASE + var room_scale: float = maxf( + viewport_size.x / plate.x, viewport_size.y / plate.y) + var actor_scale: float = room_scale / (base.x / plate.x) + var center: Vector2 = viewport_size * 0.5 + var origin: Vector2 = center - plate * room_scale * 0.5 + var point := func(frac: Vector2) -> Vector2: + return origin + frac * plate * room_scale + var rect := func(frac: Rect2) -> Rect2: + return Rect2(point.call(frac.position), + frac.size * plate * room_scale) + var walk: Rect2 = WIDE_WALK if wide else TABLET_WALK + var desk: Rect2 = WIDE_DESK if wide else TABLET_DESK + var gate: Rect2 = WIDE_GATE if wide else TABLET_GATE + return { + "wide": wide, + "room_scale": room_scale, + "actor_scale": actor_scale, + "room_center": center, + "walk_rect": rect.call(walk), + "desk_rect": rect.call(desk), + "gate_rect": rect.call(gate), + "lumi_pos": point.call(WIDE_LUMI if wide else TABLET_LUMI), + "hero_pos": point.call(WIDE_HERO if wide else TABLET_HERO), + } + + +func _ready() -> void: + add_to_group(&"moonlit_dialogue") + GateEntryStrings.ensure_loaded() + _ui = $Ui + _build_ui() + var arena_cam: Camera2D = _player.get_node("Cam") as Camera2D + if arena_cam != null: + arena_cam.enabled = false + _player.apply_hero_visual(Vault.hero()) + _player.set_move_input(Vector2.ZERO) + _stick.set_active(false) + _dash_button.visible = false + _dash_button.pressed.connect(_on_dash_pressed) + _relayout() + get_viewport().size_changed.connect(_relayout) + var layout: Dictionary = layout_for( + get_viewport().get_visible_rect().size) + _player.position = layout["hero_pos"] + _player.reset_physics_interpolation() + _lumi.position = layout["lumi_pos"] + _lumi.set_actor_scale(_actor_scale) + _lumi.set_facing(LumiGuide.FACING_RIGHT) + _boot.call_deferred() + + +## Production host, or the harness substitute when injected. +func _resolve_host() -> Node: + if host_override != null: + return host_override + return get_node_or_null("/root/ProductionHost") + + +## Current flow state for tests and the harness. +func lodge_state() -> int: + return _state + + +## Account this visit is bound to. Empty until boot reads the host. +func lodge_account() -> String: + return _account_id + + +## Claimed handle driving the lesson. Empty until the name settles. +func lodge_display() -> String: + return _display + + +func _build_ui() -> void: + _top_bar = HBoxContainer.new() + _top_bar.name = &"TopBar" + _top_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + _ui.add_child(_top_bar) + var home: Button = GateEntryStyle.make_button( + "gate.lodge.return_title") + home.name = &"ToTitle" + home.pressed.connect(_on_return_to_title) + _top_bar.add_child(home) + _build_speech() + _build_name_form() + _build_departure() + + +func _build_speech() -> void: + _speech_panel = PanelContainer.new() + _speech_panel.name = &"Speech" + GateEntryStyle.apply_card(_speech_panel) + _speech_panel.visible = false + _speech_panel.gui_input.connect(_on_speech_input) + _ui.add_child(_speech_panel) + var box := VBoxContainer.new() + box.add_theme_constant_override(&"separation", 4) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + _speech_panel.add_child(box) + _speech_speaker = GateEntryStyle.make_label( + "gate.lodge.lumi_name", GateEntryStyle.FONT_SMALL, + GateEntryStyle.ACCENT_AMBER, true) + _speech_speaker.name = &"Speaker" + box.add_child(_speech_speaker) + _speech_body = GateEntryStyle.make_label( + "", GateEntryStyle.FONT_BODY, GateEntryStyle.TEXT_MAIN) + _speech_body.name = &"Body" + _speech_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(_speech_body) + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_END + row.add_theme_constant_override(&"separation", 12) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(row) + _speech_hint = GateEntryStyle.make_label( + "gate.lodge.tap_next", GateEntryStyle.FONT_SMALL, + GateEntryStyle.TEXT_FAINT) + _speech_hint.name = &"Hint" + row.add_child(_speech_hint) + _speech_skip = GateEntryStyle.make_button("gate.lodge.skip") + _speech_skip.name = &"Skip" + _speech_skip.pressed.connect(_on_speech_skip) + row.add_child(_speech_skip) + + +func _build_name_form() -> void: + _name_panel = PanelContainer.new() + _name_panel.name = &"NameForm" + GateEntryStyle.apply_card(_name_panel) + _name_panel.visible = false + _ui.add_child(_name_panel) + var box := VBoxContainer.new() + box.add_theme_constant_override(&"separation", 4) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + _name_panel.add_child(box) + _name_head = HBoxContainer.new() + var head: HBoxContainer = _name_head + head.add_theme_constant_override(&"separation", 10) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(head) + _name_bust = TextureRect.new() + _name_bust.name = &"Bust" + _name_bust.texture = preload( + "res://assets/custom/actors/lumi/bust.png") + _name_bust.custom_minimum_size = Vector2(52, 64) + _name_bust.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _name_bust.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _name_bust.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + _name_bust.mouse_filter = Control.MOUSE_FILTER_IGNORE + head.add_child(_name_bust) + var titles := VBoxContainer.new() + titles.add_theme_constant_override(&"separation", 2) + titles.mouse_filter = Control.MOUSE_FILTER_IGNORE + head.add_child(titles) + var title: Label = GateEntryStyle.make_label( + "gate.lodge.name_title", GateEntryStyle.FONT_SUBTITLE, + GateEntryStyle.TEXT_MAIN, true) + titles.add_child(title) + var public_note: Label = GateEntryStyle.make_label( + "gate.lodge.name_public", GateEntryStyle.FONT_SMALL, + GateEntryStyle.TEXT_DIM) + public_note.name = &"PublicNote" + public_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + titles.add_child(public_note) + _name_field = LineEdit.new() + _name_field.name = &"Field" + _name_field.max_length = NAME_FIELD_MAX + _name_field.clear_button_enabled = true + _name_field.add_theme_font_override( + &"font", GateEntryStyle.body_font()) + _name_field.add_theme_font_size_override( + &"font_size", GateEntryStyle.FONT_BODY) + _name_field.add_theme_color_override( + &"font_color", GateEntryStyle.TEXT_MAIN) + _name_field.add_theme_color_override( + &"font_placeholder_color", GateEntryStyle.TEXT_FAINT) + _name_field.custom_minimum_size = Vector2( + 240, GateEntryStyle.TOUCH_HEIGHT) + _name_field.text_submitted.connect(_on_name_submitted) + box.add_child(_name_field) + _name_hint = GateEntryStyle.make_label( + "gate.lodge.name_hint", GateEntryStyle.FONT_SMALL, + GateEntryStyle.TEXT_FAINT) + box.add_child(_name_hint) + _name_status = GateEntryStyle.make_label( + "", GateEntryStyle.FONT_SMALL, GateEntryStyle.CORAL) + _name_status.name = &"Status" + _name_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(_name_status) + _name_confirm = GateEntryStyle.make_button( + "gate.lodge.name_confirm", "primary") + _name_confirm.name = &"Confirm" + _name_confirm.pressed.connect(_on_name_confirm) + box.add_child(_name_confirm) + + +func _build_departure() -> void: + _depart_panel = PanelContainer.new() + _depart_panel.name = &"Departure" + GateEntryStyle.apply_card(_depart_panel) + _depart_panel.visible = false + _ui.add_child(_depart_panel) + var box := VBoxContainer.new() + box.add_theme_constant_override(&"separation", 8) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + _depart_panel.add_child(box) + var title: Label = GateEntryStyle.make_label( + "gate.lodge.depart_title", GateEntryStyle.FONT_SUBTITLE, + GateEntryStyle.TEXT_MAIN, true) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(title) + _depart_saved = GateEntryStyle.make_label( + "", GateEntryStyle.FONT_SMALL, GateEntryStyle.TEXT_DIM) + _depart_saved.name = &"Saved" + _depart_saved.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _depart_saved.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(_depart_saved) + _depart_resume = GateEntryStyle.make_button( + "gate.lodge.depart_resume", "primary") + _depart_resume.name = &"Resume" + _depart_resume.pressed.connect(_on_depart_resume) + box.add_child(_depart_resume) + _depart_fresh = GateEntryStyle.make_button( + "gate.lodge.depart_fresh") + _depart_fresh.name = &"Fresh" + _depart_fresh.pressed.connect(_on_depart_fresh) + box.add_child(_depart_fresh) + _depart_status = GateEntryStyle.make_label( + "", GateEntryStyle.FONT_SMALL, GateEntryStyle.CORAL) + _depart_status.name = &"Status" + _depart_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _depart_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(_depart_status) + var back: Button = GateEntryStyle.make_button("gate.lodge.depart_back") + back.name = &"Back" + back.pressed.connect(_on_depart_back) + box.add_child(back) + + +## Place room, actors, collisions, and UI for the current viewport. +## The player keeps its feet (clamped into the walk); Lumi re-seats on +## her desk fraction; the camera stays room-centered and fixed. +func _relayout() -> void: + if not is_inside_tree() or _room == null: + return + var view: Vector2 = get_viewport().get_visible_rect().size + if view.x <= 0.0 or view.y <= 0.0: + return + var layout: Dictionary = layout_for(view) + _actor_scale = float(layout["actor_scale"]) + _room.texture = WIDE_PLATE if bool(layout["wide"]) else TABLET_PLATE + _room.scale = Vector2.ONE * float(layout["room_scale"]) + _room.position = layout["room_center"] + _camera.position = layout["room_center"] + _camera.make_current() + _walk_rect = layout["walk_rect"] + _desk_rect = layout["desk_rect"] + _gate_rect = layout["gate_rect"] + _lumi.position = layout["lumi_pos"] + _lumi.set_actor_scale(_actor_scale) + if _player != null: + _player.set_bounds(_walk_rect) + _player.position.x = clampf(_player.position.x, + _walk_rect.position.x, _walk_rect.end.x) + _player.position.y = clampf(_player.position.y, + _walk_rect.position.y, _walk_rect.end.y) + _layout_ui(view) + + +## Seat the dialogue surfaces: speech low, name form high (clear of the +## native keyboard), departure centered, all inside the safe area. +func _layout_ui(view: Vector2) -> void: + var full := Rect2(Vector2.ZERO, view) + var safe: Rect2 = Screen.viewport_safe_rect(get_viewport()) + if not safe.has_area(): + safe = full + _top_bar.set_anchors_preset(Control.PRESET_TOP_LEFT) + _top_bar.position = safe.position + Vector2(12, 10) + Screen.apply_safe_area(_stick, safe, full) + Screen.apply_safe_area(_dash_button, safe, full) + # Only visible panels report a real size; hidden ones seat on show. + # A keyboard-shifted name form keeps its adapted seat until the + # keyboard hides, which re-seats for the current view. + if _speech_panel.visible: + _seat_bottom(_speech_panel, safe) + if _name_panel.visible and not _name_shifted: + _seat_top(_name_panel, safe) + if _depart_panel.visible: + _seat_center(_depart_panel, safe) + + +func _panel_width(safe: Rect2) -> float: + return minf(safe.size.x - 48.0, 560.0) + + +## Wrapped labels report their minimum as if wrapped at minimum +## width, which explodes panel heights. Pin each wrapped label to the +## card's content width first, so heights measure at the real width. +func _fit_labels(panel: PanelContainer, content: float) -> void: + for label in panel.find_children("*", "Label", true, false): + if (label as Label).autowrap_mode == TextServer.AUTOWRAP_OFF: + continue + var pinned: float = content + if (label as Label).name == &"PublicNote": + pinned = maxf(content - 62.0, 120.0) + (label as Label).custom_minimum_size = Vector2(pinned, 0) + + +## Seat a panel from its fresh minimum: the width pins first, one frame +## lets wrapped minima recompute at that width, and only then does the +## size take. Sizing in the same breath reads the stale minimum and the +## panel never shrinks back. +func _seat_bottom(panel: PanelContainer, safe: Rect2) -> void: + panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) + var width: float = _panel_width(safe) + _fit_labels(panel, width - GateEntryStyle.CARD_MARGIN_H) + panel.custom_minimum_size = Vector2( + width, panel.custom_minimum_size.y) + await get_tree().process_frame + if not is_instance_valid(panel): + return + panel.size = panel.get_combined_minimum_size() + panel.position = Vector2( + safe.position.x + (safe.size.x - panel.size.x) * 0.5, + safe.end.y - panel.size.y - 12.0) + + +func _seat_top(panel: PanelContainer, safe: Rect2) -> void: + panel.set_anchors_preset(Control.PRESET_CENTER_TOP) + var width: float = _panel_width(safe) + _fit_labels(panel, width - GateEntryStyle.CARD_MARGIN_H) + panel.custom_minimum_size = Vector2( + width, panel.custom_minimum_size.y) + await get_tree().process_frame + if not is_instance_valid(panel): + return + panel.size = panel.get_combined_minimum_size() + panel.position = Vector2( + safe.position.x + (safe.size.x - panel.size.x) * 0.5, + safe.position.y + 58.0) + + +func _seat_center(panel: PanelContainer, safe: Rect2) -> void: + panel.set_anchors_preset(Control.PRESET_CENTER) + var width: float = minf(_panel_width(safe), 420.0) + _fit_labels(panel, width - GateEntryStyle.CARD_MARGIN_H) + panel.custom_minimum_size = Vector2( + width, panel.custom_minimum_size.y) + await get_tree().process_frame + if not is_instance_valid(panel): + return + panel.size = panel.get_combined_minimum_size() + panel.position = (safe.size - panel.size) * 0.5 + safe.position + + +## Keyboard visibility plus the real name-form bounds for entry QA. +## Rects are viewport-space `[x, y, w, h]` arrays; the report never +## carries the typed name or any provider personal data. The two +## keyboard figures keep their units apart: raw device pixels as the +## OS reports them, and the converted viewport occlusion the layout +## actually lifts against. +func name_form_keyboard_report() -> Dictionary: + var height: int = 0 + if DisplayServer.has_feature( + DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + height = DisplayServer.virtual_keyboard_get_height() + var view: Rect2 = get_viewport().get_visible_rect() + var report: Dictionary = { + "form_visible": _name_panel != null \ + and _name_panel.visible, + "keyboard_visible": height > 0, + "keyboard_height_device": height, + "keyboard_occlusion": keyboard_occlusion_for(view, + Vector2(DisplayServer.window_get_size()), + float(height), + get_viewport().get_screen_transform()), + "panel_rect": [], + "field_rect": [], + "confirm_rect": [], + "shifted": _name_shifted, + } + if not bool(report["form_visible"]): + return report + report["panel_rect"] = _rect_array( + _name_panel.get_global_rect()) + report["field_rect"] = _rect_array( + _name_field.get_global_rect()) + report["confirm_rect"] = _rect_array( + _name_confirm.get_global_rect()) + return report + + +func _rect_array(rect: Rect2) -> Array: + return [rect.position.x, rect.position.y, rect.size.x, + rect.size.y] + + +## Viewport-space pixels the OS keyboard occludes, from its +## device-pixel height. The keyboard spans the bottom of the device +## screen, so its top edge converts through the inverse viewport +## screen transform (scale and letterbox offset honored, never an +## assumed factor). Clamped to the viewport; zero when hidden, +## absurd, or unmappable. Pure so headless regression can pin every +## density against the actual production conversion. +static func keyboard_occlusion_for(view: Rect2, + screen_size: Vector2, keyboard_height: float, + screen_xform: Transform2D) -> float: + if keyboard_height <= 0.0 or screen_size.y <= 0.0: + return 0.0 + if absf(screen_xform.determinant()) < 0.000001: + return 0.0 + var inverse: Transform2D = screen_xform.affine_inverse() + var top_screen: float = screen_size.y - keyboard_height + var left: float = (inverse * Vector2(0.0, top_screen)).y + var right: float = (inverse * Vector2( + screen_size.x, top_screen)).y + return clampf(view.end.y - minf(left, right), + 0.0, view.size.y) + + +## Pixels the confirm action needs to rise so the OS keyboard clears it +## (plus a small margin). `keyboard_height` is viewport-space +## occlusion from `keyboard_occlusion_for`, never raw device pixels: +## the live height only arrives on a real device keyboard, converted +## at the call site. Pure so headless regression can pin it. +static func keyboard_shift_for(confirm_bottom: float, + view_height: float, keyboard_height: float) -> float: + if keyboard_height <= 0.0: + return 0.0 + return maxf(confirm_bottom + KEYBOARD_ACTION_MARGIN \ + - (view_height - keyboard_height), 0.0) + + +## The lift the name form actually takes: the needed shift, bounded so +## the panel never rises above the safe top. When little space +## remains the form parks at the safe top — field and confirm stay +## visible and tappable instead of sliding offscreen — and the lift +## is never negative. Pure so headless regression can pin it. +static func keyboard_lift_for(confirm_bottom: float, + view_height: float, occlusion: float, base_y: float, + safe_top: float) -> float: + var shift: float = keyboard_shift_for( + confirm_bottom, view_height, occlusion) + return minf(shift, maxf(base_y - safe_top, 0.0)) + + +## Lift the name form above a visible OS keyboard and adapt its chrome +## while covered; restore everything when the keyboard hides. Desktop +## reports height zero and never shifts. `height_device_px` overrides +## the live OS reading, and `window_override` the live window size, +## so headless regression and the QA harness run this same hook with +## synthetic keyboards; negatives mean live. Headless has no window +## at all, so the tests stage the window they resized to. +## A plain lift cannot fit the full card above a tall keyboard, so when +## the lift runs out the optional bust/header/explanatory copy hides, +## the card shrinks to its live minimum, and the short form (field, +## error feedback, confirm) lifts instead. An all-screen keyboard that +## still covers the short form is dismissed once per height instead of +## promising impossible UI; the focused field keeps its typed value +## and the keyboard's own submit still files the claim. Returns the +## measured outcome for QA. Idempotent: every frame assigns absolute +## geometry from the unshifted seat, never from last frame's rect. +func _apply_keyboard_shift(height_device_px := -1.0, + window_override := Vector2(-1.0, -1.0)) -> Dictionary: + if _name_panel == null or not _name_panel.visible: + _set_name_compact(false) + _name_shifted = false + return {"mode": "hidden"} + var height: float = height_device_px + if height < 0.0: + height = 0.0 + if DisplayServer.has_feature( + DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + height = float( + DisplayServer.virtual_keyboard_get_height()) + if height <= 0.0: + if _name_shifted or _name_compact: + _set_name_compact(false) + _name_panel.position.y = _name_base_y + _name_shifted = false + _dismissed_occlusion = -1.0 + # Refresh the seat for the current view: a rotation + # while shifted kept the old seat, and the hide + # restores to it. + _seat_visible(_name_panel, "top") + return {"mode": "restored"} + if not _name_shifted: + _name_base_y = _name_panel.position.y + _name_shifted = true + var view: Rect2 = get_viewport().get_visible_rect() + var safe: Rect2 = Screen.viewport_safe_rect(get_viewport()) + if not safe.has_area(): + safe = view + # Device pixels in, viewport pixels out: the raw keyboard height + # converts through the live screen transform before any layout. + var window_size: Vector2 = window_override + if window_size.x < 0.0: + window_size = Vector2(DisplayServer.window_get_size()) + var occluded: float = keyboard_occlusion_for(view, + window_size, height, + get_viewport().get_screen_transform()) + var kb_top: float = view.size.y - occluded + var avail: float = maxf(_name_base_y - safe.position.y, 0.0) + # The mode decision always measures the full card: the compact + # rect is shorter, and deciding from it would flip modes back. + var base_bottom: float = 0.0 + if _name_compact: + base_bottom = _name_base_y + maxf(_name_full_h, + _name_panel.size.y) - _name_card_bottom_margin() + else: + # Measure from the unshifted seat: the live rect already + # includes last frame's lift, and feeding it back would + # oscillate. + var lifted: float = \ + _name_base_y - _name_panel.position.y + base_bottom = _name_confirm.get_global_rect().end.y \ + + lifted + _name_full_h = _name_panel.size.y + var needed: float = keyboard_shift_for(base_bottom, + view.size.y, occluded) + if needed <= avail: + _set_name_compact(false) + var lift: float = maxf(needed, 0.0) + _name_panel.position.y = _name_base_y - lift + return {"mode": "lifted", "occlusion": occluded, + "lift": lift} + _set_name_compact(true) + var compact_h: float = \ + _name_panel.get_combined_minimum_size().y + _name_panel.size.y = compact_h + var card_bottom: float = _name_card_bottom_margin() + var need_c: float = _name_base_y + compact_h - card_bottom \ + + KEYBOARD_ACTION_MARGIN - kb_top + var lift_c: float = minf(maxf(need_c, 0.0), avail) + _name_panel.position.y = _name_base_y - lift_c + var clears: bool = _name_base_y - lift_c + compact_h \ + - card_bottom <= kb_top - KEYBOARD_ACTION_MARGIN + var dismissed: bool = false + if not clears and occluded != _dismissed_occlusion: + if DisplayServer.has_feature( + DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + DisplayServer.virtual_keyboard_hide() + _dismissed_occlusion = occluded + dismissed = true + return {"mode": "compact", "occlusion": occluded, + "lift": lift_c, "clears": clears, "dismissed": dismissed} + + +## Hide or restore the optional name-form chrome (bust, header, hint) +## and resize the card to its live minimum, full or short. The field +## keeps focus and its typed value; only siblings toggle. +func _set_name_compact(compact: bool) -> void: + if _name_panel == null or compact == _name_compact: + return + _name_compact = compact + _name_head.visible = not compact + _name_hint.visible = not compact + _name_panel.size.y = _name_panel.get_combined_minimum_size().y + + +## The card skin's bottom content margin, live: the compact layout +## parks the confirm action above it, never from a stale constant. +func _name_card_bottom_margin() -> float: + var skin := _name_panel.get_theme_stylebox("panel") as StyleBox + if skin == null: + return 0.0 + return skin.content_margin_bottom + + +## Seat one panel now that it is visible and sized. +func _seat_visible(panel: PanelContainer, seat: String) -> void: + var view: Vector2 = get_viewport().get_visible_rect().size + var full := Rect2(Vector2.ZERO, view) + var safe: Rect2 = Screen.viewport_safe_rect(get_viewport()) + if not safe.has_area(): + safe = full + if seat == "bottom": + _seat_bottom(panel, safe) + elif seat == "top": + _seat_top(panel, safe) + else: + _seat_center(panel, safe) + + +# --- boot --------------------------------------------------------------- + +func _boot() -> void: + _host = _resolve_host() + if _host == null: + _to_title_now() + return + _account_id = str((_host.account_state() as Dictionary).get( + "public_id", "")) + if _account_id.is_empty(): + _to_title_now() + return + _host.production_changed.connect(_on_production_changed) + if not bool(_host.needs_lodge_lesson()): + _enter_departure() + return + _display = str(_host.verified_display_name()) + if not _display.is_empty(): + _begin_practice() + return + _state = State.GREET + _say([ + GateEntryStrings.text("gate.lodge.greet_1"), + GateEntryStrings.text("gate.lodge.greet_2"), + GateEntryStrings.text("gate.lodge.greet_3"), + ], Callable(self, "_recover_name")) + + +## Reload the account's claimed name: a restart, a fresh install, or a +## cold cache recovers the same handle instead of asking twice. +func _recover_name() -> void: + if _state != State.GREET or not _ticket_same(): + return + _show_busy(GateEntryStrings.text("gate.lodge.name_checking")) + var reply: Dictionary = await _host.load_adventurer_name() + if _state != State.GREET or not _ticket_same(): + return + if str(reply.get("status", "")) == "ok": + var display: String = str(reply.get("display", "")) + if not display.is_empty(): + _display = display + # A cold cache may recover an already completed account + # (fresh install, evicted cache). Trust only the host's + # cached readiness: a failed cache write keeps the lesson + # open and re-seals at the gate instead. + if not bool(_host.needs_lodge_lesson()): + _enter_departure() + else: + _begin_practice() + return + _show_name_form("") + return + if _reply_lost(reply): + _lost() + return + _show_name_form(str(reply.get("code", ""))) + + +# --- speech --------------------------------------------------------------- + +## Show tap-through lines, then run `done`. Skip jumps straight to +## `done`: it skips words, never the practiced gates behind them. +func _say(lines: Array, done: Callable) -> void: + _speech_lines = lines.duplicate() + _speech_index = 0 + _speech_done = done + _speech_locked = false + _speech_hint.visible = true + _speech_skip.visible = true + _speech_panel.visible = true + _speech_body.text = str(_speech_lines[0]) + _seat_visible(_speech_panel, "bottom") + + +## One non-advancing line while an acknowledgement is in flight. +func _show_busy(line: String) -> void: + _speech_lines = [line] + _speech_index = 0 + _speech_done = Callable() + _speech_locked = true + _speech_hint.visible = false + _speech_skip.visible = false + _speech_panel.visible = true + _speech_body.text = line + _seat_visible(_speech_panel, "bottom") + + +## One non-advancing reminder that stays up during free practice. +func _show_hint(line: String) -> void: + _show_busy(line) + + +func _hide_speech() -> void: + _speech_panel.visible = false + _speech_lines = [] + _speech_done = Callable() + _speech_locked = false + + +func _on_speech_input(event: InputEvent) -> void: + if _speech_locked or not _speech_panel.visible: + return + var tapped: bool = false + if event is InputEventMouseButton: + var click: InputEventMouseButton = event + tapped = click.pressed \ + and click.button_index == MOUSE_BUTTON_LEFT + elif event is InputEventScreenTouch: + tapped = (event as InputEventScreenTouch).pressed + if tapped: + _advance_speech() + + +func _advance_speech() -> void: + if _speech_locked or _speech_lines.is_empty(): + return + _speech_index += 1 + if _speech_index >= _speech_lines.size(): + var done: Callable = _speech_done + _hide_speech() + if done.is_valid(): + done.call() + return + _speech_body.text = str(_speech_lines[_speech_index]) + _seat_visible(_speech_panel, "bottom") + + +func _on_speech_skip() -> void: + if _speech_locked or _speech_lines.is_empty(): + return + var done: Callable = _speech_done + _hide_speech() + if done.is_valid(): + done.call() + + +# --- name ----------------------------------------------------------------- + +## The register form. `load_code` presets the offline status when entry +## arrived limping; the form stays fully usable either way. +func _show_name_form(load_code: String) -> void: + _state = State.NAME + _hide_speech() + _claim_failed = false + _name_field.text = "" + _refresh_name_status(load_code) + _name_panel.visible = true + _seat_visible(_name_panel, "top") + _name_field.grab_focus.call_deferred() + + +func _on_name_submitted(_text: String) -> void: + _on_name_confirm() + + +func _on_name_confirm() -> void: + if _state != State.NAME or _claim_busy or not _ticket_same(): + return + _claim_busy = true + _name_confirm.disabled = true + _name_status.text = GateEntryStrings.text("gate.lodge.name_checking") + var reply: Dictionary = await _host.claim_adventurer_name( + _name_field.text) + _claim_busy = false + _name_confirm.disabled = false + if _state != State.NAME or not _ticket_same(): + return + if str(reply.get("status", "")) == "ok": + var display: String = str(reply.get("display", "")) + if display.is_empty(): + _refresh_name_status("empty-reply") + return + _display = display + _name_panel.visible = false + _say([GateEntryStrings.text("gate.lodge.named_ok") % display], + Callable(self, "_begin_practice")) + return + if _reply_lost(reply): + _lost() + return + _claim_failed = true + _refresh_name_status(str(reply.get("code", ""))) + + +## Map a claim/load failure onto the three honest form states. Unknown +## codes read as link trouble with a retry, never as taken: only the +## server's own collision word may say the name is gone. +func _refresh_name_status(code: String) -> void: + if code.is_empty() or code == "adventurer-not-found": + _name_status.text = "" + elif code == "name-taken": + _name_status.text = GateEntryStrings.text( + "gate.lodge.name_taken") + elif code == "invalid-name": + _name_status.text = GateEntryStrings.text( + "gate.lodge.name_invalid") + else: + _name_status.text = GateEntryStrings.text( + "gate.lodge.name_offline") + _name_confirm.text = GateEntryStrings.text( + "gate.lodge.name_retry") if _claim_failed \ + else GateEntryStrings.text("gate.lodge.name_confirm") + + +# --- practice --------------------------------------------------------------- + +## Greet a settled handle then teach only what this account has not +## actually performed yet. Sticky gates survive restarts; nothing here +## infers a lesson from a timer or a tap. +func _begin_practice() -> void: + _hide_speech() + _name_panel.visible = false + if not _display.is_empty() and _state != State.NAME: + _say([GateEntryStrings.text("gate.lodge.named_ok") % _display], + Callable(self, "_practice_step")) + else: + _practice_step() + + +func _practice_step() -> void: + if not _ticket_same(): + return + var gates: Dictionary = Vault.lodge_progress_for_account(_account_id) + if not bool(gates.get("move", false)): + _say([GateEntryStrings.text("gate.lodge.move_lesson")], + Callable(self, "_start_move")) + else: + _start_dash() + + +func _start_move() -> void: + if not _ticket_same(): + return + _state = State.MOVE + _move_accum = 0.0 + _last_pos = _player.position + _stick.set_active(true) + _show_hint(GateEntryStrings.text("gate.lodge.move_lesson")) + + +func _on_move_learned() -> void: + _state = State.LINES + Vault.mark_lodge_gate(_account_id, "move") + Onboarding.mark_done("move", true) + _say([GateEntryStrings.text("gate.lodge.move_done")], + Callable(self, "_start_dash")) + + +func _start_dash() -> void: + if not _ticket_same(): + return + var gates: Dictionary = Vault.lodge_progress_for_account(_account_id) + if bool(gates.get("dash", false)): + _start_gate_walk() + return + _state = State.DASH + _stick.set_active(true) + _dash_button.visible = true + _say([GateEntryStrings.text("gate.lodge.dash_lesson")], + Callable(self, "_dash_free")) + + +func _dash_free() -> void: + if not _ticket_same(): + return + _state = State.DASH + _show_hint(GateEntryStrings.text("gate.lodge.dash_lesson")) + + +func _on_dash_learned() -> void: + _state = State.LINES + Vault.mark_lodge_gate(_account_id, "dash") + Onboarding.mark_done("dash", true) + _say([GateEntryStrings.text("gate.lodge.dash_done")], + Callable(self, "_start_gate_walk")) + + +func _start_gate_walk() -> void: + if not _ticket_same(): + return + _state = State.GATE + _stick.set_active(true) + _dash_button.visible = true + _gate_armed = true + _gate_cooldown = _gate_rect.has_point(_player.position) + _show_hint(GateEntryStrings.text("gate.lodge.gate_lesson") % _display) + + +func _on_dash_pressed() -> void: + if _state != State.DASH and _state != State.GATE: + return + if not _ticket_same(): + return + var direction: Vector2 = _stick.get_value() + if move_override_active and move_override.length() >= 0.01: + direction = move_override + if direction.length() < 0.01: + direction = _player.facing_vector() + _player.dash(direction) + + +# --- seal and departure ------------------------------------------------------- + +## The gate seals the lesson. Only the server acknowledgement opens +## departure; a miss re-arms the gate for another explicit touch. +func _seal() -> void: + if _state != State.GATE or not _ticket_same(): + return + _state = State.SEALING + _gate_armed = false + _stick.set_active(false) + _dash_button.visible = false + _player.set_move_input(Vector2.ZERO) + _show_busy(GateEntryStrings.text("gate.lodge.gate_wait")) + var reply: Dictionary = await _host.mark_intro_complete() + if _state != State.SEALING or not _ticket_same(): + return + if str(reply.get("status", "")) == "ok": + _enter_departure() + return + if _reply_lost(reply): + _lost() + return + _say([GateEntryStrings.text("gate.lodge.gate_retry")], + Callable(self, "_start_gate_walk")) + + +## Departure choice. A living journey offers Resume against a confirmed +## fresh start; a sealed one never offers its stage back. The plan call +## enforces it all again, so this panel cannot talk the host into a +## free continuation. +func _enter_departure() -> void: + _state = State.DEPART + _display = str(_host.verified_display_name()) + _gate_armed = false + _stick.set_active(false) + _dash_button.visible = false + _player.set_move_input(Vector2.ZERO) + _hide_speech() + _name_panel.visible = false + _fresh_armed = false + var saved: Dictionary = _host.saved_gate_summary() + var has_save: bool = bool(saved.get("has_save", false)) + _depart_resume.visible = has_save + if has_save: + var where: String = GateEntryStrings.text( + "gate.lodge.depart_where") % [ + int(saved.get("cycle", 1)), + int(saved.get("zone_index", 0))] + _depart_saved.text = GateEntryStrings.text( + "gate.lodge.depart_saved") % where + else: + _depart_saved.text = "" + _depart_status.text = "" + _depart_panel.visible = true + _seat_visible(_depart_panel, "center") + + +func _on_depart_resume() -> void: + _exit_to_arena(false, true) + + +func _on_depart_fresh() -> void: + var saved: Dictionary = _host.saved_gate_summary() + if (bool(saved.get("has_save", false)) + or bool(saved.get("revive_stuck", false))) \ + and not _fresh_armed: + _fresh_armed = true + _depart_status.text = GateEntryStrings.text( + "gate.lodge.depart_fresh_confirm") + _seat_visible(_depart_panel, "center") + return + _exit_to_arena(true, true) + + +func _on_depart_back() -> void: + if _state != State.DEPART or _exiting: + return + _depart_panel.visible = false + _start_gate_walk() + + +func _exit_to_arena(fresh: bool, confirmed: bool) -> void: + if _state != State.DEPART or _exiting or not _ticket_same(): + return + _exiting = true + _depart_status.text = "" + var plan: Dictionary = _host.plan_lodge_exit(fresh, confirmed) + if str(plan.get("status", "")) != "ok": + _exiting = false + _fresh_armed = false + _depart_panel.visible = false + _say([GateEntryStrings.text("gate.lodge.gate_retry")], + Callable(self, "_start_gate_walk")) + return + var account: String = str(plan.get("account_id", "")) + if not bool(_host.confirm_entry_account(account)): + _host.cancel_entry_plan() + _exiting = false + _lost() + return + _state = State.EXITING + var arena_path: String = str(plan.get("arena", "")) + if embedded: + request_arena.emit(arena_path) + return + var packed: PackedScene = load(arena_path) as PackedScene + if packed == null: + _host.cancel_entry_plan() + _exiting = false + _depart_panel.visible = false + _say([GateEntryStrings.text("gate.lodge.gate_retry")], + Callable(self, "_start_gate_walk")) + return + get_tree().change_scene_to_packed(packed) + + +# --- account guard -------------------------------------------------------------- + +## The visit's account is still the live one, with no deletion running. +func _ticket_same() -> bool: + if _host == null or _account_id.is_empty(): + return false + var state: Dictionary = _host.account_state() + if str(state.get("public_id", "")) != _account_id: + return false + return not bool(state.get("deletion_in_flight", false)) + + +## Replies that retire the visit instead of retrying it: the call +## outlived its account, the host, or the session around them. +func _reply_lost(reply: Dictionary) -> bool: + if str(reply.get("status", "")) == "cancelled": + return true + var code: String = str(reply.get("code", "")) + return code in ["account-retired", "host-closed", + "coordinator-closed", "local-guest", "deletion-in-flight", + "account_moved", "account-moved"] + + +func _on_production_changed(state: Dictionary) -> void: + if _state == State.EXITING or _state == State.LOST: + return + if str(state.get("public_id", "")) != _account_id: + _lost() + return + if bool(state.get("deletion_in_flight", false)): + _lost() + + +## Retire the visit and walk back to the title. The plan hold releases; +## the lesson waits for the account's next entry. +func _lost() -> void: + if _state == State.LOST or _state == State.EXITING: + return + _state = State.LOST + _gate_armed = false + _stick.set_active(false) + _dash_button.visible = false + _player.set_move_input(Vector2.ZERO) + _name_panel.visible = false + _depart_panel.visible = false + _say([GateEntryStrings.text("gate.lodge.account_lost")], + Callable(self, "_to_title_now")) + + +func _on_return_to_title() -> void: + if _exiting or _state == State.EXITING: + return + _to_title_now() + + +func _to_title_now() -> void: + if _host != null: + _host.cancel_entry_plan() + if embedded: + request_title.emit() + return + get_tree().change_scene_to_file(TITLE_SCENE) + + +# --- frame ---------------------------------------------------------------------- + +func _process(_delta: float) -> void: + if _player == null or _lumi == null: + return + var practicing: bool = _state == State.MOVE \ + or _state == State.DASH or _state == State.GATE + if practicing: + if move_override_active: + _player.set_move_input(move_override) + else: + _player.set_move_input(_stick.get_value()) + else: + _player.set_move_input(Vector2.ZERO) + if _dash_button.visible: + _dash_button.set_ratio(_player.get_dash_ratio()) + _push_out_of_desk() + _push_out_of_lumi() + _apply_keyboard_shift() + _lumi.face_toward(_player.position.x) + if _state == State.MOVE: + _move_accum += _player.position.distance_to(_last_pos) + _last_pos = _player.position + if _move_accum >= MOVE_GATE_DISTANCE * _actor_scale: + _on_move_learned() + elif _state == State.DASH: + _last_pos = _player.position + if _player.is_dashing(): + _on_dash_learned() + elif _state == State.GATE: + _last_pos = _player.position + var inside: bool = _gate_rect.has_point(_player.position) + if _gate_cooldown and not inside: + _gate_cooldown = false + elif _gate_armed and inside and not _gate_cooldown: + _seal() + + +## The registry desk is furniture: slide along it, never through it. One +## pixel past the edge, so the hero never reports inside the rect again. +## The exit also stays on the walk floor: the desk's painted top hangs +## above the walk band, so a bare nearest-edge exit can strand the hero +## off the walk, where the player's own bounds clamp drags them back +## into the desk on the next physics tick. +func _push_out_of_desk() -> void: + if not _desk_rect.has_point(_player.position): + return + _player.position = desk_exit_for( + _player.position, _desk_rect, _walk_rect) + + +## Nearest point one pixel outside `desk` that stays inside `walk`. +## Pure, so collision math is testable without a window. Each exit +## keeps its untouched axis; the first edge in left/right/top/bottom +## order wins a near-tie, never float rounding. +static func desk_exit_for(point: Vector2, desk: Rect2, + walk: Rect2) -> Vector2: + var exits: Array[Vector2] = [ + Vector2(desk.position.x - 1.0, point.y), + Vector2(desk.end.x + 1.0, point.y), + Vector2(point.x, desk.position.y - 1.0), + Vector2(point.x, desk.end.y + 1.0), + ] + var spans: Array[float] = [ + absf(point.x - desk.position.x), + absf(point.x - desk.end.x), + absf(point.y - desk.position.y), + absf(point.y - desk.end.y), + ] + var best: int = -1 + for index in exits.size(): + if not _walk_holds(walk, exits[index]): + continue + if best < 0 \ + or spans[index] < spans[best] - DESK_TIE_EPSILON: + best = index + if best >= 0: + return exits[best] + # No real framing lands here: the desk's far side sits inside the + # walk at every judged size, so the bottom exit always holds. + # Clamp it into the walk rather than stranding the hero off-floor. + return Vector2( + clampf(point.x, walk.position.x, walk.end.x), + clampf(desk.end.y + 1.0, walk.position.y, walk.end.y)) + + +## The player's bounds clamp holds this point: inclusive on every side, +## matching Player._physics_process rather than Rect2.has_point, which +## drops the far edge. +static func _walk_holds(walk: Rect2, point: Vector2) -> bool: + return point.x >= walk.position.x and point.x <= walk.end.x \ + and point.y >= walk.position.y and point.y <= walk.end.y + + +## Lumi holds her ground by the desk; the hero steps around her. +func _push_out_of_lumi() -> void: + var offset: Vector2 = _player.position - _lumi.position + var radius: float = LUMI_BODY_RADIUS * _actor_scale + if offset.length() >= radius: + return + if offset.length() < 0.5: + offset = Vector2.RIGHT * radius + _player.position = _lumi.position \ + + offset.normalized() * radius diff --git a/apps/game/scripts/gameplay/gate_lodge.gd.uid b/apps/game/scripts/gameplay/gate_lodge.gd.uid new file mode 100644 index 0000000..ccd8a64 --- /dev/null +++ b/apps/game/scripts/gameplay/gate_lodge.gd.uid @@ -0,0 +1 @@ +uid://dd2x83uxtksbt diff --git a/apps/game/scripts/gameplay/journey.gd b/apps/game/scripts/gameplay/journey.gd index 9f99e2a..b0b1670 100644 --- a/apps/game/scripts/gameplay/journey.gd +++ b/apps/game/scripts/gameplay/journey.gd @@ -7,8 +7,9 @@ extends RefCounted ## safe moment — crossing a moon gate, or carrying the guardian's reward into ## the next cycle — the arena writes a segment-entry checkpoint: cycle, zone, ## deterministic route and seed, hero, held relic stacks, combat growth, -## score counters and reward-settlement bookkeeping. Dying retries that -## segment; the title's Continue steps back through its gate. +## score counters and reward-settlement bookkeeping. Dying seals the journey +## with a terminal `ended` marker instead; the title's Continue only resumes +## a run that is still alive, and a fresh expedition starts a new journey. ## ## Checkpoints are segment entries on purpose. Nothing mid-frame is ## serialized: no projectiles, enemies or physics state. Restore rebuilds the @@ -26,17 +27,22 @@ extends RefCounted ## grants exactly once: the redelivery reads `DUPLICATE` and pays 0. ## - A checkpoint records `shards_awarded` and `settled_score` as an echo of ## the ledger, and gate/cycle checkpoints settle before writing. Death -## settles the **checkpoint** score, never the defeated segment's live -## score; the lost segment grants nothing. +## settles the final live score once and seals it into the terminal +## marker's echo, so repeated exits, restores or relaunches grant nothing +## more: the redelivery reads `DUPLICATE` and pays 0. ## - Settlement failure seals nothing as settled: the checkpoint keeps its -## old echo and the next seal's cumulative target carries the value, which -## keeps retries available across restart. +## old echo and the next seal's cumulative target carries the value. +## - A paid continue journals its debit and exact revive seal in the Vault +## before the alive checkpoint is written, so a crash can only strand a +## paid seal — recovered without another charge — never an unpaid alive +## checkpoint. Entry points settle the journal before reading. ## - The very first checkpoint settles nothing. Banking a shard for merely ## starting would pay out on every fresh start without playing. -## - Records submit the checkpoint score on death. A repeat death submits -## the same total and reads `NOT_BEST`; only further progress saves anew. -## - Gate retry is free. It never spends a paid continue coin; the coin path -## (`continue_run`) is untouched and resumes in place instead. +## - Records submit the final score on death. A repeat submit of the same +## total reads `NOT_BEST`; only further progress saves anew. +## - Only a paid continue coin revives a sealed defeat, in place, and only +## the live arena's `continue_run` may clear the marker by sealing a new +## alive checkpoint. No title, backup or cloud path resumes a sealed run. ## ## Safety rules: ## @@ -44,7 +50,14 @@ extends RefCounted ## main save is never deleted, and any failure keeps a playable prior save. ## - The previous valid save rotates into the backup through the same atomic ## install. A corrupt main falls back to it; an invalid file must never -## overwrite a good backup. +## overwrite a good backup. A terminal marker instead mirrors into both +## files, backup first: no older alive backup survives the seal, so a +## later corrupt main still reads terminal. A main that validates ended +## also converges a stale alive backup forward on read, which migrates +## pairs sealed before the mirror; a live main with a same-journey ended +## backup at no older seal is a seal whose main write never finished, and +## reads terminal too. Alive pairs with no seal anywhere still recover +## from the backup exactly as before. ## - Reads are length-gated before a byte is kept. Loading validates every ## field: non-integer or future schemas, oversized or truncated files, and ## resource paths outside the hero/relic allowlists are refused as a @@ -289,6 +302,8 @@ static func write_checkpoint(data: Dictionary) -> Error: if text.is_empty() or text.to_utf8_buffer().size() > MAX_FILE_BYTES: last_error = "checkpoint too large to save" return ERR_INVALID_DATA + if is_ended(data): + return _install_ended_pair(text) # Refresh the backup first, like the Vault: a backup that cannot install # aborts the write rather than replacing the main behind a stale backup. if not _refresh_backup_from_main(): @@ -300,6 +315,27 @@ static func write_checkpoint(data: Dictionary) -> Error: return installed +## Install a terminal marker to both files, backup first, without rotation: +## the previous alive main is superseded by the seal, and rotating it into +## the backup would leave a stale alive copy able to revive the ended +## journey if the main ever corrupts. Either ordering of a crash stays +## terminal: a failed backup mirror leaves the main untouched (the seal +## never started), while a failed main install still leaves the mirrored +## backup the reads treat as terminal. Callers retry until the pair +## converges; stable hooks fire only once the main lands. +static func _install_ended_pair(text: String) -> Error: + var backup_error: Error = _install_verified(backup_path, text) + if backup_error != OK: + last_error = "could not save the journey" + return backup_error + var installed: Error = _install_verified(path, text) + if installed == OK: + _notify_stable_checkpoint(text) + return OK + last_error = "could not save the journey" + return installed + + ## Install already-serialized checkpoint text — a cloud download that passed ## validation — keeping its exact bytes. Parses and strictly validates ## first; oversized, malformed, or invalid payloads are refused without @@ -312,6 +348,8 @@ static func write_checkpoint_text(text: String) -> Error: if parser.parse(text) != OK or not validate(parser.data): last_error = "refused to write an invalid checkpoint" return ERR_INVALID_DATA + if is_ended(parser.data): + return _install_ended_pair(text) if not _refresh_backup_from_main(): last_error = "could not save the journey" return ERR_CANT_OPEN @@ -323,12 +361,39 @@ static func write_checkpoint_text(text: String) -> Error: ## Read the newest valid checkpoint: the main file first, then the backup. ## Returns `{}` when neither validates. A recovered backup is copied back to -## the main path so the next read is direct. +## the main path so the next read is direct. A valid terminal marker reads +## back like any valid checkpoint — callers gate resume on `is_ended()` or +## `has_valid_checkpoint()` — and no older alive backup of the same journey +## may revive it: an ended main converges a stale alive backup forward on +## read, and a live main with a same-journey ended backup at no older seal +## (a seal whose main write never finished) reads terminal too. static func read_checkpoint() -> Dictionary: last_error = "" var main: Dictionary = _read_candidate(path) - if not main.is_empty(): + if main.is_empty(): + return _read_fallback_backup() + if is_ended(main): + _converge_ended_backup(main) return main + var backup: Dictionary = _read_candidate(backup_path) + if backup.is_empty() or not is_ended(backup) \ + or not _same_journey(main, backup) \ + or _seal_id(main) > _seal_id(backup): + return main + # The backup proves a seal the main write never finished. Heal the + # marker forward so the pair converges, then read terminal. + var read: Dictionary = _read_bounded(backup_path) + if bool(read.get("ok", false)): + _install_verified(path, str(read.get("text", ""))) + last_error = "" + return backup + + +## The main file is missing or invalid: fall back to the backup, if any. +## A recovered backup is installed forward atomically, without rotating +## (the corrupt main must never overwrite the backup). Even if the install +## fails, the returned data is valid and the backup stays. +static func _read_fallback_backup() -> Dictionary: var main_error: String = last_error var backup: Dictionary = _read_candidate(backup_path) if backup.is_empty(): @@ -337,9 +402,6 @@ static func read_checkpoint() -> Dictionary: elif last_error.is_empty(): last_error = "no saved journey" return {} - # The backup saved the journey. Install it forward atomically, without - # rotating (the corrupt main must never overwrite the backup). Even if - # the install fails, the returned data is valid and the backup stays. var read: Dictionary = _read_bounded(backup_path) if bool(read.get("ok", false)): _install_verified(path, str(read.get("text", ""))) @@ -347,9 +409,105 @@ static func read_checkpoint() -> Dictionary: return backup +## Converge a stale alive backup behind an ended main: mirror the marker's +## exact bytes forward (best effort) so pairs sealed before the mirror end +## up redundant too. Only same-journey backups converge — a different +## journey's alive backup is pre-install lineage, never this seal's stale +## shadow, and a corrupt main may still legitimately recover from it. +static func _converge_ended_backup(main: Dictionary) -> void: + var backup: Dictionary = _read_candidate(backup_path) + if backup.is_empty() or is_ended(backup) \ + or not _same_journey(main, backup): + return + var read: Dictionary = _read_bounded(path) + if bool(read.get("ok", false)): + _install_verified(backup_path, str(read.get("text", ""))) + + +## Journey identity across int, float and string forms: Vault sequences +## stay in the `i` namespace (JSON floats count whole, like the validator), +## random fallbacks in the `s` namespace, so id `5` never aliases `"5"`. +static func _journey_key(journey_id: Variant) -> String: + if journey_id is int: + return "i%d" % int(journey_id) + if journey_id is float and journey_id == floorf(journey_id): + return "i%d" % int(journey_id) + if journey_id is String and is_safe_id(str(journey_id)): + return "s%s" % str(journey_id) + return "" + + +## True when both checkpoints name the same journey. +static func _same_journey(first: Dictionary, second: Dictionary) -> bool: + var left: String = _journey_key(first.get("journey_id")) + var right: String = _journey_key(second.get("journey_id")) + return not left.is_empty() and left == right + + +## Seal id of a validated checkpoint for ordering comparisons. +static func _seal_id(data: Dictionary) -> int: + return int(data.get("checkpoint_id", -1)) + + ## True when a checkpoint would load. Title calls this to offer Continue. +## A sealed defeat is valid but not resumable, so it reads false. static func has_valid_checkpoint() -> bool: - return not read_checkpoint().is_empty() + var data: Dictionary = read_checkpoint() + return not data.is_empty() and not is_ended(data) + + +## True when the checkpoint carries the defeat marker: a sealed run that no +## title, backup or cloud path may resume. Strictly a bool `true`; anything +## else (absent, false, or a non-bool a hostile file smuggled in) is alive. +static func is_ended(data: Dictionary) -> bool: + var marker: Variant = data.get("ended", false) + return marker is bool and bool(marker) + + +## Settle one Vault continue transaction against the journey files: the +## journaled seal is paid, so materialize it when the files still hold the +## defeat it revives. Returns `recovered` (the paid seal landed), +## `delivered` (the paid seal is already on disk), `stale` (nothing to +## deliver — the caller clears the journal), `none` (no transaction), +## `deferred` (another scope owns the receipt — nothing is touched), or +## `failed` (the materialize write failed — the caller keeps the journal +## and retries later). Only ever revives the transaction's own journey +## past its own defeat: a different journey, an alive seal at no older +## id, or a defeat at no older id clears the journal instead of writing. +static func settle_continue_txn(txn: Dictionary) -> String: + if txn.is_empty(): + return "none" + if str(txn.get("owner", active_account)) != active_account: + return "deferred" + var journey: Variant = txn.get("journey_id", null) + var attempt: Variant = txn.get("checkpoint_id", null) + var seal_text: String = str(txn.get("seal", "")) + if _journey_key(journey).is_empty() or not attempt is int \ + or seal_text.is_empty(): + return "stale" + var parser: JSON = JSON.new() + if parser.parse(seal_text) != OK or not validate(parser.data): + return "stale" + var seal: Dictionary = parser.data + if is_ended(seal) \ + or _journey_key(seal.get("journey_id")) != _journey_key(journey) \ + or int(seal.get("checkpoint_id", -1)) != int(attempt): + return "stale" + var file: Dictionary = read_checkpoint() + if file.is_empty(): + return "recovered" \ + if write_checkpoint(seal) == OK else "failed" + if not _same_journey(file, seal): + return "stale" + if not is_ended(file): + if _seal_id(file) >= int(attempt): + return "delivered" + return "recovered" \ + if write_checkpoint(seal) == OK else "failed" + if _seal_id(file) < int(attempt): + return "recovered" \ + if write_checkpoint(seal) == OK else "failed" + return "stale" ## Forget the journey: main, backup and temp files. Best effort. @@ -361,9 +519,10 @@ static func clear() -> void: last_error = "" -## Small display summary for the title's Continue row. Empty when invalid. +## Small display summary for the title's Continue row. Empty when invalid +## or when the journey sealed its defeat: there is no gate left to resume. static func summary(data: Dictionary) -> Dictionary: - if not validate(data): + if not validate(data) or is_ended(data): return {} var cycle: int = int(data["cycle"]) var zone: int = int(data["zone_index"]) @@ -453,6 +612,11 @@ static func validate(data: Variant) -> bool: return false if not _is_int_in(checkpoint.get("saved_at_unix"), 0, MAX_SAFE_INT): return false + # Additive terminal marker: absent on every checkpoint sealed before the + # defeat rules, `true` once the journey ends. Present but non-bool + # refuses the whole save like any other mistyped field. + if checkpoint.has("ended") and not checkpoint.get("ended") is bool: + return false return true diff --git a/apps/game/scripts/gameplay/onboarding.gd b/apps/game/scripts/gameplay/onboarding.gd index a214abd..9d73f34 100644 --- a/apps/game/scripts/gameplay/onboarding.gd +++ b/apps/game/scripts/gameplay/onboarding.gd @@ -16,7 +16,9 @@ extends RefCounted ## ## Static, with no autoload: the arena marks, nothing else reads. Writes ## happen only while `Journey.armed` is on, so tests, debug boards and -## capture harnesses never touch the human record. +## capture harnesses never touch the human record. The gate lodge passes +## `durable` for its practiced gates: the lesson counts as learned from +## an actual performance, which no journey arms while it runs. const DEFAULT_PATH: String = "user://onboarding.json" const SCHEMA_VERSION: int = 1 @@ -40,13 +42,14 @@ static func is_done(key: String) -> bool: return bool(_done.get(key, false)) -## Record a learned tip and persist it. Unknown keys are refused. -static func mark_done(key: String) -> void: +## Record a learned tip and persist it. Unknown keys are refused. The +## lodge passes durable for practiced move/dash gates; nothing else does. +static func mark_done(key: String, durable: bool = false) -> void: _ensure_loaded() if key not in KEYS or _done.get(key, false): return _done[key] = true - _save() + _save(durable) ## True when every guidance tip was learned. The story strip still plays. @@ -91,10 +94,11 @@ static func _ensure_loaded() -> void: _done[key] = true -static func _save() -> void: +static func _save(force: bool = false) -> void: # Only a human run may write the human record. Tests and capture either - # disarm or point `path` at a temp file; both stay silent here. - if not Journey.armed: + # disarm or point `path` at a temp file; both stay silent here. A forced + # write still targets `path`, which tests keep pointed at temp. + if not force and not Journey.armed: return var learned: Array[String] = [] for key: String in KEYS: diff --git a/apps/game/scripts/gameplay/settings.gd b/apps/game/scripts/gameplay/settings.gd index d758e36..426b233 100644 --- a/apps/game/scripts/gameplay/settings.gd +++ b/apps/game/scripts/gameplay/settings.gd @@ -11,6 +11,10 @@ signal changed const SAVE_PATH: String = "user://settings.cfg" const TEMP_SAVE_PATH: String = "user://settings.cfg.tmp" const ANALYTICS_REVOKED_PATH: String = "user://analytics_consent.revoked" +## Separate fail-closed mark for attendance reminders. Same shape as the +## analytics revocation, but the two preferences never read each other's +## mark: revoking metrics must not silence reminders and vice versa. +const REMINDER_DISABLED_PATH: String = "user://attendance_reminders.disabled" const SECTION: String = "settings" ## Languages that can be picked. Must match column names in `localization/moonlit.csv`. @@ -43,6 +47,34 @@ var analytics_cohort_unix: int = 0 var analytics_cohort_version: String = "" var analytics_retention_mask: int = 0 var _analytics_consent_persist_pending: bool = false +## Whether the player wants a local reminder when the next attendance +## reward is ready. Default off: enabling requests OS permission, and a +## denial never flips this to an active state. The disabled mark above +## outranks a stale enabled file after a crash, same as analytics. +var reminders_enabled: bool = false +var _reminders_persist_pending: bool = false +## The first receipt's reminder offer was shown once; never nag again. +var reminder_receipt_offered: bool = false +## Last known OS notification state for display only: one of `unknown`, +## `granted`, `denied`, `unsupported`. Scheduling decisions always query +## the live native status; this cache only labels the settings row. +var reminder_os_state: String = "unknown" +## What this install last asked the OS to deliver: the owning account, +## the server-confirmed eligible UTC the first fire targets, and the +## locale of the scheduled text. Rescheduling with identical inputs is +## skipped so a title refresh never shifts the deadline or stacks +## duplicate requests. One active account per install. +var reminder_sched_account: String = "" +var reminder_sched_eligible_utc: String = "" +var reminder_sched_locale: String = "" +## Unix seconds of the last delivery the native side holds for this +## record: -1 when a repeating native holds an unbounded horizon, 0 +## when a legacy schedule never reported one. +var reminder_sched_horizon_end: float = 0.0 +## First ordinal slot of that held window on the anchor's twelve-hour +## grid, -1 when the native side never reported one (legacy rows +## derive it from the end, or replan once when they cannot). +var reminder_sched_base_slot: int = -1 func _ready() -> void: @@ -99,6 +131,25 @@ func load_settings() -> void: SECTION, "analytics_cohort_version", "")).strip_edges() analytics_retention_mask = int(cfg.get_value( SECTION, "analytics_retention_mask", 0)) & ANALYTICS_RETENTION_ALLOWED_MASK + reminders_enabled = bool(cfg.get_value( + SECTION, "reminders_enabled", false)) + reminder_receipt_offered = bool(cfg.get_value( + SECTION, "reminder_receipt_offered", false)) + var saved_os_state: String = str(cfg.get_value( + SECTION, "reminder_os_state", "unknown")) + reminder_os_state = saved_os_state if saved_os_state in [ + "unknown", "granted", "denied", "unsupported", + ] else "unknown" + reminder_sched_account = str(cfg.get_value( + SECTION, "reminder_sched_account", "")) + reminder_sched_eligible_utc = str(cfg.get_value( + SECTION, "reminder_sched_eligible_utc", "")) + reminder_sched_locale = str(cfg.get_value( + SECTION, "reminder_sched_locale", "")) + reminder_sched_horizon_end = float(cfg.get_value( + SECTION, "reminder_sched_horizon_end", 0.0)) + reminder_sched_base_slot = int(cfg.get_value( + SECTION, "reminder_sched_base_slot", -1)) # A revocation mark outranks a prior GRANTED setting. Boundary so that if # the app dies the instant an atomic settings replace fails, the next # launch cannot turn collection back on. @@ -107,6 +158,13 @@ func load_settings() -> void: if FileAccess.file_exists(ANALYTICS_REVOKED_PATH) \ or DirAccess.dir_exists_absolute(revocation_absolute): analytics_consent = AnalyticsConsent.DENIED + # Same fail-closed shape for reminders, with its own mark: an old + # enabled file can never re-enable delivery after a crash. + var reminder_absolute: String = ProjectSettings.globalize_path( + REMINDER_DISABLED_PATH) + if FileAccess.file_exists(REMINDER_DISABLED_PATH) \ + or DirAccess.dir_exists_absolute(reminder_absolute): + reminders_enabled = false if analytics_consent != AnalyticsConsent.GRANTED \ or analytics_cohort_unix <= 0 \ or not _is_safe_cohort_version(analytics_cohort_version): @@ -119,6 +177,19 @@ func save_settings() -> Error: cfg.set_value(SECTION, "music", music) cfg.set_value(SECTION, "sfx", sfx) cfg.set_value(SECTION, "reduced_motion", reduced_motion) + cfg.set_value(SECTION, "reminders_enabled", reminders_enabled) + cfg.set_value(SECTION, "reminder_receipt_offered", + reminder_receipt_offered) + cfg.set_value(SECTION, "reminder_os_state", reminder_os_state) + cfg.set_value(SECTION, "reminder_sched_account", + reminder_sched_account) + cfg.set_value(SECTION, "reminder_sched_eligible_utc", + reminder_sched_eligible_utc) + cfg.set_value(SECTION, "reminder_sched_locale", reminder_sched_locale) + cfg.set_value(SECTION, "reminder_sched_horizon_end", + reminder_sched_horizon_end) + cfg.set_value(SECTION, "reminder_sched_base_slot", + reminder_sched_base_slot) # Do not write UNKNOWN to the file. Keep old-version settings and store # capture's first-run file as-is, while still remembering a real GRANTED # or DENIED choice. @@ -262,6 +333,114 @@ func set_analytics_consent(value: int) -> bool: return true +## Save the attendance-reminder intent. Same fail-closed shape as +## analytics consent: disabling writes its own mark first so a crash can +## never resurrect delivery, and enabling only sticks once the file and +## the cleared mark both land. Returns whether the choice is durable. +## This persists intent only; the reminder controller requests OS +## permission and schedules separately. +func set_reminders_enabled(value: bool) -> bool: + if value == reminders_enabled and not _reminders_persist_pending: + return true + var previous: bool = reminders_enabled + reminders_enabled = value + var mark_persisted: bool = true + if not value: + mark_persisted = _persist_reminder_disabled() + if previous != value: + changed.emit() + var error: Error = save_settings() + if not value: + _reminders_persist_pending = error != OK and not mark_persisted + return not _reminders_persist_pending + _reminders_persist_pending = error != OK + if error != OK: + reminders_enabled = previous + return false + if not _clear_reminder_disabled(): + # A leftover mark would force next launch off: roll back to the + # same fail-closed state and require a retry. + reminders_enabled = false + _reminders_persist_pending = true + return false + if previous != value: + changed.emit() + return true + + +## The first receipt's offer was shown; never offer again. Best effort: +## losing this bit only re-offers once, never grants or schedules. +func mark_reminder_receipt_offered() -> void: + if reminder_receipt_offered: + return + reminder_receipt_offered = true + save_settings() + + +## Cache the last known OS notification state for the settings row. +## Display only; scheduling always queries the live native status. +func set_reminder_os_state(state: String) -> void: + if state not in ["unknown", "granted", "denied", "unsupported"]: + return + if state == reminder_os_state: + return + reminder_os_state = state + save_settings() + changed.emit() + + +## Record what this install last asked the OS to deliver. Returns false +## when the record did not land, in which case the caller must treat +## the schedule as unknown and converge on the next refresh. +func record_reminder_schedule(account: String, eligible_utc: String, + locale_code: String, horizon_end_unix: float = 0.0, + base_slot: int = -1) -> bool: + reminder_sched_account = account + reminder_sched_eligible_utc = eligible_utc + reminder_sched_locale = locale_code + reminder_sched_horizon_end = horizon_end_unix + reminder_sched_base_slot = base_slot + return save_settings() == OK + + +## Forget the scheduled delivery after a cancel or an account change. +func clear_reminder_schedule() -> void: + if reminder_sched_account.is_empty() \ + and reminder_sched_eligible_utc.is_empty() \ + and reminder_sched_locale.is_empty() \ + and reminder_sched_horizon_end == 0.0 \ + and reminder_sched_base_slot == -1: + return + reminder_sched_account = "" + reminder_sched_eligible_utc = "" + reminder_sched_locale = "" + reminder_sched_horizon_end = 0.0 + reminder_sched_base_slot = -1 + save_settings() + + +func _persist_reminder_disabled() -> bool: + var file: FileAccess = FileAccess.open( + REMINDER_DISABLED_PATH, FileAccess.WRITE) + if file == null: + return false + file.store_string("disabled\n") + file.flush() + return FileAccess.file_exists(REMINDER_DISABLED_PATH) \ + and FileAccess.get_file_as_string( + REMINDER_DISABLED_PATH).strip_edges() == "disabled" + + +func _clear_reminder_disabled() -> bool: + var absolute: String = ProjectSettings.globalize_path( + REMINDER_DISABLED_PATH) + if DirAccess.dir_exists_absolute(absolute): + return false + if not FileAccess.file_exists(REMINDER_DISABLED_PATH): + return true + return DirAccess.remove_absolute(absolute) == OK + + ## Remember only first-consent time and app version. No permanent user ID or device value. func set_analytics_cohort(unix_time: int, version: String) -> bool: var normalized_version: String = version.strip_edges() diff --git a/apps/game/scripts/gameplay/vault.gd b/apps/game/scripts/gameplay/vault.gd index 43c7b94..1ae3a33 100644 --- a/apps/game/scripts/gameplay/vault.gd +++ b/apps/game/scripts/gameplay/vault.gd @@ -1,5 +1,7 @@ extends Node +const CloudSchema: Script = preload("res://scripts/cloud/cloud_schema.gd") + ## Moon vault — what survives a run. ## ## `Records` knows only a high score. This holds **what makes the next run @@ -219,8 +221,17 @@ func award(score: int) -> int: func begin_journey() -> Variant: journey_seq_issued += 1 var seq: int = journey_seq_issued + # A new journey moots this scope's journaled continue: it names a + # journey that no longer runs. Other scopes' receipts are untouched — + # only a confirmed fresh action in their own scope abandons them. + # Restored below if the save fails, like the sequence. + var moot_flat: Dictionary = continue_txn.duplicate(true) + var moot_parked: Dictionary = continue_txn_parked.duplicate(true) + _clear_scoped_txn() if save_vault() != OK: journey_seq_issued = seq - 1 + continue_txn = moot_flat + continue_txn_parked = moot_parked var fallback: String = "r%x%x" % [ int(Time.get_unix_time_from_system()) % 0xffffff, abs(randi())] _issued_fallback_ids[fallback] = true @@ -918,6 +929,19 @@ const MAX_CONTINUE_COIN_GRANT: int = 10 var continue_coins: int = STARTING_COINS ## Grant count per transaction key. Atomic with the balance in one file so crashes neither double nor drop grants. var continue_coin_grants: Dictionary = {} +## One in-flight paid continue per account scope: `owner`, `journey_id`, +## `checkpoint_id` and the exact `seal` text the debit paid for. Atomic +## with the balance in one save, like the grants: the debit and its journal +## land together, so a crash can only strand a paid seal — settled by +## `recover_paid_continue()` without another charge — never an unpaid alive +## checkpoint. The flat entry holds one scope's receipt; receipts journaled +## while another scope's entry sits flat park in `continue_txn_parked`. +## Every read and settle goes through `scoped_continue_txn()`, so a scope +## never sees, spends, or clears another scope's receipt. +var continue_txn: Dictionary = {} +## Parked receipts by owning scope: `owner` to the same flat entry shape. +## Persisted and loaded with the flat entry, same validation. +var continue_txn_parked: Dictionary = {} ## Grant coins. Only `Shop` calls this after a real payment is verified. @@ -969,10 +993,718 @@ func spend_continue_coin() -> bool: return true +## Owning scope of one journal entry. Entries journaled before ownership +## default to the active scope, so in-memory round-2 shapes keep working; +## persisted entries always carry an explicit owner after load validation. +func _txn_owner(entry: Dictionary) -> String: + return str(entry.get("owner", Journey.active_account)) + + +## The pending entry owned by the active slot, flat or parked. Empty when +## this scope paid for nothing pending. The only read entry points use. +func scoped_continue_txn() -> Dictionary: + if not continue_txn.is_empty() \ + and _txn_owner(continue_txn) == Journey.active_account: + return continue_txn + var parked: Variant = continue_txn_parked.get(Journey.active_account) + if parked is Dictionary and not (parked as Dictionary).is_empty(): + return parked as Dictionary + return {} + + +## True when any scope other than the active one holds a pending receipt. +func _has_foreign_txn() -> bool: + if not continue_txn.is_empty() \ + and _txn_owner(continue_txn) != Journey.active_account: + return true + for key in continue_txn_parked: + var parked: Variant = continue_txn_parked[key] + if str(key) != Journey.active_account and parked is Dictionary \ + and not (parked as Dictionary).is_empty(): + return true + return false + + +## Clear only the active scope's entry. Returns true when something was +## cleared; the caller saves. Split out so `ack_continue_txn()` and +## `begin_journey()` share one scoped clear with one rollback each. +func _clear_scoped_txn() -> bool: + if not continue_txn.is_empty() \ + and _txn_owner(continue_txn) == Journey.active_account: + continue_txn = {} + return true + if continue_txn_parked.has(Journey.active_account): + continue_txn_parked.erase(Journey.active_account) + return true + return false + + +## Debit one coin for a journey revive, journaling the exact alive seal the +## coin paid for under the active scope. The balance and the journal land +## in one atomic save: a failed save changes neither. Retrying the exact +## acknowledged bytes is a no-op that returns true without charging again; +## a rival attempt — different seal, or anything pending in this scope — +## refuses without touching the original receipt. The caller writes the +## seal only after this returns true, then clears it with `ack_continue_txn()`. +func begin_continue_txn(journey_id: Variant, checkpoint_id: int, + seal_text: String) -> bool: + var owner: String = Journey.active_account + var current: Dictionary = scoped_continue_txn() + if not current.is_empty(): + if current.get("journey_id") == journey_id \ + and int(current.get("checkpoint_id", -1)) == checkpoint_id \ + and str(current.get("seal", "")) == seal_text: + return true + return false + if continue_coins <= 0: + return false + if _normalize_loaded_journey_id(journey_id) == null: + return false + if checkpoint_id < 0 or checkpoint_id > JOURNEY_CHECKPOINT_CAP: + return false + if seal_text.is_empty(): + return false + var previous_flat: Dictionary = continue_txn.duplicate(true) + var previous_parked: Dictionary = continue_txn_parked.duplicate(true) + continue_coins -= 1 + var entry: Dictionary = { + "owner": owner, + "journey_id": journey_id, + "checkpoint_id": checkpoint_id, + "seal": seal_text, + } + if continue_txn.is_empty() or _txn_owner(continue_txn) == owner: + continue_txn = entry + else: + continue_txn_parked[owner] = entry + if save_vault() != OK: + continue_coins += 1 + continue_txn = previous_flat + continue_txn_parked = previous_parked + return false + changed.emit() + return true + + +## Clear the active scope's journaled continue once its seal has landed. +## Idempotent: an empty scope acks true. A failed save keeps the journal, +## and the next recovery settles it again — the seal it names is already +## on disk, so the retry only clears, never rewrites or recharges. Other +## scopes' receipts are never touched. +func ack_continue_txn() -> bool: + var previous_flat: Dictionary = continue_txn.duplicate(true) + var previous_parked: Dictionary = continue_txn_parked.duplicate(true) + if not _clear_scoped_txn(): + return true + if save_vault() != OK: + continue_txn = previous_flat + continue_txn_parked = previous_parked + return false + return true + + +## Settle the active scope's interrupted paid continue against the journey +## files: a stranded paid seal materializes, a delivered or moot one just +## clears. Returns the Journey settlement code (`recovered`, `delivered`, +## `stale`, `none`, or `failed`), or `deferred` when only another scope +## holds a receipt — deferred touches no file, notifies nothing, and keeps +## the foreign receipt for its own scope. The journal clears on the first +## three codes, best effort, and a lingering journal converges on the next +## pass because the seal it names is already on disk by then. +func recover_paid_continue() -> String: + var current: Dictionary = scoped_continue_txn() + if current.is_empty(): + return "deferred" if _has_foreign_txn() else "none" + var code: String = Journey.settle_continue_txn(current) + if code == "recovered" or code == "delivered" or code == "stale": + ack_continue_txn() + return code + + +## Carry one scope's receipt onto its slot's new owner after the slot's +## files moved. Refuses when the target scope already holds a receipt, so +## a move into an occupied slot keeps both. True when nothing pends under +## the old owner, when both owners match, or when the rekey lands; a +## failed save restores the entry where it was. +func rekey_continue_txn_owner(from_owner: String, to_owner: String) -> bool: + if from_owner == to_owner: + return true + var from_flat: bool = not continue_txn.is_empty() \ + and _txn_owner(continue_txn) == from_owner + var from_parked: Variant = continue_txn_parked.get(from_owner) + var has_parked: bool = from_parked is Dictionary \ + and not (from_parked as Dictionary).is_empty() + if not from_flat and not has_parked: + return true + var target_flat: bool = not continue_txn.is_empty() \ + and _txn_owner(continue_txn) == to_owner + var target_parked: Variant = continue_txn_parked.get(to_owner) + if target_flat or (target_parked is Dictionary \ + and not (target_parked as Dictionary).is_empty()): + return false + var previous_flat: Dictionary = continue_txn.duplicate(true) + var previous_parked: Dictionary = continue_txn_parked.duplicate(true) + var entry: Dictionary + if from_flat: + entry = continue_txn + continue_txn = {} + else: + entry = (from_parked as Dictionary).duplicate(true) + continue_txn_parked.erase(from_owner) + entry["owner"] = to_owner + if continue_txn.is_empty(): + continue_txn = entry + else: + continue_txn_parked[to_owner] = entry + if save_vault() != OK: + continue_txn = previous_flat + continue_txn_parked = previous_parked + return false + return true + + +## Rekey-first gate for a slot move: carry `from_owner`'s receipt onto +## `to_owner` BEFORE any file moves, so a crash between the two leaves the +## receipt ahead of its bytes — the retry completes the move instead of +## stranding a paid revive. The target's own pending receipt or journey is +## occupied even when the source carries nothing: importing unrelated +## source bytes over it would stale-clear a paid recovery, so the source +## stays and the adoption keeps both sides. Returns `ready` when the +## target is free and nothing pends under the old owner, or when the +## rekey lands; `kept` when the target holds its own receipt or journey — +## the source stays, adopt without moving, and a receipt already ahead of +## its bytes (the crash-window resume) settles from the journal without +## needing the file move; `failed` when the rekey could not save — the +## caller must not move files. +func prepare_receipt_move(from_owner: String, to_owner: String) -> String: + if from_owner == to_owner: + return "ready" + var target_flat: bool = not continue_txn.is_empty() \ + and _txn_owner(continue_txn) == to_owner + var target_parked: Variant = continue_txn_parked.get(to_owner) + if target_flat or (target_parked is Dictionary \ + and not (target_parked as Dictionary).is_empty()): + return "kept" + if not to_owner.is_empty() and FileAccess.file_exists( + Journey.account_main_path(to_owner)): + return "kept" + var from_flat: bool = not continue_txn.is_empty() \ + and _txn_owner(continue_txn) == from_owner + var from_parked: Variant = continue_txn_parked.get(from_owner) + var has_from: bool = from_flat or (from_parked is Dictionary \ + and not (from_parked as Dictionary).is_empty()) + if not has_from: + return "ready" + return "ready" if rekey_continue_txn_owner(from_owner, to_owner) \ + else "failed" + + +## Migrate the legacy save into `owner`'s slot jointly with its paid +## receipt, rekey-first: the receipt rekeys onto the owner BEFORE any file +## moves, so every failure or death between the steps retries cleanly — a +## failed rekey migrates nothing, and a receipt already ahead only waits +## for its bytes. An occupied slot keeps files and receipt jointly legacy +## (deferred, never imported); a legacy receipt with no legacy files left +## is never adopted blindly — its bytes belong to another slot or none. +## Returns `ok` with `moved_main`, `moved_backup`, `kept`, and `code`. +func adopt_legacy_slot(owner: String) -> Dictionary: + var result: Dictionary = {"moved_main": false, "moved_backup": false, + "kept": false, "ok": true, "code": ""} + if owner.is_empty(): + result["ok"] = false + result["code"] = "legacy-adopt-refused" + return result + if not FileAccess.file_exists(Journey.DEFAULT_PATH) \ + and not FileAccess.file_exists(Journey.DEFAULT_BACKUP_PATH): + return result + if FileAccess.file_exists(Journey.account_main_path(owner)): + result["kept"] = true + return result + var readiness: String = prepare_receipt_move("", owner) + if readiness == "failed": + result["ok"] = false + result["code"] = "legacy-adopt-held" + return result + if readiness == "kept": + # The slot's main is missing but the owner holds its own receipt: + # the same joint hold as an occupied slot — adopt nothing, lose + # nothing, and converge once that receipt settles. + result["kept"] = true + return result + var migration: Dictionary = Journey.migrate_legacy_to_account(owner) + result["moved_main"] = bool(migration.get("moved_main", false)) + result["moved_backup"] = bool(migration.get("moved_backup", false)) + if not bool(migration.get("ok", false)): + result["ok"] = false + result["code"] = "legacy-move-failed" + return result + return result + + +## Verified adventurer names by owning public ID: `display`, `key`, and +## `intro_complete`. Written only from server-acknowledged states, so an +## entry always names a globally claimed handle; separate accounts never +## share, and the arena reads the active account's entry only. Bounded; +## the oldest-inserted entry evicts first. Holds no token, email, or +## identity profile. Entries are shape-checked here and re-verified by +## the server on every write, so a hand-edited cache can only mislabel +## local text, never forge a claim. +const MAX_VERIFIED_NAMES: int = 8 +var verified_names: Dictionary = {} + + +## One account's verified handle, or empty when it claimed nothing (or +## nothing verified yet). The caller names the scope explicitly, so an +## account switch can never leak another account's handle. +func verified_name_for_account(public_id: String) -> Dictionary: + var entry: Variant = verified_names.get(public_id) + if entry is Dictionary and not (entry as Dictionary).is_empty(): + return (entry as Dictionary).duplicate(true) + return {} + + +## Remember a server-acknowledged handle. Refuses malformed entries and +## restores the cache when the save fails, so a failed write never +## certifies a name offline that the server never saw. The tutorial bit is +## monotone per name: a late incomplete same-name response never clears a +## stored completion. A different scope or a different key starts over. +func cache_verified_name(public_id: String, display: String, + key: String, intro_complete: bool) -> bool: + if public_id.is_empty() or display.is_empty() or key.is_empty(): + return false + if key != display.to_lower(): + return false + if display.length() < 2 or display.length() > 12: + return false + var stored: Dictionary = verified_names.get(public_id, {}) + if not stored.is_empty() and str(stored.get("key", "")) == key \ + and bool(stored.get("intro_complete", false)): + intro_complete = true + var previous: Dictionary = verified_names.duplicate(true) + verified_names[public_id] = { + "display": display, + "key": key, + "intro_complete": intro_complete, + } + while verified_names.size() > MAX_VERIFIED_NAMES: + verified_names.erase(verified_names.keys()[0]) + if save_vault() != OK: + verified_names = previous + return false + return true + + +## Drop one deleted account's handle and keep every other scope's. True +## when nothing is cached under the owner or when the clear lands; a +## failed save restores the entry. +func clear_verified_name_for_owner(owner: String) -> bool: + if not verified_names.has(owner): + return true + var previous: Dictionary = verified_names.duplicate(true) + verified_names.erase(owner) + if save_vault() != OK: + verified_names = previous + return false + return true + + +## This install's durable attendance binding: a random hex string minted +## once and persisted. Server attendance rows record it as the receiving +## install, so one grant lands in exactly one wallet. Never a token, +## email, or identity profile; the pending value stays hidden until its +## save lands, exactly like a public ID. +var install_id: String = "" +var _pending_install_id: String = "" + +## Attendance receipt watermark per public ID: `{key, seconds}` of the +## newest locally granted claim. Bounds the wallet ledger: attendance +## receipt keys past the newest few prune, and the watermark (not the +## pruned key) stops them from ever granting again. +const MAX_ATTENDANCE_MARKS: int = 8 +var attendance_marks: Dictionary = {} + +## Global attendance floor: the highest claim seconds folded out of an +## evicted owner watermark. Owner marks prune past eight accounts, and an +## evicted mark alone would re-arm its receipt; the floor keeps the +## bounded ledger safe instead. Any strict (non-fresh) grant at or below +## the floor for an untracked owner fails closed as ambiguous: it may be +## a replay of a forgotten grant, and the wallet stays playable and +## untouched rather than guessing. Fresh server commits bypass the floor +## (their conditional write proves novelty), so same-second cross-owner +## ties still grant. +var attendance_floor: int = 0 + +## Last server-confirmed attendance deadline per public ID, for display +## and reminder scheduling only: `{next_utc, remaining, confirmed_at}`. +## Never an authority to grant coins; only a live server read grants. +var attendance_next: Dictionary = {} + +## Lodge lesson practice gates per canonical public ID: `{move, dash}`. +## Sticky per account so a restart resumes practice instead of re-teaching +## finished gates; re-practising a dropped gate costs no coins and is safe. +var lodge_progress: Dictionary = {} + + +## Durable install binding, minted on first use. Returns "" while the +## mint is not yet durable: callers fail closed rather than binding a +## claim to an identity that changes on relaunch. +func ensure_install_id() -> String: + if not install_id.is_empty(): + return install_id + if _pending_install_id.is_empty(): + var crypto: Crypto = Crypto.new() + _pending_install_id = crypto.generate_random_bytes( + 16).hex_encode() + install_id = _pending_install_id + if save_vault() != OK: + install_id = "" + return "" + _pending_install_id = "" + return install_id + + +## Grant one acknowledged attendance claim: exactly two coins under the +## stable derived receipt key. Idempotent by key, duplicate at or below +## the owner watermark (a pruned receipt never grants again), pruned to +## the newest few attendance keys with purchased keys never evicted, and +## rolled back whole when the single atomic save fails. An untracked +## owner at or below the eviction floor fails closed as ambiguous unless +## `fresh_commit` carries the server's novelty proof (a just-landed +## conditional commit, whose twelve-hour rule makes the receipt unique), +## so same-second cross-owner ties still grant. Returns `{status, key, +## granted, duplicate}`. +func grant_attendance_coins(public_id: String, claim_seconds: int, + receiving_install: String, fresh_commit: bool = false) -> Dictionary: + if public_id.is_empty() or claim_seconds <= 0 \ + or not CloudSchema.is_valid_install_id(receiving_install): + return {"status": "failure", "code": "invalid-attendance-grant", + "key": "", "granted": false, "duplicate": false} + var key: String = CloudSchema.attendance_receipt_key( + public_id, claim_seconds, receiving_install) + if continue_coin_grants.has(key): + _repair_attendance_mark(public_id, key, claim_seconds) + return {"status": "ok", "key": key, "granted": false, + "duplicate": true} + var mark: Dictionary = attendance_marks.get(public_id, {}) + if not mark.is_empty() \ + and claim_seconds <= int(mark.get("seconds", 0)): + return {"status": "ok", "key": key, "granted": false, + "duplicate": true} + if mark.is_empty() and not fresh_commit \ + and claim_seconds <= attendance_floor: + return {"status": "failure", + "code": "attendance-replay-ambiguous", + "key": key, "granted": false, "duplicate": false} + var previous_coins: int = continue_coins + var previous_grants: Dictionary = continue_coin_grants.duplicate(true) + var previous_marks: Dictionary = attendance_marks.duplicate(true) + var previous_floor: int = attendance_floor + continue_coins += CloudSchema.ATTENDANCE_GRANT_COINS + continue_coin_grants[key] = CloudSchema.ATTENDANCE_GRANT_COINS + attendance_marks[public_id] = {"key": key, "seconds": claim_seconds} + _prune_attendance_keys() + while attendance_marks.size() > MAX_ATTENDANCE_MARKS: + _drop_oldest_attendance_mark() + if save_vault() != OK: + continue_coins = previous_coins + continue_coin_grants = previous_grants + attendance_marks = previous_marks + attendance_floor = previous_floor + return {"status": "failure", "code": "attendance-save-failed", + "key": key, "granted": false, "duplicate": false} + changed.emit() + return {"status": "ok", "key": key, "granted": true, + "duplicate": false} + + +## True when this wallet already holds one receipt: its key is present, +## or its owner watermark already covers its seconds. Per owner, granted +## seconds advance monotonically (a claim commits only after the previous +## reward applied), so a covered stamp can only be the same claim back +## for a top-up, never a new reward. +func attendance_receipt_applied(public_id: String, claim_seconds: int, + receiving_install: String) -> bool: + if public_id.is_empty() or claim_seconds <= 0: + return false + var key: String = CloudSchema.attendance_receipt_key( + public_id, claim_seconds, receiving_install) + if continue_coin_grants.has(key): + return true + var mark: Dictionary = attendance_marks.get(public_id, {}) + return not mark.is_empty() \ + and claim_seconds <= int(mark.get("seconds", 0)) + + +## A present receipt key proves its grant: if the owner watermark went +## missing or lower (eviction, hand edit), restore it so pruned older +## keys stay refused. Best effort after the proven grant stands. +func _repair_attendance_mark(public_id: String, key: String, + claim_seconds: int) -> void: + var mark: Dictionary = attendance_marks.get(public_id, {}) + if not mark.is_empty() \ + and int(mark.get("seconds", 0)) >= claim_seconds: + return + attendance_marks[public_id] = {"key": key, "seconds": claim_seconds} + while attendance_marks.size() > MAX_ATTENDANCE_MARKS: + _drop_oldest_attendance_mark() + if save_vault() != OK: + attendance_marks.erase(public_id) + if not mark.is_empty(): + attendance_marks[public_id] = mark + + +## Drop the oldest owner watermark, folding its seconds into the global +## floor first. A mark is never dropped without folding: the floor keeps +## refusing the forgotten seconds after the owner entry is gone. +func _drop_oldest_attendance_mark() -> void: + if attendance_marks.is_empty(): + return + var oldest_key: String = str(attendance_marks.keys()[0]) + var oldest: Dictionary = attendance_marks.get(oldest_key, {}) + attendance_floor = maxi(attendance_floor, + int(oldest.get("seconds", 0))) + attendance_marks.erase(oldest_key) + + +## Keep the newest few attendance receipt keys across all scopes, oldest +## first by embedded claim seconds. Only `attendance:`-prefixed keys are +## candidates: purchased transaction keys are never evicted. +func _prune_attendance_keys() -> void: + var candidates: Array = [] + for key in continue_coin_grants: + var seconds: int = CloudSchema.attendance_key_seconds( + str(key)) + if seconds > 0: + candidates.append({"key": str(key), "seconds": seconds}) + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return int(a.get("seconds", 0)) < int(b.get("seconds", 0))) + while candidates.size() > CloudSchema.MAX_ATTENDANCE_KEYS: + var oldest: Dictionary = candidates.pop_front() + continue_coin_grants.erase(str(oldest.get("key", ""))) + + +## Remember the last server-confirmed deadline for display and reminder +## scheduling. Display only: reads never consult this cache. +func cache_attendance_next(public_id: String, next_utc: String, + remaining: int, confirmed_at: int) -> bool: + if public_id.is_empty() or next_utc.is_empty(): + return false + if remaining < 0 \ + or remaining > CloudSchema.ATTENDANCE_COOLDOWN_SECONDS: + return false + if confirmed_at <= 0: + return false + var previous: Dictionary = attendance_next.duplicate(true) + attendance_next[public_id] = {"next_utc": next_utc, + "remaining": remaining, "confirmed_at": confirmed_at} + while attendance_next.size() > MAX_ATTENDANCE_MARKS: + attendance_next.erase(attendance_next.keys()[0]) + if save_vault() != OK: + attendance_next = previous + return false + return true + + +## Lodge practice gates for one account: `{move, dash}`. Missing gates +## read false, so a pruned entry only re-teaches, never breaks setup. +func lodge_progress_for_account(public_id: String) -> Dictionary: + var gates: Dictionary = {"move": false, "dash": false} + var entry: Variant = lodge_progress.get(public_id) + if entry is Dictionary: + gates["move"] = bool((entry as Dictionary).get("move", false)) + gates["dash"] = bool((entry as Dictionary).get("dash", false)) + return gates + + +## Mark one practiced gate sticky for the account. Re-practising is a +## no-op write of the same true; unknown gates refuse without a save. +func mark_lodge_gate(public_id: String, gate: String) -> bool: + if public_id.is_empty() or (gate != "move" and gate != "dash"): + return false + var previous: Dictionary = lodge_progress.duplicate(true) + var gates: Dictionary = lodge_progress_for_account(public_id) + gates[gate] = true + lodge_progress[public_id] = gates + while lodge_progress.size() > MAX_ATTENDANCE_MARKS: + lodge_progress.erase(lodge_progress.keys()[0]) + if save_vault() != OK: + lodge_progress = previous + return false + return true + + +## Drop one deleted account's lesson gates and keep every other scope's. +## A failed save restores the entry. +func clear_lodge_for_owner(owner: String) -> bool: + var previous: Dictionary = lodge_progress.duplicate(true) + lodge_progress.erase(owner) + if save_vault() != OK: + lodge_progress = previous + return false + return true + + +## Last confirmed deadline for one account, or {} when never confirmed. +func attendance_next_for_account(public_id: String) -> Dictionary: + var entry: Variant = attendance_next.get(public_id) + if not entry is Dictionary: + return {} + return (entry as Dictionary).duplicate() + + +## Drop one deleted account's attendance state and keep every other +## scope's. Grant keys stay: they back the device balance and prune by +## bound; the marks and deadline cache for the gone account go, with the +## mark's seconds folded into the floor like any eviction. +func clear_attendance_for_owner(owner: String) -> bool: + var previous_marks: Dictionary = attendance_marks.duplicate(true) + var previous_next: Dictionary = attendance_next.duplicate(true) + var previous_floor: int = attendance_floor + var dropped: Dictionary = attendance_marks.get(owner, {}) + if not dropped.is_empty(): + attendance_floor = maxi(attendance_floor, + int(dropped.get("seconds", 0))) + attendance_marks.erase(owner) + attendance_next.erase(owner) + if save_vault() != OK: + attendance_marks = previous_marks + attendance_next = previous_next + attendance_floor = previous_floor + return false + return true + + +## One persisted handle, validated or dropped. Malformed entries drop, so +## a hand-edited cache can only lose a handle, never forge or share one. +func _validated_name_entry(raw: Variant) -> Dictionary: + if not raw is Dictionary: + return {} + var candidate: Dictionary = raw as Dictionary + var display: String = str(candidate.get("display", "")) + var key: String = str(candidate.get("key", "")) + if display.is_empty() or key.is_empty(): + return {} + if key != display.to_lower(): + return {} + if display.length() < 2 or display.length() > 12: + return {} + return { + "display": display, + "key": key, + "intro_complete": bool(candidate.get("intro_complete", false)), + } + + +## One persisted attendance watermark, validated or dropped. The key +## must carry the same seconds it claims, or a hand edit could re-arm an +## older receipt. +func _validated_attendance_mark(raw: Variant) -> Dictionary: + if not raw is Dictionary: + return {} + var candidate: Dictionary = raw as Dictionary + var key: String = str(candidate.get("key", "")) + var seconds: int = int(candidate.get("seconds", 0)) + if CloudSchema.attendance_key_seconds(key) != seconds: + return {} + if seconds <= 0: + return {} + return {"key": key, "seconds": seconds} + + +## One persisted deadline cache entry, validated or dropped. Display +## only, so a bad entry simply loses a cached time. +func _validated_attendance_next(raw: Variant) -> Dictionary: + if not raw is Dictionary: + return {} + var candidate: Dictionary = raw as Dictionary + var next_utc: String = str(candidate.get("next_utc", "")) + var remaining: int = int(candidate.get("remaining", -1)) + var confirmed_at: int = int(candidate.get("confirmed_at", 0)) + if next_utc.is_empty() or remaining < 0 \ + or remaining > CloudSchema.ATTENDANCE_COOLDOWN_SECONDS: + return {} + if confirmed_at <= 0: + return {} + return {"next_utc": next_utc, "remaining": remaining, + "confirmed_at": confirmed_at} + + +## One persisted lesson-gate entry, validated or dropped. Malformed +## entries drop, so a hand-edited wallet only re-teaches a gate. +func _validated_lodge_progress(raw: Variant) -> Dictionary: + if not raw is Dictionary: + return {} + var candidate: Dictionary = raw as Dictionary + return {"move": bool(candidate.get("move", false)), + "dash": bool(candidate.get("dash", false))} + + +## Drop one deleted account's receipt and keep every other scope's. True +## when nothing pends under the owner or when the clear lands; a failed +## save restores the entry. +func clear_continue_txn_for_owner(owner: String) -> bool: + var previous_flat: Dictionary = continue_txn.duplicate(true) + var previous_parked: Dictionary = continue_txn_parked.duplicate(true) + var cleared: bool = false + if not continue_txn.is_empty() and _txn_owner(continue_txn) == owner: + continue_txn = {} + cleared = true + if continue_txn_parked.has(owner): + continue_txn_parked.erase(owner) + cleared = true + if not cleared: + return true + if save_vault() != OK: + continue_txn = previous_flat + continue_txn_parked = previous_parked + return false + return true + + +## One persisted journal entry, validated or dropped. Ownerless entries +## predate ownership and load under the legacy scope; `allow_ownerless` +## stamps them instead of dropping. Malformed entries drop, so a +## hand-edited journal can only lose a pending revive, never mint a coin +## or forge a seal (recovery revalidates the bytes). +func _validated_txn_entry(raw: Variant, allow_ownerless: bool) -> Dictionary: + if not raw is Dictionary: + return {} + var candidate: Dictionary = raw as Dictionary + var txn_owner: Variant = candidate.get("owner") + if txn_owner == null and allow_ownerless: + txn_owner = "" + if not txn_owner is String: + return {} + var txn_journey: Variant = _normalize_loaded_journey_id( + candidate.get("journey_id")) + var txn_cid: Variant = candidate.get("checkpoint_id") + var txn_seal: Variant = candidate.get("seal") + if txn_journey == null or not txn_cid is int \ + or int(txn_cid) < 0 \ + or int(txn_cid) > JOURNEY_CHECKPOINT_CAP \ + or not txn_seal is String or str(txn_seal).is_empty(): + return {} + return { + "owner": str(txn_owner), + "journey_id": txn_journey, + "checkpoint_id": int(txn_cid), + "seal": str(txn_seal), + } + + func load_vault() -> void: shards = 0 continue_coins = STARTING_COINS continue_coin_grants.clear() + continue_txn = {} + continue_txn_parked = {} + verified_names = {} + install_id = "" + _pending_install_id = "" + attendance_marks = {} + attendance_floor = 0 + attendance_next = {} + lodge_progress = {} ranks.clear() opened.clear() hero_sources.clear() @@ -996,6 +1728,84 @@ func load_vault() -> void: if not transaction_key.is_empty() \ and count > 0 and count <= MAX_CONTINUE_COIN_GRANT: continue_coin_grants[transaction_key] = count + # The continue journal is optional in every schema: old saves predate + # it and load with nothing pending. Validation lives in + # `_validated_txn_entry`, shared by the flat and parked entries. + var saved_txn: Variant = file.get_value(SECTION, "continue_txn", {}) + if saved_txn is Dictionary: + var flat: Dictionary = _validated_txn_entry( + (saved_txn as Dictionary), true) + if not flat.is_empty(): + continue_txn = flat + var saved_parked: Variant = file.get_value( + SECTION, "continue_txn_parked", {}) + if saved_parked is Dictionary: + for key in (saved_parked as Dictionary): + var parked: Dictionary = _validated_txn_entry( + (saved_parked as Dictionary)[key], false) + if parked.is_empty(): + continue + if str(parked.get("owner", "")) != str(key): + continue + continue_txn_parked[str(key)] = parked + # Verified handles are optional in every schema like the journal: + # old saves predate them and load unnamed. Malformed entries drop. + var saved_names: Variant = file.get_value( + SECTION, "verified_names", {}) + if saved_names is Dictionary: + for key in (saved_names as Dictionary): + if verified_names.size() >= MAX_VERIFIED_NAMES: + break + var handle: Dictionary = _validated_name_entry( + (saved_names as Dictionary)[key]) + if handle.is_empty() or str(key).is_empty(): + continue + verified_names[str(key)] = handle + # Attendance state is optional in every schema like handles: old saves + # predate it and mint an install binding on first use. Malformed + # entries drop; valid marks past the cap fold into the floor instead, + # so loading never re-arms a pruned receipt. + var saved_install: String = str(file.get_value( + SECTION, "install_id", "")) + if CloudSchema.is_valid_install_id(saved_install): + install_id = saved_install + attendance_floor = maxi( + int(file.get_value(SECTION, "attendance_floor", 0)), 0) + var saved_marks: Variant = file.get_value( + SECTION, "attendance_marks", {}) + if saved_marks is Dictionary: + for key in (saved_marks as Dictionary): + var mark: Dictionary = _validated_attendance_mark( + (saved_marks as Dictionary)[key]) + if mark.is_empty() or str(key).is_empty(): + continue + if attendance_marks.size() >= MAX_ATTENDANCE_MARKS: + attendance_floor = maxi(attendance_floor, + int(mark.get("seconds", 0))) + continue + attendance_marks[str(key)] = mark + var saved_next: Variant = file.get_value( + SECTION, "attendance_next", {}) + if saved_next is Dictionary: + for key in (saved_next as Dictionary): + if attendance_next.size() >= MAX_ATTENDANCE_MARKS: + break + var deadline: Dictionary = _validated_attendance_next( + (saved_next as Dictionary)[key]) + if deadline.is_empty() or str(key).is_empty(): + continue + attendance_next[str(key)] = deadline + var saved_lodge: Variant = file.get_value( + SECTION, "lodge_progress", {}) + if saved_lodge is Dictionary: + for key in (saved_lodge as Dictionary): + if lodge_progress.size() >= MAX_ATTENDANCE_MARKS: + break + var gates: Dictionary = _validated_lodge_progress( + (saved_lodge as Dictionary)[key]) + if gates.is_empty() or str(key).is_empty(): + continue + lodge_progress[str(key)] = gates if saved_version >= 3: var saved_sources: Variant = file.get_value(SECTION, "hero_sources", {}) if saved_sources is Dictionary: @@ -1078,6 +1888,14 @@ func save_vault() -> Error: file.set_value(SECTION, "shards", shards) file.set_value(SECTION, "continue_coins", continue_coins) file.set_value(SECTION, "continue_coin_grants", continue_coin_grants) + file.set_value(SECTION, "continue_txn", continue_txn) + file.set_value(SECTION, "continue_txn_parked", continue_txn_parked) + file.set_value(SECTION, "verified_names", verified_names) + file.set_value(SECTION, "install_id", install_id) + file.set_value(SECTION, "attendance_marks", attendance_marks) + file.set_value(SECTION, "attendance_floor", attendance_floor) + file.set_value(SECTION, "attendance_next", attendance_next) + file.set_value(SECTION, "lodge_progress", lodge_progress) file.set_value(SECTION, "hero", chosen) file.set_value(SECTION, "opened", opened) file.set_value(SECTION, "hero_sources", hero_sources) diff --git a/apps/game/scripts/net/production_host.gd b/apps/game/scripts/net/production_host.gd index 97dd8e1..7d66e29 100644 --- a/apps/game/scripts/net/production_host.gd +++ b/apps/game/scripts/net/production_host.gd @@ -26,6 +26,7 @@ signal production_changed(state: Dictionary) signal production_conflict(local: Dictionary, cloud: Dictionary) signal production_hall(rows: Array, meta: Dictionary) signal production_error(error: Dictionary) +signal production_attendance(snapshot: Dictionary) const ACCOUNT_SCRIPT: Script = preload("res://scripts/net/player_account.gd") const ADAPTER_SCRIPT: Script = preload( @@ -41,8 +42,11 @@ const TRANSPORT_SCRIPT: Script = preload( const SCHEMA_SCRIPT: Script = preload("res://scripts/cloud/cloud_schema.gd") const FIREBASE_SCRIPT: Script = preload( "res://scripts/net/firebase_config.gd") +const REMINDERS_SCRIPT: Script = preload( + "res://scripts/gameplay/attendance_reminders.gd") const ARENA_SCENE: String = "res://scenes/gameplay/arena.tscn" +const LODGE_SCENE: String = "res://scenes/gameplay/gate_lodge.tscn" const RELEASE_TAG: String = "4.0.0" const TOKEN_REFRESH_MARGIN_SECONDS: float = 60.0 const TOKEN_WAIT_SECONDS: float = 15.0 @@ -87,6 +91,16 @@ var _draining: bool = false var _draining_provider: String = "" var _deletion_generation: int = 0 var _deletion_ticket: Dictionary = {} +## Single attendance check owner: entry, foreground, and explicit calls +## share one claim; overlapping triggers skip instead of queueing. +var _attendance_in_flight: bool = false +## Granted-coins receipt awaiting a HUD: gate-time grants queue here and +## show on arena entry; arena-time grants flush immediately. +var _attendance_receipt_pending: String = "" +## Local reminder controller, owned here so it outlives scene changes. +## Reads the attendance view and the MoonlitIdentity bridge; inert +## without a native side. +var _reminders: Node = null var _deletion_waiters: Array = [] var _entry_in_flight: bool = false ## Initial empty-slot cloud check. Owned synchronously in `_adopted_canonical` @@ -100,6 +114,11 @@ var _restore_generation: int = 0 ## until an explicit retry or offline decision; `offline` is that decision. var _restore_state: String = "none" var _restore_code: String = "" +## Legacy joint-adoption hold: "" when the legacy save needs nothing or +## migrated cleanly, else the `adopt_legacy_slot` code (`legacy-adopt-held`, +## `legacy-move-failed`) the next startup retries. The bytes and receipt +## wait jointly on disk, so ordinary play is unaffected. +var _legacy_hold_code: String = "" ## Checkpoint-read claim. One fetch runs at a time, so retries can never ## stack parallel pulls. The counter is monotonic and never reset — the ## open slot below is what clears — so a retired completion can never @@ -132,10 +151,27 @@ func _init() -> void: func _ready() -> void: _build_services() + _build_reminder_controller() if get_tree() != null: get_tree().scene_changed.connect(_on_scene_changed) +## The owned reminder controller for settings and QA. Null only before +## `_ready` or in bare unit constructions that never entered a tree. +func reminder_controller() -> Node: + return _reminders + + +func _build_reminder_controller() -> void: + if _reminders != null and is_instance_valid(_reminders): + return + _reminders = REMINDERS_SCRIPT.new() + _reminders.name = "AttendanceReminders" + add_child(_reminders) + _reminders.configure(self, + get_node_or_null("/root/MoonlitIdentity")) + + func _exit_tree() -> void: shutdown() @@ -219,13 +255,29 @@ func startup() -> Dictionary: _started = true _startup_ticks = Time.get_ticks_msec() Journey.use_account(public_id) - Journey.migrate_legacy_to_account(public_id) + _run_legacy_adoption(public_id) _account.call("refresh_session") _maybe_configure_cloud() + _watch_foreground_returns() production_changed.emit(account_state()) return account_state() +## Foreground returns re-check attendance once each. Window focus exists +## only on a real display; headless runs (tests, harnesses) drive +## `notify_foreground_return` explicitly instead of auto-firing. +func _watch_foreground_returns() -> void: + if not is_inside_tree(): + return + if DisplayServer.get_name() == "headless": + return + var root: Window = get_tree().root + if root == null: + return + if not root.focus_entered.is_connected(notify_foreground_return): + root.focus_entered.connect(notify_foreground_return) + + ## True once `startup()` landed a durable ID. func is_started() -> bool: return _started @@ -269,6 +321,7 @@ func account_state() -> Dictionary: "restore_failed": restore_live and _restore_state == "failed", "restore_offline": restore_live and _restore_state == "offline", "restore_code": _restore_code if restore_live else "", + "legacy_hold": _legacy_hold_code, "account": coord, "save": save, } @@ -680,18 +733,25 @@ func flush_saves() -> Dictionary: ## What the saved gate holds: hero, wave, and display lines. Empty when no -## valid checkpoint exists. The hero resource loads only from an already -## validated checkpoint path, never from a raw string. +## valid checkpoint exists, or when the journey already sealed its defeat: +## a sealed run offers a fresh expedition, never a free continuation. The +## hero resource loads only from an already validated checkpoint path, never +## from a raw string. func saved_gate_summary() -> Dictionary: + var recovery: String = Vault.recover_paid_continue() var data: Dictionary = Journey.read_checkpoint() - if data.is_empty(): - return {"has_save": false} + if data.is_empty() or Journey.is_ended(data): + # A stuck paid revive is not a save, but it is not nothing either: + # a fresh expedition over it still asks first, so the paid coin is + # only ever abandoned explicitly. + return {"has_save": false, "revive_stuck": recovery == "failed"} var info: Dictionary = Journey.summary(data) if info.is_empty(): - return {"has_save": false} + return {"has_save": false, "revive_stuck": recovery == "failed"} var hero: Hero = load(str(info.get("hero_path", ""))) as Hero return { "has_save": true, + "revive_stuck": false, "cycle": int(info.get("cycle", 1)), "zone_index": int(info.get("zone_index", 0)), "terrain": int(info.get("terrain", 0)), @@ -704,10 +764,12 @@ func saved_gate_summary() -> Dictionary: } -## Plan one Arena entry. Fresh over an existing save needs `confirmed`; -## resume needs a valid checkpoint; a second plan while one is in flight -## is refused. On success the Journey is armed and the caller loads the -## returned scene through the loading overlay. +## Plan one Arena entry. Fresh over an existing save — or over a paid +## revive stuck behind failing writes — needs `confirmed`; resume needs a +## valid checkpoint; a second plan while one is in flight is refused. A +## stranded paid seal settles before anything plans, so the revive greets +## the entry instead of a refusal. On success the Journey is armed and the +## caller loads the returned scene through the loading overlay. func plan_entry(fresh: bool, confirmed: bool) -> Dictionary: if not _started or not _account.call("is_identity_ready"): return {"status": "refused", "code": "identity_not_ready"} @@ -719,9 +781,13 @@ func plan_entry(fresh: bool, confirmed: bool) -> Dictionary: return {"status": "refused", "code": "login_in_flight"} if _restore_blocks_entry(): return {"status": "refused", "code": _restore_block_code()} + if needs_lodge_lesson(): + return {"status": "refused", "code": "needs_lodge"} var account_id: String = _account.call("public_id") var saved: Dictionary = saved_gate_summary() - if fresh and bool(saved.get("has_save", false)) and not confirmed: + if fresh and not confirmed \ + and (bool(saved.get("has_save", false)) \ + or bool(saved.get("revive_stuck", false))): return {"status": "refused", "code": "needs_confirmation"} if not fresh and not bool(saved.get("has_save", false)): return {"status": "refused", "code": "no_save"} @@ -738,6 +804,79 @@ func plan_entry(fresh: bool, confirmed: bool) -> Dictionary: "hero_path": str(saved.get("hero_path", ""))} +## True when the live account must visit the gate lodge before the Arena: +## a cloud-linked account whose durable cache holds no verified handle or +## no acknowledged lesson bit. Synchronous, from the vault cache only, so +## plan_entry refuses the Arena for unsettled accounts even offline. A +## local-only guest has no claim to settle and plays unnamed as before. +func needs_lodge_lesson() -> bool: + if _vault == null or not _cloud_session_present(): + return false + if _account == null: + return false + var public_id: String = _account.call("public_id") + if public_id.is_empty(): + return false + var entry: Dictionary = _vault.call("verified_name_for_account", + public_id) + if str(entry.get("display", "")).is_empty(): + return true + return not bool(entry.get("intro_complete", false)) + + +## Plan one lodge visit. Same guards as an Arena plan, but the lesson is +## not an expedition: no journey arms, no title stamp lands, no save is +## touched. Refuses when the account already settled, so completed +## accounts plan the Arena directly. The visit holds the entry lock until +## plan_lodge_exit or cancel_entry_plan releases it. +func plan_lodge_entry() -> Dictionary: + if not _started or not _account.call("is_identity_ready"): + return {"status": "refused", "code": "identity_not_ready"} + if not _deletion_ticket.is_empty(): + return {"status": "refused", "code": "deletion_in_flight"} + if _entry_in_flight: + return {"status": "refused", "code": "entry_in_flight"} + if _account_has_pending(): + return {"status": "refused", "code": "login_in_flight"} + if _restore_blocks_entry(): + return {"status": "refused", "code": _restore_block_code()} + if not needs_lodge_lesson(): + return {"status": "refused", "code": "lodge_complete"} + var account_id: String = _account.call("public_id") + _entry_in_flight = true + _planned_account = account_id + production_changed.emit(account_state()) + return {"status": "ok", "lodge": LODGE_SCENE, "account_id": account_id, + "needs_name": verified_display_name().is_empty()} + + +## Leave the lodge for the Arena after the lesson settled. Requires the +## live lodge plan on the same account and a settled account: a +## double tap before the acknowledgement lands still refuses. Releases +## the lodge hold, then plans exactly like the title would, so Resume +## versus confirmed fresh departure keeps its defeat-safe meaning and a +## defeated journey never resumes its sealed stage from here either. +func plan_lodge_exit(fresh: bool, confirmed: bool) -> Dictionary: + if not _entry_in_flight: + return {"status": "refused", "code": "no_lodge_plan"} + if _account_has_pending(): + return {"status": "refused", "code": "login_in_flight"} + if _planned_account.is_empty() \ + or _planned_account != _account.call("public_id"): + return {"status": "refused", "code": "account_moved"} + if needs_lodge_lesson(): + return {"status": "refused", "code": "needs_lodge"} + var held: String = _planned_account + _entry_in_flight = false + _planned_account = "" + var planned: Dictionary = plan_entry(fresh, confirmed) + if str(planned.get("status", "")) != "ok": + _entry_in_flight = true + _planned_account = held + return planned + return planned + + ## True when a planned entry may still swap: same plan, same account, and ## no login moved under it. The entry scene checks this before placing the ## loaded Arena, so a stale load can never enter as the wrong account. @@ -1034,6 +1173,7 @@ func _wire_signals() -> void: _coordinator.conflict_found.connect(_on_coordinator_conflict) _coordinator.hall_changed.connect(_on_coordinator_hall) _coordinator.rank_changed.connect(_on_coordinator_rank) + _coordinator.attendance_changed.connect(_on_coordinator_attendance) func _on_account_changed(state: Dictionary) -> void: @@ -1093,6 +1233,16 @@ func _on_id_token_ready(token: Dictionary) -> void: production_changed.emit(account_state()) +## Adopt the legacy save jointly with its paid receipt, honoring the hold: +## a failed joint adoption migrates nothing and records its code for the +## next startup's retry instead of proceeding over a split receipt. +func _run_legacy_adoption(public_id: String) -> void: + _legacy_hold_code = "" + var adoption: Dictionary = Vault.adopt_legacy_slot(public_id) + if not bool(adoption.get("ok", false)): + _legacy_hold_code = str(adoption.get("code", "legacy-adopt-held")) + + ## The live account moved (sign-in, sign-out, switch, delete): retire the ## in-memory token, repoint the Journey slot, and reconfigure the cloud ## for the new session. Old slots and old replies stay untouched. @@ -1122,7 +1272,7 @@ func _on_account_env_changed() -> void: if public_id.is_empty(): return Journey.use_account(public_id) - Journey.migrate_legacy_to_account(public_id) + _run_legacy_adoption(public_id) _maybe_configure_cloud() @@ -1559,12 +1709,22 @@ func _on_coordinator_rank(_snapshot: Dictionary) -> void: _push_rank_to_hud() +func _on_coordinator_attendance(snapshot: Dictionary) -> void: + if _closing: + return + production_attendance.emit((snapshot as Dictionary).duplicate()) + + func _conflict_option(summary: Dictionary, tag_key: String) -> Dictionary: var gate_text: String = str(summary.get("gate", "")) var cycles: int = int(summary.get("cycles", 0)) + var ended_marker: Variant = summary.get("ended", false) var detail: String = "" + if ended_marker is bool and bool(ended_marker): + detail = GateEntryStrings.text("gate.conflict.ended") if not gate_text.is_empty(): - detail = gate_text + detail = gate_text if detail.is_empty() \ + else "%s · %s" % [detail, gate_text] if cycles > 0: var cycle_text: String = "%d" % cycles detail = cycle_text if detail.is_empty() \ @@ -1593,6 +1753,309 @@ func _submit_best() -> void: _push_rank_to_hud() +## The live account's verified adventurer handle from the durable cache, +## or "" when it claimed nothing (or nothing verified yet). Synchronous: +## the arena and the lodge read this without starting any request. +func verified_display_name() -> String: + if _vault == null: + return "" + var public_id: String = _account.call("public_id") + if public_id.is_empty(): + return "" + return str((_vault.call("verified_name_for_account", public_id) + as Dictionary).get("display", "")) + + +## Claim an adventurer name for the live cloud account, restoring the one +## it already owns. Needs the cloud session: a local-only guest gets +## `local-guest`, never a certified global claim. A verified claim also +## attaches to the existing best row; see the coordinator result legs. +## The ticket pins the call to the initiating account: a token wait that +## outlives a switch, deletion, or shutdown cancels instead of dispatching +## onto the next account, and a failed token sends no mutation. +func claim_adventurer_name(raw_display: String) -> Dictionary: + if not _cloud_session_present(): + return {"status": "failure", "code": "local-guest", + "retryable": false} + if _coordinator == null or _configured_uid.is_empty(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + if not _deletion_ticket.is_empty(): + return {"status": "failure", "code": "deletion-in-flight", + "retryable": true} + var ticket: Dictionary = _name_ticket() + var token: Dictionary = await ensure_token(false) + if not _name_ticket_live(ticket): + return _name_stale_result() + if str(token.get("status", "")) != "ok": + return {"status": "failure", + "code": str(token.get("code", "token_error")), + "retryable": false} + var claimed: Dictionary = await _coordinator.call( + "claim_adventurer_name", raw_display) + if not _name_ticket_live(ticket): + return _name_stale_result() + production_changed.emit(account_state()) + return claimed + + +## Reload the live account's claimed name into its offline cache. Pinned +## to the initiating account exactly like a claim. +func load_adventurer_name() -> Dictionary: + if not _cloud_session_present(): + return {"status": "failure", "code": "local-guest", + "retryable": false} + if _coordinator == null or _configured_uid.is_empty(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + if not _deletion_ticket.is_empty(): + return {"status": "failure", "code": "deletion-in-flight", + "retryable": true} + var ticket: Dictionary = _name_ticket() + var token: Dictionary = await ensure_token(false) + if not _name_ticket_live(ticket): + return _name_stale_result() + if str(token.get("status", "")) != "ok": + return {"status": "failure", + "code": str(token.get("code", "token_error")), + "retryable": false} + var loaded: Dictionary = await _coordinator.call( + "fetch_adventurer_name") + if not _name_ticket_live(ticket): + return _name_stale_result() + production_changed.emit(account_state()) + return loaded + + +## Flip the tutorial bit on the live account's claimed name. Idempotent, +## and pinned to the initiating account exactly like a claim. +func mark_intro_complete() -> Dictionary: + if not _cloud_session_present(): + return {"status": "failure", "code": "local-guest", + "retryable": false} + if _coordinator == null or _configured_uid.is_empty(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + if not _deletion_ticket.is_empty(): + return {"status": "failure", "code": "deletion-in-flight", + "retryable": true} + var ticket: Dictionary = _name_ticket() + var token: Dictionary = await ensure_token(false) + if not _name_ticket_live(ticket): + return _name_stale_result() + if str(token.get("status", "")) != "ok": + return {"status": "failure", + "code": str(token.get("code", "token_error")), + "retryable": false} + var done: Dictionary = await _coordinator.call("complete_intro") + if not _name_ticket_live(ticket): + return _name_stale_result() + production_changed.emit(account_state()) + return done + + +## Attach the live account's verified handle to its existing best row. +## Pinned to the initiating account exactly like a claim. +func backfill_hall_name() -> Dictionary: + if not _cloud_session_present(): + return {"status": "failure", "code": "local-guest", + "retryable": false} + if _coordinator == null or _configured_uid.is_empty(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + if not _deletion_ticket.is_empty(): + return {"status": "failure", "code": "deletion-in-flight", + "retryable": true} + var ticket: Dictionary = _name_ticket() + var token: Dictionary = await ensure_token(false) + if not _name_ticket_live(ticket): + return _name_stale_result() + if str(token.get("status", "")) != "ok": + return {"status": "failure", + "code": str(token.get("code", "token_error")), + "retryable": false} + var filled: Dictionary = await _coordinator.call( + "backfill_hall_name") + if not _name_ticket_live(ticket): + return _name_stale_result() + production_changed.emit(account_state()) + return filled + + +## A name request's account/coordinator lifetime ticket, captured BEFORE +## the token await: the coordinator instance that must receive the call, +## the UID it was configured for, and the deletion generation. Pinning the +## generation retires requests across a deletion run even when the UID and +## coordinator stay put: a request begun before deletion stays cancelled +## even if the deletion later fails on the same account. +func _name_ticket() -> Dictionary: + return {"coordinator": _coordinator, "uid": _configured_uid, + "deletion": _deletion_generation} + + +## True while the ticket's coordinator is still the live one, still valid, +## and still configured for the ticket's UID on an open host with no +## deletion in flight and no deletion run since capture. A switch, +## reconfig, deletion begin, or shutdown retires it; a failed deletion +## still retires requests begun before it, while genuinely new requests +## capture the fresh generation and proceed. +func _name_ticket_live(ticket: Dictionary) -> bool: + if _closing: + return false + if not _deletion_ticket.is_empty(): + return false + if int(ticket.get("deletion", -1)) != _deletion_generation: + return false + if _coordinator == null or not is_instance_valid(_coordinator): + return false + var wanted: Variant = ticket.get("coordinator") + if wanted == null or not is_instance_valid(wanted): + return false + if not is_same(_coordinator, wanted): + return false + var uid: String = str(ticket.get("uid", "")) + return not uid.is_empty() and uid == _configured_uid + + +## Cancellation for a retired ticket: the host closed, or the initiating +## account moved on. Neither dispatches nor emits for the next account. +func _name_stale_result() -> Dictionary: + if _closing: + return {"status": "cancelled", "code": "host-closed", + "retryable": false} + return {"status": "cancelled", "code": "account-retired", + "retryable": false} + + +## Claim this attendance period for the live cloud account: two continue +## coins at most once per rolling twelve hours of server time. Pinned to +## the initiating account exactly like a name claim: a token wait that +## outlives a switch, deletion, or shutdown cancels instead of dispatching +## onto the next account, and a failed token sends no mutation. See the +## coordinator result legs: granted, already-claimed, cooldown, offline, +## and local-grant-failed recovery. +func claim_attendance() -> Dictionary: + if not _cloud_session_present(): + return {"status": "failure", "code": "local-guest", + "retryable": false} + if _coordinator == null or _configured_uid.is_empty(): + return {"status": "unregistered", "code": "account-not-ready", + "retryable": false} + if not _deletion_ticket.is_empty(): + return {"status": "failure", "code": "deletion-in-flight", + "retryable": true} + var ticket: Dictionary = _name_ticket() + var token: Dictionary = await ensure_token(false) + if not _name_ticket_live(ticket): + return _name_stale_result() + if str(token.get("status", "")) != "ok": + return {"status": "failure", + "code": str(token.get("code", "token_error")), + "retryable": false} + var claimed: Dictionary = await _coordinator.call( + "claim_attendance") + if not _name_ticket_live(ticket): + return _name_stale_result() + production_changed.emit(account_state()) + return claimed + + +## Current attendance view for the reminder controller: absolute +## server-confirmed deadline plus bounded server-derived remaining. +## Cached deadlines arrive labeled and grant nothing. +func attendance_view() -> Dictionary: + if _coordinator == null or not is_instance_valid(_coordinator): + return {"state": "unregistered", "source": "none", + "public_id": _configured_uid, "receipt": "", + "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + return _coordinator.call("attendance_snapshot") + + +## One bounded nonblocking attendance check for genuine account entry, +## foreground return, and explicit calls. Skips (never errors, never +## blocks play) for local guests, unready accounts, in-flight deletion +## or restore checks, and overlapping triggers. Shows the receipt only +## for coins newly granted by this call. +func request_attendance() -> Dictionary: + if _closing: + return {"status": "cancelled", "code": "host-closed", + "retryable": false} + if not _cloud_session_present(): + return {"status": "skipped", "code": "local-guest"} + if _coordinator == null or _configured_uid.is_empty(): + return {"status": "skipped", "code": "account-not-ready"} + if not _deletion_ticket.is_empty(): + return {"status": "skipped", "code": "deletion-in-flight"} + if _restore_state == "checking" or _restore_fetch_open != 0: + return {"status": "skipped", "code": "restore-pending"} + if _attendance_in_flight: + return {"status": "skipped", "code": "in-flight"} + _attendance_in_flight = true + var result: Dictionary = await claim_attendance() + _attendance_in_flight = false + var fresh_coins: int = 0 + if bool(result.get("granted", false)): + fresh_coins += SCHEMA_SCRIPT.ATTENDANCE_GRANT_COINS + if bool(result.get("backfilled", false)): + fresh_coins += SCHEMA_SCRIPT.ATTENDANCE_GRANT_COINS + if fresh_coins > 0: + _attendance_receipt_pending = tr("ATTENDANCE_RECEIPT") % [ + fresh_coins] + _flush_attendance_receipt() + return result + + +## Foreground return: one bounded attendance check. Connected to window +## focus on real displays; headless runs drive this explicitly instead. +func notify_foreground_return() -> Dictionary: + return await request_attendance() + + +## Show a queued granted-coins receipt on the HUD. Gate-time grants wait +## for arena entry; an IME field or an open NPC dialogue drops this +## receipt instead of overlaying it — the wallet already holds the coins +## and the snapshot retains the deadline. +func _flush_attendance_receipt() -> void: + if _attendance_receipt_pending.is_empty(): + return + var tree: SceneTree = get_tree() + if tree == null: + return + if tree.get_nodes_in_group("moonlit_hud").is_empty(): + return + if _attendance_overlay_blocked(tree): + _attendance_receipt_pending = "" + return + var offer: String = "" + if _reminders != null and is_instance_valid(_reminders): + offer = str(_reminders.call("offer_for_receipt")) + var offered: bool = false + for hud in tree.get_nodes_in_group("moonlit_hud"): + if not offer.is_empty() and not offered \ + and (hud as Node).has_method("announce_offer"): + hud.call("announce_offer", _attendance_receipt_pending, + offer, Callable(_reminders, "user_enable")) + offered = true + else: + hud.call("announce", _attendance_receipt_pending, + Color(1.0, 0.86, 0.5, 1)) + _attendance_receipt_pending = "" + + +## True while a receipt must not overlay: a text field holds focus (an +## IME may be composing a name) or an NPC dialogue scene is open. +func _attendance_overlay_blocked(tree: SceneTree) -> bool: + var focus: Control = tree.root.gui_get_focus_owner() + if focus is LineEdit or focus is TextEdit: + return true + for member in tree.get_nodes_in_group("moonlit_dialogue"): + if (member as Node).has_method("is_open") \ + and bool(member.call("is_open")): + return true + return false + + func _store_token(receipt: Dictionary) -> void: _id_token = str(receipt.get("id_token", "")) _token_expires_at = float(receipt.get("token_expires_at", 0)) @@ -1708,6 +2171,7 @@ func _map_hall_rows(rows: Array) -> Array: "rank": standing, "score": score, "id": str(row.get("public_id", "")), + "display": str(row.get("display", "")), "hero": hero, "hero_name": "", }) @@ -1740,8 +2204,9 @@ func _rank_source_label(source: String) -> String: return GateEntryStrings.text("gate.rank.unregistered") -## Delete our four owned cloud rows (Hall, checkpoint, reservation, and -## profile) in ONE atomic `documents:commit` through the owned CloudAccount +## Delete our five owned cloud rows (Hall, checkpoint, attendance, +## reservation, and profile) in ONE atomic `documents:commit` through the +## owned CloudAccount ## helper on the real transport. The live owned-cloud rules require the ## profile and reservation halves to go together with the Hall row already ## gone; sequential DELETEs are denied there, so the single commit is the @@ -1765,9 +2230,13 @@ func _delete_owned_cloud_rows(uid: String, public_id: String, Callable(self, "supply_token"), Callable(_sender, "send")) transport.call("set_account_uid", uid) var helper: RefCounted = CLOUD_ACCOUNT_SCRIPT.new() + var cached_key: String = "" + if _vault != null: + cached_key = str((_vault.call("verified_name_for_account", + public_id) as Dictionary).get("key", "")) var reply: Dictionary = await helper.call("delete_account_data", - transport, uid, public_id) - _last_delete_commit = delete_commit_request(uid, public_id) + transport, uid, public_id, cached_key) + _last_delete_commit = delete_commit_request(uid, public_id, cached_key) _last_delete_commit["reply_status"] = str(reply.get("status", "")) _last_delete_commit["reply_code"] = str(reply.get("code", "")) if not _ticket_live(ticket): @@ -1784,12 +2253,13 @@ func _delete_owned_cloud_rows(uid: String, public_id: String, ## The exact deletion commit account removal sends, built by the same ## CloudAccount helper the run uses, without sending it: method, relative -## path, JSON body, and the four owned document names in commit order. +## path, JSON body, and the owned document names in commit order (seven +## when the account claimed a name, five for unnamed accounts). ## The director replays this shape against the real Firebase emulator. -static func delete_commit_request(uid: String, - public_id: String) -> Dictionary: +static func delete_commit_request(uid: String, public_id: String, + name_key: String = "") -> Dictionary: var body: Dictionary = CLOUD_ACCOUNT_SCRIPT.deletion_commit_body( - uid, public_id) + uid, public_id, name_key) var names: Array = [] for write in (body.get("writes", []) as Array): names.append(str((write as Dictionary).get("delete", ""))) @@ -1815,6 +2285,17 @@ func _remove_account_slot(public_id: String) -> void: if FileAccess.file_exists(path): DirAccess.remove_absolute( ProjectSettings.globalize_path(path)) + # The deleted scope's pending receipt goes with its slot; every other + # scope's receipt stays. Best effort like the file removals: on a + # failed save the entry lingers, owned by a scope that no longer + # activates, so it defers everywhere instead of resurrecting. + Vault.clear_continue_txn_for_owner(public_id) + # The deleted scope's verified handle goes with it too, so a local + # nickname can never outlive its account's deletion. + Vault.clear_verified_name_for_owner(public_id) + # The deleted scope's attendance watermark and cached deadline go + # with it; its granted coins stay in the device wallet ledger. + Vault.clear_attendance_for_owner(public_id) ## A 401/403 from the wire means the token died: refresh once and let the @@ -1836,3 +2317,5 @@ func _on_scene_changed() -> void: _planned_account = "" _handoff_ticks = Time.get_ticks_msec() _push_rank_to_hud() + _flush_attendance_receipt() + request_attendance.call_deferred() diff --git a/apps/game/scripts/ui/dialogue_scene.gd b/apps/game/scripts/ui/dialogue_scene.gd index 9c198cb..23c03ee 100644 --- a/apps/game/scripts/ui/dialogue_scene.gd +++ b/apps/game/scripts/ui/dialogue_scene.gd @@ -38,6 +38,7 @@ var _open: bool = false func _ready() -> void: + add_to_group("moonlit_dialogue") visible = false process_mode = Node.PROCESS_MODE_ALWAYS set_process(false) diff --git a/apps/game/scripts/ui/gate_hall_panel.gd b/apps/game/scripts/ui/gate_hall_panel.gd index 8f8a02b..420eee8 100644 --- a/apps/game/scripts/ui/gate_hall_panel.gd +++ b/apps/game/scripts/ui/gate_hall_panel.gd @@ -67,7 +67,9 @@ func _ready() -> void: ## Render `rows` exactly as supplied. `meta` may hold `cached` and `offline` ## flags for the header badges. Row dictionaries read `rank` (int), ## `score` (int), `id` (String), and either `hero` (Hero) or `portrait` -## (Texture2D) with an optional `hero_name`. +## (Texture2D) with an optional `hero_name`. A verified `display` handle +## joins the headline; rows without one keep the honest rank-and-score +## headline with the full ID on its own line. func show_rows(rows: Array, meta: Dictionary = {}) -> void: for child in _rows_box.get_children(): _rows_box.remove_child(child) @@ -129,7 +131,11 @@ func _make_row(data: Dictionary, index: int) -> PanelContainer: var headline := GateEntryStyle.make_label( "", GateEntryStyle.FONT_BODY, GateEntryStyle.TEXT_MAIN, true) headline.name = &"Headline" - headline.text = "#%d · %d" % [rank, score] + var display: String = str(data.get("display", "")) + if display.is_empty(): + headline.text = "#%d · %d" % [rank, score] + else: + headline.text = "#%d · %d · %s" % [rank, score, display] middle.add_child(headline) var hero_name: String = str(data.get("hero_name", "")) var hero: Hero = data.get("hero") as Hero diff --git a/apps/game/scripts/ui/hud.gd b/apps/game/scripts/ui/hud.gd index 85583dd..cbc5c20 100644 --- a/apps/game/scripts/ui/hud.gd +++ b/apps/game/scripts/ui/hud.gd @@ -87,6 +87,10 @@ var _banner_visible_before_suppression: bool = false ## Freeze the banner only while store device capture reads a verified frame. ## Only an Arena on a debug APK that consumed an explicit request file can turn this on. var _debug_capture_banner_locked: bool = false +## One-time reminder offer chip under the banner. Built on demand so the +## combat HUD carries no extra node until the first receipt offers one. +var _offer_button: WorldButton = null +var _offer_callback: Callable = Callable() ## Secondary stat lines rest dim and pop to full only when their value changes. ## @@ -649,9 +653,27 @@ func set_cycle(value: int) -> void: ## Default 1.5s is tuned for in-combat notices. A first-learn sentence needs ## time to read; on device people said "the tutorial never shows" — it did, ## then vanished as soon as they started moving. +## Receipt plus a one-time reminder offer chip. Accept runs the given +## callback (the reminder controller's enable flow, which may request OS +## permission); ignoring it plays on: the chip fades with the banner. +func announce_offer(text: String, offer_text: String, + on_accept: Callable, hold: float = 4.0) -> void: + announce(text, Color(1.0, 0.86, 0.5, 1), hold) + _offer_callback = on_accept + _ensure_offer_button() + _offer_button.text = offer_text + _offer_button.visible = true + _offer_button.modulate.a = 0.0 + var pop: Tween = create_tween() + pop.tween_property(_offer_button, "modulate:a", 1.0, 0.18) + if _banner_tween != null and _banner_tween.is_valid(): + _banner_tween.chain().tween_callback(_hide_offer) + + func announce(text: String, tone: Color, hold: float = 1.5) -> void: if _debug_capture_banner_locked: return + _hide_offer() if _banner_tween != null and _banner_tween.is_valid(): _banner_tween.kill() _banner.text = text @@ -689,6 +711,46 @@ func announce(text: String, tone: Color, hold: float = 1.5) -> void: ## Hide the center banner briefly so it does not show through the pause title. ## Tree and Tween are paused too, so keep visibility instead of deleting, and ## resume from remaining time. +func _ensure_offer_button() -> void: + if _offer_button != null: + return + _offer_button = WorldButton.new() + _offer_button.kind = "steel" + _offer_button.visible = false + _offer_button.set_anchors_preset(Control.PRESET_CENTER) + _offer_button.anchor_left = 0.5 + _offer_button.anchor_top = 0.5 + _offer_button.anchor_right = 0.5 + _offer_button.anchor_bottom = 0.5 + _offer_button.offset_left = -110.0 + _offer_button.offset_top = 92.0 + _offer_button.offset_right = 110.0 + _offer_button.offset_bottom = 124.0 + _offer_button.grow_horizontal = Control.GROW_DIRECTION_BOTH + _offer_button.grow_vertical = Control.GROW_DIRECTION_BOTH + _offer_button.add_theme_font_override( + "font", _banner.get_theme_font("font")) + _offer_button.add_theme_font_size_override("font_size", 13) + _offer_button.add_theme_color_override( + "font_color", Color(0.92, 0.9, 0.98, 1)) + _offer_button.pressed.connect(_on_offer_pressed) + add_child(_offer_button) + + +func _on_offer_pressed() -> void: + var callback: Callable = _offer_callback + _offer_callback = Callable() + _hide_offer() + if callback.is_valid(): + callback.call() + + +func _hide_offer() -> void: + _offer_callback = Callable() + if _offer_button != null: + _offer_button.visible = false + + func set_banner_suppressed(value: bool) -> void: if _banner_suppressed == value: return diff --git a/apps/game/scripts/ui/ladder_panel.gd b/apps/game/scripts/ui/ladder_panel.gd index bdc1c3e..eff153f 100644 --- a/apps/game/scripts/ui/ladder_panel.gd +++ b/apps/game/scripts/ui/ladder_panel.gd @@ -25,6 +25,14 @@ const BOARD_FONT: Font = preload( ## Record waiting to be submitted. Empty means view-only. var _pending: Dictionary = {} +## Verified account handle for this submit, or "" for the classic typed +## name. A verified submit publishes the stored handle only: the entry +## shows it read-only and the widget text is never trusted. +var _verified_handle: String = "" +## Fields of the last legacy global upload this panel started, or empty +## when the last submit stayed local. Verified submits never upload: the +## owned Hall carries that account's record. +var _last_global_upload: Dictionary = {} var _mine: int = -1 ## Global board. Empty means draw local. var _global: Array = [] @@ -68,6 +76,9 @@ func ask(score: int, rank_letter: String, cycles: int, hero_id: String, _hero_name(hero_id), Ladder.current_version(), score] _pending_summary.visible = true _entry.visible = true + _verified_handle = "" + _last_global_upload = {} + _name.editable = true # Prefill the last name. **Make people type every time and they skip submit on run two.** _name.text = Ladder.last_name _name.select_all() @@ -75,11 +86,42 @@ func ask(score: int, rank_letter: String, cycles: int, hero_id: String, _show() +## Record under a verified account handle. The result screen calls this +## for a named production player instead of `ask`: the handle shows +## read-only, submit publishes the stored handle locally, and no legacy +## global upload runs — the owned Hall carries this account's record. +func ask_named(handle: String, score: int, rank_letter: String, + cycles: int, hero_id: String, run_id: String = "") -> void: + _accept_global = false + _global = [] + _pending = { + "score": score, + "rank": rank_letter, + "cycles": cycles, + "hero": hero_id, + "run_id": run_id, + } + _mine = -1 + _title.text = tr("LADDER_NEW") + _pending_summary.text = tr("LADDER_PENDING") % [ + _hero_name(hero_id), Ladder.current_version(), score] + _pending_summary.visible = true + _entry.visible = true + _verified_handle = handle + _last_global_upload = {} + _name.text = handle + _name.editable = false + _draw_board() + _show() + + ## Just look at ranks. Called from the title. func view() -> void: _accept_global = true _global = [] _pending = {} + _verified_handle = "" + _last_global_upload = {} _mine = -1 _title.text = tr("LADDER_TITLE") _pending_summary.visible = false @@ -113,7 +155,8 @@ func close() -> void: func _on_submit() -> bool: if _pending.is_empty(): return true - var player: String = _name.text + var player: String = _verified_handle \ + if not _verified_handle.is_empty() else _name.text var hero: String = str(_pending.get("hero", "")) var score: int = int(_pending.get("score", 0)) var letter: String = str(_pending.get("rank", "D")) @@ -130,11 +173,21 @@ func _on_submit() -> bool: _entry.visible = true _name.grab_focus() return false - # Upload globally too. Failure is a no-op — it already lives locally. - GlobalLadder.submit(Ladder.last_name, hero, score, letter, cycles, run_id) + if _verified_handle.is_empty(): + # Upload globally too. Failure is a no-op — it already lives locally. + _last_global_upload = { + "name": Ladder.last_name, + "hero": hero, + "score": score, + "rank": letter, + "cycles": cycles, + } + GlobalLadder.submit( + Ladder.last_name, hero, score, letter, cycles, run_id) _accept_global = false _global = [] _pending = {} + _verified_handle = "" _pending_summary.visible = false _entry.visible = false _title.text = tr("LADDER_TITLE") diff --git a/apps/game/scripts/ui/production_entry.gd b/apps/game/scripts/ui/production_entry.gd index 218bfcc..1c1c1e3 100644 --- a/apps/game/scripts/ui/production_entry.gd +++ b/apps/game/scripts/ui/production_entry.gd @@ -45,6 +45,7 @@ var _swapping: bool = false var _load_path: String = "" var _load_account: String = "" var _load_fresh: bool = false +var _load_is_lodge: bool = false var _fresh_confirmed: bool = false var _busy_provider: String = "" var _error_hold: String = "" @@ -369,9 +370,14 @@ func _begin_planned_entry(fresh: bool) -> void: if status != "ok": if str(plan.get("code", "")) == "needs_confirmation": _show_confirm() + return + if str(plan.get("code", "")) == "needs_lodge": + _begin_lodge_visit(host) + return return _fresh_confirmed = false _load_fresh = fresh + _load_is_lodge = false _load_path = str(plan.get("arena", "")) _load_account = str(plan.get("account_id", "")) _sfx.play() @@ -379,6 +385,24 @@ func _begin_planned_entry(fresh: bool) -> void: "gate.loading.preparing")) +## Detour an unsettled account through the gate lodge. The lodge plan +## holds the same entry lock as an Arena plan, and the lodge's own +## departure choice supersedes the title button that led here. +func _begin_lodge_visit(host: Node) -> void: + var plan: Dictionary = host.plan_lodge_entry() + if str(plan.get("status", "")) != "ok": + _refresh_identity() + return + _fresh_confirmed = false + _load_fresh = false + _load_is_lodge = true + _load_path = str(plan.get("lodge", "")) + _load_account = str(plan.get("account_id", "")) + _sfx.play() + _entry.load_scene(_load_path, GateEntryStrings.text( + "gate.loading.preparing")) + + func _on_loading_finished(path: String, packed: PackedScene, token: int) -> void: if _swapping: @@ -394,6 +418,7 @@ func _on_loading_finished(path: String, packed: PackedScene, host.cancel_entry_plan() _load_path = "" _load_account = "" + _load_is_lodge = false _refresh_identity() return _swapping = true @@ -429,11 +454,16 @@ func _on_loading_retry(_path: String) -> void: var host: Node = _host() if host == null: return - var plan: Dictionary = host.plan_entry(_load_fresh, true) + var plan: Dictionary + if _load_is_lodge: + plan = host.plan_lodge_entry() + else: + plan = host.plan_entry(_load_fresh, true) if str(plan.get("status", "")) != "ok": host.cancel_entry_plan() _load_path = "" _load_account = "" + _load_is_lodge = false func _on_loading_return() -> void: @@ -443,6 +473,7 @@ func _on_loading_return() -> void: host.cancel_entry_plan() _load_path = "" _load_account = "" + _load_is_lodge = false _refresh_identity() diff --git a/apps/game/scripts/ui/result_panel.gd b/apps/game/scripts/ui/result_panel.gd index bdd6847..3ad9a71 100644 --- a/apps/game/scripts/ui/result_panel.gd +++ b/apps/game/scripts/ui/result_panel.gd @@ -88,8 +88,7 @@ func _ready() -> void: func show_result( won: bool, score: Score, is_best: bool, can_record: bool = false, - places_restored: int = -1, gate_retry_available: bool = false, - hero_path: String = "" + places_restored: int = -1, hero_path: String = "" ) -> void: _accepting = false _reveal_finished = false @@ -119,10 +118,10 @@ func show_result( # A run they returned from and settled is already over. Showing paid # continue then treats a win like a death, so it is only on a loss. _continue.visible = not won - # After a lost run with a checkpoint, Retry returns through the saved - # gate instead of starting over. The button says which one it is. - if gate_retry_available and not won: - _retry.text = tr("RESULT_GATE_RETRY") + # A lost run is over: Retry starts over from the beginning, never from + # the defeated segment. A settled run keeps the plain fresh-run retry. + if not won: + _retry.text = tr("RESULT_START_OVER") else: _retry.text = tr("RESULT_RETRY") _title.text = tr("RESULT_WIN") \ @@ -363,6 +362,11 @@ func _set_actions_enabled(value: bool) -> void: ## button is go-buy. **Do not put a price on the button** — the store sets ## display price per region, so they read it on the shop screen. func _refresh_continue() -> void: + # A receipt this scope already paid for retries its own revive — even + # broke — instead of sending the player to buy another coin. + if not Vault.scoped_continue_txn().is_empty(): + _continue.text = tr("RESULT_CONTINUE_RETRY") + return var coins: int = Vault.continue_coins if coins > 0: _continue.text = tr("RESULT_CONTINUE_COINS") % coins @@ -374,7 +378,8 @@ func _request_continue() -> void: if not _accepting or _overlay_blocked or _continue.disabled: return _accepting = false - if Vault.continue_coins > 0: + if Vault.continue_coins > 0 \ + or not Vault.scoped_continue_txn().is_empty(): continue_requested.emit() return continue_purchase_requested.emit() diff --git a/apps/game/scripts/ui/settings_panel.gd b/apps/game/scripts/ui/settings_panel.gd index 51a3b57..26840ac 100644 --- a/apps/game/scripts/ui/settings_panel.gd +++ b/apps/game/scripts/ui/settings_panel.gd @@ -26,12 +26,24 @@ const UNPICKED: Color = Color(0.5, 0.52, 0.62, 1) const ANALYTICS_FONT_MAX: int = 13 const ANALYTICS_FONT_MIN: int = 9 const ANALYTICS_AVAILABLE_WIDTH: float = 196.0 +## Reminder label fit: the column is 128px (-172 to -44) with an 8px +## allocated gap to the button at -36. English is 164px at 15 and +## 119px at 11, so the fit lands on 11 there and stays 15 in the +## other four locales; 11 matches the locale buttons, never tiny. +const REMINDER_LABEL_FONT_MAX: int = 15 +const REMINDER_LABEL_FONT_MIN: int = 11 +const REMINDER_LABEL_AVAILABLE_WIDTH: float = 128.0 @onready var _title: Label = $Title @onready var _music_bar: HBoxContainer = $MusicBar @onready var _sfx_bar: HBoxContainer = $SfxBar @onready var _analytics_label: Label = $AnalyticsLabel @onready var _analytics: Button = $Analytics +## Reminder row, built lazily on first open: two fewer dormant nodes +## in the combat tree (late-game node budget), exactly like the beads +## below. Null until open(); every touch guards or ensures first. +var _reminder_label: Label = null +var _reminder: Button = null @onready var _analytics_consent: AnalyticsConsentPanel = $Dim/AnalyticsConsent @onready var _external_links: HBoxContainer = $ExternalLinks @onready var _privacy: Button = $ExternalLinks/Privacy @@ -109,6 +121,7 @@ func _redraw() -> void: button.modulate = PICKED if Settings.locale == locale_code else UNPICKED _fill(_music_cells, Settings.music) _fill(_sfx_cells, Settings.sfx) + _redraw_reminder() var analytics_available: bool = Analytics.configured() _analytics_label.visible = analytics_available _analytics.visible = analytics_available @@ -126,11 +139,115 @@ func _redraw() -> void: analytics_key = "SETTINGS_ANALYTICS_OFF" _analytics.text = tr(analytics_key) _analytics.tooltip_text = "%s — %s" % [tr("SETTINGS_ANALYTICS"), _analytics.text] - _fit_analytics_button() + _fit_status_button(_analytics) if _link_failed: _link_status.text = tr("SETTINGS_LINK_FAILED") +## Reminder row, always rendered once opened: it never depends on +## analytics availability or consent. Statuses: on, off, OS-denied +## (tap disables; tap again from off opens OS settings), unknown +## (tap mirrors denied; an undetermined OS state never shows as on), +## or unsupported on builds without a native side (tapping does +## nothing). No-op before first open, when the row does not exist. +func _redraw_reminder() -> void: + if _reminder == null or _reminder_label == null: + return + _reminder_label.text = tr("SETTINGS_REMINDERS") + _fit_reminder_label() + var key: String = "SETTINGS_REMINDERS_OFF" + _reminder.disabled = false + if Settings.reminder_os_state == "unsupported": + key = "SETTINGS_REMINDERS_UNSUPPORTED" + _reminder.disabled = true + elif Settings.reminders_enabled \ + and Settings.reminder_os_state == "denied": + key = "SETTINGS_REMINDERS_DENIED" + elif Settings.reminders_enabled \ + and Settings.reminder_os_state == "unknown": + key = "SETTINGS_REMINDERS_UNKNOWN" + elif Settings.reminders_enabled: + key = "SETTINGS_REMINDERS_ON" + _reminder.text = tr(key) + _reminder.tooltip_text = "%s — %s" % [ + tr("SETTINGS_REMINDERS"), _reminder.text] + _fit_status_button(_reminder) + + +## Build the reminder row on first open, replicating the scene's old +## layout, fonts, and colors exactly (same anchors, offsets, theme, +## and steel button kind). Appended after the scene children, which is +## invisible: the rows never overlap. +func _ensure_reminder_row() -> void: + if _reminder != null and _reminder_label != null: + return + var font: Font = _analytics_label.get_theme_font("font") + _reminder_label = Label.new() + _reminder_label.name = &"ReminderLabel" + _reminder_label.set_anchors_preset(Control.PRESET_CENTER) + _reminder_label.anchor_left = 0.5 + _reminder_label.anchor_top = 0.5 + _reminder_label.anchor_right = 0.5 + _reminder_label.anchor_bottom = 0.5 + _reminder_label.offset_left = -172.0 + _reminder_label.offset_top = 49.0 + _reminder_label.offset_right = -44.0 + _reminder_label.offset_bottom = 83.0 + _reminder_label.grow_horizontal = Control.GROW_DIRECTION_BOTH + _reminder_label.grow_vertical = Control.GROW_DIRECTION_BOTH + _reminder_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _reminder_label.add_theme_color_override("font_color", + Color(0.85, 0.88, 0.96, 1)) + _reminder_label.add_theme_font_override("font", font) + _reminder_label.add_theme_font_size_override("font_size", 15) + _reminder_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _reminder_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(_reminder_label) + _reminder = WorldButton.new() + _reminder.name = &"Reminder" + _reminder.kind = "steel" + _reminder.set_anchors_preset(Control.PRESET_CENTER) + _reminder.anchor_left = 0.5 + _reminder.anchor_top = 0.5 + _reminder.anchor_right = 0.5 + _reminder.anchor_bottom = 0.5 + _reminder.offset_left = -36.0 + _reminder.offset_top = 49.0 + _reminder.offset_right = 172.0 + _reminder.offset_bottom = 83.0 + _reminder.grow_horizontal = Control.GROW_DIRECTION_BOTH + _reminder.grow_vertical = Control.GROW_DIRECTION_BOTH + _reminder.add_theme_color_override("font_color", + Color(0.92, 0.9, 0.98, 1)) + _reminder.add_theme_font_override("font", font) + _reminder.add_theme_font_size_override("font_size", 13) + _reminder.pressed.connect(_on_reminder_pressed) + add_child(_reminder) + + +func _on_reminder_pressed() -> void: + if _reminder == null or _reminder.disabled: + return + var controller: Node = _reminder_controller() + if controller == null: + # No host in this tree (bare panel exercise): persist the + # intent flip directly; the next launch converges it. + Settings.set_reminders_enabled( + not Settings.reminders_enabled) + return + controller.call("user_toggle") + + +func _reminder_controller() -> Node: + var host: Node = get_node_or_null("/root/ProductionHost") + if host == null or not host.has_method("reminder_controller"): + return null + var controller: Variant = host.call("reminder_controller") + if controller is Node and is_instance_valid(controller): + return controller + return null + + func _open_analytics_consent() -> void: # Even a code-emitted pressed signal from an invisible Button must not open # the prompt. Only a build with real shipping config may ask for consent. @@ -184,14 +301,27 @@ func _restore_analytics_focus() -> void: _analytics.grab_focus() -func _fit_analytics_button() -> void: - var font: Font = _analytics.get_theme_font("font") +func _fit_status_button(target: Button) -> void: + var font: Font = target.get_theme_font("font") var font_size: int = ANALYTICS_FONT_MAX while font_size > ANALYTICS_FONT_MIN and font.get_string_size( - _analytics.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x \ + target.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x \ > ANALYTICS_AVAILABLE_WIDTH: font_size -= 1 - _analytics.add_theme_font_size_override("font_size", font_size) + target.add_theme_font_size_override("font_size", font_size) + + +## Shrink the reminder label until its text fits the 128px column. +## Runs on every redraw, so locale changes and repeated opens +## recompute from the max instead of sticking at a stale size. +func _fit_reminder_label() -> void: + var font: Font = _reminder_label.get_theme_font("font") + var font_size: int = REMINDER_LABEL_FONT_MAX + while font_size > REMINDER_LABEL_FONT_MIN and font.get_string_size( + _reminder_label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, + float(font_size)).x > REMINDER_LABEL_AVAILABLE_WIDTH: + font_size -= 1 + _reminder_label.add_theme_font_size_override("font_size", font_size) func _fill(cells: Array[ColorRect], step: int) -> void: @@ -207,8 +337,13 @@ func open() -> void: _beads_hung = true _flank_bead(0.0, -20.0, -8.0) _flank_bead(1.0, 8.0, 20.0) + _ensure_reminder_row() visible = true _configure_external_links() + var controller: Node = _reminder_controller() + if controller != null \ + and controller.has_method("refresh_permission"): + controller.call("refresh_permission") _redraw() $Close.grab_focus() diff --git a/apps/game/scripts/ui/title_menu.gd b/apps/game/scripts/ui/title_menu.gd index 437bd3d..d183996 100644 --- a/apps/game/scripts/ui/title_menu.gd +++ b/apps/game/scripts/ui/title_menu.gd @@ -172,6 +172,10 @@ var _journey_buttons: HBoxContainer = null var _journey_confirm: VBoxContainer = null var _journey_confirm_label: Label = null var _journey_force_fresh: bool = false +## A paid revive whose seal cannot land yet. The panel names it and offers +## only a confirmed fresh start; a tap must confirm, never resume or +## silently abandon it. +var _journey_stuck: bool = false var _ready_draw_frame: int = CAPTURE_DRAW_FRAME_UNSET ## Store masters must not change with whichever blink brightness the host ## stops on. Turn this on only after a debug capture request actually arrives; @@ -1190,7 +1194,10 @@ func _journey_style_button(button: Button) -> void: ## Show Continue only for a checkpoint that really loads. A save the -## validator refuses leaves the plain tap-to-start title, never a promise. +## validator refuses — or a journey that already sealed its defeat — leaves +## the plain tap-to-start title, never a promise. A stranded paid continue +## settles first, so the paid revive greets the relaunch; one whose seal +## cannot land shows a stuck panel with only a confirmed fresh start. func _refresh_journey_ui() -> void: if _journey_panel == null: return @@ -1199,9 +1206,23 @@ func _refresh_journey_ui() -> void: # Continue panel would promise a direct entry it cannot give. _journey_panel.visible = false return + var recovery: String = Vault.recover_paid_continue() + _journey_stuck = recovery == "failed" var prior_error: String = Journey.last_error var data: Dictionary = Journey.read_checkpoint() - if data.is_empty(): + if data.is_empty() or Journey.is_ended(data): + if _journey_stuck: + var shown: Dictionary = data + if shown.is_empty(): + var seal_parser: JSON = JSON.new() + var seal_text: String = str( + Vault.scoped_continue_txn().get("seal", "")) + if seal_parser.parse(seal_text) == OK \ + and seal_parser.data is Dictionary: + shown = seal_parser.data + if not shown.is_empty(): + _show_stuck_revive_panel(shown) + return _journey_panel.visible = false return var info: Dictionary = Journey.summary(data) @@ -1220,6 +1241,30 @@ func _refresh_journey_ui() -> void: # Code-built buttons hold translated text, not keys: re-translate on # every refresh so a settings locale switch relabels them too. (_journey_buttons.get_child(0) as Button).text = tr("JOURNEY_CONTINUE") + (_journey_buttons.get_child(0) as Button).visible = true + (_journey_buttons.get_child(1) as Button).text = tr("JOURNEY_NEW") + var confirm_row: HBoxContainer = _journey_confirm.get_child(1) as HBoxContainer + (confirm_row.get_child(0) as Button).text = tr("JOURNEY_CONFIRM_ERASE") + (confirm_row.get_child(1) as Button).text = tr("JOURNEY_CONFIRM_KEEP") + _show_journey_main() + _journey_panel.visible = true + + +## A paid revive that cannot land yet: its write keeps failing, so the +## files still hold the defeat. Name the stuck run and offer only the +## confirmed fresh start — an explicit abandon — never a resume that could +## not open. No new copy: the save-failed line and the stock confirm say it. +func _show_stuck_revive_panel(shown: Dictionary) -> void: + var cycle: int = maxi(int(shown.get("cycle", 1)), 1) + _journey_title.text = tr("JOURNEY_SAVE_FAILED") + var hero: Hero = load(str(shown.get("hero_path", ""))) as Hero + var hero_name: String = tr(hero.display_name) if hero != null else "" + var step: Dictionary = Expedition.TERRAINS[clampi( + int(shown.get("zone_index", 0)), 0, Expedition.TERRAINS.size() - 1)] + _journey_detail.text = "%s · %s · %s" % [ + tr("HUD_WAVE") % cycle, tr(str(step["name"])), hero_name] + _journey_error.visible = false + (_journey_buttons.get_child(0) as Button).visible = false (_journey_buttons.get_child(1) as Button).text = tr("JOURNEY_NEW") var confirm_row: HBoxContainer = _journey_confirm.get_child(1) as HBoxContainer (confirm_row.get_child(0) as Button).text = tr("JOURNEY_CONFIRM_ERASE") @@ -1392,6 +1437,12 @@ func request_start(opening_event: InputEvent = null) -> void: return external_start_requested.emit() return + # A stuck paid revive confirms before anything boots: resuming could + # not open, and a silent fresh start would abandon the paid coin. + if _journey_stuck and not _journey_force_fresh: + _show_journey_confirm() + get_viewport().set_input_as_handled() + return # A shown Continue panel means stepping back through the saved gate; a # tap with no save, or the confirmed fresh start, begins a new journey. # Both arm journey writes for the arena that follows. diff --git a/apps/game/tests/support/ladder_spy.gd b/apps/game/tests/support/ladder_spy.gd index 2be7aaf..050cef6 100644 --- a/apps/game/tests/support/ladder_spy.gd +++ b/apps/game/tests/support/ladder_spy.gd @@ -3,11 +3,13 @@ extends Control ## Ladder panel stand-in for inspecting the Arena result route. var ask_count: int = 0 +var ask_named_count: int = 0 var last_score: int = -1 var last_rank: String = "" var last_cycles: int = -1 var last_hero: String = "" var last_run_id: String = "" +var last_handle: String = "" var asked_run_ids: Array[String] = [] @@ -20,3 +22,15 @@ func ask(score: int, rank_letter: String, cycles: int, hero_id: String, last_hero = hero_id last_run_id = run_id asked_run_ids.append(run_id) + + +func ask_named(handle: String, score: int, rank_letter: String, + cycles: int, hero_id: String, run_id: String = "") -> void: + ask_named_count += 1 + last_handle = handle + last_score = score + last_rank = rank_letter + last_cycles = cycles + last_hero = hero_id + last_run_id = run_id + asked_run_ids.append(run_id) diff --git a/apps/game/tests/test_account_recovery.gd b/apps/game/tests/test_account_recovery.gd new file mode 100644 index 0000000..e8d67de --- /dev/null +++ b/apps/game/tests/test_account_recovery.gd @@ -0,0 +1,551 @@ +extends Node + +## Paid revive recovery stays in its owning account: a journaled debit +## materializes only in the slot that paid it, a pending receipt survives +## other accounts' play, and the loss-result button retries its own paid +## revive instead of sending a broke player to the shop. +## +## The director reproduced the transfer with the real journal API: seal +## under A, journal the debit, switch to empty B, and recovery wrote A's +## alive checkpoint into B's slot while clearing A's receipt. These pin the +## owner binding with real account slots, reloads, switches, late cloud +## hooks, B-side fresh play and B-side paid continue, the switch back to A, +## and the real result-button signal route. Uses throwaway account slots, +## so no human save is touched. The Vault autoload is real but isolated. + +const ARENA_SCENE: PackedScene = preload("res://scenes/gameplay/arena.tscn") +const RESULT_SCENE: PackedScene = preload("res://scenes/ui/result_panel.tscn") +const OWNER_A: String = "MB-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const OWNER_B: String = "MB-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +const OWNER_C: String = "MB-cccccccccccccccccccccccccccccccc" +const SHARP_MOON: String = "res://resources/relics/sharp_moon.tres" + +var _failed: int = 0 +var _checked: int = 0 +var _stable_texts: Array[String] = [] + + +func _ready() -> void: + if not _is_isolated(): + get_tree().quit(2) + return + process_mode = Node.PROCESS_MODE_ALWAYS + get_tree().paused = false + _run.call_deferred() + + +func _run() -> void: + _fresh_slots() + _test_cross_account_recovery_defers() + _fresh_slots() + _test_b_plays_while_a_pends() + _fresh_slots() + _test_same_attempt_validates_exact_seal() + _fresh_slots() + await _test_result_button_retries_paid_revive() + _fresh_slots() + _test_owner_primitives() + + _restore_slots() + Journey.disarm() + if _failed > 0: + printerr("account recovery tests failed — ", _failed, "/", _checked, + " case(s)") + get_tree().quit(1) + return + print("account recovery tests passed — ", _checked, " case(s)") + get_tree().quit(0) + + +func _is_isolated() -> bool: + var expected_root: String = OS.get_environment( + "MOONLIT" + "_VAULT" + "_TEST" + "_ROOT").simplify_path() + var user_root: String = ProjectSettings.globalize_path("user://").simplify_path() + var safe: bool = not expected_root.is_empty() and user_root.begins_with(expected_root + "/") + if not safe: + printerr("account recovery tests aborted: user:// path is not isolated — ", user_root) + return safe + + +func _wipe_slot(owner: String) -> void: + for candidate in [Journey.account_main_path(owner), + Journey.account_backup_path(owner), + Journey.account_main_path(owner) + ".tmp", + Journey.account_backup_path(owner) + ".tmp", + Journey.account_revision_path(owner), + Journey.account_rejected_path(owner, "local"), + Journey.account_rejected_path(owner, "remote")]: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + + +func _fresh_slots() -> void: + _wipe_slot(OWNER_A) + _wipe_slot(OWNER_B) + Journey.use_account("") + Journey.clear_stable_hooks() + _stable_texts.clear() + Vault.continue_coins = 2 + Vault.continue_txn = {} + if Vault.get("continue_txn_parked") is Dictionary: + Vault.set("continue_txn_parked", {}) + Vault.save_vault() + Journey.disarm() + Journey.last_error = "" + Journey.install_fault = Journey.InstallFault.NONE + RunEntry.from_title = false + get_tree().paused = false + + +func _restore_slots() -> void: + _wipe_slot(OWNER_A) + _wipe_slot(OWNER_B) + Journey.use_account("") + Journey.clear_stable_hooks() + _stable_texts.clear() + Vault.continue_txn = {} + if Vault.get("continue_txn_parked") is Dictionary: + Vault.set("continue_txn_parked", {}) + Journey.disarm() + Journey.last_error = "" + Journey.install_fault = Journey.InstallFault.NONE + RunEntry.from_title = false + get_tree().paused = false + + +func _valid_checkpoint(journey_id: String, checkpoint_id: int) -> Dictionary: + return { + "schema_version": 1, + "journey_id": journey_id, + "checkpoint_id": checkpoint_id, + "cycle": 3, + "zone_index": 1, + "route": [0, 4, -1], + "run_seed": 7, + "hero_path": Vault.HEROES[0], + "relic_stacks": {SHARP_MOON: 2}, + "level": 4, + "to_next": 12, + "level_progress": 3, + "missile_power": 2, + "missile_progress": 1, + "first_core_collected": true, + "kills": 20, + "kill_score": 300, + "survived": 120.0, + "lit_count": 1, + "overcharge_successes": 0, + "guardian_meetings": {"0": 1}, + "places_seen": ["forest"], + "shards_awarded": 5, + "settled_score": 2400, + "gate_direction": [1.0, 0.0], + "opening_played": true, + "saved_at_unix": 1700000000, + } + + +func _on_stable(info: Dictionary) -> void: + _stable_texts.append(str(info.get("text", ""))) + + +## Checkpoint id of the current scope's journaled entry, or -1 when the +## scoping API is absent (pre-fix) or nothing pends. Keeps the suite +## runnable — failing, not aborting — before the fix lands. +func _scoped_cid() -> int: + var scoped: Variant = Vault.call("scoped_continue_txn") + if not scoped is Dictionary: + return -1 + return int((scoped as Dictionary).get("checkpoint_id", 0)) + + +func _stable_ids() -> Array: + var ids: Array = [] + for text in _stable_texts: + var parser: JSON = JSON.new() + if parser.parse(text) != OK: + ids.append(-1) + continue + var data: Dictionary = parser.data as Dictionary + ids.append(int(data.get("checkpoint_id", -1))) + return ids + + +## The seal/journal round trip one paid revive pays for: alive seals, the +## terminal marker, then the journaled debit — all through the real API. +func _seal_and_journal(owner: String, journey: String, seal_id: int, + revive_id: int) -> void: + Journey.use_account(owner) + var alive: Dictionary = _valid_checkpoint(journey, seal_id) + _expect_equal(Journey.write_checkpoint(alive), OK, + "the alive seal writes") + var sealed: Dictionary = alive.duplicate(true) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "the defeat seals") + var revive: Dictionary = _valid_checkpoint(journey, revive_id) + _expect_true(Vault.begin_continue_txn(journey, revive_id, + JSON.stringify(revive)), "the debit journals") + + +# --- cross-account deferral --------------------------------------------------- +## The director's probe, registered: a debit journaled under A, reloaded, +## then met in B's empty slot defers without touching B's files, without +## notifying, and without consuming A's receipt. A late cloud hook stays +## silent until A itself recovers exactly one paid revive. +func _test_cross_account_recovery_defers() -> void: + Vault.continue_coins = 2 + Vault.save_vault() + _seal_and_journal(OWNER_A, "probe-a-1", 5, 6) + _expect_equal(Vault.continue_coins, 1, + "defer: the journal charges exactly one coin") + Vault.load_vault() + _expect_equal(Vault.continue_coins, 1, + "defer: the reload keeps the acknowledged charge") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "defer: the reload keeps the journaled seal") + var seal_text: String = str( + (Vault.continue_txn as Dictionary).get("seal", "")) + + Journey.use_account(OWNER_B) + _expect_false(FileAccess.file_exists( + Journey.account_main_path(OWNER_B)), + "defer: B's slot starts empty") + Journey.subscribe_stable_checkpoint(_on_stable) + _expect_equal(Vault.recover_paid_continue(), "deferred", + "defer: recovery on another account defers") + _expect_false(FileAccess.file_exists( + Journey.account_main_path(OWNER_B)), + "defer: nothing imports into B's main") + _expect_false(FileAccess.file_exists( + Journey.account_backup_path(OWNER_B)), + "defer: nothing imports into B's backup") + _expect_true(_stable_texts.is_empty(), + "defer: the late hook hears nothing for B") + _expect_equal(Vault.continue_coins, 1, + "defer: the balance is untouched") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "defer: A's receipt survives") + _expect_equal(str((Vault.continue_txn as Dictionary).get( + "owner", Journey.active_account)), OWNER_A, + "defer: the receipt still names A") + _expect_false(Journey.has_valid_checkpoint(), + "defer: B's empty slot offers no resume") + + # The settle itself refuses foreign receipts, not just the Vault + # wrapper: a direct call with A's entry defers without writing. + var foreign: Dictionary = {"owner": OWNER_A, + "journey_id": "probe-a-1", "checkpoint_id": 6, "seal": seal_text} + _expect_equal(Journey.settle_continue_txn(foreign), "deferred", + "defer: a direct settle of a foreign receipt defers") + _expect_false(FileAccess.file_exists( + Journey.account_main_path(OWNER_B)), + "defer: the direct settle writes nothing into B") + + # Back in A's scope the same receipt settles exactly once. + Journey.use_account(OWNER_A) + _expect_equal(Vault.recover_paid_continue(), "recovered", + "defer: A materializes its paid seal") + _expect_equal(_stable_ids(), [6], + "defer: the late hook fires once, for A's seal") + _expect_equal(Vault.continue_coins, 1, + "defer: the materialize charges no second coin") + _expect_true(Journey.has_valid_checkpoint(), + "defer: A's paid seal is resumable") + _expect_equal(int(Journey.read_checkpoint().get("checkpoint_id", 0)), 6, + "defer: A's revive is the correct original seal") + Journey.unsubscribe_stable_checkpoint(_on_stable) + + +# --- b plays while a pends ---------------------------------------------------- +## A's pending receipt survives B starting, playing, quitting and +## continuing its own run: B's fresh start never clears it, B's files stay +## byte-identical across B-side recovery, and both attempts settle +## independently with exactly one debit each. +func _test_b_plays_while_a_pends() -> void: + Vault.continue_coins = 2 + Vault.save_vault() + _seal_and_journal(OWNER_A, "stays-a-1", 5, 6) + _expect_equal(Vault.continue_coins, 1, + "B-side: A's debit lands first") + + # B starts fresh: a new journey in B's scope must not abandon A's + # receipt, which only a confirmed fresh action in A's scope clears. + Journey.use_account(OWNER_B) + Vault.begin_journey() + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "B-side: B's fresh start keeps A's receipt") + _expect_equal(Vault.continue_coins, 1, + "B-side: B's fresh start charges nothing") + + # B plays and quits. Recovery in B defers to A and leaves B's own + # living files byte-identical. + var first: Dictionary = _valid_checkpoint("runs-b-1", 1) + first["cycle"] = 2 + _expect_equal(Journey.write_checkpoint(first), OK, + "B-side: B's run seals") + var second: Dictionary = _valid_checkpoint("runs-b-1", 2) + second["cycle"] = 2 + _expect_equal(Journey.write_checkpoint(second), OK, + "B-side: B's run seals again") + Vault.load_vault() + var main_before: String = FileAccess.get_file_as_string( + Journey.account_main_path(OWNER_B)) + var backup_before: String = FileAccess.get_file_as_string( + Journey.account_backup_path(OWNER_B)) + _expect_equal(Vault.recover_paid_continue(), "deferred", + "B-side: B's recovery defers to A") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_main_path(OWNER_B)), main_before, + "B-side: B's main is byte-identical") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_backup_path(OWNER_B)), backup_before, + "B-side: B's backup is byte-identical") + _expect_true(Journey.has_valid_checkpoint(), + "B-side: B's living run stays resumable") + + # B continues its own run: its own debit, its own seal, A's receipt + # still parked beside it. + var sealed_b: Dictionary = second.duplicate(true) + sealed_b["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed_b), OK, + "B-side: B's defeat seals") + var revived_b: Dictionary = _valid_checkpoint("runs-b-1", 3) + revived_b["cycle"] = 2 + _expect_true(Vault.begin_continue_txn("runs-b-1", 3, + JSON.stringify(revived_b)), "B-side: B's debit journals") + _expect_equal(Vault.continue_coins, 0, + "B-side: B's debit is its own coin") + _expect_equal(Vault.recover_paid_continue(), "recovered", + "B-side: B materializes its paid seal") + _expect_true(Journey.has_valid_checkpoint(), + "B-side: B's paid seal is resumable") + _expect_equal(int(Journey.read_checkpoint().get("checkpoint_id", 0)), 3, + "B-side: B's revive is B's own seal") + _expect_equal(Vault.continue_coins, 0, + "B-side: B's materialize charges nothing more") + + # Back in A: the original receipt settles as the one paid revive. + Journey.use_account(OWNER_A) + _expect_equal(Vault.recover_paid_continue(), "recovered", + "B-side: A materializes afterwards") + _expect_equal(int(Journey.read_checkpoint().get("checkpoint_id", 0)), 6, + "B-side: A's revive is the correct original seal") + _expect_equal(Vault.continue_coins, 0, + "B-side: two debits total, one per account, none doubled") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "B-side: no journal survives both settlements") + Vault.load_vault() + _expect_equal(Vault.continue_coins, 0, + "B-side: balance and receipts agree after reload") + + +# --- exact same-attempt validation --------------------------------------------- +## Retrying the same numbers with different seal bytes is not the +## acknowledged transaction: it refuses without charging or overwriting. +## The exact retry still rides free, and the same numbers in another scope +## are a new debit — never a free ride on A's coin. +func _test_same_attempt_validates_exact_seal() -> void: + Vault.continue_coins = 2 + Vault.save_vault() + Journey.use_account(OWNER_A) + var revive: Dictionary = _valid_checkpoint("exact-a-1", 6) + _expect_true(Vault.begin_continue_txn("exact-a-1", 6, + JSON.stringify(revive)), "exact: the debit journals") + _expect_equal(Vault.continue_coins, 1, + "exact: the journal charges exactly one coin") + var rival: Dictionary = _valid_checkpoint("exact-a-1", 6) + rival["kill_score"] = 99999 + _expect_false(Vault.begin_continue_txn("exact-a-1", 6, + JSON.stringify(rival)), + "exact: rival bytes for the same numbers refuse") + _expect_equal(Vault.continue_coins, 1, + "exact: the refusal charges nothing") + _expect_equal(str((Vault.continue_txn as Dictionary).get( + "seal", "")), JSON.stringify(revive), + "exact: the refusal overwrites nothing") + _expect_true(Vault.begin_continue_txn("exact-a-1", 6, + JSON.stringify(revive)), + "exact: the byte-identical retry rides free") + _expect_equal(Vault.continue_coins, 1, + "exact: the free retry charges nothing") + + # The same numbers under B are B's own attempt, not A's receipt. + Journey.use_account(OWNER_B) + _expect_true(Vault.begin_continue_txn("exact-a-1", 6, + JSON.stringify(revive)), + "exact: B's same-numbered attempt journals separately") + _expect_equal(Vault.continue_coins, 0, + "exact: B's attempt spends B's coin, not A's receipt") + + +# --- result button retries its paid revive --------------------------------------- +## The real loss-result button: one coin, a real defeat, a failed alive +## install. The debit spent the last coin into the journal; the reopened +## panel must offer a save retry — not the shop — and the retry must +## revive without charging again. A true zero balance with no receipt +## still routes to the shop. +func _test_result_button_retries_paid_revive() -> void: + Journey.use_account(OWNER_A) + Vault.continue_coins = 1 + Vault.save_vault() + Journey.begin_fresh() + RunEntry.mark_from_title() + var arena: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(arena) + await get_tree().process_frame + await get_tree().process_frame + arena.set("_cycle", 2) + arena.set("_zone_index", 1) + _expect_true(bool(arena.call("_journey_checkpoint", true)), + "button: the gate seals before the death") + arena.call("_finish", false) + await get_tree().process_frame + _expect_true(bool(arena.get("_over")), "button: the run is over") + + Journey.install_fault = Journey.InstallFault.FAIL_ALL + _expect_false(bool(arena.call("continue_run")), + "button: the install failure refuses the revive") + _expect_equal(Vault.continue_coins, 0, + "button: the last coin sits in the journal") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "button: the paid revive pends") + var panel: Control = arena.get_node("Ui/Result") as Control + panel.reopen_after_failed_continue() + await get_tree().process_frame + var retry_button: Button = panel.get_node("Actions/Continue") as Button + _expect_equal(retry_button.text, tr("RESULT_CONTINUE_RETRY"), + "button: the broke panel offers a save retry") + var routes: Dictionary = {"continue": 0, "purchase": 0} + panel.continue_requested.connect( + func() -> void: routes["continue"] += 1) + panel.continue_purchase_requested.connect( + func() -> void: routes["purchase"] += 1) + # The shop route would swap the test scene away; unhook the swap and + # keep the emission itself as the assertion, like the probe. + panel.continue_purchase_requested.disconnect( + Callable(arena, "_on_continue_purchase_requested")) + retry_button.pressed.emit() + await get_tree().process_frame + _expect_equal(int(routes["continue"]), 1, + "button: the tap retries the paid revive") + _expect_equal(int(routes["purchase"]), 0, + "button: the tap never routes to the shop") + _expect_true(bool(arena.get("_over")), + "button: the blocked retry stays defeated") + + Journey.install_fault = Journey.InstallFault.NONE + retry_button.pressed.emit() + await get_tree().process_frame + _expect_equal(int(routes["continue"]), 2, + "button: the released tap retries again") + _expect_false(bool(arena.get("_over")), + "button: the released retry revives") + _expect_equal(Vault.continue_coins, 0, + "button: the recovery never charges again") + _expect_true(Journey.has_valid_checkpoint(), + "button: the revived run is resumable") + arena.queue_free() + await get_tree().process_frame + + # Control: a true zero balance with no receipt still goes to the shop. + _fresh_slots() + Journey.use_account(OWNER_A) + Vault.continue_coins = 0 + var bare: Control = RESULT_SCENE.instantiate() as Control + add_child(bare) + await get_tree().process_frame + var bare_routes: Dictionary = {"continue": 0, "purchase": 0} + bare.continue_requested.connect( + func() -> void: bare_routes["continue"] += 1) + bare.continue_purchase_requested.connect( + func() -> void: bare_routes["purchase"] += 1) + bare.reopen_after_failed_continue() + await get_tree().process_frame + var buy_button: Button = bare.get_node("Actions/Continue") as Button + _expect_equal(buy_button.text, tr("RESULT_CONTINUE_BUY"), + "control: the receiptless panel still offers the shop") + buy_button.pressed.emit() + await get_tree().process_frame + _expect_equal(int(bare_routes["continue"]), 0, + "control: no receipt retries nothing") + _expect_equal(int(bare_routes["purchase"]), 1, + "control: the broke tap routes to the shop") + bare.queue_free() + await get_tree().process_frame + + +# --- owner primitives ---------------------------------------------------------------- +## The Vault-level moves the account flows build on: scoping reads to the +## active slot, rekeying an entry when its slot moves, and clearing only +## the deleted account's entry. +func _test_owner_primitives() -> void: + Vault.continue_coins = 2 + Vault.save_vault() + _seal_and_journal(OWNER_A, "prim-a-1", 5, 6) + Journey.use_account(OWNER_B) + _seal_and_journal(OWNER_B, "prim-b-1", 2, 3) + _expect_equal(Vault.continue_coins, 0, + "primitives: both debits land") + _expect_equal(_scoped_cid(), 3, + "primitives: the scoped read sees B's entry") + Journey.use_account(OWNER_A) + _expect_equal(_scoped_cid(), 6, + "primitives: the scoped read sees A's entry") + + # Rekey follows a slot move: onto an empty target it carries the + # entry; onto an occupied one it refuses and keeps both. + _expect_true(bool(Vault.call("rekey_continue_txn_owner", + OWNER_A, OWNER_C)), "primitives: A rekeys onto empty C") + _expect_equal(_scoped_cid(), 0, + "primitives: A keeps nothing after the move") + Journey.use_account(OWNER_C) + _expect_equal(_scoped_cid(), 6, + "primitives: the moved entry settles under C") + Vault.load_vault() + Journey.use_account(OWNER_C) + _expect_equal(_scoped_cid(), 6, + "primitives: the rekey survives reload") + _expect_false(bool(Vault.call("rekey_continue_txn_owner", + OWNER_C, OWNER_B)), + "primitives: rekey onto occupied B refuses") + Journey.use_account(OWNER_B) + _expect_equal(_scoped_cid(), 3, + "primitives: the refusal keeps B's entry") + Journey.use_account(OWNER_C) + _expect_equal(_scoped_cid(), 6, + "primitives: the refusal keeps C's entry") + + _expect_true(bool(Vault.call("clear_continue_txn_for_owner", + OWNER_C)), "primitives: deleting C clears C's entry") + _expect_equal(_scoped_cid(), 0, + "primitives: C's entry is gone") + Journey.use_account(OWNER_B) + _expect_equal(_scoped_cid(), 3, + "primitives: the deletion spares B's entry") + Vault.load_vault() + Journey.use_account(OWNER_C) + _expect_equal(_scoped_cid(), 0, + "primitives: the deletion survives reload") + Journey.use_account(OWNER_B) + _expect_equal(_scoped_cid(), 3, + "primitives: B's entry survives the reload too") + + +# --- expectations --------------------------------------------------------------- +func _expect_true(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + printerr("FAIL: ", label) + + +func _expect_false(value: bool, label: String) -> void: + _expect_true(not value, label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual != expected: + _failed += 1 + printerr("FAIL: ", label, " — got ", actual, ", want ", expected) diff --git a/apps/game/tests/test_account_recovery.gd.uid b/apps/game/tests/test_account_recovery.gd.uid new file mode 100644 index 0000000..7f6b717 --- /dev/null +++ b/apps/game/tests/test_account_recovery.gd.uid @@ -0,0 +1 @@ +uid://cfxxc6swh3b7l diff --git a/apps/game/tests/test_account_recovery.tscn b/apps/game/tests/test_account_recovery.tscn new file mode 100644 index 0000000..0f410d8 --- /dev/null +++ b/apps/game/tests/test_account_recovery.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/test_account_recovery.gd" id="1"] + +[node name="AccountRecoveryTest" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tests/test_attendance_reminders.gd b/apps/game/tests/test_attendance_reminders.gd new file mode 100644 index 0000000..f2e4ad3 --- /dev/null +++ b/apps/game/tests/test_attendance_reminders.gd @@ -0,0 +1,1550 @@ +extends Node + +## Attendance-reminder controller tests: intent, permission, schedule. +## +## The controller, Settings persistence, the settings row, and the HUD +## offer are real; the production host and the native bridge are fakes +## speaking the same contracts. Native delivery itself runs on devices +## the director drives; these tests prove the game side asks for exactly +## the right deliveries and never invents one. + +const REMINDERS_SCRIPT: Script = preload( + "res://scripts/gameplay/attendance_reminders.gd") +const PANEL_SCENE: PackedScene = preload( + "res://scenes/ui/settings_panel.tscn") +const HUD_SCENE: PackedScene = preload("res://scenes/ui/hud.tscn") +const LOCALES: Array[String] = ["ko", "en", "ja", "zh_CN", "zh_TW"] +const FAKE_ID: String = "MB-remindertest00000000000000001" +const OTHER_ID: String = "MB-remindertest00000000000000002" + +var _failed: int = 0 +var _checked: int = 0 + + +class ReminderFakeHost extends Node: + signal production_attendance(snapshot: Dictionary) + signal production_changed(state: Dictionary) + + var view: Dictionary = {"state": "unregistered", "source": "none", + "public_id": "", "receipt": "", "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + + func attendance_view() -> Dictionary: + return view.duplicate() + + func publish() -> void: + production_attendance.emit(attendance_view()) + + func live(public_id: String, eligible_utc: String, + remaining: int) -> void: + view = {"state": "cooldown", "source": "live", + "public_id": public_id, "receipt": "r", + "last_claim_utc": "2026-10-07T00:00:00Z", + "next_eligible_utc": eligible_utc, + "remaining_seconds": remaining} + + +class ReminderFakeBridge extends Node: + signal request_completed(outcome: Dictionary) + + var permission: String = "unknown" + var permission_behavior: String = "pending" + var status_behavior: String = "sync" + var status_receipt: Dictionary = {} + var schedule_receipt: Dictionary = {} + var permission_ids: Array = [] + var status_ids: Array = [] + var schedule_log: Array = [] + var cancels: int = 0 + var opens: int = 0 + var _counter: int = 0 + + func reminder_status() -> Dictionary: + if not status_receipt.is_empty(): + return status_receipt.duplicate() + if status_behavior == "pending": + _counter += 1 + var request_id: String = "stat-%d" % _counter + status_ids.append(request_id) + return {"status": "pending", "request_id": request_id} + return {"status": "ok", "permission": permission, + "scheduled": not schedule_log.is_empty()} + + func answer_status(outcome: Dictionary) -> void: + var answered: Dictionary = outcome.duplicate() + answered["request_id"] = str(status_ids.back()) + if str(answered.get("status", "")) == "ok": + permission = str(answered.get("permission", permission)) + request_completed.emit(answered) + + func reminder_request_permission() -> Dictionary: + if permission_behavior == "sync-granted": + return {"status": "ok", "permission": "granted"} + if permission_behavior == "sync-denied": + return {"status": "ok", "permission": "denied"} + if permission_behavior == "sync-unsupported": + return {"status": "unsupported", + "code": "native_bridge_unavailable"} + _counter += 1 + var request_id: String = "perm-%d" % _counter + permission_ids.append(request_id) + return {"status": "pending", "request_id": request_id} + + func answer_permission(granted: bool) -> void: + var request_id: String = str(permission_ids.back()) + permission = "granted" if granted else "denied" + request_completed.emit({"status": "ok", + "permission": permission, "request_id": request_id}) + + func answer_stale() -> void: + request_completed.emit({"status": "ok", + "permission": "granted", "request_id": "perm-stale"}) + + func reminder_schedule(args: Dictionary) -> Dictionary: + schedule_log.append(args.duplicate()) + if not schedule_receipt.is_empty(): + return schedule_receipt.duplicate() + return {"status": "ok", + "eligible_millis": args.get("eligible_utc_millis", 0), + "horizon_end_unix": -1.0} + + func reminder_cancel() -> Dictionary: + cancels += 1 + schedule_log.clear() + return {"status": "ok"} + + func reminder_open_settings() -> Dictionary: + opens += 1 + return {"status": "ok"} + + +var _host: ReminderFakeHost +var _bridge: ReminderFakeBridge +var _controller: Node +var _original_settings: Dictionary = {} +var _original_file_bytes: PackedByteArray = PackedByteArray() +var _original_file_present: bool = false +var _original_sentinel_bytes: PackedByteArray = PackedByteArray() +var _original_sentinel_present: bool = false +var _host_owned_reminders: Node = null +var _host_owned_host: Node = null +var _host_owned_bridge: Node = null + + +func _ready() -> void: + _snapshot_settings() + _host = ReminderFakeHost.new() + _bridge = ReminderFakeBridge.new() + _controller = REMINDERS_SCRIPT.new() + add_child(_host) + add_child(_bridge) + add_child(_controller) + _controller.configure(_host, _bridge) + _mute_host_reminders() + await get_tree().process_frame + _clean_reminder_state() + _test_defaults_idle() + _test_enable_grant_schedules() + _test_reschedule_skips_when_unchanged() + _test_locale_change_reschedules() + _test_new_reward_reschedules() + _test_eligible_now_schedules_same_anchor() + _test_short_window_schedules_first_delay() + _test_bad_deadline_schedules_nothing() + _test_unknown_source_keeps_existing() + _test_cached_deadline_schedules() + _test_live_denial_blocks_schedule() + _test_toggle_from_denied_opens_settings() + _test_disable_cancels_and_seals() + _test_save_failure_stays_off() + _test_stale_permission_dropped() + _test_account_switch_retires() + _test_sign_out_retires() + _test_unsupported_bridge_honest() + _test_receipt_offer_once() + _test_permission_denial_not_enabled() + _test_native_schedule_denial_not_recorded() + _test_native_status_unsupported_clears() + _test_transient_native_error_keeps_schedule() + _test_revocation_surfaces_on_same_deadline() + _test_async_status_converges() + _test_async_denial_schedules_nothing() + _test_pending_status_still_offers() + _test_async_timeout_keeps_cache() + _test_pending_status_then_off_ignores_late_grant() + _test_pending_status_then_source_none_schedules_nothing() + _test_reenable_after_late_grant_schedules_once() + _test_horizon_window_matrix() + _test_refill_window_reschedules_same_anchor() + _test_expired_cache_schedules_once() + _test_duplicate_receipt_keeps_known_end() + await _test_panel_without_analytics() + await _test_panel_builds_reminder_row_on_open() + await _test_unknown_row_never_shows_on() + await _test_panel_fits_locales() + await _test_reminder_label_recomputes() + await _test_hud_offer() + _restore_host_reminders() + _restore_settings() + if _failed > 0: + printerr("reminders test failed — ", _failed, "/", + _checked, " case(s)") + get_tree().quit(1) + return + print("reminders: ", _checked, " checks, 0 failed") + get_tree().quit(0) + + +## The ProductionHost autoload owns a second reminder controller in +## this scene, and both reconcile the same shared Settings record: an +## emit under test lets the host's controller retire the planted +## record through the real bridge before the test controller's own +## verdict is asserted. Production runs exactly one controller, so the +## suite unhosts the spare (keeping its bridge for the panel taps) +## and restores it at the end. +func _mute_host_reminders() -> void: + var host: Node = get_node_or_null("/root/ProductionHost") + if host == null or not host.has_method("reminder_controller"): + return + var owned: Variant = host.call("reminder_controller") + if not (owned is Node and is_instance_valid(owned)): + return + _host_owned_reminders = owned + _host_owned_host = owned.get("_host") + _host_owned_bridge = owned.get("_bridge") + owned.call("configure", null, _host_owned_bridge) + + +func _restore_host_reminders() -> void: + if _host_owned_reminders == null \ + or not is_instance_valid(_host_owned_reminders): + return + _host_owned_reminders.call("configure", _host_owned_host, + _host_owned_bridge) + _host_owned_reminders = null + _host_owned_host = null + _host_owned_bridge = null + + +func _snapshot_settings() -> void: + _original_settings = { + "locale": Settings.locale, + "enabled": Settings.reminders_enabled, + "offered": Settings.reminder_receipt_offered, + "os": Settings.reminder_os_state, + "account": Settings.reminder_sched_account, + "eligible": Settings.reminder_sched_eligible_utc, + "sched_locale": Settings.reminder_sched_locale, + } + _original_file_present = FileAccess.file_exists( + Settings.SAVE_PATH) + if _original_file_present: + _original_file_bytes = FileAccess.get_file_as_bytes( + Settings.SAVE_PATH) + _original_sentinel_present = FileAccess.file_exists( + Settings.REMINDER_DISABLED_PATH) + if _original_sentinel_present: + _original_sentinel_bytes = FileAccess.get_file_as_bytes( + Settings.REMINDER_DISABLED_PATH) + + +func _restore_settings() -> void: + _remove_temp_blocker() + Settings.locale = str(_original_settings["locale"]) + Settings.reminders_enabled = bool(_original_settings["enabled"]) + Settings.reminder_receipt_offered = bool( + _original_settings["offered"]) + Settings.reminder_os_state = str(_original_settings["os"]) + Settings.reminder_sched_account = str( + _original_settings["account"]) + Settings.reminder_sched_eligible_utc = str( + _original_settings["eligible"]) + Settings.reminder_sched_locale = str( + _original_settings["sched_locale"]) + Analytics._test_mode = false + if _original_file_present: + var file: FileAccess = FileAccess.open( + Settings.SAVE_PATH, FileAccess.WRITE) + if file != null: + file.store_buffer(_original_file_bytes) + else: + var absolute: String = ProjectSettings.globalize_path( + Settings.SAVE_PATH) + if FileAccess.file_exists(Settings.SAVE_PATH): + DirAccess.remove_absolute(absolute) + if _original_sentinel_present: + var mark: FileAccess = FileAccess.open( + Settings.REMINDER_DISABLED_PATH, FileAccess.WRITE) + if mark != null: + mark.store_buffer(_original_sentinel_bytes) + else: + var mark_path: String = ProjectSettings.globalize_path( + Settings.REMINDER_DISABLED_PATH) + if FileAccess.file_exists(Settings.REMINDER_DISABLED_PATH): + DirAccess.remove_absolute(mark_path) + elif DirAccess.dir_exists_absolute(mark_path): + DirAccess.remove_absolute(mark_path) + + +func _clean_reminder_state() -> void: + Settings.reminders_enabled = false + Settings.reminder_receipt_offered = false + Settings.reminder_os_state = "unknown" + Settings.reminder_sched_account = "" + Settings.reminder_sched_eligible_utc = "" + Settings.reminder_sched_locale = "" + Settings.reminder_sched_horizon_end = 0.0 + Settings.reminder_sched_base_slot = -1 + Settings.save_settings() + var mark_path: String = ProjectSettings.globalize_path( + Settings.REMINDER_DISABLED_PATH) + if FileAccess.file_exists(Settings.REMINDER_DISABLED_PATH): + DirAccess.remove_absolute(mark_path) + _host.view = {"state": "unregistered", "source": "none", + "public_id": "", "receipt": "", "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + _bridge.permission = "unknown" + _bridge.permission_behavior = "pending" + _bridge.status_behavior = "sync" + _bridge.status_receipt = {} + _bridge.schedule_receipt = {} + _bridge.permission_ids.clear() + _bridge.status_ids.clear() + _bridge.schedule_log.clear() + _bridge.cancels = 0 + _bridge.opens = 0 + _controller.configure(_host, _bridge) + + +func _future_utc(seconds_out: int) -> String: + var unix: int = int(Time.get_unix_time_from_system()) \ + + seconds_out + return Time.get_datetime_string_from_unix_time(unix, true) + "Z" + + +func _past_utc(seconds_ago: int) -> String: + var unix: int = int(Time.get_unix_time_from_system()) \ + - seconds_ago + return Time.get_datetime_string_from_unix_time(unix, true) + "Z" + + +## Last fire of a 48-slot window on its anchor: base plus 47 repeats. +func _window_end(anchor_unix: int, base: int) -> float: + return float(anchor_unix + (base + 47) * 43200) + + +func _test_defaults_idle() -> void: + _expect_false(Settings.reminders_enabled, + "reminders default off") + _expect_equal(Settings.reminder_os_state, "unknown", + "os state defaults unknown") + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 0, + "nothing schedules while off") + _expect_equal(_bridge.cancels, 0, "nothing cancels while off") + + +func _test_enable_grant_schedules() -> void: + _clean_reminder_state() + var eligible: String = _future_utc(10800) + _host.live(FAKE_ID, eligible, 10800) + _controller.user_enable() + _expect_true(Settings.reminders_enabled, + "enable persists the intent") + _expect_equal(_bridge.permission_ids.size(), 1, + "enable asks the OS once") + _expect_equal(_bridge.schedule_log.size(), 0, + "nothing schedules before the grant") + _bridge.answer_permission(true) + _expect_equal(_bridge.schedule_log.size(), 1, + "grant schedules one delivery") + var args: Dictionary = _bridge.schedule_log[0] + var unix: int = int( + Time.get_unix_time_from_datetime_string( + eligible.left(eligible.length() - 1))) + _expect_equal(int(args["eligible_utc_millis"]), unix * 1000, + "first fire targets the server deadline") + _expect_equal(str(args["account"]), FAKE_ID, + "schedule names the owning account") + _expect_equal(str(args["locale"]), Settings.locale, + "schedule names the text locale") + _expect_false(str(args["title"]).is_empty(), + "schedule carries a title") + _expect_false(str(args["body"]).is_empty(), + "schedule carries a body") + _expect_false(args.has("repeat_seconds"), + "no short repeat leaks into the native call") + _expect_equal(Settings.reminder_sched_account, FAKE_ID, + "scheduled account recorded") + _expect_equal(Settings.reminder_sched_eligible_utc, eligible, + "scheduled deadline recorded") + _expect_equal(Settings.reminder_os_state, "granted", + "os state cached granted") + + +func _test_reschedule_skips_when_unchanged() -> void: + _controller.refresh() + _host.publish() + _expect_equal(_bridge.schedule_log.size(), 1, + "identical refresh never reschedules") + + +func _test_locale_change_reschedules() -> void: + # The suite default follows the device (en here), so a hardcoded + # "en" change is a no-op: the old pass secretly relied on the + # host controller's interference clearing the record first. Pick + # a genuinely different locale and restore the original after. + var before: String = Settings.locale + var other: String = "ko" if before != "ko" else "en" + Settings.locale = other + Settings.save_settings() + Settings.changed.emit() + _expect_equal(_bridge.schedule_log.size(), 2, + "locale change reschedules the text") + _expect_equal(str(_bridge.schedule_log[1]["locale"]), other, + "reschedule names the new locale") + _expect_equal(Settings.reminder_sched_locale, other, + "recorded locale follows") + Settings.locale = before + Settings.save_settings() + + +func _test_new_reward_reschedules() -> void: + var eligible: String = _future_utc(21600) + _host.live(FAKE_ID, eligible, 21600) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 3, + "new reward reschedules") + _expect_equal(Settings.reminder_sched_eligible_utc, eligible, + "recorded deadline follows the reward") + + +func _test_eligible_now_schedules_same_anchor() -> void: + # An expired known anchor still schedules: the foreground claim + # may never come (an offline return that leaves again), so the + # native side plans future-only slots from the same deadline + # instead of starving on an exhausted horizon. + var eligible: String = _future_utc(10800) + _host.live(FAKE_ID, eligible, 0) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 4, + "expired anchor schedules once") + var args: Dictionary = _bridge.schedule_log[3] + var unix: int = int( + Time.get_unix_time_from_datetime_string( + eligible.left(eligible.length() - 1))) + _expect_equal(int(args["eligible_utc_millis"]), unix * 1000, + "expired anchor keeps its deadline") + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 4, + "expired anchor skips while held") + + +func _test_short_window_schedules_first_delay() -> void: + # The director's thirty-second probe: newly enabled, permission + # granted, eligibility half a minute out. This must send one + # schedule with the confirmed future deadline; the native side + # carries it as the first delay under twelve-hour repeats. + var eligible: String = _future_utc(30) + _host.live(FAKE_ID, eligible, 30) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 5, + "thirty-second window schedules one delivery") + var args: Dictionary = _bridge.schedule_log[4] + var unix: int = int( + Time.get_unix_time_from_datetime_string( + eligible.left(eligible.length() - 1))) + _expect_equal(int(args["eligible_utc_millis"]), unix * 1000, + "short window targets the confirmed deadline") + _expect_false(args.has("repeat_seconds"), + "short window never becomes a short repeat") + + +func _test_bad_deadline_schedules_nothing() -> void: + _host.live(FAKE_ID, "not-a-time", 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 5, + "unparseable deadline arms nothing") + + +func _test_unknown_source_keeps_existing() -> void: + _host.view = {"state": "unregistered", "source": "none", + "public_id": FAKE_ID, "receipt": "", "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 5, + "unknown source schedules nothing new") + _expect_equal(_bridge.cancels, 0, + "unknown source cancels nothing known") + + +func _test_cached_deadline_schedules() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + var eligible: String = _future_utc(7200) + _host.view = {"state": "cooldown", "source": "cache", + "public_id": FAKE_ID, "receipt": "", "last_claim_utc": "", + "next_eligible_utc": eligible, "remaining_seconds": 7200} + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "cached deadline schedules a reminder") + _expect_equal(Settings.reminder_sched_account, FAKE_ID, + "cached schedule records its owner") + + +func _test_live_denial_blocks_schedule() -> void: + _clean_reminder_state() + _bridge.permission = "denied" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 0, + "live denial schedules nothing") + _expect_equal(Settings.reminder_os_state, "denied", + "live denial cached for display") + + +func _test_toggle_from_denied_opens_settings() -> void: + _clean_reminder_state() + Settings.set_reminder_os_state("denied") + _controller.user_toggle() + _expect_equal(_bridge.opens, 1, + "off plus denied opens OS settings") + _expect_false(Settings.reminders_enabled, + "opening settings enables nothing") + _expect_equal(_bridge.schedule_log.size(), 0, + "opening settings schedules nothing") + + +func _test_disable_cancels_and_seals() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "disable test schedules first") + _controller.user_disable() + _expect_equal(_bridge.cancels, 1, + "disable cancels the native delivery") + _expect_true(Settings.reminder_sched_account.is_empty(), + "disable forgets the schedule") + _expect_false(Settings.reminders_enabled, + "disable persists off") + _expect_true(FileAccess.file_exists( + Settings.REMINDER_DISABLED_PATH), + "disable writes its own mark") + # A stale enabled file under the mark still boots off. + var cfg: ConfigFile = ConfigFile.new() + cfg.set_value(Settings.SECTION, "reminders_enabled", true) + cfg.save(Settings.SAVE_PATH) + Settings.reminders_enabled = true + Settings.load_settings() + _expect_false(Settings.reminders_enabled, + "the mark outranks a stale enabled file") + + +func _remove_temp_blocker() -> void: + var absolute: String = ProjectSettings.globalize_path( + Settings.TEMP_SAVE_PATH) + if DirAccess.dir_exists_absolute(absolute): + DirAccess.remove_absolute(absolute) + elif FileAccess.file_exists(Settings.TEMP_SAVE_PATH): + DirAccess.remove_absolute(absolute) + + +func _test_save_failure_stays_off() -> void: + _clean_reminder_state() + _remove_temp_blocker() + var absolute: String = ProjectSettings.globalize_path( + Settings.TEMP_SAVE_PATH) + _expect_equal(DirAccess.make_dir_absolute(absolute), OK, + "temp path blocked with a directory") + _expect_false(Settings.set_reminders_enabled(true), + "enable reports its failed save") + _expect_false(Settings.reminders_enabled, + "failed enable stays off") + _expect_false(Settings.record_reminder_schedule( + FAKE_ID, _future_utc(10800), "ko"), + "failed schedule record reports false") + _remove_temp_blocker() + _expect_true(Settings.set_reminders_enabled(true), + "retry after the fault persists") + _expect_true(Settings.reminders_enabled, + "retry after the fault sticks") + + +func _test_stale_permission_dropped() -> void: + _clean_reminder_state() + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.user_enable() + _expect_equal(_bridge.permission_ids.size(), 1, + "stale test asks once") + _controller.configure(_host, _bridge) + _bridge.answer_permission(true) + _expect_equal(_bridge.schedule_log.size(), 0, + "answer past a rewire schedules nothing") + _bridge.answer_stale() + _expect_equal(_bridge.schedule_log.size(), 0, + "answer for an unknown id schedules nothing") + _controller.user_enable() + _bridge.answer_permission(true) + _expect_equal(_bridge.schedule_log.size(), 1, + "fresh enable after the stale answer schedules") + + +func _test_account_switch_retires() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "switch test schedules first") + var eligible: String = _future_utc(9000) + _host.live(OTHER_ID, eligible, 9000) + _controller.refresh() + _expect_equal(_bridge.cancels, 1, + "switch cancels the old owner's delivery") + _expect_equal(_bridge.schedule_log.size(), 1, + "switch schedules the new owner") + _expect_equal(str(_bridge.schedule_log[0]["account"]), + OTHER_ID, "new schedule names the new owner") + _expect_equal(Settings.reminder_sched_account, OTHER_ID, + "recorded owner follows the switch") + + +func _test_sign_out_retires() -> void: + _host.view = {"state": "unregistered", "source": "none", + "public_id": "", "receipt": "", "last_claim_utc": "", + "next_eligible_utc": "", "remaining_seconds": -1} + _controller.refresh() + _expect_equal(_bridge.cancels, 2, + "sign-out cancels the delivery") + _expect_true(Settings.reminder_sched_account.is_empty(), + "sign-out forgets the schedule") + + +func _test_unsupported_bridge_honest() -> void: + _clean_reminder_state() + _controller.configure(_host, null) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.user_enable() + _expect_false(Settings.reminders_enabled, + "unsupported enable persists nothing") + _expect_equal(Settings.reminder_os_state, "unsupported", + "unsupported cached for display") + _controller.refresh() + _expect_equal(Settings.reminder_os_state, "unsupported", + "refresh stays honestly unsupported") + _expect_equal(_controller.offer_for_receipt(), "", + "no offer where no bridge exists") + _controller.configure(_host, _bridge) + + +func _test_receipt_offer_once() -> void: + _clean_reminder_state() + _expect_equal(_controller.offer_for_receipt(), + tr("REMINDER_OFFER"), "first receipt offers once") + _expect_true(Settings.reminder_receipt_offered, + "offer marks itself shown") + _expect_equal(_controller.offer_for_receipt(), "", + "second receipt offers nothing") + Settings.set_reminders_enabled(true) + Settings.reminder_receipt_offered = false + _expect_equal(_controller.offer_for_receipt(), "", + "enabled accounts are never offered") + + +func _test_permission_denial_not_enabled() -> void: + _clean_reminder_state() + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.user_enable() + _bridge.answer_permission(false) + _expect_equal(Settings.reminder_os_state, "denied", + "denial cached for display") + _expect_equal(_bridge.schedule_log.size(), 0, + "denial schedules nothing") + _controller.user_toggle() + _expect_false(Settings.reminders_enabled, + "tap from denied disables") + _expect_equal(_bridge.cancels, 1, + "tap from denied cancels") + + +func _test_native_schedule_denial_not_recorded() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + _bridge.schedule_receipt = {"status": "denied", + "permission": "denied", "code": "permission_denied"} + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_true(Settings.reminder_sched_account.is_empty(), + "refused schedule records nothing") + _expect_equal(Settings.reminder_os_state, "denied", + "refused schedule caches the denial") + + +func _test_native_status_unsupported_clears() -> void: + _clean_reminder_state() + Settings.set_reminders_enabled(true) + Settings.record_reminder_schedule( + FAKE_ID, _future_utc(7200), "ko") + _bridge.status_receipt = {"status": "unsupported", + "code": "native_bridge_unavailable"} + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(Settings.reminder_os_state, "unsupported", + "lost native cached for display") + _expect_true(Settings.reminder_sched_account.is_empty(), + "lost native clears the stale record") + _expect_equal(_bridge.cancels, 1, + "lost native cancels defensively") + + +func _test_transient_native_error_keeps_schedule() -> void: + _clean_reminder_state() + Settings.set_reminders_enabled(true) + var kept: String = _future_utc(7200) + Settings.record_reminder_schedule(FAKE_ID, kept, "ko") + # Direct display-cache set, no emit: the host autoload owns a + # second controller in this scene, and an emit between plant and + # assert would let it retire the shared record through the real + # bridge before the error path under test runs. + Settings.reminder_os_state = "granted" + _bridge.status_receipt = {"status": "error", + "code": "no_activity", "retryable": true} + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(Settings.reminder_sched_account, FAKE_ID, + "transient error keeps the owned record") + _expect_equal(Settings.reminder_sched_eligible_utc, kept, + "transient error keeps the owned deadline") + _expect_equal(Settings.reminder_os_state, "granted", + "transient error keeps the cached display state") + _expect_equal(_bridge.cancels, 0, + "transient error cancels nothing") + _expect_equal(_bridge.schedule_log.size(), 0, + "transient error schedules nothing") + _bridge.status_receipt = {} + _bridge.permission = "granted" + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "recovery after the outage converges") + + +func _test_revocation_surfaces_on_same_deadline() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + var eligible: String = _future_utc(10800) + _host.live(FAKE_ID, eligible, 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "revocation: the grant schedules once") + _expect_equal(Settings.reminder_os_state, "granted", + "revocation: the grant caches granted") + _bridge.permission = "denied" + _controller.refresh() + _expect_equal(Settings.reminder_os_state, "denied", + "revocation: the same deadline surfaces denied") + _expect_equal(Settings.reminder_sched_account, FAKE_ID, + "revocation: denial keeps the owned record") + _expect_equal(_bridge.cancels, 0, + "revocation: denial cancels nothing native") + _expect_equal(_bridge.schedule_log.size(), 1, + "revocation: denial schedules nothing new") + _bridge.permission = "granted" + _controller.refresh() + _expect_equal(Settings.reminder_os_state, "granted", + "revocation: a re-grant caches granted again") + _expect_equal(_bridge.schedule_log.size(), 1, + "revocation: the re-grant reschedules nothing held") + + +func _test_async_status_converges() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 0, + "async: nothing schedules before the verdict") + _expect_equal(Settings.reminder_os_state, "unknown", + "async: the cache stands while pending") + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(Settings.reminder_os_state, "granted", + "async: the granted outcome caches granted") + _expect_equal(_bridge.schedule_log.size(), 1, + "async: the granted outcome schedules once") + _controller.refresh() + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(_bridge.schedule_log.size(), 1, + "async: the held schedule never duplicates") + _bridge.answer_status({"status": "ok", "permission": "denied"}) + _expect_equal(Settings.reminder_os_state, "granted", + "async: a spent id settles nothing twice") + _expect_equal(_bridge.schedule_log.size(), 1, + "async: a spent id schedules nothing") + _bridge.request_completed.emit({"status": "ok", + "permission": "denied", "request_id": "stat-stale"}) + _expect_equal(Settings.reminder_os_state, "granted", + "async: a stale id settles nothing") + _controller._apply_status_outcome({"status": "ok", + "permission": "denied"}, 999) + _expect_equal(Settings.reminder_os_state, "granted", + "async: a foreign generation settles nothing") + + +func _test_async_denial_schedules_nothing() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _bridge.answer_status({"status": "ok", "permission": "denied"}) + _expect_equal(Settings.reminder_os_state, "denied", + "async denial: the outcome caches denied") + _expect_equal(_bridge.schedule_log.size(), 0, + "async denial: nothing schedules") + _expect_true(Settings.reminder_sched_account.is_empty(), + "async denial: nothing records") + _bridge.status_behavior = "sync" + _bridge.permission = "granted" + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "async denial: a later grant still schedules") + + +func _test_pending_status_still_offers() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + _host.live(FAKE_ID, _future_utc(10800), 10800) + var offer: String = _controller.offer_for_receipt() + _expect_equal(offer, tr("REMINDER_OFFER"), + "pending: the unknown verdict still offers once") + _expect_true(Settings.reminder_receipt_offered, + "pending: the offer marks itself shown") + _expect_equal(_bridge.status_ids.size(), 1, + "pending: the offer waits on the verdict") + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(Settings.reminder_os_state, "granted", + "pending: the verdict still converges") + + +func _test_async_timeout_keeps_cache() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + Settings.set_reminders_enabled(true) + Settings.reminder_os_state = "granted" + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _bridge.answer_status({"status": "error", "code": "request_timeout", + "retryable": true}) + _expect_equal(Settings.reminder_os_state, "granted", + "async timeout: the cache stands") + _expect_equal(_bridge.schedule_log.size(), 0, + "async timeout: nothing schedules") + _expect_equal(_bridge.cancels, 0, + "async timeout: nothing cancels") + + +func _test_horizon_window_matrix() -> void: + # Shared agreement vectors with the native planner test + # (scripts/lib/reminder-planner.test.mjs): whole-second stamps so + # both integer-millisecond sides agree bit-for-bit. + var anchor: int = 1700000000 + var interval: int = AttendanceReminders.REPEAT_SECONDS + var now: float = Time.get_unix_time_from_system() + _expect_true(AttendanceReminders._horizon_holds( + -1.0, -1, anchor, now, "iOS"), + "window: unbounded holds on iOS") + _expect_true(AttendanceReminders._horizon_holds( + -1.0, -1, anchor, now, "Android"), + "window: unbounded holds anywhere") + _expect_true(AttendanceReminders._horizon_holds( + 0.0, -1, anchor, now, "Android"), + "window: legacy holds repeating") + _expect_false(AttendanceReminders._horizon_holds( + 0.0, -1, anchor, now, "iOS"), + "window: legacy iOS replans once") + _expect_true(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, float(anchor), + "iOS"), "window: exact eligibility holds") + _expect_true(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, + float(anchor + 39 * interval), "iOS"), + "window: nine future slots hold") + _expect_true(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, + float(anchor + 40 * interval), "iOS"), + "window: eight future slots hold") + _expect_true(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, + float(anchor + 39 * interval + 21600), "iOS"), + "window: eight future slots hold mid-slot") + _expect_false(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, + float(anchor + 41 * interval), "iOS"), + "window: seven future slots refill") + _expect_false(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, + float(anchor + 40 * interval + 21600), "iOS"), + "window: seven future slots refill mid-slot") + _expect_false(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), 0, anchor, + float(anchor + 50 * interval), "iOS"), + "window: day twenty-five exhausts the old window") + _expect_true(AttendanceReminders._horizon_holds( + _window_end(anchor, 50), 50, anchor, + float(anchor + 50 * interval), "iOS"), + "window: day twenty-five holds its refilled window") + _expect_false(AttendanceReminders._horizon_holds( + _window_end(anchor, 40), 40, anchor, float(anchor), + "iOS"), "window: a base ahead of now refills") + _expect_true(AttendanceReminders._horizon_holds( + _window_end(anchor, 0), -1, anchor, + float(anchor + 40 * interval), "iOS"), + "window: a legacy end derives its base and holds") + _expect_false(AttendanceReminders._horizon_holds( + _window_end(anchor, 0) + 1.0, -1, anchor, + float(anchor + 40 * interval), "iOS"), + "window: an off-grid legacy end replans once") + _expect_false(AttendanceReminders._horizon_holds( + float(anchor + 10 * interval), -1, anchor, + float(anchor + 40 * interval), "iOS"), + "window: a short legacy end replans once") + + +func _test_refill_window_reschedules_same_anchor() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + var now: int = int(Time.get_unix_time_from_system()) + # Expired 21 days and an hour: first future is slot 43, one hour + # from either grid edge so the live clock cannot flake it. + var anchor_unix: int = now - 21 * 86400 - 3600 + var eligible: String = Time.get_datetime_string_from_unix_time( + anchor_unix, true) + "Z" + var first_end: float = _window_end(anchor_unix, 0) + _bridge.schedule_receipt = {"status": "ok", + "eligible_millis": anchor_unix * 1000, + "base_slot": 0, "scheduled_slots": 5, + "horizon_end_unix": first_end} + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, eligible, 0) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "refill: the expired anchor schedules") + _expect_equal(Settings.reminder_sched_horizon_end, first_end, + "refill: the receipt end records") + _expect_equal(Settings.reminder_sched_base_slot, 0, + "refill: the receipt base records") + var second_end: float = _window_end(anchor_unix, 43) + _bridge.schedule_receipt = {"status": "ok", + "eligible_millis": anchor_unix * 1000, + "base_slot": 43, "scheduled_slots": 48, + "horizon_end_unix": second_end} + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 2, + "refill: the consumed window re-asks the native side") + var first: Dictionary = _bridge.schedule_log[0] + var second: Dictionary = _bridge.schedule_log[1] + _expect_equal(int(second["eligible_utc_millis"]), + int(first["eligible_utc_millis"]), + "refill: the re-ask keeps the same anchor") + _expect_equal(Settings.reminder_sched_eligible_utc, eligible, + "refill: the recorded deadline never moves") + _expect_equal(Settings.reminder_sched_horizon_end, second_end, + "refill: the new end records") + _expect_equal(Settings.reminder_sched_base_slot, 43, + "refill: the new base records") + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 2, + "refill: the full window skips again") + + +func _test_pending_status_then_off_ignores_late_grant() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 0, + "late grant: nothing schedules before the verdict") + _controller.user_disable() + _expect_false(Settings.reminders_enabled, + "late grant: the tap turns intent off") + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(Settings.reminder_os_state, "granted", + "late grant: the OS cache still updates while off") + _expect_equal(_bridge.schedule_log.size(), 0, + "late grant: no delivery after off") + _expect_true(Settings.reminder_sched_account.is_empty(), + "late grant: the record stays cleared") + + +func _test_pending_status_then_source_none_schedules_nothing() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + Settings.set_reminders_enabled(true) + var eligible: String = _future_utc(10800) + _host.live(FAKE_ID, eligible, 10800) + _controller.refresh() + # The account scope drops out but the dict retains its stale + # deadline fields: a schedule must read the source, not them. + _host.view = {"state": "unregistered", "source": "none", + "public_id": FAKE_ID, "receipt": "r", + "last_claim_utc": "2026-10-07T00:00:00Z", + "next_eligible_utc": eligible, "remaining_seconds": 10800} + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(_bridge.schedule_log.size(), 0, + "source none: retained fields schedule nothing") + _expect_true(Settings.reminder_sched_account.is_empty(), + "source none: nothing records") + + +func _test_reenable_after_late_grant_schedules_once() -> void: + _clean_reminder_state() + _bridge.status_behavior = "pending" + Settings.set_reminders_enabled(true) + _host.live(FAKE_ID, _future_utc(10800), 10800) + _controller.refresh() + _controller.user_disable() + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(_bridge.schedule_log.size(), 0, + "re-enable: the late grant schedules nothing while off") + _controller.user_enable() + _expect_equal(_bridge.schedule_log.size(), 0, + "re-enable: nothing schedules before the re-grant") + _bridge.answer_permission(true) + _bridge.answer_status({"status": "ok", "permission": "granted"}) + _expect_equal(_bridge.schedule_log.size(), 1, + "re-enable: a genuine view schedules once") + _controller.refresh() + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "re-enable: held refreshes never duplicate") + + +func _test_expired_cache_schedules_once() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + var now: int = int(Time.get_unix_time_from_system()) + # Offline return 25 days out: the cached deadline is long past + # (first future slot 51), but the native side still plans 48 + # future slots from it instead of starving. + var anchor_unix: int = now - 25 * 86400 - 3600 + var eligible: String = Time.get_datetime_string_from_unix_time( + anchor_unix, true) + "Z" + var coins: int = Vault.continue_coins + _host.view = {"state": "cooldown", "source": "cache", + "public_id": FAKE_ID, "receipt": "r", + "last_claim_utc": "2026-10-07T00:00:00Z", + "next_eligible_utc": eligible, "remaining_seconds": 0} + _bridge.schedule_receipt = {"status": "ok", + "eligible_millis": anchor_unix * 1000, + "base_slot": 51, "scheduled_slots": 48, + "horizon_end_unix": _window_end(anchor_unix, 51)} + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "expired cache schedules once") + var args: Dictionary = _bridge.schedule_log[0] + _expect_equal(int(args["eligible_utc_millis"]), + anchor_unix * 1000, + "expired cache keeps its anchor") + _expect_equal(Vault.continue_coins, coins, + "expired cache grants no coins") + _controller.refresh() + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "expired cache skips while held") + _expect_equal(Vault.continue_coins, coins, + "held refreshes grant no coins") + _expect_equal(Settings.reminder_sched_base_slot, 51, + "expired cache records its base") + + +func _test_duplicate_receipt_keeps_known_end() -> void: + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + var now: int = int(Time.get_unix_time_from_system()) + var anchor_unix: int = now - 21 * 86400 - 3600 + var eligible: String = Time.get_datetime_string_from_unix_time( + anchor_unix, true) + "Z" + var end: float = _window_end(anchor_unix, 0) + # A consumed window answered by a metadata-less duplicate keeps + # its known metadata instead of recording an unknown zero. + _bridge.schedule_receipt = {"status": "ok", + "eligible_millis": anchor_unix * 1000, + "base_slot": 0, "scheduled_slots": 5, + "horizon_end_unix": end} + _host.live(FAKE_ID, eligible, 0) + _controller.refresh() + _bridge.schedule_receipt = {"status": "ok", "duplicate": true, + "eligible_millis": anchor_unix * 1000} + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 2, + "duplicate: the consumed window re-asks once") + _expect_equal(Settings.reminder_sched_horizon_end, end, + "duplicate: the known end survives a bare duplicate") + _expect_equal(Settings.reminder_sched_base_slot, 0, + "duplicate: the known base survives a bare duplicate") + # And a truthful duplicate on a held window never re-asks: four + # refreshes, one schedule, no self-sustaining loop. + _clean_reminder_state() + _bridge.permission = "granted" + Settings.set_reminders_enabled(true) + var fresh_unix: int = now + 7200 + var fresh: String = Time.get_datetime_string_from_unix_time( + fresh_unix, true) + "Z" + var fresh_end: float = _window_end(fresh_unix, 0) + _bridge.schedule_receipt = {"status": "ok", "duplicate": true, + "eligible_millis": fresh_unix * 1000, + "base_slot": 0, "scheduled_slots": 48, + "horizon_end_unix": fresh_end} + _host.live(FAKE_ID, fresh, 7200) + _controller.refresh() + _controller.refresh() + _controller.refresh() + _controller.refresh() + _expect_equal(_bridge.schedule_log.size(), 1, + "duplicate: a held window never re-asks") + _expect_equal(Settings.reminder_sched_horizon_end, fresh_end, + "duplicate: the held end records") + _expect_equal(Settings.reminder_sched_base_slot, 0, + "duplicate: the held base records") + + +func _test_panel_without_analytics() -> void: + _clean_reminder_state() + Analytics._test_configure(false, false) + var panel: Control = PANEL_SCENE.instantiate() as Control + add_child(panel) + panel.open() + await get_tree().process_frame + await get_tree().process_frame + var reminder: Button = panel.get_node("Reminder") as Button + var label: Label = panel.get_node("ReminderLabel") as Label + _expect_false( + (panel.get_node("Analytics") as Button).visible, + "analytics stays hidden while unconfigured") + _expect_true(label.visible, + "reminder label renders without analytics") + _expect_true(reminder.visible, + "reminder row renders without analytics") + # Opening refreshes the live permission: headless has no native + # side, so the row must land on an honest unsupported instead of a + # fake scheduled success. + _expect_equal(Settings.reminder_os_state, "unsupported", + "open without a native stays honestly unsupported") + _expect_equal(reminder.text, + tr("SETTINGS_REMINDERS_UNSUPPORTED"), + "row labels the unsupported state") + _expect_true(reminder.disabled, + "unsupported row takes no taps") + # Reset to off, then press through the real autoload controller: + # the enable attempt must converge back to unsupported. + Settings.reminders_enabled = false + Settings.reminder_os_state = "unknown" + Settings.changed.emit() + await get_tree().process_frame + _expect_false(reminder.disabled, + "off row stays tappable without analytics") + reminder.pressed.emit() + await get_tree().process_frame + _expect_equal(Settings.reminder_os_state, "unsupported", + "press without a native converges to unsupported") + panel.queue_free() + await get_tree().process_frame + Settings.set_reminders_enabled(false) + Analytics._test_mode = false + + +func _test_panel_builds_reminder_row_on_open() -> void: + _clean_reminder_state() + var panel: Control = PANEL_SCENE.instantiate() as Control + add_child(panel) + await get_tree().process_frame + _expect_true(panel.get_node_or_null("Reminder") == null, + "lazy: a closed panel carries no Reminder button") + _expect_true(panel.get_node_or_null("ReminderLabel") == null, + "lazy: a closed panel carries no Reminder label") + panel.open() + await get_tree().process_frame + var reminder: Button = panel.get_node("Reminder") as Button + var label: Label = panel.get_node("ReminderLabel") as Label + _expect_true(reminder is WorldButton, + "lazy: the opened row is a steel world button") + _expect_equal((reminder as WorldButton).kind, "steel", + "lazy: the opened row keeps its kind") + _expect_equal(reminder.offset_left, -36.0, + "lazy: the button keeps its scene seat") + _expect_equal(reminder.offset_top, 49.0, + "lazy: the button keeps its scene row") + _expect_equal(reminder.offset_right, 172.0, + "lazy: the button keeps its scene width") + _expect_equal(reminder.offset_bottom, 83.0, + "lazy: the button keeps its scene height") + _expect_equal(label.offset_left, -172.0, + "lazy: the label keeps its scene seat") + _expect_equal(label.offset_right, -44.0, + "lazy: the label keeps its scene width") + _expect_equal(reminder.get_theme_font_size("font_size"), 13, + "lazy: the button keeps its scene font size") + var lazy_fs: int = label.get_theme_font_size("font_size") + _expect_true(lazy_fs >= 11 and lazy_fs <= 15, + "lazy: the label fits within readable sizes (got %d)" % lazy_fs) + var lazy_font: Font = label.get_theme_font("font") + var lazy_width: float = lazy_font.get_string_size( + label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, + float(lazy_fs)).x + _expect_true(lazy_width <= 128.0 + 0.5, + "lazy: the label text fits its column (%s)" % lazy_width) + _expect_true(label.get_combined_minimum_size().x <= 128.0 + 0.5, + "lazy: the label minimum fits its column") + _expect_equal(label.text, tr("SETTINGS_REMINDERS"), + "lazy: the label names reminders") + var lazy_label_rect: Rect2 = label.get_global_rect() + var lazy_button_rect: Rect2 = reminder.get_global_rect() + _expect_true( + lazy_label_rect.end.x <= lazy_button_rect.position.x + 0.5, + "lazy: the label rect stays clear of the button (%s / %s)" % [ + lazy_label_rect, lazy_button_rect]) + panel.open() + await get_tree().process_frame + var copies: int = 0 + var label_copies: int = 0 + for child in panel.get_children(): + if (child as Node).name == &"Reminder": + copies += 1 + if (child as Node).name == &"ReminderLabel": + label_copies += 1 + _expect_equal(copies, 1, + "lazy: a second open builds no second row") + _expect_equal(label_copies, 1, + "lazy: a second open builds no second label") + panel.queue_free() + await get_tree().process_frame + + +func _test_unknown_row_never_shows_on() -> void: + _clean_reminder_state() + var panel: Control = PANEL_SCENE.instantiate() as Control + add_child(panel) + panel.open() + await get_tree().process_frame + Settings.reminders_enabled = true + Settings.reminder_os_state = "unknown" + Settings.changed.emit() + await get_tree().process_frame + var reminder: Button = panel.get_node("Reminder") as Button + _expect_equal(reminder.text, tr("SETTINGS_REMINDERS_UNKNOWN"), + "unknown: an undetermined state never shows as on") + _expect_false(reminder.disabled, + "unknown: the row stays tappable") + reminder.pressed.emit() + await get_tree().process_frame + _expect_false(Settings.reminders_enabled, + "unknown: a tap disables like a denial") + panel.queue_free() + await get_tree().process_frame + Settings.set_reminders_enabled(false) + + +func _test_panel_fits_locales() -> void: + _clean_reminder_state() + var original_locale: String = TranslationServer.get_locale() + var original_size: Vector2i = get_tree().root.size + var framings: Array[Vector2i] = [ + Vector2i(808, 360), Vector2i(808, 532), + Vector2i(808, 606)] + for framing in framings: + get_tree().root.size = framing + get_tree().root.content_scale_size = framing + await get_tree().process_frame + for locale in LOCALES: + TranslationServer.set_locale(locale) + Settings.locale = locale + await _check_panel_framing(framing, locale) + TranslationServer.set_locale(original_locale) + Settings.locale = str(_original_settings["locale"]) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_size + await get_tree().process_frame + + +func _check_panel_framing(framing: Vector2i, + locale: String) -> void: + var states: Array = [ + {"enabled": false, "os": "unknown"}, + {"enabled": true, "os": "granted"}, + {"enabled": true, "os": "denied"}, + {"enabled": true, "os": "unknown"}, + {"enabled": false, "os": "unsupported"}, + ] + var panel: Control = PANEL_SCENE.instantiate() as Control + add_child(panel) + panel.open() + await get_tree().process_frame + for state in states: + Settings.reminders_enabled = bool(state["enabled"]) + Settings.reminder_os_state = str(state["os"]) + Settings.changed.emit() + await get_tree().process_frame + await get_tree().process_frame + var bounds := Rect2(Vector2.ZERO, Vector2(framing)) + var safe: Rect2 = bounds.grow(-12.0) + for path in ["Reminder", "ReminderLabel", "Credits", + "Close", "ExternalLinks", "LinkStatus"]: + var node: Control = panel.get_node(path) as Control + _expect_true(safe.encloses(node.get_global_rect()), + "%s %s keeps %s in the safe area" % [ + framing, locale, path]) + var card: Control = panel.get_node("Card") as Control + _expect_true(bounds.encloses(card.get_global_rect()), + "%s %s keeps the card on screen" % [ + framing, locale]) + var reminder: Button = panel.get_node("Reminder") + var font: Font = reminder.get_theme_font("font") + var font_size: int = reminder.get_theme_font_size( + "font_size") + var text_width: float = font.get_string_size( + reminder.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, + float(font_size)).x + _expect_true(text_width <= 196.0 + 0.5, + "%s %s reminder text fits its button (%s)" % [ + framing, locale, state["os"]]) + _expect_true(font_size >= 9 and font_size <= 13, + "%s %s reminder button stays readable (%s)" % [ + framing, locale, state["os"]]) + var expected_key: String = "SETTINGS_REMINDERS_OFF" + if str(state["os"]) == "unsupported": + expected_key = "SETTINGS_REMINDERS_UNSUPPORTED" + elif bool(state["enabled"]) \ + and str(state["os"]) == "denied": + expected_key = "SETTINGS_REMINDERS_DENIED" + elif bool(state["enabled"]) \ + and str(state["os"]) == "unknown": + expected_key = "SETTINGS_REMINDERS_UNKNOWN" + elif bool(state["enabled"]): + expected_key = "SETTINGS_REMINDERS_ON" + _expect_equal(reminder.text, tr(expected_key), + "%s %s reminder names its state (%s)" % [ + framing, locale, state["os"]]) + var row_label: Label = panel.get_node("ReminderLabel") + _expect_equal(row_label.text, tr("SETTINGS_REMINDERS"), + "%s %s label keeps its full copy (%s)" % [ + framing, locale, state["os"]]) + var label_fs: int = row_label.get_theme_font_size( + "font_size") + _expect_true(label_fs >= 11 and label_fs <= 15, + "%s %s label stays readable (%s)" % [ + framing, locale, state["os"]]) + var label_font: Font = row_label.get_theme_font("font") + var label_width: float = label_font.get_string_size( + row_label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, + float(label_fs)).x + _expect_true(label_width <= 128.0 + 0.5, + "%s %s label text fits its column (%s)" % [ + framing, locale, state["os"]]) + _expect_true( + row_label.get_combined_minimum_size().x <= 128.0 + 0.5, + "%s %s label minimum fits its column (%s)" % [ + framing, locale, state["os"]]) + var label_rect: Rect2 = row_label.get_global_rect() + var button_rect: Rect2 = reminder.get_global_rect() + _expect_true( + label_rect.end.x <= button_rect.position.x + 0.5, + "%s %s label rect stays clear of the button (%s)" % [ + framing, locale, state["os"]]) + var text_right: float = label_rect.position.x + label_width + if row_label.horizontal_alignment \ + == HORIZONTAL_ALIGNMENT_CENTER: + text_right = label_rect.position.x \ + + (label_rect.size.x + label_width) * 0.5 + elif row_label.horizontal_alignment \ + == HORIZONTAL_ALIGNMENT_RIGHT: + text_right = label_rect.end.x + var gap: float = button_rect.position.x - text_right + _expect_true(gap + 0.5 >= 6.0, + "%s %s keeps 6px text-to-button gap (%s: %s)" % [ + framing, locale, state["os"], gap]) + panel.queue_free() + await get_tree().process_frame + _clean_reminder_state() + + +## Translation changes and repeated opens must recompute the fit +## from the max: en shrinks, ko regrows, and a second open keeps +## the separation without duplicating the lazy row. +func _test_reminder_label_recomputes() -> void: + _clean_reminder_state() + var original_locale: String = TranslationServer.get_locale() + var original_size: Vector2i = get_tree().root.size + get_tree().root.size = Vector2i(808, 360) + get_tree().root.content_scale_size = Vector2i(808, 360) + await get_tree().process_frame + var panel: Control = PANEL_SCENE.instantiate() as Control + add_child(panel) + panel.open() + await get_tree().process_frame + await get_tree().process_frame + var label: Label = panel.get_node("ReminderLabel") as Label + var ko_size: int = -1 + var en_size: int = -1 + for locale in LOCALES: + TranslationServer.set_locale(locale) + Settings.locale = locale + Settings.changed.emit() + await get_tree().process_frame + await get_tree().process_frame + _expect_equal(label.text, tr("SETTINGS_REMINDERS"), + "recompute %s keeps its full copy" % locale) + var current: int = label.get_theme_font_size("font_size") + if locale == "ko": + ko_size = current + if locale == "en": + en_size = current + _check_row_gap(panel, locale, "recompute") + _expect_equal(ko_size, 15, "recompute ko keeps the max") + _expect_true(en_size >= 11 and en_size < 15, + "recompute en shrinks but stays readable (got %d)" % en_size) + TranslationServer.set_locale("ko") + Settings.locale = "ko" + Settings.changed.emit() + await get_tree().process_frame + await get_tree().process_frame + _expect_equal(label.get_theme_font_size("font_size"), 15, + "recompute ko regrows after en") + _check_row_gap(panel, "ko", "recompute-regrow") + panel.close() + await get_tree().process_frame + panel.open() + await get_tree().process_frame + await get_tree().process_frame + TranslationServer.set_locale("en") + Settings.locale = "en" + Settings.changed.emit() + await get_tree().process_frame + await get_tree().process_frame + var reopened: int = label.get_theme_font_size("font_size") + _expect_true(reopened >= 11 and reopened < 15, + "reopen en keeps its fit (got %d)" % reopened) + _check_row_gap(panel, "en", "reopen") + var copies: int = 0 + var label_copies: int = 0 + for child in panel.get_children(): + if (child as Node).name == &"Reminder": + copies += 1 + if (child as Node).name == &"ReminderLabel": + label_copies += 1 + _expect_equal(copies, 1, "reopen builds no second row") + _expect_equal(label_copies, 1, "reopen builds no second label") + panel.queue_free() + await get_tree().process_frame + TranslationServer.set_locale(original_locale) + Settings.locale = str(_original_settings["locale"]) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_size + await get_tree().process_frame + _clean_reminder_state() + + +func _check_row_gap(panel: Control, locale: String, + context: String) -> void: + var row_label: Label = panel.get_node("ReminderLabel") as Label + var row_button: Button = panel.get_node("Reminder") as Button + var label_fs: int = row_label.get_theme_font_size("font_size") + _expect_true(label_fs >= 11 and label_fs <= 15, + "%s %s label stays readable" % [context, locale]) + var label_font: Font = row_label.get_theme_font("font") + var label_width: float = label_font.get_string_size( + row_label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, + float(label_fs)).x + _expect_true(label_width <= 128.0 + 0.5, + "%s %s label text fits (%s)" % [context, locale, label_width]) + _expect_true( + row_label.get_combined_minimum_size().x <= 128.0 + 0.5, + "%s %s label minimum fits" % [context, locale]) + var label_rect: Rect2 = row_label.get_global_rect() + var button_rect: Rect2 = row_button.get_global_rect() + _expect_true(label_rect.end.x <= button_rect.position.x + 0.5, + "%s %s rects separate (%s / %s)" % [ + context, locale, label_rect, button_rect]) + var text_right: float = label_rect.position.x + label_width + if row_label.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER: + text_right = label_rect.position.x \ + + (label_rect.size.x + label_width) * 0.5 + elif row_label.horizontal_alignment == HORIZONTAL_ALIGNMENT_RIGHT: + text_right = label_rect.end.x + var gap: float = button_rect.position.x - text_right + _expect_true(gap + 0.5 >= 6.0, + "%s %s keeps 6px gap (%s)" % [context, locale, gap]) + + +var _offer_accepted: int = 0 + + +func _test_hud_offer() -> void: + _offer_accepted = 0 + var hud: Control = HUD_SCENE.instantiate() as Control + add_child(hud) + await get_tree().process_frame + hud.call("announce_offer", "receipt", "offer", + _on_test_offer_accepted) + await get_tree().process_frame + var chip: Button = null + for child in hud.get_children(): + if child is WorldButton \ + and (child as Control).visible: + chip = child + _expect_true(chip != null, "offer shows its chip") + if chip != null: + _expect_equal(chip.text, "offer", "chip carries the text") + chip.pressed.emit() + await get_tree().process_frame + _expect_equal(_offer_accepted, 1, + "accept runs the callback once") + _expect_false(chip.visible, "accept hides the chip") + hud.call("announce_offer", "receipt", "offer", + _on_test_offer_accepted) + await get_tree().process_frame + hud.call("announce", "plain", Color.WHITE) + await get_tree().process_frame + var visible_chips: int = 0 + for child in hud.get_children(): + if child is WorldButton \ + and (child as Control).visible: + visible_chips += 1 + _expect_equal(visible_chips, 0, + "plain banner clears the chip") + hud.queue_free() + await get_tree().process_frame + + +func _on_test_offer_accepted() -> void: + _offer_accepted += 1 + + +func _expect_true(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + printerr("FAIL reminders: ", label) + + +func _expect_false(value: bool, label: String) -> void: + _expect_true(not value, label) + + +func _expect_equal(actual: Variant, expected: Variant, + label: String) -> void: + _checked += 1 + if actual != expected: + _failed += 1 + printerr("FAIL reminders: ", label, " (got ", actual, + ", want ", expected, ")") diff --git a/apps/game/tests/test_attendance_reminders.gd.uid b/apps/game/tests/test_attendance_reminders.gd.uid new file mode 100644 index 0000000..938bf51 --- /dev/null +++ b/apps/game/tests/test_attendance_reminders.gd.uid @@ -0,0 +1 @@ +uid://l5ssvs14wsrw diff --git a/apps/game/tests/test_attendance_reminders.tscn b/apps/game/tests/test_attendance_reminders.tscn new file mode 100644 index 0000000..6257086 --- /dev/null +++ b/apps/game/tests/test_attendance_reminders.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/test_attendance_reminders.gd" id="1"] + +[node name="TestAttendanceReminders" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tests/test_cloud_account.gd b/apps/game/tests/test_cloud_account.gd index 005bf6e..df11709 100644 --- a/apps/game/tests/test_cloud_account.gd +++ b/apps/game/tests/test_cloud_account.gd @@ -40,6 +40,9 @@ func _run() -> void: await _test_deletion_plan_order() await _test_full_deletion_is_one_atomic_commit() await _test_missing_rows_are_commit_no_ops() + await _test_named_deletion_removes_the_pair() + await _test_named_key_skips_resolution() + await _test_failed_resolution_stops_before_any_delete() await _test_failure_applies_nothing() await _test_offline_reports_remaining() _test_local_store_round_trip() @@ -68,34 +71,57 @@ func _test_deletion_plan_order() -> void: for entry in plan: steps.append(str((entry as Dictionary).get("step", ""))) _expect_equal(steps, - ["hall", "checkpoint", "reservation", "profile"], + ["hall", "checkpoint", "attendance", "reservation", "profile"], "deletion runs Hall first and profile last") _expect_true(str((plan[0] as Dictionary).get("path", "")).contains( "mb_hall_v1/%s" % PUBLIC_ID), "Hall step targets the public row") - _expect_true(str((plan[3] as Dictionary).get("path", "")).contains( + _expect_true(str((plan[2] as Dictionary).get("path", "")).contains( + "mb_attendance_v1/%s" % PUBLIC_ID), + "attendance step targets the public row") + _expect_true(str((plan[4] as Dictionary).get("path", "")).contains( "mb_profiles_v1/%s" % UID), "profile step targets the UID row") +func _adventurer_get_body(key: String) -> String: + return JSON.stringify({ + "name": "projects/%s/databases/(default)/documents/mb_adventurers_v1/%s" + % [PROJECT_ID, PUBLIC_ID], + "fields": { + "public_id": {"stringValue": PUBLIC_ID}, + "uid": {"stringValue": UID}, + "name_key": {"stringValue": key}, + "display": {"stringValue": "Luna"}, + "intro_complete": {"booleanValue": true}, + "schema": {"integerValue": "1"}, + "created_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + "updated_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + }, + }) + + func _test_full_deletion_is_one_atomic_commit() -> void: var sender: RefCounted = FAKE_SENDER_SCRIPT.new() - sender.call("queue_ok", "{\"writeResults\":[{},{},{},{}]}") + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{},{},{},{},{}]}") var account: RefCounted = ACCOUNT_SCRIPT.new() var result: Dictionary = await account.call("delete_account_data", _new_transport(sender), UID, PUBLIC_ID) _expect_equal(result.get("status", ""), "ok", "full deletion ok") _expect_equal(result.get("completed", []), - ["hall", "checkpoint", "reservation", "profile"], - "all four steps complete together") + ["hall", "checkpoint", "attendance", "reservation", "profile"], + "all five steps complete together") _expect_equal(result.get("remaining", []), [], "acknowledged commit leaves nothing remaining") - _expect_equal(sender.calls.size(), 1, "one commit, never four deletes") + _expect_equal(sender.calls.size(), 2, + "one name lookup plus one commit, never five deletes") var call: Dictionary = sender.call("last_call") _expect_equal(call.get("method", ""), "POST", "deletion commits via POST") _expect_true(str(call.get("url", "")).contains("documents:commit"), "deletion hits the commit endpoint") var writes: Array = JSON.parse_string(str(call.get("body", ""))).get( "writes", []) - _expect_equal(writes.size(), 4, "commit carries all four deletes") + _expect_equal(writes.size(), 5, "commit carries all five deletes") var names: Array[String] = [] for write in writes: names.append(str((write as Dictionary).get("delete", ""))) @@ -103,9 +129,11 @@ func _test_full_deletion_is_one_atomic_commit() -> void: "Hall row deleted in the commit") _expect_true(names[1].contains("mb_checkpoints_v1/%s" % UID), "checkpoint deleted in the commit") - _expect_true(names[2].contains("mb_reservations_v1/%s" % PUBLIC_ID), + _expect_true(names[2].contains("mb_attendance_v1/%s" % PUBLIC_ID), + "attendance row deleted in the commit") + _expect_true(names[3].contains("mb_reservations_v1/%s" % PUBLIC_ID), "reservation deleted in the commit") - _expect_true(names[3].contains("mb_profiles_v1/%s" % UID), + _expect_true(names[4].contains("mb_profiles_v1/%s" % UID), "profile deleted in the commit") for write in writes: _expect_false((write as Dictionary).has("currentDocument"), @@ -114,16 +142,84 @@ func _test_full_deletion_is_one_atomic_commit() -> void: func _test_missing_rows_are_commit_no_ops() -> void: var sender: RefCounted = FAKE_SENDER_SCRIPT.new() - sender.call("queue_ok", "{\"writeResults\":[{},{},{},{}]}") + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{},{},{},{},{}]}") var account: RefCounted = ACCOUNT_SCRIPT.new() var result: Dictionary = await account.call("delete_account_data", _new_transport(sender), UID, PUBLIC_ID) _expect_equal(result.get("status", ""), "ok", "repeat over missing rows still finishes ok") - _expect_equal((result.get("completed", []) as Array).size(), 4, + _expect_equal((result.get("completed", []) as Array).size(), 5, "missing rows count as completed") + _expect_equal(sender.calls.size(), 2, + "repeat deletion is still one lookup plus a single commit") + + +func _test_named_deletion_removes_the_pair() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_ok", _adventurer_get_body("luna")) + sender.call("queue_ok", + "{\"writeResults\":[{},{},{},{},{},{},{}]}") + var account: RefCounted = ACCOUNT_SCRIPT.new() + var result: Dictionary = await account.call("delete_account_data", + _new_transport(sender), UID, PUBLIC_ID) + _expect_equal(result.get("status", ""), "ok", "named deletion ok") + _expect_equal(result.get("completed", []), + ["hall", "name", "adventurer", "checkpoint", "attendance", + "reservation", "profile"], + "all seven steps complete together") + var writes: Array = JSON.parse_string(str( + sender.call("last_call").get("body", ""))).get("writes", []) + _expect_equal(writes.size(), 7, "commit carries all seven deletes") + var names: Array[String] = [] + for write in writes: + names.append(str((write as Dictionary).get("delete", ""))) + _expect_true(names[0].contains("mb_hall_v1/%s" % PUBLIC_ID), + "Hall row goes first") + _expect_true(names[1].contains("mb_names_v1/luna"), + "name claim goes with the commit") + _expect_true(names[2].contains("mb_adventurers_v1/%s" % PUBLIC_ID), + "adventurer row goes with the commit") + _expect_true(names[3].contains("mb_checkpoints_v1/%s" % UID), + "checkpoint still goes") + _expect_true(names[4].contains("mb_attendance_v1/%s" % PUBLIC_ID), + "attendance goes with the commit") + _expect_true(names[5].contains("mb_reservations_v1/%s" % PUBLIC_ID), + "reservation still goes") + _expect_true(names[6].contains("mb_profiles_v1/%s" % UID), + "profile still goes last") + + +func _test_named_key_skips_resolution() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_ok", + "{\"writeResults\":[{},{},{},{},{},{},{}]}") + var account: RefCounted = ACCOUNT_SCRIPT.new() + var result: Dictionary = await account.call("delete_account_data", + _new_transport(sender), UID, PUBLIC_ID, "luna") + _expect_equal(result.get("status", ""), "ok", + "keyed deletion ok") + _expect_equal(sender.calls.size(), 1, + "a known key commits without a lookup") + var writes: Array = JSON.parse_string(str( + sender.call("last_call").get("body", ""))).get("writes", []) + _expect_equal(writes.size(), 7, "keyed commit carries all seven deletes") + + +func _test_failed_resolution_stops_before_any_delete() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_reply", {"transport": "ok", "code": 400, + "body": "bad"}) + var account: RefCounted = ACCOUNT_SCRIPT.new() + var result: Dictionary = await account.call("delete_account_data", + _new_transport(sender), UID, PUBLIC_ID) + _expect_equal(result.get("status", ""), "failure", + "failed resolution reports failure") + _expect_equal(result.get("completed", []), [], + "failed resolution completes nothing") _expect_equal(sender.calls.size(), 1, - "repeat deletion is still a single commit") + "failed resolution never reaches the commit") func _test_failure_applies_nothing() -> void: @@ -140,8 +236,8 @@ func _test_failure_applies_nothing() -> void: _expect_equal(result.get("completed", []), [], "atomic failure completes nothing: no stranded profile") _expect_equal(result.get("remaining", []), - ["hall", "checkpoint", "reservation", "profile"], - "all four steps remain for a clean retry") + ["hall", "checkpoint", "attendance", "reservation", "profile"], + "all five steps remain for a clean retry") func _test_offline_reports_remaining() -> void: @@ -155,8 +251,8 @@ func _test_offline_reports_remaining() -> void: "offline deletion reports offline") _expect_equal(result.get("completed", []), [], "offline completes nothing") - _expect_equal((result.get("remaining", []) as Array).size(), 4, - "offline keeps all four steps remaining") + _expect_equal((result.get("remaining", []) as Array).size(), 5, + "offline keeps all five steps remaining") func _test_local_store_round_trip() -> void: diff --git a/apps/game/tests/test_cloud_attendance.gd b/apps/game/tests/test_cloud_attendance.gd new file mode 100644 index 0000000..a6ef5ad --- /dev/null +++ b/apps/game/tests/test_cloud_attendance.gd @@ -0,0 +1,551 @@ +extends SceneTree + +## Attendance service contract: server-time cooldown reads, conditional +## claims, and honest already-claimed/offline/error legs. No wallet here; +## the coordinator applies acknowledged claims to the Vault exactly once. + +const ATTEND_SCRIPT: Script = preload( + "res://scripts/cloud/cloud_attendance.gd") +const SCHEMA_SCRIPT: Script = preload( + "res://scripts/cloud/cloud_schema.gd") +const TRANSPORT_SCRIPT: Script = preload( + "res://scripts/cloud/cloud_transport.gd") +const FAKE_SENDER_SCRIPT: Script = preload( + "res://tests/support/fake_cloud_sender.gd") + +const UID_A: String = "uid-attendance-a" +const PUBLIC_A: String = "MB-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" +const INSTALL_A: String = "install-a-001" +const INSTALL_B: String = "install-b-002" + +## Fixed server clock for every reply: device time never enters. +const NOW: String = "2026-10-07T10:00:00Z" +const NOW_SECONDS: int = 1791367200 +const HOURS_13_AGO: String = "2026-10-06T21:00:00Z" +const HOURS_11_AGO: String = "2026-10-06T23:00:00Z" +const EXACTLY_12H_AGO: String = "2026-10-06T22:00:00Z" +const SECOND_BEFORE_12H: String = "2026-10-06T22:00:01Z" +const DAYS_5_AGO: String = "2026-10-02T10:00:00Z" + +var _checked: int = 0 +var _failed: int = 0 + + +func _init() -> void: + call_deferred("_run") + + +func _run() -> void: + await _test_schema_timestamps() + await _test_schema_cooldown_math() + await _test_receipt_key_shape() + await _test_first_claim() + await _test_cooldown_claim_commits_cas() + await _test_held_period_grants_nothing() + await _test_cooldown_boundaries() + await _test_no_catchup_stacking() + await _test_conflict_converges() + await _test_denied_stays_denied() + await _test_malformed_server_data_fails_closed() + await _test_invalid_inputs_send_nothing() + await _test_advance_conflict_converges() + await _test_prev_carry_parses() + await _test_invalid_advance_sends_nothing() + if _failed > 0: + print("cloud attendance tests failed - %d/%d cases" % [ + _failed, _checked]) + quit(1) + return + print("cloud attendance tests passed - %d cases" % _checked) + quit(0) + + +func _new_transport(sender: RefCounted) -> RefCounted: + var transport: RefCounted = TRANSPORT_SCRIPT.new() + transport.call("configure", "moonlitbeacon-778ee", "web-key", + func() -> String: return "t", sender.call("sender_callable")) + transport.call("set_account_uid", UID_A) + return transport + + +func _missing_body(read_time: String = NOW) -> String: + return JSON.stringify([{"missing": "mb_attendance_v1/x", + "readTime": read_time}]) + + +func _found_body(last_claim_at: String, install_id: String, + read_time: String = NOW, + update_time: String = "2026-10-06T21:00:00.5Z", + prev_claim_at: String = "", + prev_install: String = "") -> String: + var fields: Dictionary = { + "public_id": {"stringValue": PUBLIC_A}, + "uid": {"stringValue": UID_A}, + "install_id": {"stringValue": install_id}, + "last_claim_at": {"timestampValue": last_claim_at}, + "schema": {"integerValue": "1"}, + } + if not prev_claim_at.is_empty(): + fields["prev_claim_at"] = {"timestampValue": prev_claim_at} + if not prev_install.is_empty(): + fields["prev_install_id"] = {"stringValue": prev_install} + return JSON.stringify([{ + "found": { + "name": "mb_attendance_v1/x", + "fields": fields, + "updateTime": update_time, + }, + "readTime": read_time, + }]) + + +func _commit_ack_body(commit_time: String = NOW) -> String: + return JSON.stringify({"writeResults": [{}], + "commitTime": commit_time}) + + +func _expect_equal(actual: Variant, expected: Variant, + label: String) -> void: + _checked += 1 + if actual != expected: + _failed += 1 + print("FAIL: %s - got %s, want %s" % [label, actual, expected]) + + +func _expect_true(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + print("FAIL: %s" % label) + + +func _test_schema_timestamps() -> void: + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339(NOW), NOW_SECONDS, + "timestamps: the fixed clock parses") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339( + "2026-10-07T10:00:00.987654321Z"), NOW_SECONDS, + "timestamps: fractional seconds truncate") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339("garbage"), 0, + "timestamps: garbage yields zero") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339(""), 0, + "timestamps: empty yields zero") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339( + "2026-02-30T00:00:00Z"), 0, + "timestamps: an impossible day yields zero") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339( + "2026-13-01T00:00:00Z"), 0, + "timestamps: a wild month yields zero") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339( + "2026-10-07T25:00:00Z"), 0, + "timestamps: a wild hour yields zero") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339( + "2024-02-29T12:00:00Z"), 1709208000, + "timestamps: leap day parses") + _expect_equal(SCHEMA_SCRIPT.unix_from_rfc3339( + "2026-10-07 10:00:00"), NOW_SECONDS, + "timestamps: a space join parses") + _expect_equal(SCHEMA_SCRIPT.rfc3339_from_unix(NOW_SECONDS), NOW, + "timestamps: the fixed clock round-trips") + _expect_equal(SCHEMA_SCRIPT.rfc3339_from_unix( + NOW_SECONDS + 43200), "2026-10-07T22:00:00Z", + "timestamps: the deadline formats Zulu") + + +func _test_schema_cooldown_math() -> void: + var schema_now: int = NOW_SECONDS + var just_held: Dictionary = SCHEMA_SCRIPT.attendance_cooldown( + schema_now, schema_now - 43199) + _expect_equal(bool(just_held.get("eligible", true)), false, + "cooldown: one second short stays held") + _expect_equal(int(just_held.get("remaining", 0)), 1, + "cooldown: one second short waits one second") + var exact: Dictionary = SCHEMA_SCRIPT.attendance_cooldown( + schema_now, schema_now - 43200) + _expect_equal(bool(exact.get("eligible", false)), true, + "cooldown: exactly twelve hours is eligible") + var future: Dictionary = SCHEMA_SCRIPT.attendance_cooldown( + schema_now, schema_now + 60) + _expect_equal(bool(future.get("eligible", true)), false, + "cooldown: a future stamp never grants") + _expect_equal(int(future.get("remaining", 0)), 43200, + "cooldown: a future stamp waits the full period") + + +func _test_receipt_key_shape() -> void: + var key: String = SCHEMA_SCRIPT.attendance_receipt_key( + PUBLIC_A, NOW_SECONDS, INSTALL_A) + _expect_equal(key, "attendance:%s:%d:%s" % [ + PUBLIC_A, NOW_SECONDS, INSTALL_A], + "receipt: the key derives from row plus install") + _expect_equal(SCHEMA_SCRIPT.attendance_key_seconds(key), NOW_SECONDS, + "receipt: the seconds parse back") + _expect_equal(SCHEMA_SCRIPT.attendance_key_seconds("nope"), -1, + "receipt: garbage parses to -1") + _expect_equal(SCHEMA_SCRIPT.attendance_key_seconds( + "attendance:x:0:y"), -1, + "receipt: zero seconds parses to -1") + _expect_true(SCHEMA_SCRIPT.is_valid_install_id(INSTALL_A), + "receipt: the test install binds") + _expect_true(not SCHEMA_SCRIPT.is_valid_install_id(""), + "receipt: empty install never binds") + + +func _first_call(sender: RefCounted) -> Dictionary: + return (sender.calls as Array)[0] + + +func _test_first_claim() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_missing_body()) + sender.queue_ok(_commit_ack_body()) + var claimed: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(claimed.get("status", "")), "ok", + "first claim succeeds") + _expect_equal(int(claimed.get("claim_seconds", 0)), NOW_SECONDS, + "first claim stamps from the commit time") + _expect_equal(str(claimed.get("next_eligible_utc", "")), + "2026-10-07T22:00:00Z", + "first claim confirms the next deadline") + _expect_equal(int(claimed.get("remaining_seconds", 0)), 43200, + "first claim waits a full period") + _expect_equal((sender.calls as Array).size(), 2, + "first claim reads once and commits once") + _expect_true(str(_first_call(sender).get("url", "")).contains( + "documents:batchGet"), "the read uses batchGet for readTime") + var commit: Dictionary = (sender.calls as Array)[1] + var writes: Array = ((JSON.parse_string( + str(commit.get("body", ""))) as Dictionary).get("writes", [])) + var write: Dictionary = writes[0] + _expect_equal(bool((write.get("currentDocument", {}) as Dictionary + ).get("exists", true)), false, + "first claim creates with exists:false") + var transforms: Array = write.get("updateTransforms", []) + _expect_equal(str((transforms[0] as Dictionary).get( + "setToServerValue", "")), "REQUEST_TIME", + "the stamp is server-set, never client-written") + _expect_true(not (write.get("update", {}) as Dictionary).get( + "fields", {}).has("last_claim_at"), + "no client stamp rides beside the transform") + var create_fields: Dictionary = (write.get("update", {}) + as Dictionary).get("fields", {}) + _expect_true(not create_fields.has("prev_claim_at"), + "a first claim carries no previous stamp") + _expect_true(not create_fields.has("prev_install_id"), + "a first claim carries no previous install") + + +func _test_cooldown_claim_commits_cas() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + var fractured: String = "2026-10-06T21:00:00.123456789Z" + sender.queue_ok(_found_body(fractured, INSTALL_B)) + var eligible: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(eligible.get("status", "")), "eligible", + "elapsed claim reports eligible without committing") + _expect_equal((sender.calls as Array).size(), 1, + "eligible commits nothing before backfill") + _expect_equal(str(eligible.get("last_claim_rfc", "")), fractured, + "eligible carries the raw stamp opaquely") + sender.queue_ok(_commit_ack_body()) + var claimed: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, + str(eligible.get("update_time", "")), + str(eligible.get("last_claim_rfc", "")), + str(eligible.get("install_id", ""))) + _expect_equal(str(claimed.get("status", "")), "ok", + "elapsed advance succeeds for a new receiving install") + var commit: Dictionary = (sender.calls as Array)[1] + var write: Dictionary = (((JSON.parse_string( + str(commit.get("body", ""))) as Dictionary).get("writes", [])) + as Array)[0] + _expect_equal(str((write.get("currentDocument", {}) as Dictionary + ).get("updateTime", "")), "2026-10-06T21:00:00.5Z", + "elapsed advance compare-and-swaps on the read updateTime") + var fields: Dictionary = (write.get("update", {}) as Dictionary + ).get("fields", {}) + _expect_equal(str((fields.get("install_id", {}) as Dictionary).get( + "stringValue", "")), INSTALL_A, + "elapsed advance binds the receiving install") + _expect_equal(str((fields.get("prev_claim_at", {}) as Dictionary + ).get("timestampValue", "")), fractured, + "elapsed advance carries the old stamp byte for byte") + _expect_equal(str((fields.get("prev_install_id", {}) as Dictionary + ).get("stringValue", "")), INSTALL_B, + "elapsed advance carries the old install") + + +func _test_held_period_grants_nothing() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_found_body(HOURS_11_AGO, INSTALL_A)) + var held: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(held.get("status", "")), "already-claimed", + "held period reports already-claimed") + _expect_equal(int(held.get("remaining_seconds", 0)), 3600, + "held period waits out the server remainder") + _expect_equal(str(held.get("next_eligible_utc", "")), + "2026-10-07T11:00:00Z", + "held period confirms the server deadline") + _expect_true(bool(held.get("mine", false)), + "held period names this install as receiver") + _expect_equal((sender.calls as Array).size(), 1, + "held period commits nothing") + sender.reset() + sender.queue_ok(_found_body(HOURS_11_AGO, INSTALL_B)) + var foreign: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(foreign.get("status", "")), "already-claimed", + "foreign-held period reports already-claimed") + _expect_true(not bool(foreign.get("mine", true)), + "foreign-held period names the other receiver") + _expect_equal((sender.calls as Array).size(), 1, + "foreign-held period commits nothing") + + +func _test_cooldown_boundaries() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_found_body(SECOND_BEFORE_12H, INSTALL_A)) + var held: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(int(held.get("remaining_seconds", 0)), 1, + "boundary: one second short waits one second") + _expect_equal((sender.calls as Array).size(), 1, + "boundary: one second short commits nothing") + sender.reset() + sender.queue_ok(_found_body(EXACTLY_12H_AGO, INSTALL_A)) + var ready: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(ready.get("status", "")), "eligible", + "boundary: exactly twelve hours is eligible") + _expect_equal((sender.calls as Array).size(), 1, + "boundary: eligibility commits nothing yet") + sender.queue_ok(_commit_ack_body()) + var claimed: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, + str(ready.get("update_time", "")), + str(ready.get("last_claim_rfc", "")), + str(ready.get("install_id", ""))) + _expect_equal(str(claimed.get("status", "")), "ok", + "boundary: exactly twelve hours claims") + _expect_equal((sender.calls as Array).size(), 2, + "boundary: exactly twelve hours commits") + + +func _test_no_catchup_stacking() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_found_body(DAYS_5_AGO, INSTALL_A)) + var ready: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(ready.get("status", "")), "eligible", + "no stacking: a stale row reports eligible") + sender.queue_ok(_commit_ack_body()) + var claimed: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, + str(ready.get("update_time", "")), + str(ready.get("last_claim_rfc", "")), + str(ready.get("install_id", ""))) + _expect_equal(str(claimed.get("status", "")), "ok", + "no stacking: a stale row still claims once") + _expect_equal(int(claimed.get("remaining_seconds", 0)), 43200, + "no stacking: the next wait is one period, not five days") + _expect_equal(str(claimed.get("next_eligible_utc", "")), + "2026-10-07T22:00:00Z", + "no stacking: the deadline counts from the new claim") + + +func _test_conflict_converges() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_missing_body()) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_ok(_found_body(NOW, INSTALL_A)) + var same: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(same.get("status", "")), "already-claimed", + "conflict: the lost same-install race converges") + _expect_true(bool(same.get("mine", false)), + "conflict: the same-install winner keeps the grant") + sender.reset() + sender.queue_ok(_missing_body()) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_ok(_found_body(NOW, INSTALL_B)) + var other: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(other.get("status", "")), "already-claimed", + "conflict: the lost cross-install race converges") + _expect_true(not bool(other.get("mine", true)), + "conflict: the other install keeps its grant") + sender.reset() + sender.queue_ok(_missing_body()) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_ok(_missing_body()) + var raced: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(raced.get("code", "")), "attendance-claim-race", + "conflict: an incoherent recheck stays retryable") + _expect_true(bool(raced.get("retryable", false)), + "conflict: the next trigger may retry the race") + + +func _test_denied_stays_denied() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 403, "body": "denied"}) + var blocked: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(blocked.get("code", "")), "permission-denied", + "denied: a 403 read never becomes a first claim") + _expect_equal((sender.calls as Array).size(), 1, + "denied: a 403 read never reaches the commit") + sender.reset() + sender.queue_ok(_missing_body()) + sender.queue_reply({"transport": "ok", "code": 403, "body": "denied"}) + var refused: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(refused.get("status", "")), "failure", + "denied: a 403 commit fails the claim") + _expect_equal(str(refused.get("code", "")), "permission-denied", + "denied: a 403 commit stays a configuration error") + + +func _test_malformed_server_data_fails_closed() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(JSON.stringify([{"missing": "x"}])) + var timeless: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(timeless.get("code", "")), + "attendance-no-server-time", + "malformed: a read without readTime grants nothing") + sender.reset() + var fields: Dictionary = { + "public_id": {"stringValue": "MB-someone-else-000000000001"}, + "uid": {"stringValue": "uid-stranger"}, + "install_id": {"stringValue": INSTALL_A}, + "last_claim_at": {"timestampValue": HOURS_13_AGO}, + "schema": {"integerValue": "1"}, + } + sender.queue_ok(JSON.stringify([{"found": {"name": "x", + "fields": fields, "updateTime": "u"}, "readTime": NOW}])) + var foreign: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(foreign.get("code", "")), "attendance-row-mismatch", + "malformed: a foreign row is never adopted") + sender.reset() + sender.queue_ok(_missing_body()) + sender.queue_ok("{\"writeResults\": [{}]}") + var uncertain: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(uncertain.get("code", "")), + "attendance-uncertain-ack", + "malformed: a commit without commitTime stays uncertain") + _expect_true(bool(uncertain.get("retryable", false)), + "malformed: the next trigger resolves the uncertainty") + + +func _test_invalid_inputs_send_nothing() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + var empty_install: Dictionary = await service.call( + "claim_attendance", _new_transport(sender), UID_A, PUBLIC_A, "") + _expect_equal(str(empty_install.get("code", "")), "invalid-install-id", + "invalid: an empty install never claims") + _expect_equal((sender.calls as Array).size(), 0, + "invalid: an empty install sends nothing") + var bad_uid: Dictionary = await service.call("claim_attendance", + _new_transport(sender), "!!", PUBLIC_A, INSTALL_A) + _expect_equal(str(bad_uid.get("code", "")), "invalid-uid", + "invalid: a bad UID never claims") + _expect_equal((sender.calls as Array).size(), 0, + "invalid: a bad UID sends nothing") + + +func _test_advance_conflict_converges() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_found_body(HOURS_13_AGO, INSTALL_B)) + var ready: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(ready.get("status", "")), "eligible", + "advance race: the stale row is eligible") + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_ok(_found_body(NOW, INSTALL_B)) + var lost: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, + str(ready.get("update_time", "")), + str(ready.get("last_claim_rfc", "")), + str(ready.get("install_id", ""))) + _expect_equal(str(lost.get("status", "")), "already-claimed", + "advance race: the lost swap converges on the winner") + _expect_true(not bool(lost.get("mine", true)), + "advance race: the other install keeps its grant") + + sender.reset() + sender.queue_ok(_found_body(HOURS_13_AGO, INSTALL_A)) + var fresh: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_ok(_missing_body()) + var raced: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, + str(fresh.get("update_time", "")), + str(fresh.get("last_claim_rfc", "")), + str(fresh.get("install_id", ""))) + _expect_equal(str(raced.get("code", "")), "attendance-claim-race", + "advance race: an incoherent recheck stays retryable") + + +func _test_prev_carry_parses() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + sender.queue_ok(_found_body(HOURS_11_AGO, INSTALL_B, NOW, + "2026-10-06T23:00:00.25Z", DAYS_5_AGO, INSTALL_A)) + var held: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(held.get("status", "")), "already-claimed", + "prev: the held row still holds") + _expect_equal(int(held.get("prev_claim_at", 0)), 1790935200, + "prev: the carried stamp parses to seconds") + _expect_equal(str(held.get("prev_install_id", "")), INSTALL_A, + "prev: the carried install parses") + + sender.reset() + sender.queue_ok(_found_body(HOURS_11_AGO, INSTALL_B, NOW, + "2026-10-06T23:00:00.25Z", "not-a-stamp", INSTALL_A)) + var broken: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(str(broken.get("code", "")), "attendance-bad-stamp", + "prev: a malformed carried stamp fails closed") + + sender.reset() + sender.queue_ok(_found_body(HOURS_11_AGO, INSTALL_B)) + var legacy: Dictionary = await service.call("claim_attendance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A) + _expect_equal(int(legacy.get("prev_claim_at", -1)), 0, + "prev: a pre-chain row reads zero") + _expect_equal(str(legacy.get("prev_install_id", "x")), "", + "prev: a pre-chain row reads empty") + + +func _test_invalid_advance_sends_nothing() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var service: RefCounted = ATTEND_SCRIPT.new() + var no_swap: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, "", + HOURS_13_AGO, INSTALL_B) + _expect_equal(str(no_swap.get("code", "")), "attendance-bad-advance", + "advance: a missing swap never commits") + _expect_equal((sender.calls as Array).size(), 0, + "advance: a missing swap sends nothing") + var no_carry: Dictionary = await service.call("commit_advance", + _new_transport(sender), UID_A, PUBLIC_A, INSTALL_A, "u", "", "") + _expect_equal(str(no_carry.get("code", "")), "attendance-bad-advance", + "advance: a missing carry never commits") + _expect_equal((sender.calls as Array).size(), 0, + "advance: a missing carry sends nothing") diff --git a/apps/game/tests/test_cloud_attendance.gd.uid b/apps/game/tests/test_cloud_attendance.gd.uid new file mode 100644 index 0000000..3d14d4d --- /dev/null +++ b/apps/game/tests/test_cloud_attendance.gd.uid @@ -0,0 +1 @@ +uid://70efpqwia46m diff --git a/apps/game/tests/test_cloud_checkpoint.gd b/apps/game/tests/test_cloud_checkpoint.gd index 00e7d9e..567c627 100644 --- a/apps/game/tests/test_cloud_checkpoint.gd +++ b/apps/game/tests/test_cloud_checkpoint.gd @@ -36,6 +36,7 @@ func _run() -> void: await _test_switch_account_retires_inflight() await _test_choose_local_returns_fresh_remote() await _test_load_needs_validator() + _test_summary_marks_ended_payload() if _failed > 0: printerr("cloud checkpoint tests failed — ", _failed, "/", _checked, " cases") @@ -348,6 +349,25 @@ func _test_load_needs_validator() -> void: "2026-10-01T04:00:00Z", "compare-and-swap guard decoded") +## Conflict summaries name a sealed run without leaking the payload: the +## defeat marker rides along only when a present bool says so. +func _test_summary_marks_ended_payload() -> void: + var sealed: Dictionary = SCHEMA_SCRIPT.summarize_payload( + "{\"cycle\":3,\"ended\":true}", 4) + _expect_equal(sealed.get("ended", false), true, + "a sealed payload marks its summary") + _expect_equal(sealed.get("cycle", 0), 3, + "the sealed summary keeps its cycle") + var living: Dictionary = SCHEMA_SCRIPT.summarize_payload( + "{\"cycle\":3}", 4) + _expect_false(living.has("ended"), + "a payload sealed before the defeat rules carries no key") + var smuggled: Dictionary = SCHEMA_SCRIPT.summarize_payload( + "{\"cycle\":3,\"ended\":1}", 4) + _expect_false(smuggled.has("ended"), + "a non-bool marker never rides the summary") + + func _expect_true(value: bool, label: String) -> void: _checked += 1 if not value: diff --git a/apps/game/tests/test_cloud_coordinator.gd b/apps/game/tests/test_cloud_coordinator.gd index e77d874..fcc86f9 100644 --- a/apps/game/tests/test_cloud_coordinator.gd +++ b/apps/game/tests/test_cloud_coordinator.gd @@ -35,6 +35,7 @@ const HERO_KNIGHT: String = "res://resources/heroes/knight.tres" var _failed: int = 0 var _checked: int = 0 +var _stable_texts: Array[String] = [] func _ready() -> void: @@ -100,6 +101,17 @@ func _run() -> void: await _test_restore_cancel_discards_late_reply() await _test_restore_second_claim_preempts_first() await _test_sender_pre_bound_and_close() + await _test_tombstone_round_trip_uploads_and_restores() + await _test_tombstone_offline_retry_commits() + await _test_restore_keeps_local_defeat_over_stale_remote() + await _test_restore_adopts_remote_defeat_over_stale_local() + await _test_remote_choice_refused_over_local_defeat() + await _test_local_choice_refused_over_remote_defeat() + await _test_different_journey_defeat_conflict_still_explicit() + await _test_stale_remote_death_retires_against_acknowledged_revive() + await _test_unacknowledged_revive_conflicts_guarded() + await _test_stale_dialog_local_refused_over_fresh_revive() + await _test_paid_revive_restores_over_observed_defeat() _reset_all([GUEST_A, GUEST_B, CANON_C]) if _failed > 0: printerr("cloud coordinator tests failed — ", _failed, "/", _checked, @@ -357,6 +369,22 @@ func _own_get_call_count(sender: RefCounted) -> int: return count +func _on_crash_stable(info: Dictionary) -> void: + _stable_texts.append(str(info.get("text", ""))) + + +func _stable_ids() -> Array: + var ids: Array = [] + for text in _stable_texts: + var parser: JSON = JSON.new() + if parser.parse(text) != OK: + ids.append(-1) + continue + var data: Dictionary = parser.data as Dictionary + ids.append(int(data.get("checkpoint_id", -1))) + return ids + + func _commit_call_count(sender: RefCounted) -> int: var count: int = 0 for call in sender.get("calls"): @@ -1249,6 +1277,557 @@ func _ready_account(sender: RefCounted, coordinator: Node, uid: String, await _settle_account(coordinator) +## A sealed defeat uploads like any checkpoint and restores terminal on a +## fresh device: the marker rides the normal revision flow and grants +## nothing on arrival. +func _test_tombstone_round_trip_uploads_and_restores() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("tomb-j-1", 1, 0)), OK, + "tomb: the alive seal writes") + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var first: Dictionary = await coordinator.call("flush") + _expect_equal(first.get("status", ""), "ok", + "tomb: the alive seal uploads") + var alive_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + var sealed: Dictionary = _valid_checkpoint("tomb-j-1", 1, 12, + {"ended": true, "settled_score": 3100}) + _expect_equal(Journey.write_checkpoint(sealed), OK, + "tomb: the defeat seals") + sender.call("queue_ok", _remote_body(UID_A, 1, alive_bytes, + "2026-10-01T01:00:00Z")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var second: Dictionary = await coordinator.call("flush") + _expect_equal(second.get("status", ""), "ok", + "tomb: the defeat uploads over its own baseline") + _expect_equal(second.get("revision", 0), 2, + "tomb: the defeat acks one past the alive seal") + var commit: Dictionary = JSON.parse_string(str( + sender.call("last_call").get("body", ""))) + var commit_writes: Array = commit.get("writes", []) + var commit_update: Dictionary = ( + commit_writes[0] as Dictionary).get("update", {}) + var commit_fields: Dictionary = commit_update.get("fields", {}) + var commit_payload: String = str((commit_fields.get("payload", {}) + as Dictionary).get("stringValue", "")) + _expect_true(bool(JSON.parse_string(commit_payload).get("ended", false)), + "tomb: the commit carries the marker") + var tomb_bytes: String = _read_text(Journey.account_main_path(GUEST_A)) + _wipe_slot(GUEST_A) + sender.call("queue_ok", _remote_body(UID_A, 2, tomb_bytes, + "2026-10-01T02:00:00Z")) + var bank_before: int = Vault.shards + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("code", ""), "restored", + "tomb: a fresh device restores the marker") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "tomb: the restored file is terminal") + _expect_false(Journey.has_valid_checkpoint(), + "tomb: the restored defeat offers no Continue") + _expect_equal(Vault.shards, bank_before, + "tomb: the restored defeat grants nothing") + _close_coordinator(coordinator) + + +## An offline defeat upload stays queued and commits on retry: the marker +## is never dropped and never sent twice. +func _test_tombstone_offline_retry_commits() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("retry-j-1", 1, 0)), OK, + "offline tomb: the alive seal writes") + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var first: Dictionary = await coordinator.call("flush") + _expect_equal(first.get("status", ""), "ok", + "offline tomb: the alive seal uploads") + var alive_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + _expect_equal(Journey.write_checkpoint(_valid_checkpoint("retry-j-1", 1, + 9, {"ended": true, "settled_score": 2800})), OK, + "offline tomb: the defeat seals") + sender.call("queue_reply", {"transport": "offline", "code": 0, + "body": ""}) + var dropped: Dictionary = await coordinator.call("flush") + _expect_equal(dropped.get("status", ""), "offline", + "offline tomb: the offline flush reports offline") + _expect_true(bool((coordinator.call("_test_state") as Dictionary).get( + "has_pending", false)), + "offline tomb: the marker stays queued") + sender.call("queue_ok", _remote_body(UID_A, 1, alive_bytes, + "2026-10-01T01:00:00Z")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var retried: Dictionary = await coordinator.call("flush") + _expect_equal(retried.get("status", ""), "ok", + "offline tomb: the retry commits") + _expect_equal(retried.get("revision", 0), 2, + "offline tomb: the retry acks one past the alive seal") + _expect_equal(_commit_call_count(sender), 3, + "offline tomb: reservation plus two uploads, no duplicate commit") + _close_coordinator(coordinator) + + +## A stale alive download never resurrects a sealed local run: the restore +## keeps the defeat, touches no file, and opens no dialog. +func _test_restore_keeps_local_defeat_over_stale_remote() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("keep-j-1", 1, 4)), OK, + "kept defeat: the first seal writes") + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("keep-j-1", 2, 9)), OK, + "kept defeat: the second seal writes") + _expect_equal(Journey.write_checkpoint(_valid_checkpoint("keep-j-1", 2, + 11, {"ended": true, "settled_score": 3000})), OK, + "kept defeat: the defeat seals") + var before: String = _read_text(Journey.account_main_path(GUEST_A)) + var stale: String = JSON.stringify(_valid_checkpoint("keep-j-1", 1, 4)) + sender.call("queue_ok", _remote_body(UID_A, 7, stale, + "2026-10-01T03:00:00Z")) + var bank_before: int = Vault.shards + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("status", ""), "ok", + "kept defeat: the stale download resolves") + _expect_equal(restored.get("code", ""), "defeat-kept", + "kept defeat: the local seal wins outright") + _expect_equal(_read_text(Journey.account_main_path(GUEST_A)), before, + "kept defeat: the local file is untouched") + _expect_true((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "kept defeat: no dialog opens") + _expect_equal(Vault.shards, bank_before, + "kept defeat: the stale download grants nothing") + _close_coordinator(coordinator) + + +## A defeat sealed on another device ends this device's stale same-journey +## save: the restore installs the marker, preserves the superseded bytes, +## and floors the receipt with zero granted. +func _test_restore_adopts_remote_defeat_over_stale_local() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("adopt-j-1", 2, 9)), OK, + "adopted defeat: the stale local seal writes") + var local_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + var sealed: String = JSON.stringify(_valid_checkpoint("adopt-j-1", 2, + 11, {"ended": true, "settled_score": 3000})) + sender.call("queue_ok", _remote_body(UID_A, 5, sealed, + "2026-10-01T03:00:00Z")) + var bank_before: int = Vault.shards + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("status", ""), "ok", + "adopted defeat: the remote seal resolves") + _expect_equal(restored.get("code", ""), "defeat-adopted", + "adopted defeat: the death wins outright") + _expect_false(bool(restored.get("remapped", true)), + "adopted defeat: a string journey installs byte-identical") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "adopted defeat: the local file is terminal now") + _expect_false(Journey.has_valid_checkpoint(), + "adopted defeat: the adopted defeat offers no Continue") + _expect_equal((coordinator.call("recovery_payload", "local") as Dictionary + ).get("text", ""), local_bytes, + "adopted defeat: the superseded bytes are preserved") + _expect_equal((coordinator.call("save_snapshot") as Dictionary).get( + "floor_code", ""), "floored", + "adopted defeat: the install floors its receipt") + _expect_equal(Vault.shards, bank_before, + "adopted defeat: the adoption grants nothing") + _close_coordinator(coordinator) + + +## A remote choice made after the local run sealed its defeat is refused: +## installing the stale alive download would resume the sealed run. The +## sealed marker itself can still land explicitly afterwards. +func _test_remote_choice_refused_over_local_defeat() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("guard-j-1", 2, 9)), OK, + "refused remote: the local seal writes") + var remote_text: String = JSON.stringify(_valid_checkpoint("guard-j-1", + 2, 14, {"cycle": 5, "kill_score": 900})) + sender.call("queue_ok", _remote_body(UID_A, 9, remote_text, + "2026-10-01T01:00:00Z")) + var flushed: Dictionary = await coordinator.call("flush") + _expect_equal(flushed.get("status", ""), "conflict", + "refused remote: the diverged seals conflict") + _expect_equal(Journey.write_checkpoint(_valid_checkpoint("guard-j-1", 2, + 11, {"ended": true, "settled_score": 3000})), OK, + "refused remote: the local run seals its defeat after the dialog") + var refused: Dictionary = await coordinator.call( + "resolve_conflict", "remote") + _expect_equal(refused.get("status", ""), "failure", + "refused remote: the stale choice is refused") + _expect_equal(refused.get("code", ""), "defeat-kept", + "refused remote: the refusal names the sealed defeat") + _expect_equal(_commit_call_count(sender), 1, + "refused remote: only the reservation commit was ever sent") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "refused remote: the local file stays sealed") + _expect_false((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "refused remote: the conflict stays open") + sender.call("queue_ok", _remote_body(UID_A, 9, remote_text, + "2026-10-01T01:00:00Z")) + sender.call("queue_ok", _remote_body(UID_A, 9, remote_text, + "2026-10-01T01:00:00Z")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var kept: Dictionary = await coordinator.call( + "resolve_conflict", "local") + _expect_equal(kept.get("code", ""), "local-kept", + "refused remote: the sealed marker still lands explicitly") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "refused remote: the landed file stays sealed") + _close_coordinator(coordinator) + + +## A local choice made after the server sealed the journey's defeat is +## refused: rebasing stale alive bytes over it would resurrect the run on +## the server. A restore afterwards adopts the death instead. +func _test_local_choice_refused_over_remote_defeat() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("guard2-j-1", 2, 9)), OK, + "refused local: the local seal writes") + var remote_text: String = JSON.stringify(_valid_checkpoint("guard2-j-1", + 2, 14, {"cycle": 5, "kill_score": 900})) + sender.call("queue_ok", _remote_body(UID_A, 9, remote_text, + "2026-10-01T01:00:00Z")) + var flushed: Dictionary = await coordinator.call("flush") + _expect_equal(flushed.get("status", ""), "conflict", + "refused local: the diverged seals conflict") + var sealed_text: String = JSON.stringify(_valid_checkpoint("guard2-j-1", + 2, 16, {"ended": true, "settled_score": 3200})) + sender.call("queue_ok", _remote_body(UID_A, 10, sealed_text, + "2026-10-01T02:00:00Z")) + var refused: Dictionary = await coordinator.call( + "resolve_conflict", "local") + _expect_equal(refused.get("status", ""), "failure", + "refused local: the stale choice is refused") + _expect_equal(refused.get("code", ""), "defeat-kept", + "refused local: the refusal names the sealed defeat") + _expect_equal(_commit_call_count(sender), 1, + "refused local: only the reservation commit was ever sent") + _expect_false((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "refused local: the conflict stays open") + sender.call("queue_ok", _remote_body(UID_A, 10, sealed_text, + "2026-10-01T02:00:00Z")) + var adopted: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(adopted.get("code", ""), "defeat-adopted", + "refused local: the restore adopts the death instead") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "refused local: the local file is terminal now") + _close_coordinator(coordinator) + + +## Different journeys still choose explicitly: a fresh local run against a +## sealed older journey opens a real dialog, names the sealed side, and +## either pick lands honestly. +func _test_different_journey_defeat_conflict_still_explicit() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("fresh-j-9", 1, 2)), OK, + "explicit defeat: the fresh local run seals") + var local_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + var sealed_text: String = JSON.stringify(_valid_checkpoint("old-j-1", + 4, 20, {"ended": true, "settled_score": 4100})) + sender.call("queue_ok", _remote_body(UID_A, 3, sealed_text, + "2026-10-01T03:00:00Z")) + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("status", ""), "conflict", + "explicit defeat: different journeys conflict") + _expect_equal(restored.get("code", ""), "restore-differs", + "explicit defeat: the conflict names its cause") + _expect_equal((restored.get("remote_summary", {}) as Dictionary).get( + "ended", false), true, + "explicit defeat: the sealed side is marked") + _expect_equal((restored.get("local_summary", {}) as Dictionary).get( + "ended", false), false, + "explicit defeat: the living side is unmarked") + var bank_before: int = Vault.shards + var kept: Dictionary = await coordinator.call( + "resolve_conflict", "remote") + _expect_equal(kept.get("code", ""), "remote-kept", + "explicit defeat: the explicit remote pick lands") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "explicit defeat: the installed pick is terminal") + _expect_false(Journey.has_valid_checkpoint(), + "explicit defeat: the installed pick offers no Continue") + _expect_equal((coordinator.call("recovery_payload", "local") as Dictionary + ).get("text", ""), local_bytes, + "explicit defeat: the rejected run is preserved") + _expect_equal(Vault.shards, bank_before, + "explicit defeat: the install grants nothing") + _close_coordinator(coordinator) + + +## A stale downloaded death retires silently against an acknowledged newer +## seal: our revive already landed on the server, so the older ended bytes +## resolve without a dialog and touch no file. +func _test_stale_remote_death_retires_against_acknowledged_revive() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("stale-j-1", 2, 9)), OK, + "retired death: the alive seal writes") + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var first: Dictionary = await coordinator.call("flush") + _expect_equal(first.get("status", ""), "ok", + "retired death: the alive seal uploads") + var alive_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("stale-j-1", 3, 11)), OK, + "retired death: the revive seals") + sender.call("queue_ok", _remote_body(UID_A, 1, alive_bytes, + "2026-10-01T01:00:00Z")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var second: Dictionary = await coordinator.call("flush") + _expect_equal(second.get("status", ""), "ok", + "retired death: the revive uploads") + var revived_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + var stale_text: String = JSON.stringify(_valid_checkpoint("stale-j-1", + 2, 11, {"ended": true, "settled_score": 3000})) + sender.call("queue_ok", _remote_body(UID_A, 5, stale_text, + "2026-10-01T03:00:00Z")) + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("status", ""), "ok", + "retired death: the stale download resolves") + _expect_equal(restored.get("code", ""), "stale-ended-retired", + "retired death: the acknowledged revive wins outright") + _expect_equal(_read_text(Journey.account_main_path(GUEST_A)), + revived_bytes, "retired death: the local file is untouched") + _expect_true((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "retired death: no dialog opens") + _expect_true(Journey.has_valid_checkpoint(), + "retired death: the revive stays resumable") + _close_coordinator(coordinator) + + +## An unflushed revive still opens the explicit dialog against a stale +## downloaded death — but the guarded resolve refuses to install it, while +## the live side rebases honestly. +func _test_unacknowledged_revive_conflicts_guarded() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("guard3-j-1", 3, 11)), OK, + "guarded revive: the unflushed revive seals") + var stale_text: String = JSON.stringify(_valid_checkpoint("guard3-j-1", + 2, 11, {"ended": true, "settled_score": 3000})) + sender.call("queue_ok", _remote_body(UID_A, 4, stale_text, + "2026-10-01T03:00:00Z")) + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("status", ""), "conflict", + "guarded revive: the unacknowledged seal opens a dialog") + var refused: Dictionary = await coordinator.call( + "resolve_conflict", "remote") + _expect_equal(refused.get("status", ""), "failure", + "guarded revive: the stale death is refused") + _expect_equal(refused.get("code", ""), "stale-ended-retired", + "guarded revive: the refusal names the stale death") + _expect_false((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "guarded revive: the conflict stays open") + sender.call("queue_ok", _remote_body(UID_A, 4, stale_text, + "2026-10-01T03:00:00Z")) + sender.call("queue_ok", _remote_body(UID_A, 4, stale_text, + "2026-10-01T03:00:00Z")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var kept: Dictionary = await coordinator.call( + "resolve_conflict", "local") + _expect_equal(kept.get("code", ""), "local-kept", + "guarded revive: the live side still rebases honestly") + _expect_true(Journey.has_valid_checkpoint(), + "guarded revive: the rebased revive stays resumable") + _close_coordinator(coordinator) + + +## A stale dialog holding an ended candidate refuses its local choice once +## the server holds the journey's newer alive seal — and the acknowledged +## revive then restores onto the defeated client through the remote choice. +func _test_stale_dialog_local_refused_over_fresh_revive() -> void: + _reset_all([GUEST_A]) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("dlg-j-1", 2, 9)), OK, + "stale dialog: the local seal writes") + var remote_text: String = JSON.stringify(_valid_checkpoint("dlg-j-1", + 2, 14, {"cycle": 5, "kill_score": 900})) + sender.call("queue_ok", _remote_body(UID_A, 9, remote_text, + "2026-10-01T01:00:00Z")) + var flushed: Dictionary = await coordinator.call("flush") + _expect_equal(flushed.get("status", ""), "conflict", + "stale dialog: the diverged seals conflict") + _expect_equal(Journey.write_checkpoint(_valid_checkpoint("dlg-j-1", 2, + 11, {"ended": true, "settled_score": 3000})), OK, + "stale dialog: the local run seals its defeat after the dialog") + # The revive lands on the server from the device that paid for it. + var revived_text: String = JSON.stringify( + _valid_checkpoint("dlg-j-1", 3, 11)) + sender.call("queue_ok", _remote_body(UID_A, 10, revived_text, + "2026-10-01T02:00:00Z")) + var refused: Dictionary = await coordinator.call( + "resolve_conflict", "local") + _expect_equal(refused.get("status", ""), "failure", + "stale dialog: the stale death is refused") + _expect_equal(refused.get("code", ""), "stale-ended-retired", + "stale dialog: the refusal names the stale death") + _expect_equal(_commit_call_count(sender), 1, + "stale dialog: only the reservation commit was ever sent") + _expect_false((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "stale dialog: the conflict stays open") + var remote_refused: Dictionary = await coordinator.call( + "resolve_conflict", "remote") + _expect_equal(remote_refused.get("code", ""), "defeat-kept", + "stale dialog: the stale alive download is refused too") + # Pulling instead restores the acknowledged revive honestly. + sender.call("queue_ok", _remote_body(UID_A, 10, revived_text, + "2026-10-01T02:00:00Z")) + var pulled: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(pulled.get("status", ""), "conflict", + "stale dialog: the newer revive conflicts honestly") + var kept: Dictionary = await coordinator.call( + "resolve_conflict", "remote") + _expect_equal(kept.get("code", ""), "remote-kept", + "stale dialog: the acknowledged revive still installs") + _expect_true(Journey.has_valid_checkpoint(), + "stale dialog: the installed revive is resumable") + _close_coordinator(coordinator) + + +## A paid continuation acknowledged on one client restores on another that +## already observed the defeat: the newer alive seal survives the explicit +## conflict and installs by remote choice. A pending local transaction — +## the crash client's intermediate state — neither poisons that +## comparison nor publishes its unlanded seal. +func _test_paid_revive_restores_over_observed_defeat() -> void: + _reset_all([GUEST_A]) + Vault.continue_coins = 2 + _expect_equal(Vault.save_vault(), OK, + "cross-client: the purse persists") + _stable_texts.clear() + Journey.subscribe_stable_checkpoint(_on_crash_stable) + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var coordinator: Node = _new_coordinator() + await _ready_account(sender, coordinator, UID_A, GUEST_A) + _expect_equal(Journey.write_checkpoint( + _valid_checkpoint("cb-remote-1", 1, 9)), OK, + "cross-client: the alive seal writes") + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var first: Dictionary = await coordinator.call("flush") + _expect_equal(first.get("status", ""), "ok", + "cross-client: the alive seal uploads") + var alive_bytes: String = _read_text( + Journey.account_main_path(GUEST_A)) + var sealed: Dictionary = _valid_checkpoint("cb-remote-1", 1, 12, + {"ended": true, "settled_score": 3100}) + _expect_equal(Journey.write_checkpoint(sealed), OK, + "cross-client: the defeat seals") + sender.call("queue_ok", _remote_body(UID_A, 1, alive_bytes, + "2026-10-01T01:00:00Z")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var second: Dictionary = await coordinator.call("flush") + _expect_equal(second.get("status", ""), "ok", + "cross-client: the observed defeat uploads") + _expect_equal(_stable_ids(), [1, 1], + "cross-client: only the two local seals notified") + var commits_after_defeat: int = _commit_call_count(sender) + + # The crash client's intermediate state: a debited journal for a seal + # that never landed. Journaling alone publishes nothing. + var journaled: Dictionary = _valid_checkpoint("cb-remote-1", 5, 21) + _expect_true(Vault.begin_continue_txn("cb-remote-1", 5, + JSON.stringify(journaled)), + "cross-client: the newer debit journals") + _expect_equal(Vault.continue_coins, 1, + "cross-client: the journal charges exactly one coin") + _expect_equal(_stable_ids(), [1, 1], + "cross-client: the journal notifies nothing stable") + + # The acknowledged paid seal arrives from the other client. The + # comparison ignores the intermediate journal: same conflict, same + # install, and the journaled seal never uploads. + var revived_text: String = JSON.stringify( + _valid_checkpoint("cb-remote-1", 2, 14)) + sender.call("queue_ok", _remote_body(UID_A, 5, revived_text, + "2026-10-01T03:00:00Z")) + var restored: Dictionary = await coordinator.call("restore_from_cloud") + _expect_equal(restored.get("status", ""), "conflict", + "cross-client: the newer revive conflicts honestly") + _expect_equal(restored.get("code", ""), "restore-differs", + "cross-client: the journal does not preempt the dialog") + _expect_equal(_stable_ids(), [1, 1], + "cross-client: the conflict installs nothing") + var kept: Dictionary = await coordinator.call( + "resolve_conflict", "remote") + _expect_equal(kept.get("code", ""), "remote-kept", + "cross-client: the acknowledged revive installs by remote choice") + var installed: Dictionary = Journey.read_checkpoint() + _expect_false(Journey.is_ended(installed), + "cross-client: the installed seal is alive") + _expect_equal(int(installed.get("checkpoint_id", 0)), 2, + "cross-client: the installed seal is the remote one, not the journal") + _expect_equal(str(installed.get("journey_id", "")), "cb-remote-1", + "cross-client: the installed seal keeps the journey") + _expect_true(Journey.has_valid_checkpoint(), + "cross-client: the installed revive is resumable") + _expect_true((coordinator.call("conflict_snapshot") as Dictionary + ).is_empty(), "cross-client: the choice clears the dialog") + _expect_equal(_stable_ids(), [1, 1, 2], + "cross-client: only the acknowledged seal notifies") + _expect_equal(_commit_call_count(sender), commits_after_defeat, + "cross-client: the journaled seal never uploads") + _expect_equal(int((Vault.continue_txn as Dictionary).get( + "checkpoint_id", 0)), 5, + "cross-client: the cloud dance leaves the journal alone") + _expect_equal(Vault.continue_coins, 1, + "cross-client: the cloud dance charges nothing") + + # The journal still settles afterwards as one paid continuation. + _expect_equal(Vault.recover_paid_continue(), "recovered", + "cross-client: the journal still materializes afterwards") + _expect_equal(Vault.continue_coins, 1, + "cross-client: the materialize charges no second coin") + Journey.unsubscribe_stable_checkpoint(_on_crash_stable) + _close_coordinator(coordinator) + + func _test_save_conflict_after_baseline_match() -> void: _reset_all([GUEST_A]) var sender: RefCounted = FAKE_SENDER_SCRIPT.new() diff --git a/apps/game/tests/test_cloud_hall.gd b/apps/game/tests/test_cloud_hall.gd index 66c7ac8..afb75c1 100644 --- a/apps/game/tests/test_cloud_hall.gd +++ b/apps/game/tests/test_cloud_hall.gd @@ -30,6 +30,13 @@ func _run() -> void: await _test_equal_retry_is_idempotent() await _test_higher_score_updates_monotonically() await _test_write_race_reports_not_best() + await _test_submit_carries_verified_display() + await _test_submit_preserves_stored_display() + await _test_submit_rejects_forged_display() + await _test_unnamed_rows_render_honestly() + await _test_backfill_attaches_at_same_score() + await _test_backfill_invents_no_row() + await _test_backfill_never_downgrades() await _test_top_board_query_cache_and_throttle() await _test_rank_counts_strictly_greater() await _test_rank_throttle_bounds_and_account() @@ -110,6 +117,24 @@ func _rank_body(greater: int) -> String: }]) +func _named_row_body(score: int, display: String, hero: String = HERO, + cycles: int = 3) -> String: + return JSON.stringify({ + "name": "projects/%s/databases/(default)/documents/mb_hall_v1/%s" + % [PROJECT_ID, PUBLIC_ID], + "fields": { + "public_id": {"stringValue": PUBLIC_ID}, + "hero": {"stringValue": hero}, + "score": {"integerValue": str(score)}, + "cycles": {"integerValue": str(cycles)}, + "release": {"stringValue": "4.0.0"}, + "schema": {"integerValue": "1"}, + "updated_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + "display": {"stringValue": display}, + }, + }) + + func _row_body_for(public_id: String, score: int, hero: String = HERO, cycles: int = 3) -> String: return JSON.stringify({ @@ -262,6 +287,117 @@ func _test_write_race_reports_not_best() -> void: "monotonic-rule denial reads as not-best, not a crash") +func _test_submit_carries_verified_display() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var hall: RefCounted = HALL_SCRIPT.new() + var result: Dictionary = await hall.call("submit_best", + _new_transport(sender), PUBLIC_ID, HERO, 500, 3, "4.0.0", + "Luna") + _expect_equal(result.get("status", ""), "ok", "named submit ok") + var fields: Dictionary = (JSON.parse_string(str( + sender.call("last_call").get("body", ""))).get("writes", [])[0].get( + "update", {}) as Dictionary).get("fields", {}) + _expect_equal(str(fields.get("display", {}).get("stringValue", "")), + "Luna", "submit carries the verified display") + + +func _test_submit_preserves_stored_display() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_ok", _named_row_body(500, "Luna")) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var hall: RefCounted = HALL_SCRIPT.new() + var result: Dictionary = await hall.call("submit_best", + _new_transport(sender), PUBLIC_ID, HERO, 800, 4, "4.0.0") + _expect_equal(result.get("status", ""), "ok", + "cold-cache submit ok") + var fields: Dictionary = (JSON.parse_string(str( + sender.call("last_call").get("body", ""))).get("writes", [])[0].get( + "update", {}) as Dictionary).get("fields", {}) + _expect_equal(str(fields.get("display", {}).get("stringValue", "")), + "Luna", "cold cache keeps the row's own display") + + +func _test_submit_rejects_forged_display() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var hall: RefCounted = HALL_SCRIPT.new() + var result: Dictionary = await hall.call("submit_best", + _new_transport(sender), PUBLIC_ID, HERO, 500, 3, "4.0.0", + "a/b") + _expect_equal(result.get("code", ""), "invalid-name", + "forged display fails before any request") + _expect_equal(sender.calls.size(), 0, "forged display sends nothing") + + +func _test_unnamed_rows_render_honestly() -> void: + var hall: RefCounted = HALL_SCRIPT.new() + var own: Dictionary = hall.call("parse_row", _row_body(500)) + _expect_equal(str((own.get("row", {}) as Dictionary).get( + "display", "MISSING")), "", "unnamed own row reads empty") + var named: Dictionary = hall.call( + "parse_row", _named_row_body(500, "Luna")) + _expect_equal(str((named.get("row", {}) as Dictionary).get( + "display", "")), "Luna", "named own row reads its handle") + + +func _test_backfill_attaches_at_same_score() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_ok", _row_body(500, + "res://resources/heroes/keeper.tres", 7)) + sender.call("queue_ok", _row_body(500, + "res://resources/heroes/keeper.tres", 7)) + sender.call("queue_ok", "{\"writeResults\":[{}]}") + var hall: RefCounted = HALL_SCRIPT.new() + var result: Dictionary = await hall.call("backfill_display", + _new_transport(sender), PUBLIC_ID, "Luna") + _expect_equal(result.get("status", ""), "ok", "backfill ok") + _expect_true(bool(result.get("backfilled", false)), + "backfill reports backfilled") + var fields: Dictionary = (JSON.parse_string(str( + sender.call("last_call").get("body", ""))).get("writes", [])[0].get( + "update", {}) as Dictionary).get("fields", {}) + _expect_equal(str(fields.get("score", {}).get("integerValue", "")), + "500", "backfill keeps the row's own score") + _expect_equal(str(fields.get("hero", {}).get("stringValue", "")), + "res://resources/heroes/keeper.tres", + "backfill keeps the saved hero, not the selected one") + _expect_equal(str(fields.get("cycles", {}).get("integerValue", "")), + "7", "backfill keeps the saved cycles") + _expect_equal(str(fields.get("display", {}).get("stringValue", "")), + "Luna", "backfill attaches the handle") + + +func _test_backfill_invents_no_row() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_reply", {"transport": "ok", "code": 404, + "body": "missing"}) + var hall: RefCounted = HALL_SCRIPT.new() + var result: Dictionary = await hall.call("backfill_display", + _new_transport(sender), PUBLIC_ID, "Luna") + _expect_equal(result.get("status", ""), "ok", + "backfill without a row still ok") + _expect_false(bool(result.get("backfilled", true)), + "backfill without a row writes nothing") + _expect_equal(result.get("code", ""), "no-row", + "backfill without a row names no-row") + _expect_equal(sender.calls.size(), 1, "backfill without a row reads once") + + +func _test_backfill_never_downgrades() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + sender.call("queue_ok", _row_body(500)) + sender.call("queue_ok", _row_body(900)) + var hall: RefCounted = HALL_SCRIPT.new() + var result: Dictionary = await hall.call("backfill_display", + _new_transport(sender), PUBLIC_ID, "Luna") + _expect_equal(result.get("code", ""), "not-best", + "backfill against a newer best reports not-best, never writes it down") + _expect_equal(_own_get_count(sender), 2, + "the retry re-reads the row instead of assuming") + + func _test_top_board_query_cache_and_throttle() -> void: var sender: RefCounted = FAKE_SENDER_SCRIPT.new() sender.call("queue_ok", _top_body([900, 700])) diff --git a/apps/game/tests/test_cloud_name.gd b/apps/game/tests/test_cloud_name.gd new file mode 100644 index 0000000..f3840e7 --- /dev/null +++ b/apps/game/tests/test_cloud_name.gd @@ -0,0 +1,410 @@ +extends SceneTree + +## Adventurer name claim, restore, and intro-bit tests. No network: the +## transport runs against a scripted fake sender. + +const TRANSPORT_SCRIPT: Script = preload( + "res://scripts/cloud/cloud_transport.gd") +const NAME_SCRIPT: Script = preload("res://scripts/cloud/cloud_name.gd") +const SCHEMA_SCRIPT: Script = preload("res://scripts/cloud/cloud_schema.gd") +const FAKE_SENDER_SCRIPT: Script = preload( + "res://tests/support/fake_cloud_sender.gd") + +const PROJECT_ID: String = "moonlitbeacon-778ee" +const TOKEN: String = "test-id-token-abc" +const UID_A: String = "uid-alice-001" +const PUBLIC_A: String = "MB-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +var _failed: int = 0 +var _checked: int = 0 + + +func _init() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_normalization_matrix() + await _test_claim_body_shape() + await _test_first_claim() + await _test_restore_existing() + await _test_taken_name() + await _test_uncertain_ack_recovers() + await _test_invalid_name_fails_fast() + await _test_permission_denied_is_not_taken() + await _test_offline_claims_nothing() + await _test_mismatched_adventurer_never_adopted() + await _test_double_claim_converges() + await _test_intro_complete_idempotent() + await _test_intro_without_claim() + await _test_missing_transport() + if _failed > 0: + printerr("cloud name tests failed — ", _failed, "/", _checked, + " cases") + quit(1) + return + print("cloud name tests passed — ", _checked, " cases") + quit(0) + + +func _new_transport(sender: RefCounted) -> RefCounted: + var transport: RefCounted = TRANSPORT_SCRIPT.new() + transport.call("configure", PROJECT_ID, "web-key", + func() -> String: return TOKEN, sender.call("sender_callable")) + transport.call("set_account_uid", UID_A) + return transport + + +func _adventurer_get_body(uid: String, public_id: String, display: String, + key: String, intro_complete: bool) -> String: + return JSON.stringify({ + "name": "projects/%s/databases/(default)/documents/mb_adventurers_v1/%s" + % [PROJECT_ID, public_id], + "fields": { + "public_id": {"stringValue": public_id}, + "uid": {"stringValue": uid}, + "name_key": {"stringValue": key}, + "display": {"stringValue": display}, + "intro_complete": {"booleanValue": intro_complete}, + "schema": {"integerValue": "1"}, + "created_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + "updated_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + }, + }) + + +func _test_normalization_matrix() -> void: + var valid: Array = [ + ["Luna", "Luna", "luna"], + [" Abby ", "Abby", "abby"], + ["Moon Knight", "Moon Knight", "moon knight"], + ["a1_", "a1_", "a1_"], + ["ab", "ab", "ab"], + ["abcdefghijkl", "abcdefghijkl", "abcdefghijkl"], + ["루나", "루나", "루나"], + ["달빛기사단", "달빛기사단", "달빛기사단"], + ["ルミー", "ルミー", "ルミー"], + ["ひかり", "ひかり", "ひかり"], + ["月光", "月光", "月光"], + ["Moon月光_1", "Moon月光_1", "moon月光_1"], + ] + for row in valid: + var result: Dictionary = SCHEMA_SCRIPT.normalize_adventurer_name( + str(row[0])) + _expect_true(bool(result.get("ok", false)), + "names accept " + str(row[0])) + _expect_equal(str(result.get("display", "")), str(row[1]), + "display keeps " + str(row[0])) + _expect_equal(str(result.get("key", "")), str(row[2]), + "key folds " + str(row[0])) + var invalid: Array[String] = [ + "", " ", "a", "abcdefghijklm", " A", "A ", "A B", + "a/b", "a\\b", "a.b", "a-b", "a\tb", "a\nb", + "é", "e" + String.chr(0x0301), "ㅎㅎ", "😀ab", "AB", "・ab", + String.chr(0x00A0) + "Moon", "Moon" + String.chr(0x00A0), + "Mo" + String.chr(0x00A0) + "on", + ] + for raw in invalid: + var result: Dictionary = SCHEMA_SCRIPT.normalize_adventurer_name( + raw) + _expect_false(bool(result.get("ok", true)), + "names reject " + JSON.stringify(raw)) + _expect_equal(str(result.get("error", "")), "invalid-name", + "rejection names invalid-name") + + +func _test_claim_body_shape() -> void: + var names: RefCounted = NAME_SCRIPT.new() + var body: Dictionary = names.call("claim_commit_body", + UID_A, PUBLIC_A, "루나", "루나") + var writes: Array = body.get("writes", []) + _expect_equal(writes.size(), 2, "claim commits both halves") + for write in writes: + _expect_equal(str((write as Dictionary).get( + "currentDocument", {}).get("exists", true)), "false", + "claim halves require absence") + var text: String = JSON.stringify(body) + _expect_true(text.contains("루나"), + "commit JSON carries raw Unicode, never escapes") + _expect_false(text.contains("%"), + "commit JSON carries no percent-encoding") + var relative: String = str(names.call( + "name_relative_path", "루나")) + _expect_true(relative.contains("%") and not relative.contains("루나"), + "request URLs encode the name segment") + _expect_equal(relative.uri_decode(), + "documents/mb_names_v1/루나", + "the encoded segment decodes back exactly") + + +func _test_first_claim() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "first claim succeeds") + _expect_equal(str(claimed.get("display", "")), "Luna", + "claim returns the display") + _expect_equal(str(claimed.get("key", "")), "luna", + "claim returns the immutable key") + _expect_false(bool(claimed.get("restored", true)), + "first claim is not a restore") + _expect_false(bool(claimed.get("intro_complete", true)), + "first claim starts the tutorial incomplete") + _expect_equal((sender.calls as Array).size(), 2, + "claim reads once and commits once") + var commit: Dictionary = sender.last_call() + _expect_true(str(commit.get("body", "")).contains("mb_names_v1"), + "commit carries the name half") + _expect_true(str(commit.get("body", "")).contains("mb_adventurers_v1"), + "commit carries the adventurer half") + + +func _test_restore_existing() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_ok(_adventurer_get_body( + UID_A, PUBLIC_A, "Luna", "luna", true)) + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Other") + _expect_equal(str(claimed.get("status", "")), "ok", + "restore succeeds") + _expect_equal(str(claimed.get("display", "")), "Luna", + "restore returns the canonical display, not the new ask") + _expect_true(bool(claimed.get("restored", false)), + "restore is labeled restored") + _expect_true(bool(claimed.get("intro_complete", false)), + "restore returns the real completion bit") + _expect_equal((sender.calls as Array).size(), 1, + "restore commits nothing") + + +func _test_taken_name() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(claimed.get("status", "")), "conflict", + "taken name conflicts") + _expect_equal(str(claimed.get("code", "")), "name-taken", + "taken name names name-taken") + _expect_equal(str(claimed.get("key", "")), "luna", + "taken name echoes the losing key") + + +func _test_uncertain_ack_recovers() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_ok(_adventurer_get_body( + UID_A, PUBLIC_A, "Luna", "luna", false)) + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "uncertain ack recovers") + _expect_true(bool(claimed.get("restored", false)), + "uncertain ack restores the landed row") + _expect_equal(str(claimed.get("display", "")), "Luna", + "uncertain ack returns the canonical display") + _expect_false(bool(claimed.get("intro_complete", true)), + "uncertain ack carries the landed row's bit") + + +func _test_invalid_name_fails_fast() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "a/b") + _expect_equal(str(claimed.get("status", "")), "failure", + "invalid name fails") + _expect_equal(str(claimed.get("code", "")), "invalid-name", + "invalid name names invalid-name") + _expect_true((sender.calls as Array).is_empty(), + "invalid name sends nothing") + + +func _test_permission_denied_is_not_taken() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_reply( + {"transport": "ok", "code": 403, "body": "denied"}) + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(claimed.get("status", "")), "failure", + "denied commit fails") + _expect_equal(str(claimed.get("code", "")), "permission-denied", + "denied commit is never renamed to taken") + sender.reset() + sender.queue_reply( + {"transport": "ok", "code": 403, "body": "denied"}) + var loaded: Dictionary = await names.call("fetch_adventurer", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(loaded.get("code", "")), "permission-denied", + "denied read stays permission-denied") + + sender.reset() + sender.queue_reply( + {"transport": "ok", "code": 403, "body": "denied"}) + var blocked: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(blocked.get("status", "")), "failure", + "denied first read fails the claim") + _expect_equal(str(blocked.get("code", "")), "permission-denied", + "denied first read is never renamed to taken") + _expect_equal((sender.calls as Array).size(), 1, + "denied first read never reaches the commit") + + sender.reset() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_reply( + {"transport": "ok", "code": 403, "body": "denied"}) + var recheck: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(recheck.get("status", "")), "failure", + "denied recheck after conflict fails") + _expect_equal(str(recheck.get("code", "")), "permission-denied", + "denied recheck is never renamed to taken") + + +func _test_offline_claims_nothing() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "offline", "code": 0, "body": ""}) + var claimed: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(claimed.get("status", "")), "offline", + "offline claim reports offline") + _expect_equal((sender.calls as Array).size(), 1, + "offline claim never reaches the commit") + + +func _test_mismatched_adventurer_never_adopted() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_ok(_adventurer_get_body( + "uid-eve-999", PUBLIC_A, "Luna", "luna", false)) + var wrong_uid: Dictionary = await names.call("fetch_adventurer", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(wrong_uid.get("code", "")), "adventurer-mismatch", + "another UID's row is never adopted") + sender.reset() + sender.queue_ok(_adventurer_get_body( + UID_A, "MB-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "Luna", "luna", false)) + var wrong_id: Dictionary = await names.call("fetch_adventurer", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(wrong_id.get("code", "")), "adventurer-mismatch", + "another public ID's row is never adopted") + sender.reset() + sender.queue_ok(_adventurer_get_body( + UID_A, PUBLIC_A, "Luna", "bob", false)) + var wrong_key: Dictionary = await names.call("fetch_adventurer", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(wrong_key.get("code", "")), "adventurer-mismatch", + "a forged key is never adopted") + + +func _test_double_claim_converges() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + var first: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(first.get("status", "")), "ok", + "first tap claims") + sender.reset() + sender.queue_ok(_adventurer_get_body( + UID_A, PUBLIC_A, "Luna", "luna", false)) + var second: Dictionary = await names.call("claim_name", + _new_transport(sender), UID_A, PUBLIC_A, "Luna") + _expect_equal(str(second.get("status", "")), "ok", + "second tap succeeds") + _expect_true(bool(second.get("restored", false)), + "second tap restores instead of duplicating") + _expect_equal(str(second.get("key", "")), "luna", + "second tap returns the same key") + + +func _test_intro_complete_idempotent() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_ok(_adventurer_get_body( + UID_A, PUBLIC_A, "Luna", "luna", false)) + sender.queue_ok("{\"writeResults\": [{}]}") + var done: Dictionary = await names.call("mark_intro_complete", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(done.get("status", "")), "ok", + "intro completes") + _expect_true(bool(done.get("intro_complete", false)), + "intro reports complete") + var update: Dictionary = sender.last_call() + _expect_true(str(update.get("body", "")).contains("\"exists\":true"), + "intro update requires the row") + _expect_true(str(update.get("body", "")).contains( + "\"booleanValue\":true"), + "intro update flips the bit") + sender.reset() + sender.queue_ok(_adventurer_get_body( + UID_A, PUBLIC_A, "Luna", "luna", true)) + var again: Dictionary = await names.call("mark_intro_complete", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(again.get("status", "")), "ok", + "repeat intro succeeds") + _expect_equal((sender.calls as Array).size(), 1, + "repeat intro writes nothing") + + +func _test_intro_without_claim() -> void: + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var names: RefCounted = NAME_SCRIPT.new() + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var done: Dictionary = await names.call("mark_intro_complete", + _new_transport(sender), UID_A, PUBLIC_A) + _expect_equal(str(done.get("code", "")), "adventurer-not-found", + "intro without a claim reports not-found") + _expect_equal((sender.calls as Array).size(), 1, + "intro without a claim writes nothing") + + +func _test_missing_transport() -> void: + var names: RefCounted = NAME_SCRIPT.new() + var claimed: Dictionary = await names.call("claim_name", + null, UID_A, PUBLIC_A, "Luna") + _expect_equal(str(claimed.get("status", "")), "unconfigured", + "claim without transport is unconfigured") + var loaded: Dictionary = await names.call("fetch_adventurer", + null, UID_A, PUBLIC_A) + _expect_equal(str(loaded.get("status", "")), "unconfigured", + "fetch without transport is unconfigured") + + +func _expect_true(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + printerr("FAIL: ", label) + + +func _expect_false(value: bool, label: String) -> void: + _checked += 1 + if value: + _failed += 1 + printerr("FAIL: ", label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual != expected: + _failed += 1 + printerr("FAIL: ", label, " — expected=", expected, + " actual=", actual) diff --git a/apps/game/tests/test_cloud_name.gd.uid b/apps/game/tests/test_cloud_name.gd.uid new file mode 100644 index 0000000..548010e --- /dev/null +++ b/apps/game/tests/test_cloud_name.gd.uid @@ -0,0 +1 @@ +uid://dly12p3tohots diff --git a/apps/game/tests/test_crash_boundaries.gd b/apps/game/tests/test_crash_boundaries.gd new file mode 100644 index 0000000..f5aef35 --- /dev/null +++ b/apps/game/tests/test_crash_boundaries.gd @@ -0,0 +1,680 @@ +extends Node + +## Crash-boundary regressions: the actual defeat file pair stays terminal +## through a failed primary, and a paid continuation debits durably before +## any alive seal is materialized, notified, or uploaded. +## +## The director reproduced both defects in isolated storage: the real +## defeat writer's pair revived after one corrupt main, and `continue_run()` +## stranded a resumable checkpoint with no durable debit at the alive +## stable hook. These pin the guards with the real writer, real reloads, +## real title/host/arena entry, and fault injection at each durable +## boundary — never a hand-built file pair or a simulated ordering. The +## cross-client half lives in `test_cloud_coordinator.gd`, beside its +## fixture. Uses temp journey paths under the isolated test HOME, so no +## human save is touched. The Vault autoload is real but isolated. + +const ARENA_SCENE: PackedScene = preload("res://scenes/gameplay/arena.tscn") +const TITLE_SCENE: PackedScene = preload("res://scenes/menus/title_menu.tscn") +const TEST_PATH: String = "user://test_crash.json" +const TEST_BACKUP: String = "user://test_crash.json.bak" +const VAULT_TMP: String = "user://vault.cfg.tmp" +const SHARP_MOON: String = "res://resources/relics/sharp_moon.tres" + +var _failed: int = 0 +var _checked: int = 0 +var _stable_texts: Array[String] = [] + + +func _ready() -> void: + if not _is_isolated(): + get_tree().quit(2) + return + process_mode = Node.PROCESS_MODE_ALWAYS + get_tree().paused = false + _run.call_deferred() + + +func _run() -> void: + _fresh_paths() + _test_actual_defeat_pair_survives_failed_primary() + _fresh_paths() + await _test_failed_primary_hides_title_and_host_gate() + _fresh_paths() + _test_paid_debit_lands_before_seal() + _fresh_paths() + await _test_double_fault_keeps_terminal_then_retries() + _fresh_paths() + await _test_stuck_revive_asks_before_boot() + _fresh_paths() + await _test_double_tap_debits_once() + + _restore_paths() + Journey.disarm() + if _failed > 0: + printerr("crash boundary tests failed — ", _failed, "/", _checked, + " case(s)") + get_tree().quit(1) + return + print("crash boundary tests passed — ", _checked, " case(s)") + get_tree().quit(0) + + +func _is_isolated() -> bool: + var expected_root: String = OS.get_environment( + "MOONLIT" + "_VAULT" + "_TEST" + "_ROOT").simplify_path() + var user_root: String = ProjectSettings.globalize_path("user://").simplify_path() + var safe: bool = not expected_root.is_empty() and user_root.begins_with(expected_root + "/") + if not safe: + printerr("crash boundary tests aborted: user:// path is not isolated — ", user_root) + return safe + + +func _fresh_paths() -> void: + Journey.path = TEST_PATH + Journey.backup_path = TEST_BACKUP + for candidate in [TEST_PATH, TEST_BACKUP, TEST_PATH + ".tmp", + TEST_BACKUP + ".tmp"]: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + for occupied in [TEST_PATH + ".tmp", TEST_BACKUP + ".tmp", VAULT_TMP]: + if DirAccess.dir_exists_absolute( + ProjectSettings.globalize_path(occupied)): + DirAccess.remove_absolute(ProjectSettings.globalize_path(occupied)) + Journey.clear_stable_hooks() + _stable_texts.clear() + Vault.continue_txn = {} + Journey.disarm() + Journey.last_error = "" + Journey.install_fault = Journey.InstallFault.NONE + RunEntry.from_title = false + get_tree().paused = false + + +func _restore_paths() -> void: + for candidate in [TEST_PATH, TEST_BACKUP, TEST_PATH + ".tmp", + TEST_BACKUP + ".tmp", VAULT_TMP]: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(VAULT_TMP)): + DirAccess.remove_absolute(ProjectSettings.globalize_path(VAULT_TMP)) + Journey.path = Journey.DEFAULT_PATH + Journey.backup_path = Journey.DEFAULT_BACKUP_PATH + Journey.clear_stable_hooks() + _stable_texts.clear() + Vault.continue_txn = {} + Journey.disarm() + Journey.last_error = "" + Journey.install_fault = Journey.InstallFault.NONE + RunEntry.from_title = false + get_tree().paused = false + + +## A human run starts from the title: writes armed, entry marked. +func _begin_human_fresh() -> void: + Journey.begin_fresh() + RunEntry.mark_from_title() + + +func _write_text(candidate: String, text: String) -> void: + var handle: FileAccess = FileAccess.open(candidate, FileAccess.WRITE) + handle.store_string(text) + handle.close() + + +## Raw JSON of one file, bypassing the Journey validator. Empty when the +## file is missing or malformed. +func _read_json_file(candidate: String) -> Dictionary: + if not FileAccess.file_exists(candidate): + return {} + var parser: JSON = JSON.new() + if parser.parse(FileAccess.get_file_as_string(candidate)) != OK: + return {} + return parser.data as Dictionary if parser.data is Dictionary else {} + + +func _valid_checkpoint(journey_id: String, checkpoint_id: int) -> Dictionary: + return { + "schema_version": 1, + "journey_id": journey_id, + "checkpoint_id": checkpoint_id, + "cycle": 3, + "zone_index": 1, + "route": [0, 4, -1], + "run_seed": 7, + "hero_path": Vault.HEROES[0], + "relic_stacks": {SHARP_MOON: 2}, + "level": 4, + "to_next": 12, + "level_progress": 3, + "missile_power": 2, + "missile_progress": 1, + "first_core_collected": true, + "kills": 20, + "kill_score": 300, + "survived": 120.0, + "lit_count": 1, + "overcharge_successes": 0, + "guardian_meetings": {"0": 1}, + "places_seen": ["forest"], + "shards_awarded": 5, + "settled_score": 2400, + "gate_direction": [1.0, 0.0], + "opening_played": true, + "saved_at_unix": 1700000000, + } + + +## Occupy a path as a directory so the next write there fails. Always paired +## with `_release_dir`. +func _occupy_as_dir(candidate: String) -> void: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + DirAccess.make_dir_absolute(ProjectSettings.globalize_path(candidate)) + + +func _release_dir(candidate: String) -> void: + if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(candidate)): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + + +func _on_stable(info: Dictionary) -> void: + _stable_texts.append(str(info.get("text", ""))) + + +func _stable_ids() -> Array: + var ids: Array = [] + for text in _stable_texts: + var parser: JSON = JSON.new() + if parser.parse(text) != OK: + ids.append(-1) + continue + var data: Dictionary = parser.data as Dictionary + ids.append(int(data.get("checkpoint_id", -1))) + return ids + + +func _stable_ended_flags() -> Array: + var flags: Array = [] + for text in _stable_texts: + var parser: JSON = JSON.new() + if parser.parse(text) != OK: + flags.append(null) + continue + var data: Dictionary = parser.data as Dictionary + flags.append(bool(data.get("ended", false))) + return flags + + +func _find_button(node: Node, button_name: String) -> Button: + if node is Button and node.name == StringName(button_name): + return node as Button + for child in node.get_children(): + var found: Button = _find_button(child, button_name) + if found != null: + return found + return null + + +func _claim_threaded_arena(label: String) -> void: + var progress: Array = [] + var status: ResourceLoader.ThreadLoadStatus = \ + ResourceLoader.load_threaded_get_status( + ARENA_SCENE.resource_path, progress) + var guard: int = 0 + while status == ResourceLoader.THREAD_LOAD_IN_PROGRESS and guard < 600: + guard += 1 + await get_tree().process_frame + status = ResourceLoader.load_threaded_get_status( + ARENA_SCENE.resource_path, progress) + var claimed: Resource = ResourceLoader.load_threaded_get( + ARENA_SCENE.resource_path) as Resource + _expect_true(claimed != null, "%s drains its arena load" % label) + + +## Defeat one arena run through the real route and return it over. +func _defeat_arena(cycle: int, zone: int) -> Node2D: + _begin_human_fresh() + var arena: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(arena) + await get_tree().process_frame + await get_tree().process_frame + arena.set("_cycle", cycle) + arena.set("_zone_index", zone) + _expect_true(bool(arena.call("_journey_checkpoint", true)), + "the gate seals before the death") + arena.call("_finish", false) + await get_tree().process_frame + _expect_true(bool(arena.get("_over")), "the run is over") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "death seals the marker on the main") + return arena + + +# --- defeat crash pair -------------------------------------------------------- +## The file pair left by the real defeat writer — alive seals, then the +## terminal marker — cannot resurrect after one failed primary. Corrupt or +## delete the main and an independent reload still reads ended; the backup +## heals the main forward as terminal. A healthy alive pair still recovers +## from its backup, so the seal rule did not break living recovery. +func _test_actual_defeat_pair_survives_failed_primary() -> void: + var first: Dictionary = _valid_checkpoint("cb-pair", 1) + _expect_equal(Journey.write_checkpoint(first), OK, + "the first alive seal writes") + var second: Dictionary = _valid_checkpoint("cb-pair", 2) + _expect_equal(Journey.write_checkpoint(second), OK, + "the second alive seal writes") + var sealed: Dictionary = second.duplicate(true) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "the real writer seals the defeat") + _expect_true(bool(_read_json_file(TEST_BACKUP).get("ended", false)), + "the writer's own pair carries the seal in the backup") + + _write_text(TEST_PATH, "{a corrupt main is not a checkpoint") + var recovered: Dictionary = Journey.read_checkpoint() + _expect_true(not recovered.is_empty(), + "a corrupt main falls back instead of vanishing") + _expect_true(Journey.is_ended(recovered), + "the fallback reads ended, never alive") + _expect_equal(int(recovered.get("checkpoint_id", 0)), 2, + "the fallback keeps the seal id") + _expect_false(Journey.has_valid_checkpoint(), + "the corrupt main offers no free resume") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "the fallback heals the main forward as terminal") + + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) + var revived: Dictionary = Journey.read_checkpoint() + _expect_true(Journey.is_ended(revived), + "a deleted main still reads ended from the backup") + _expect_false(Journey.has_valid_checkpoint(), + "the deleted main offers no free resume") + + # Each fallback read above healed the main back; both must go now. + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_BACKUP)) + _expect_true(Journey.read_checkpoint().is_empty(), + "no pair at all reads empty, not alive") + _expect_false(Journey.has_valid_checkpoint(), + "no pair at all offers no resume") + + # Control: an alive pair still recovers its run from the backup. + var alive_first: Dictionary = _valid_checkpoint("cb-alive", 1) + _expect_equal(Journey.write_checkpoint(alive_first), OK, + "control: the alive seal writes") + var alive_second: Dictionary = _valid_checkpoint("cb-alive", 2) + _expect_equal(Journey.write_checkpoint(alive_second), OK, + "control: the second alive seal writes") + _write_text(TEST_PATH, "{a corrupt main is not a checkpoint") + var living: Dictionary = Journey.read_checkpoint() + _expect_false(Journey.is_ended(living), + "control: the alive backup recovers a living run") + _expect_equal(int(living.get("checkpoint_id", 0)), 1, + "control: the recovery is the older alive seal") + _expect_true(Journey.has_valid_checkpoint(), + "control: living recovery stays resumable") + + +## The failed primary hides every entry: the production host reports no +## save, and the title shows no Continue. The same title then proves an +## alive pair still offers its run. +func _test_failed_primary_hides_title_and_host_gate() -> void: + var first: Dictionary = _valid_checkpoint("cb-gate", 1) + _expect_equal(Journey.write_checkpoint(first), OK, + "gate: the alive seal writes") + var sealed: Dictionary = _valid_checkpoint("cb-gate", 1) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "gate: the defeat seals") + _write_text(TEST_PATH, "{a corrupt main is not a checkpoint") + var summary: Dictionary = ProductionHost.saved_gate_summary() + _expect_false(bool(summary.get("has_save", true)), + "gate: the host reports no save for the failed primary") + _expect_false(bool(summary.get("revive_stuck", true)), + "gate: no journal means no stuck revive") + + var title: Control = TITLE_SCENE.instantiate() as Control + add_child(title) + await get_tree().process_frame + await get_tree().process_frame + var panel: Control = title.get_node("Ui/Screen/JourneyPanel") as Control + _expect_false(panel.visible, + "gate: the title shows no Continue for the failed primary") + _expect_equal(Journey.pending, Journey.Pending.NONE, + "gate: nothing arms behind the hidden panel") + title.queue_free() + await get_tree().process_frame + + # Control: the same title offers a living run after an alive quit. + _fresh_paths() + var living: Dictionary = _valid_checkpoint("cb-gate-alive", 3) + _expect_equal(Journey.write_checkpoint(living), OK, + "gate control: the alive quit seals") + var live_title: Control = TITLE_SCENE.instantiate() as Control + add_child(live_title) + await get_tree().process_frame + await get_tree().process_frame + var live_panel: Control = live_title.get_node( + "Ui/Screen/JourneyPanel") as Control + _expect_true(live_panel.visible, + "gate control: the title offers the living run") + _expect_true(_find_button(live_panel, "JourneyContinue") != null, + "gate control: the living run keeps its Continue") + live_title.queue_free() + await get_tree().process_frame + + +# --- paid debit before seal --------------------------------------------------- +## The coin debit is durably acknowledged before the revive seal exists +## anywhere resumable: the journal lands on disk first, the seal +## materializes only from it, and the stable hook fires only for the +## landed seal. A crash between the two recovers exactly one paid +## continuation without another charge; the retry never re-notifies. +func _test_paid_debit_lands_before_seal() -> void: + Vault.continue_coins = 2 + _expect_equal(Vault.save_vault(), OK, "debit: the purse persists") + var first: Dictionary = _valid_checkpoint("cb-debit", 1) + _expect_equal(Journey.write_checkpoint(first), OK, + "debit: the alive seal writes") + var second: Dictionary = _valid_checkpoint("cb-debit", 2) + _expect_equal(Journey.write_checkpoint(second), OK, + "debit: the last alive seal writes") + var sealed: Dictionary = second.duplicate(true) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "debit: the defeat seals") + _expect_false(Journey.has_valid_checkpoint(), + "debit: the sealed run is not resumable") + + Journey.subscribe_stable_checkpoint(_on_stable) + var revive: Dictionary = _valid_checkpoint("cb-debit", 3) + _expect_true(Vault.begin_continue_txn("cb-debit", 3, + JSON.stringify(revive)), "debit: the journaled debit lands") + _expect_equal(Vault.continue_coins, 1, + "debit: the journal charges exactly one coin") + _expect_true(_stable_texts.is_empty(), + "debit: journaling alone notifies nothing stable") + _expect_false(Journey.has_valid_checkpoint(), + "debit: the journal alone resumes nothing") + + # The crash: the purse file cannot be rewritten, then the process + # dies. A relaunch reloads the acknowledged debit from disk. + _occupy_as_dir(VAULT_TMP) + Vault.load_vault() + _expect_equal(Vault.continue_coins, 1, + "debit: the reload keeps the acknowledged charge") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "debit: the reload keeps the journaled seal") + _expect_equal(Vault.recover_paid_continue(), "recovered", + "debit: the relaunch materializes the paid seal") + _expect_equal(_stable_ids(), [3], + "debit: the hook fires once, for the landed seal") + _expect_equal(_stable_ended_flags(), [false], + "debit: the landed seal is alive") + _expect_true(Journey.has_valid_checkpoint(), + "debit: the paid seal is resumable") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "debit: the blocked purse keeps the journal for retry") + + # The retry with a working purse delivers without charging again. + _release_dir(VAULT_TMP) + _expect_equal(Vault.recover_paid_continue(), "delivered", + "debit: the retry delivers the landed seal") + _expect_equal(Vault.continue_coins, 1, + "debit: the retry charges no second coin") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "debit: the delivery clears the journal") + _expect_equal(_stable_ids(), [3], + "debit: the retry never re-notifies the seal") + Vault.load_vault() + _expect_equal(Vault.continue_coins, 1, + "debit: balance and receipts agree after reload") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "debit: no journal survives the reload") + Journey.unsubscribe_stable_checkpoint(_on_stable) + + # Control: an alive seal written before any debit is immediately + # resumable with the coin unspent. A passing control documents the + # leak the journal-first order exists to close. + _fresh_paths() + Vault.continue_coins = 2 + var early: Dictionary = _valid_checkpoint("cb-early", 3) + Journey.subscribe_stable_checkpoint(_on_stable) + _expect_equal(Journey.write_checkpoint(early), OK, + "control: the pre-debit seal writes") + _expect_equal(_stable_ids(), [3], + "control: the pre-debit seal notifies while unpaid") + _expect_true(Journey.has_valid_checkpoint(), + "control: the pre-debit seal resumes with coins unspent") + _expect_equal(Vault.continue_coins, 2, + "control: no coin moved for the early seal") + Journey.unsubscribe_stable_checkpoint(_on_stable) + + +# --- double fault --------------------------------------------------------------- +## A blocked purse — alone, then together with failed journey installs — +## keeps the defeat terminal: no alive checkpoint, no stable alive +## payload, no journal, no charge. The retry with working storage then +## revives exactly once. +func _test_double_fault_keeps_terminal_then_retries() -> void: + Vault.continue_coins = 2 + Vault.save_vault() + Journey.subscribe_stable_checkpoint(_on_stable) + var arena: Node2D = await _defeat_arena(2, 1) + var sealed_id: int = int( + (arena.get("_journey_snapshot") as Dictionary)["checkpoint_id"]) + _stable_texts.clear() + # A blocked purse alone already refuses: the debit is attempted before + # any alive seal exists, so a swapped order would strand a free resume + # here instead. + _occupy_as_dir(VAULT_TMP) + _expect_false(bool(arena.call("continue_run")), + "double fault: the blocked purse refuses first") + _expect_equal(Vault.continue_coins, 2, + "double fault: the blocked purse spends nothing") + _expect_true(_stable_texts.is_empty(), + "double fault: the blocked purse notifies nothing") + _expect_false(Journey.has_valid_checkpoint(), + "double fault: the blocked purse resumes nothing") + Journey.install_fault = Journey.InstallFault.FAIL_ALL + _expect_false(bool(arena.call("continue_run")), + "double fault: the revive refuses") + _expect_equal(Vault.continue_coins, 2, + "double fault: the balance stays whole") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "double fault: no journal survives the refused debit") + _expect_true(_stable_texts.is_empty(), + "double fault: no alive payload reaches the hook") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "double fault: the main stays sealed") + _expect_false(Journey.has_valid_checkpoint(), + "double fault: nothing is resumable") + _expect_false(bool(ProductionHost.saved_gate_summary().get( + "has_save", true)), + "double fault: the host gate stays closed") + _expect_true(bool(arena.get("_over")), + "double fault: the arena stays defeated") + _expect_equal(Vault.recover_paid_continue(), "none", + "double fault: recovery finds no transaction") + + _release_dir(VAULT_TMP) + Journey.install_fault = Journey.InstallFault.NONE + _expect_true(bool(arena.call("continue_run")), + "double fault: the retry revives") + _expect_equal(Vault.continue_coins, 1, + "double fault: the retry debits exactly one coin") + _expect_equal(int(_read_json_file(TEST_PATH).get("checkpoint_id", 0)), + sealed_id + 1, "double fault: the retry seals once past the death") + _expect_equal(_stable_ids(), [sealed_id + 1], + "double fault: the hook fires once, for the paid seal") + _expect_true(Journey.has_valid_checkpoint(), + "double fault: the paid seal is resumable") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "double fault: the landed seal clears the journal") + Journey.unsubscribe_stable_checkpoint(_on_stable) + arena.queue_free() + await get_tree().process_frame + + +# --- stuck revive ------------------------------------------------------------- +## A journaled debit whose seal cannot materialize never boots: the host +## reports a stuck revive instead of a save, and the title asks before +## anything starts. Releasing storage delivers the paid seal without +## another charge; abandoning it instead starts fresh only by confirm. +func _test_stuck_revive_asks_before_boot() -> void: + Vault.continue_coins = 2 + _expect_equal(Vault.save_vault(), OK, "stuck: the purse persists") + var first: Dictionary = _valid_checkpoint("cb-stuck", 1) + _expect_equal(Journey.write_checkpoint(first), OK, + "stuck: the alive seal writes") + var sealed: Dictionary = first.duplicate(true) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "stuck: the defeat seals") + var revive: Dictionary = _valid_checkpoint("cb-stuck", 2) + _expect_true(Vault.begin_continue_txn("cb-stuck", 2, + JSON.stringify(revive)), "stuck: the debit journals") + _expect_equal(Vault.continue_coins, 1, + "stuck: the journal charges exactly one coin") + Journey.install_fault = Journey.InstallFault.FAIL_ALL + + var summary: Dictionary = ProductionHost.saved_gate_summary() + _expect_false(bool(summary.get("has_save", true)), + "stuck: the host offers no save") + _expect_true(bool(summary.get("revive_stuck", false)), + "stuck: the host names the stuck revive") + _expect_false(Journey.has_valid_checkpoint(), + "stuck: the sealed run is not resumable") + + var title: Control = TITLE_SCENE.instantiate() as Control + add_child(title) + title.start_requested.disconnect(Callable(title, "_enter_arena")) + await get_tree().process_frame + await get_tree().process_frame + var panel: Control = title.get_node("Ui/Screen/JourneyPanel") as Control + _expect_true(panel.visible, "stuck: the title names the failure") + var resume_button: Button = _find_button(panel, "JourneyContinue") + _expect_true(resume_button != null + and not resume_button.is_visible_in_tree(), + "stuck: no Continue boots the unopenable seal") + var erase_button: Button = _find_button(panel, "JourneyErase") + _expect_false(erase_button.is_visible_in_tree(), + "stuck: the confirm waits for an explicit tap") + title.request_start() + await get_tree().process_frame + _expect_true(erase_button.is_visible_in_tree(), + "stuck: the tap asks instead of booting") + _expect_equal(Journey.pending, Journey.Pending.NONE, + "stuck: the tap arms no resume") + _expect_false(Journey.armed, "stuck: the tap arms no writes") + + # Releasing storage delivers the paid seal without another charge. + Journey.install_fault = Journey.InstallFault.NONE + _expect_equal(Vault.recover_paid_continue(), "recovered", + "stuck: released storage materializes the paid seal") + _expect_equal(Vault.continue_coins, 1, + "stuck: the delivery charges no second coin") + _expect_true(Journey.has_valid_checkpoint(), + "stuck: the paid seal is resumable") + title.queue_free() + await get_tree().process_frame + + # Abandoning the stuck revive instead starts fresh only by confirm. + _fresh_paths() + Vault.continue_coins = 2 + Vault.save_vault() + var doomed: Dictionary = _valid_checkpoint("cb-abandon", 1) + doomed["ended"] = true + _expect_equal(Journey.write_checkpoint(doomed), OK, + "stuck: the second defeat seals") + var doomed_revive: Dictionary = _valid_checkpoint("cb-abandon", 2) + _expect_true(Vault.begin_continue_txn("cb-abandon", 2, + JSON.stringify(doomed_revive)), "stuck: the second debit journals") + Journey.install_fault = Journey.InstallFault.FAIL_ALL + var second: Control = TITLE_SCENE.instantiate() as Control + add_child(second) + second.start_requested.disconnect(Callable(second, "_enter_arena")) + await get_tree().process_frame + await get_tree().process_frame + var second_panel: Control = second.get_node( + "Ui/Screen/JourneyPanel") as Control + _find_button(second_panel, "JourneyNew").pressed.emit() + await get_tree().process_frame + _expect_equal(Journey.pending, Journey.Pending.NONE, + "stuck: asking alone starts nothing") + Journey.install_fault = Journey.InstallFault.NONE + _find_button(second_panel, "JourneyErase").pressed.emit() + _expect_equal(Journey.pending, Journey.Pending.FRESH, + "stuck: the confirmed erase starts fresh") + _expect_true(Journey.armed, "stuck: the fresh start arms writes") + # The confirmed boot is a real fresh arena: a new journey, the first + # cycle, the moot journal cleared, the abandoned coin untouched. + var booted: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(booted) + await get_tree().process_frame + await get_tree().process_frame + _expect_equal(int(booted.get("_cycle")), 1, + "stuck: the fresh boot begins the first cycle") + _expect_false(str(booted.get("_journey_id")) == "cb-abandon", + "stuck: the fresh boot leaves the doomed journey behind") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "stuck: the fresh boot clears the moot journal") + _expect_equal(Vault.continue_coins, 1, + "stuck: the abandoned coin is neither refunded nor double-spent") + booted.queue_free() + await get_tree().process_frame + await get_tree().create_timer(1.2).timeout + second.queue_free() + await get_tree().process_frame + await _claim_threaded_arena("Stuck erase") + Journey.disarm() + + +# --- double tap ----------------------------------------------------------------- +## Two rapid taps on the result screen revive once: the first debits one +## coin and seals one past the death, the second finds a living run and +## spends nothing. +func _test_double_tap_debits_once() -> void: + Vault.continue_coins = 2 + Journey.subscribe_stable_checkpoint(_on_stable) + var arena: Node2D = await _defeat_arena(3, 0) + var sealed_id: int = int( + (arena.get("_journey_snapshot") as Dictionary)["checkpoint_id"]) + _stable_texts.clear() + _expect_true(bool(arena.call("continue_run")), + "double tap: the first tap revives") + _expect_false(bool(arena.call("continue_run")), + "double tap: the second tap finds a living run") + _expect_equal(Vault.continue_coins, 1, + "double tap: exactly one coin moves") + _expect_equal(int(_read_json_file(TEST_PATH).get("checkpoint_id", 0)), + sealed_id + 1, "double tap: exactly one seal lands past the death") + _expect_equal(_stable_ids(), [sealed_id + 1], + "double tap: the hook fires exactly once") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "double tap: no journal survives the revive") + _expect_equal(int(arena.get("_analytics_continues")), 1, + "double tap: the run counts one continuation") + Journey.unsubscribe_stable_checkpoint(_on_stable) + arena.queue_free() + await get_tree().process_frame + + +# --- expectations --------------------------------------------------------------- +func _expect_true(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + printerr("FAIL: ", label) + + +func _expect_false(value: bool, label: String) -> void: + _expect_true(not value, label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual != expected: + _failed += 1 + printerr("FAIL: ", label, " — got ", actual, ", want ", expected) diff --git a/apps/game/tests/test_crash_boundaries.gd.uid b/apps/game/tests/test_crash_boundaries.gd.uid new file mode 100644 index 0000000..e484fd0 --- /dev/null +++ b/apps/game/tests/test_crash_boundaries.gd.uid @@ -0,0 +1 @@ +uid://dno4udkabesxi diff --git a/apps/game/tests/test_crash_boundaries.tscn b/apps/game/tests/test_crash_boundaries.tscn new file mode 100644 index 0000000..1c81c2b --- /dev/null +++ b/apps/game/tests/test_crash_boundaries.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/test_crash_boundaries.gd" id="1"] + +[node name="CrashBoundariesTest" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tests/test_defeat_terminal.gd b/apps/game/tests/test_defeat_terminal.gd new file mode 100644 index 0000000..bb2a706 --- /dev/null +++ b/apps/game/tests/test_defeat_terminal.gd @@ -0,0 +1,718 @@ +extends Node + +## Sealed defeat: the terminal marker outranks backup and stale data, the +## paid continue is transactional, and a fresh expedition resets the run +## while wallets, unlocks and records survive. +## +## Death seals the journey file with `ended: true` at the last seal's id. No +## title, relaunch, backup or cloud path resumes it; only a coin continue in +## the live arena replaces the marker with a new alive seal, spending exactly +## one coin. These pin the parts a regression could quietly break: the main +## file beating an older alive backup, the title hiding Continue for a sealed +## run but showing it after an alive quit, the fresh cycle-1/zone-0 reset +## with permanent wealth kept, the revive preserving hero/cycle/score/growth +## in place, and every failure (zero coins, blocked journey write, blocked +## Vault save, blocked defeat seal) keeping balances and terminal status +## safe. Cloud precedence has its own cases in `test_cloud_coordinator.gd`. +## +## Uses temp journey/onboarding/chronicle paths under the isolated test HOME, +## so no human save is touched. The Vault and Records autoloads are real but +## isolated; assertions use deltas, never absolutes. + +const ARENA_SCENE: PackedScene = preload("res://scenes/gameplay/arena.tscn") +const TITLE_SCENE: PackedScene = preload("res://scenes/menus/title_menu.tscn") +const TEST_PATH: String = "user://test_defeat.json" +const TEST_BACKUP: String = "user://test_defeat.json.bak" +const TEST_ONBOARD: String = "user://test_defeat_onboarding.json" +const TEST_CHRONICLE: String = "user://test_defeat_chronicle.json" +const VAULT_TMP: String = "user://vault.cfg.tmp" +const SHARP_MOON: String = "res://resources/relics/sharp_moon.tres" +const TOUGH_LIFE: String = "res://resources/relics/tough_life.tres" + +var _failed: int = 0 +var _checked: int = 0 + + +func _ready() -> void: + if not _is_isolated(): + get_tree().quit(2) + return + process_mode = Node.PROCESS_MODE_ALWAYS + get_tree().paused = false + _run.call_deferred() + + +func _run() -> void: + _fresh_paths() + _test_terminal_main_outranks_alive_backup() + _fresh_paths() + await _test_death_hides_title_continue_alive_quit_shows_it() + _fresh_paths() + await _test_fresh_restart_resets_run_keeps_permanents() + _fresh_paths() + await _test_coin_continue_revives_transactionally() + _fresh_paths() + await _test_coin_continue_zero_and_save_failures() + _fresh_paths() + await _test_defeat_seal_failure_blocks_exit() + _fresh_paths() + await _test_alive_quit_resumes() + + _restore_paths() + Journey.disarm() + if _failed > 0: + printerr("defeat test failed — ", _failed, "/", _checked, " case(s)") + get_tree().quit(1) + return + print("defeat test passed — ", _checked, " case(s)") + get_tree().quit(0) + + +func _is_isolated() -> bool: + var expected_root: String = OS.get_environment( + "MOONLIT" + "_VAULT" + "_TEST" + "_ROOT").simplify_path() + var user_root: String = ProjectSettings.globalize_path("user://").simplify_path() + var safe: bool = not expected_root.is_empty() and user_root.begins_with(expected_root + "/") + if not safe: + printerr("defeat test aborted: user:// path is not isolated — ", user_root) + return safe + + +func _fresh_paths() -> void: + Journey.path = TEST_PATH + Journey.backup_path = TEST_BACKUP + Onboarding.path = TEST_ONBOARD + Chronicle.path = TEST_CHRONICLE + for candidate in [TEST_PATH, TEST_BACKUP, TEST_PATH + ".tmp", + TEST_BACKUP + ".tmp", TEST_ONBOARD, TEST_ONBOARD + ".tmp", + TEST_CHRONICLE]: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + for occupied in [TEST_PATH + ".tmp", TEST_BACKUP + ".tmp", VAULT_TMP]: + if DirAccess.dir_exists_absolute( + ProjectSettings.globalize_path(occupied)): + DirAccess.remove_absolute(ProjectSettings.globalize_path(occupied)) + Onboarding.forget_cache() + Chronicle.forget_cache() + Journey.disarm() + Journey.last_error = "" + Journey.install_fault = Journey.InstallFault.NONE + RunEntry.from_title = false + get_tree().paused = false + + +func _restore_paths() -> void: + for candidate in [TEST_PATH, TEST_BACKUP, TEST_PATH + ".tmp", TEST_ONBOARD, + TEST_ONBOARD + ".tmp", TEST_CHRONICLE]: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + Journey.path = Journey.DEFAULT_PATH + Journey.backup_path = Journey.DEFAULT_BACKUP_PATH + Onboarding.path = Onboarding.DEFAULT_PATH + Chronicle.path = Chronicle.DEFAULT_PATH + Onboarding.forget_cache() + Chronicle.forget_cache() + Journey.disarm() + Journey.last_error = "" + RunEntry.from_title = false + get_tree().paused = false + + +## A human run starts from the title: writes armed, entry marked. +func _begin_human_fresh() -> void: + Journey.begin_fresh() + RunEntry.mark_from_title() + + +func _begin_human_resume() -> void: + Journey.begin_resume() + RunEntry.mark_from_title() + + +func _write_text(candidate: String, text: String) -> void: + var handle: FileAccess = FileAccess.open(candidate, FileAccess.WRITE) + handle.store_string(text) + handle.close() + + +## Raw JSON of one file, bypassing the Journey validator. Empty when the +## file is missing or malformed. +func _read_json_file(candidate: String) -> Dictionary: + if not FileAccess.file_exists(candidate): + return {} + var parser: JSON = JSON.new() + if parser.parse(FileAccess.get_file_as_string(candidate)) != OK: + return {} + return parser.data as Dictionary if parser.data is Dictionary else {} + + +func _valid_checkpoint() -> Dictionary: + return { + "schema_version": 1, + "journey_id": "d1", + "checkpoint_id": 1, + "cycle": 3, + "zone_index": 1, + "route": [0, 4, -1], + "run_seed": 7, + "hero_path": Vault.HEROES[0], + "relic_stacks": {SHARP_MOON: 2}, + "level": 4, + "to_next": 12, + "level_progress": 3, + "missile_power": 2, + "missile_progress": 1, + "first_core_collected": true, + "kills": 20, + "kill_score": 300, + "survived": 120.0, + "lit_count": 1, + "overcharge_successes": 0, + "guardian_meetings": {"0": 1}, + "places_seen": ["forest"], + "shards_awarded": 5, + "settled_score": 2400, + "gate_direction": [1.0, 0.0], + "opening_played": true, + "saved_at_unix": 1700000000, + } + + +func _stack_counts(arena: Node2D) -> Dictionary: + var counts: Dictionary = {} + for item in arena.get("_taken"): + if item == null: + continue + var relic_path: String = str(item.get_meta("path", "")) + if relic_path.is_empty(): + relic_path = (item as Relic).resource_path + counts[relic_path] = int(counts.get(relic_path, 0)) + 1 + return counts + + +## Occupy a path as a directory so the next write there fails. Always paired +## with `_release_dir`. +func _occupy_as_dir(candidate: String) -> void: + if FileAccess.file_exists(candidate): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + DirAccess.make_dir_absolute(ProjectSettings.globalize_path(candidate)) + + +func _release_dir(candidate: String) -> void: + if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(candidate)): + DirAccess.remove_absolute(ProjectSettings.globalize_path(candidate)) + + +# --- terminal precedence ------------------------------------------------------ +## The real writer leaves no older alive backup behind a seal: the terminal +## marker mirrors into both files, backup first, so a corrupt or deleted +## main still reads terminal and heals forward. A mistyped marker is +## hostile, not ended: the whole save refuses and the sealed pair on disk +## survives the refused write. +func _test_terminal_main_outranks_alive_backup() -> void: + var first: Dictionary = _valid_checkpoint() + first["checkpoint_id"] = 1 + _expect_equal(Journey.write_checkpoint(first), OK, + "the first checkpoint writes") + var second: Dictionary = _valid_checkpoint() + second["checkpoint_id"] = 2 + _expect_equal(Journey.write_checkpoint(second), OK, + "the second checkpoint writes") + _expect_false(bool(_read_json_file(TEST_BACKUP).get("ended", false)), + "alive seals rotate the older seal into the backup") + var sealed: Dictionary = second.duplicate(true) + sealed["ended"] = true + _expect_true(Journey.validate(sealed), "an ended checkpoint validates") + _expect_equal(Journey.write_checkpoint(sealed), OK, + "the terminal marker writes") + _expect_true(bool(_read_json_file(TEST_BACKUP).get("ended", false)), + "the seal mirrors into the backup") + _expect_equal(FileAccess.get_file_as_string(TEST_BACKUP), + FileAccess.get_file_as_string(TEST_PATH), + "the mirrored pair is byte-identical") + var read: Dictionary = Journey.read_checkpoint() + _expect_true(bool(read.get("ended", false)), + "the read returns the terminal pair") + _expect_equal(int(read.get("checkpoint_id", 0)), 2, + "the read keeps the marker's seal id") + _expect_false(Journey.has_valid_checkpoint(), + "a sealed pair offers no Continue") + _expect_true(Journey.summary(read).is_empty(), + "a sealed pair summarizes to nothing") + + # A corrupt main over the mirrored pair restores the marker — never an + # older alive seal — and heals the main forward to terminal. + _write_text(TEST_PATH, "{\"schema_version\": 1, \"cycle\":") + var recovered: Dictionary = Journey.read_checkpoint() + _expect_true(bool(recovered.get("ended", false)), + "a corrupt main restores the mirrored marker") + _expect_equal(int(recovered.get("checkpoint_id", 0)), 2, + "the restored marker keeps its seal id") + _expect_false(Journey.has_valid_checkpoint(), + "a restored marker offers no Continue") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "the restored marker heals the main forward") + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "a deleted main restores the mirrored marker") + _expect_false(Journey.has_valid_checkpoint(), + "a deleted main offers no Continue") + + # A mistyped marker is hostile, not ended: the whole save refuses and + # the sealed pair on disk survives the refused write. + for bad_marker in [1, "true", 1.0]: + var hostile: Dictionary = _valid_checkpoint() + hostile["ended"] = bad_marker + _expect_false(Journey.validate(hostile), + "a non-bool ended marker is refused: %s" % str(bad_marker)) + _expect_not_equal(Journey.write_checkpoint(hostile), OK, + "a mistyped marker never writes") + _expect_true(bool(Journey.read_checkpoint().get("ended", false)), + "refused writes keep the sealed pair") + + +# --- title -------------------------------------------------------------------- +## A real death hides the title's Continue panel; quitting while alive keeps +## showing it, and the alive quit still resumes. +func _test_death_hides_title_continue_alive_quit_shows_it() -> void: + _begin_human_fresh() + var doomed: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(doomed) + await get_tree().process_frame + await get_tree().process_frame + doomed.set("_level", 4) + doomed.set("_kill_score", 900) + _expect_true(bool(doomed.call("_journey_checkpoint", true)), + "the gate seals before the death") + doomed.call("_finish", false) + await get_tree().process_frame + doomed.queue_free() + await get_tree().process_frame + + var lost_title: Control = TITLE_SCENE.instantiate() as Control + add_child(lost_title) + await get_tree().process_frame + await get_tree().process_frame + _expect_false((lost_title.get_node("Ui/Screen/JourneyPanel") as Control).visible, + "a sealed defeat hides Continue on the title") + lost_title.queue_free() + await get_tree().process_frame + + # Quitting while alive is the honest Continue: it shows and it resumes. + _begin_human_fresh() + var living: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(living) + await get_tree().process_frame + await get_tree().process_frame + living.set("_cycle", 2) + living.set("_kill_score", 1200) + _expect_true(bool(living.call("_journey_checkpoint", true)), + "the alive quit seals its gate") + living.queue_free() + await get_tree().process_frame + var waiting_title: Control = TITLE_SCENE.instantiate() as Control + add_child(waiting_title) + await get_tree().process_frame + await get_tree().process_frame + _expect_true((waiting_title.get_node("Ui/Screen/JourneyPanel") as Control).visible, + "an alive quit shows Continue on the title") + waiting_title.queue_free() + await get_tree().process_frame + _begin_human_resume() + var returned: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(returned) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(returned.get("_journey_resuming")), + "an alive quit resumes its run") + _expect_equal(int(returned.get("_cycle")), 2, + "the alive quit keeps its cycle") + returned.queue_free() + await get_tree().process_frame + + +# --- fresh restart ------------------------------------------------------------ +## A fresh expedition after a defeat starts at cycle 1/zone 0 with a new +## journey, while shards, coins, unlocks and records survive untouched. The +## new journey settles only its own value afterwards. +func _test_fresh_restart_resets_run_keeps_permanents() -> void: + Vault.continue_coins = 2 + _begin_human_fresh() + var doomed: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(doomed) + await get_tree().process_frame + await get_tree().process_frame + doomed.set("_level", 5) + doomed.set("_kill_score", 1500) + doomed.set("_survived", 200.0) + _expect_true(bool(doomed.call("_journey_checkpoint", true)), + "the gate seals before the death") + var old_journey: String = str(doomed.get("_journey_id")) + doomed.set("_kill_score", 2600) + doomed.call("_finish", false) + await get_tree().process_frame + var banked: int = Vault.shards + var best: int = Records.best_score + var opened: Array = (Vault.opened as Array).duplicate() + doomed.queue_free() + await get_tree().process_frame + + # What `_restart` arms, without the scene reload the test cannot take: + # a fresh expedition over the sealed defeat. + _begin_human_fresh() + var fresh: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(fresh) + await get_tree().process_frame + await get_tree().process_frame + _expect_equal(int(fresh.get("_cycle")), 1, + "a fresh expedition starts at cycle 1") + _expect_equal(int(fresh.get("_zone_index")), 0, + "a fresh expedition starts at zone 0") + _expect_equal(int(fresh.get("_lit_count")), 0, + "a fresh expedition lights nothing yet") + _expect_not_equal(str(fresh.get("_journey_id")), old_journey, + "a fresh expedition begins a new journey") + _expect_false(bool(fresh.get("_over")), "a fresh expedition is alive") + _expect_equal(Vault.shards, banked, + "a fresh expedition banks nothing by starting") + _expect_equal(Records.best_score, best, + "a fresh expedition keeps the records") + _expect_equal(Vault.opened as Array, opened, + "a fresh expedition keeps the unlocks") + _expect_equal(Vault.continue_coins, 2, + "a fresh expedition spends no coins") + _expect_false(bool(_read_json_file(TEST_PATH).get("ended", false)), + "a fresh expedition clears the marker") + _expect_equal(int(_read_json_file(TEST_PATH).get("checkpoint_id", 0)), 1, + "a fresh expedition seals its first checkpoint") + + # The new journey's own defeat settles only its own value: the sealed + # run's receipt never pays twice across the restart. + fresh.set("_kill_score", 400) + fresh.call("_finish", false) + await get_tree().process_frame + _expect_true(Vault.shards >= banked, + "the new journey settles only its own value") + _expect_equal(int(_read_json_file(TEST_PATH).get("journey_id", -1)), + int(fresh.get("_journey_id")), + "the new defeat seals under the new journey") + fresh.queue_free() + await get_tree().process_frame + + +# --- coin continue ------------------------------------------------------------ +## A paid continuation revives the current run in place — hero, cycle/zone, +## score and growth — debits exactly one coin, and durably restores a +## resumable live journey. Quitting after the revive resumes the revived +## state, defeated-segment loot included. +func _test_coin_continue_revives_transactionally() -> void: + Vault.continue_coins = 2 + Vault.continue_txn = {} + _begin_human_fresh() + var arena: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(arena) + await get_tree().process_frame + await get_tree().process_frame + arena.set("_cycle", 2) + arena.set("_zone_index", 1) + arena.set("_level", 6) + arena.set("_kill_score", 1500) + arena.set("_survived", 300.0) + _expect_true(bool(arena.call("_journey_checkpoint", true)), + "the gate seals before the death") + var sealed_id: int = int( + (arena.get("_journey_snapshot") as Dictionary)["checkpoint_id"]) + var journey: String = str(arena.get("_journey_id")) + var hero_path: String = str(arena.get("_run_hero_path")) + arena.set("_kill_score", 2400) + var relic_panel: Control = arena.get_node("Ui/Relic") as Control + arena.call("_on_relic_picked", + relic_panel.call("take_named", TOUGH_LIFE), false) + var live_stacks: Dictionary = _stack_counts(arena) + arena.call("_finish", false) + await get_tree().process_frame + var banked: int = Vault.shards + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "death seals the marker before the coin") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "death journals no transaction") + + _expect_true(bool(arena.call("continue_run")), + "a paid continuation revives the run") + _expect_equal(Vault.continue_coins, 1, + "the revive debits exactly one coin") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "the landed seal clears the journal") + _expect_false(bool(arena.get("_over")), "the revived run is alive") + _expect_equal(int(arena.get("_health")), int(arena.get("_max_health")), + "the revive restores full health") + _expect_equal(int(arena.get("_cycle")), 2, + "the revive keeps the cycle") + _expect_equal(int(arena.get("_zone_index")), 1, + "the revive keeps the zone") + _expect_equal(int(arena.get("_kill_score")), 2400, + "the revive keeps the score") + _expect_equal(str(arena.get("_run_hero_path")), hero_path, + "the revive keeps the hero") + _expect_equal(_stack_counts(arena), live_stacks, + "the revive keeps the growth") + _expect_equal(str(arena.get("_journey_id")), journey, + "the revive keeps the journey") + _expect_equal(Vault.shards, banked, + "the revive itself settles no receipt") + var revived_file: Dictionary = _read_json_file(TEST_PATH) + _expect_false(bool(revived_file.get("ended", false)), + "the revive durably clears the marker") + _expect_equal(int(revived_file.get("checkpoint_id", 0)), sealed_id + 1, + "the revive seals the next checkpoint") + _expect_true(Journey.has_valid_checkpoint(), + "the revive restores a resumable journey") + + # Quitting after the revive resumes the revived state, not the defeat. + arena.queue_free() + await get_tree().process_frame + _begin_human_resume() + var returned: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(returned) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(returned.get("_journey_resuming")), + "the revived journey resumes") + _expect_equal(int(returned.get("_kill_score")), 2400, + "the resumed revive keeps its score") + _expect_equal(_stack_counts(returned), live_stacks, + "the resumed revive keeps its growth") + returned.queue_free() + await get_tree().process_frame + + +## Zero coins and injected save failures keep balances and terminal status +## safe: a failed debit writes nothing and journals nothing, while a failed +## seal keeps the marker with the debit journaled — so the retry reuses it +## without charging again and no failure strands an unpaid alive checkpoint. +func _test_coin_continue_zero_and_save_failures() -> void: + # No coins: nothing moves and the result choice reopens. + Vault.continue_coins = 0 + Vault.continue_txn = {} + _begin_human_fresh() + var broke: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(broke) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(broke.call("_journey_checkpoint", true)), + "broke: the gate seals before the death") + broke.call("_finish", false) + await get_tree().process_frame + _expect_false(bool(broke.call("continue_run")), + "broke: the revive refuses without coins") + _expect_equal(Vault.continue_coins, 0, "broke: the balance stays zero") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "broke: no debit journals a transaction") + _expect_true(bool(broke.get("_over")), "broke: the run stays over") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "broke: the marker stays sealed") + broke.call("_on_continue_requested") + await get_tree().process_frame + _expect_true(bool((broke.get_node("Ui/Result") as Control).visible), + "broke: the result choice reopens") + broke.queue_free() + await get_tree().process_frame + + # A blocked seal write: the debit journals first, the marker stands, and + # the retry after recovery reuses the journal without charging again. + Vault.continue_coins = 2 + Vault.continue_txn = {} + _begin_human_fresh() + var blocked: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(blocked) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(blocked.call("_journey_checkpoint", true)), + "blocked seal: the gate seals before the death") + var sealed_id: int = int( + (blocked.get("_journey_snapshot") as Dictionary)["checkpoint_id"]) + blocked.call("_finish", false) + await get_tree().process_frame + _occupy_as_dir(TEST_PATH + ".tmp") + _expect_false(bool(blocked.call("continue_run")), + "blocked seal: the revive fails") + _expect_equal(Vault.continue_coins, 1, + "blocked seal: the debit lands before the seal") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "blocked seal: the debit journals its seal") + _expect_equal(int((Vault.continue_txn as Dictionary).get( + "checkpoint_id", 0)), sealed_id + 1, + "blocked seal: the journal names the next seal") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "blocked seal: the marker stands") + _expect_false(Journey.has_valid_checkpoint(), + "blocked seal: no unpaid checkpoint is resumable") + _expect_true(bool((blocked.get("_journey_snapshot") as Dictionary).get( + "ended", false)), "blocked seal: the run stays sealed in memory") + _expect_equal(int(blocked.get("_journey_checkpoint_id")), sealed_id, + "blocked seal: the checkpoint counter is restored") + _release_dir(TEST_PATH + ".tmp") + _expect_true(bool(blocked.call("continue_run")), + "blocked seal: the retry after recovery revives") + _expect_equal(Vault.continue_coins, 1, + "blocked seal: the retry charges nothing more") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "blocked seal: the landed seal clears the journal") + blocked.queue_free() + await get_tree().process_frame + + # A blocked Vault save: the debit cannot journal, so no seal is even + # attempted. Both writes failing together behaves the same; then each + # recovery in turn lands exactly one debit and one seal in total. + Vault.continue_coins = 2 + Vault.continue_txn = {} + _begin_human_fresh() + var poor_save: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(poor_save) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(poor_save.call("_journey_checkpoint", true)), + "blocked debit: the gate seals before the death") + var spend_sealed_id: int = int( + (poor_save.get("_journey_snapshot") as Dictionary)["checkpoint_id"]) + poor_save.call("_finish", false) + await get_tree().process_frame + _occupy_as_dir(VAULT_TMP) + _occupy_as_dir(TEST_PATH + ".tmp") + _expect_false(bool(poor_save.call("continue_run")), + "blocked debit: the revive fails") + _expect_equal(Vault.continue_coins, 2, + "blocked debit: the balance is untouched") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "blocked debit: a failed debit journals nothing") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "blocked debit: the marker stands") + _expect_equal(int(poor_save.get("_journey_checkpoint_id")), + spend_sealed_id, "blocked debit: the checkpoint counter is restored") + _expect_false(Journey.has_valid_checkpoint(), + "blocked debit: the sealed run is not resumable") + _release_dir(VAULT_TMP) + _expect_false(bool(poor_save.call("continue_run")), + "blocked debit: the seal still fails with the vault healed") + _expect_equal(Vault.continue_coins, 1, + "blocked debit: the healed debit journals first") + _expect_false((Vault.continue_txn as Dictionary).is_empty(), + "blocked debit: the journal waits for its seal") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "blocked debit: the marker still stands") + _release_dir(TEST_PATH + ".tmp") + _expect_true(bool(poor_save.call("continue_run")), + "blocked debit: the retry after recovery revives") + _expect_equal(Vault.continue_coins, 1, + "blocked debit: exactly one coin spent in total") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "blocked debit: the landed seal clears the journal") + poor_save.queue_free() + await get_tree().process_frame + + +## A defeat whose terminal seal fails cannot be left: result exits reoffer +## the choice until the marker lands, so the older alive file behind it +## never becomes a free continuation. +func _test_defeat_seal_failure_blocks_exit() -> void: + Vault.continue_coins = 2 + _begin_human_fresh() + var arena: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(arena) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(arena.call("_journey_checkpoint", true)), + "the gate seals before the death") + _occupy_as_dir(TEST_PATH + ".tmp") + arena.call("_finish", false) + await get_tree().process_frame + _expect_true(bool(arena.get("_journey_defeat_pending")), + "the failed seal stays pending") + _expect_false(bool(_read_json_file(TEST_PATH).get("ended", false)), + "the older alive file is still in place") + _expect_true(bool(_read_json_file(TEST_BACKUP).get("ended", false)), + "the backup already mirrors the seal") + _expect_false(Journey.has_valid_checkpoint(), + "the mirrored backup keeps the pair terminal meanwhile") + # Restart would reload the scene; the blocked retry reopens instead, so + # the run cannot be left and the test scene survives. + arena.call("_on_result_dismissed", int(arena.ResultAction.RESTART)) + await get_tree().process_frame + _expect_true(bool(arena.get("_over")), "the blocked exit stays over") + _expect_true(bool((arena.get_node("Ui/Result") as Control).visible), + "the blocked exit reoffers the choice") + _expect_true(is_instance_valid(arena), "the blocked exit reloads nothing") + _release_dir(TEST_PATH + ".tmp") + _expect_true(bool(arena.call("_retry_pending_result_persistence")), + "the retry after recovery lands the seal") + _expect_false(bool(arena.get("_journey_defeat_pending")), + "the landed seal clears the pending marker") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "the landed seal writes the marker") + arena.queue_free() + await get_tree().process_frame + + +## Quitting while alive — no finish, no death — resumes the sealed gate with +## its growth, health restored, guidance quiet. +func _test_alive_quit_resumes() -> void: + _begin_human_fresh() + var arena: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(arena) + await get_tree().process_frame + await get_tree().process_frame + arena.set("_cycle", 2) + arena.set("_zone_index", 1) + arena.set("_kill_score", 1100) + var relic_panel: Control = arena.get_node("Ui/Relic") as Control + arena.call("_on_relic_picked", + relic_panel.call("take_named", SHARP_MOON), false) + var want_stacks: Dictionary = _stack_counts(arena) + _expect_true(bool(arena.call("_journey_checkpoint", true)), + "the alive quit seals its gate") + arena.queue_free() + await get_tree().process_frame + _begin_human_resume() + var returned: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(returned) + await get_tree().process_frame + await get_tree().process_frame + _expect_true(bool(returned.get("_journey_resuming")), + "the alive quit resumes") + _expect_equal(int(returned.get("_cycle")), 2, + "the alive quit keeps its cycle") + _expect_equal(int(returned.get("_zone_index")), 1, + "the alive quit keeps its zone") + _expect_equal(_stack_counts(returned), want_stacks, + "the alive quit keeps its growth") + _expect_equal(int(returned.get("_health")), + int(returned.get("_max_health")), + "the alive quit restores full health") + _expect_false(bool(returned.get("_over")), "the alive quit is alive") + returned.queue_free() + await get_tree().process_frame + + +# --- expectations ------------------------------------------------------------- +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual == expected: + return + _failed += 1 + printerr(" failed: ", label, " — expected ", expected, ", got ", actual) + + +func _expect_not_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual != expected: + return + _failed += 1 + printerr(" failed: ", label, " — expected not ", expected) + + +func _expect_true(value: bool, label: String) -> void: + _expect_equal(value, true, label) + + +func _expect_false(value: bool, label: String) -> void: + _expect_equal(value, false, label) diff --git a/apps/game/tests/test_defeat_terminal.gd.uid b/apps/game/tests/test_defeat_terminal.gd.uid new file mode 100644 index 0000000..2cd4dd1 --- /dev/null +++ b/apps/game/tests/test_defeat_terminal.gd.uid @@ -0,0 +1 @@ +uid://swpk3hycud4 diff --git a/apps/game/tests/test_defeat_terminal.tscn b/apps/game/tests/test_defeat_terminal.tscn new file mode 100644 index 0000000..b866b92 --- /dev/null +++ b/apps/game/tests/test_defeat_terminal.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/test_defeat_terminal.gd" id="1"] + +[node name="DefeatTerminalTest" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tests/test_gate_lodge.gd b/apps/game/tests/test_gate_lodge.gd new file mode 100644 index 0000000..f09b747 --- /dev/null +++ b/apps/game/tests/test_gate_lodge.gd @@ -0,0 +1,1615 @@ +extends Node + +## Gate lodge scene tests: layout math, Lumi rig, first-login flow. +## +## Scene-based because the lodge binds the player rig, the room plates, +## and the host account in one tree. The production host behind it is a +## fake that speaks the same ten-method contract; the player, the stick, +## the dash button, and the Vault/Onboarding writes are all real. + +const LODGE_SCENE: PackedScene = preload( + "res://scenes/gameplay/gate_lodge.tscn") +const REVIEW_SCRIPT: Script = preload("res://tools/lodge_review.gd") +const FRAMINGS: Array[Vector2] = [ + Vector2(808, 360), Vector2(840, 360), Vector2(808, 606), + Vector2(808, 532)] +const LOCALES: Array[String] = ["ko", "en", "ja", "zh_CN", "zh_TW"] +const FAKE_ID: String = "MB-lodgetest0000000000000000000001" + +## Thirty-three dialogue keys the lodge reads through GateEntryStrings. +const LODGE_KEYS: Array[String] = [ + "gate.lodge.lumi_name", "gate.lodge.greet_1", "gate.lodge.greet_2", + "gate.lodge.greet_3", "gate.lodge.ask_name", "gate.lodge.name_title", + "gate.lodge.name_hint", "gate.lodge.name_public", + "gate.lodge.name_confirm", "gate.lodge.name_retry", + "gate.lodge.name_checking", "gate.lodge.name_invalid", + "gate.lodge.name_taken", "gate.lodge.name_offline", + "gate.lodge.named_ok", "gate.lodge.move_lesson", + "gate.lodge.move_done", "gate.lodge.dash_lesson", + "gate.lodge.dash_done", "gate.lodge.gate_lesson", + "gate.lodge.gate_wait", "gate.lodge.gate_retry", "gate.lodge.skip", + "gate.lodge.tap_next", "gate.lodge.depart_title", + "gate.lodge.depart_saved", "gate.lodge.depart_where", + "gate.lodge.depart_resume", "gate.lodge.depart_fresh", + "gate.lodge.depart_fresh_confirm", "gate.lodge.depart_back", + "gate.lodge.account_lost", "gate.lodge.return_title", +] + +var _failed: int = 0 +var _checked: int = 0 + + +## The ten lodge-facing host methods plus the account signal. Claim and +## seal replies come from queues so each test scripts its own network. +class LodgeFakeHost extends Node: + signal production_changed(state: Dictionary) + + var public_id: String = FAKE_ID + var display: String = "" + var intro: bool = false + var load_reply: Dictionary = {} + var cache_on_load: bool = false + var claim_queue: Array = [] + var seal_queue: Array = [] + var saved: Dictionary = {"has_save": false} + var exit_plan: Dictionary = {} + var exit_calls: Array = [] + var cancels: int = 0 + var deleted: bool = false + + func account_state() -> Dictionary: + return {"public_id": public_id, "deletion_in_flight": deleted} + + func needs_lodge_lesson() -> bool: + return display.is_empty() or not intro + + func verified_display_name() -> String: + return display + + func load_adventurer_name() -> Dictionary: + await get_tree().process_frame + if not load_reply.is_empty(): + # An acknowledging coordinator caches the verified row, so + # the host's cached readiness flips with the reply. + if cache_on_load \ + and str(load_reply.get("status", "")) == "ok": + display = str(load_reply.get("display", display)) + intro = bool(load_reply.get("intro_complete", intro)) + return load_reply.duplicate(true) + if display.is_empty(): + return {"status": "failure", "code": "adventurer-not-found", + "retryable": false} + return {"status": "ok", "display": display, "key": "key", + "intro_complete": intro, "source": "cloud"} + + func claim_adventurer_name(_text: String) -> Dictionary: + await get_tree().process_frame + if claim_queue.is_empty(): + return {"status": "failure", "code": "offline", + "retryable": true} + return (claim_queue.pop_front() as Dictionary).duplicate(true) + + func mark_intro_complete() -> Dictionary: + await get_tree().process_frame + if seal_queue.is_empty(): + intro = true + return {"status": "ok"} + var reply: Dictionary = ( + seal_queue.pop_front() as Dictionary).duplicate(true) + if str(reply.get("status", "")) == "ok": + intro = true + return reply + + func saved_gate_summary() -> Dictionary: + return saved.duplicate(true) + + func plan_lodge_exit(fresh: bool, confirmed: bool) -> Dictionary: + exit_calls.append({"fresh": fresh, "confirmed": confirmed}) + if not exit_plan.is_empty(): + return exit_plan.duplicate(true) + return {"status": "ok", + "arena": "res://scenes/gameplay/arena.tscn", + "fresh": fresh, "account_id": public_id} + + func confirm_entry_account(account_id: String) -> bool: + return account_id == public_id + + func cancel_entry_plan() -> void: + cancels += 1 + + +func _ready() -> void: + var expected_root: String = OS.get_environment( + "MOONLIT" + "_VAULT" + "_TEST" + "_ROOT").simplify_path() + var user_root: String = ProjectSettings.globalize_path( + "user://").simplify_path() + if expected_root.is_empty() or not user_root.begins_with( + expected_root + "/"): + printerr("lodge tests aborted: user:// path is not isolated — ", + user_root) + get_tree().quit(2) + return + GateEntryStrings.ensure_loaded() + Onboarding.path = "user://lodge_onboarding_test.json" + _run.call_deferred() + + +func _run() -> void: + _wipe() + _test_layout_framings() + _test_lumi_rig() + _test_lumi_world_bounds() + _test_lumi_idle_feet_planted() + _test_csv_keys() + _test_strings_resolve() + _test_name_keyboard_math() + await _test_name_keyboard_compact() + await _test_name_keyboard_dismiss() + await _test_greeting_keeps_hero_visible() + await _test_contact_board_audit() + await _test_boot_completed_departs() + await _test_boot_cold_completed_departs() + await _test_boot_recovers_claimed_name() + await _test_boot_form_states() + await _test_claim_buckets() + await _test_full_lesson_to_departure() + await _test_seal_retry_and_departure_choice() + await _test_account_loss_retires() + await _test_guards_and_push_out() + await _test_desk_projection_stays_on_walk() + await _test_desk_negative_control() + _test_desk_exit_fallback() + _report() + + +func _wipe() -> void: + for path in ["user://vault.cfg", "user://vault.cfg.tmp", + "user://lodge_onboarding_test.json"]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute( + ProjectSettings.globalize_path(path)) + Vault.load_vault() + Onboarding.forget_cache() + Journey.disarm() + + +func _expect_true(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + printerr("FAIL lodge: ", label) + + +func _expect_false(value: bool, label: String) -> void: + _expect_true(not value, label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual != expected: + _failed += 1 + printerr("FAIL lodge: ", label, " (got ", actual, + ", want ", expected, ")") + + +func _frames(count: int) -> void: + for _index in count: + await get_tree().process_frame + + +func _make_lodge(fake: LodgeFakeHost) -> Node: + add_child(fake) + var lodge: Node = LODGE_SCENE.instantiate() + lodge.host_override = fake + lodge.embedded = true + add_child(lodge) + return lodge + + +func _free_lodge(lodge: Node, fake: LodgeFakeHost) -> void: + lodge.queue_free() + fake.queue_free() + await get_tree().process_frame + + +func _tap_speech(lodge: Node) -> void: + lodge._advance_speech() + + +## Keyboard-aware name form: the device-pixel keyboard height +## converts through the live screen transform before any layout, the +## shift math clears the confirm action above it, the lift parks at +## the safe top when space runs out, and the QA report carries +## bounds only with device and viewport figures kept apart. +func _test_name_keyboard_math() -> void: + # Director's 808x360 desktop geometry: confirm ends at y275, a + # 144px keyboard covers y216-360, so the form rises 67px. + _expect_equal(GateLodge.keyboard_shift_for(275.0, 360.0, + 144.0), 67.0, "keyboard lifts the covered confirm") + _expect_equal(GateLodge.keyboard_shift_for(200.0, 606.0, + 200.0), 0.0, "tall viewport needs no lift") + _expect_equal(GateLodge.keyboard_shift_for(275.0, 360.0, + 0.0), 0.0, "hidden keyboard never shifts") + _expect_equal(GateLodge.keyboard_shift_for(275.0, 360.0, + -5.0), 0.0, "absurd height never shifts") + var view := Rect2(Vector2.ZERO, Vector2(808, 360)) + # Director's measured case: 432 device px at 3x over a 360 + # viewport occludes 144 viewport px. Raw pixels fed straight + # into the shift wrongly returned 354; converted, the confirm + # at 274 rises exactly 66. + var x3 := Transform2D(Vector2(3, 0), Vector2(0, 3), + Vector2.ZERO) + _expect_equal(GateLodge.keyboard_occlusion_for(view, + Vector2(2424, 1080), 432.0, x3), 144.0, + "keyboard converts 3x device px to viewport px") + _expect_equal(GateLodge.keyboard_shift_for(274.0, 360.0, + GateLodge.keyboard_occlusion_for(view, + Vector2(2424, 1080), 432.0, x3)), 66.0, + "keyboard converts, then lifts 66px, never 354") + var x1 := Transform2D(Vector2(1, 0), Vector2(0, 1), + Vector2.ZERO) + _expect_equal(GateLodge.keyboard_occlusion_for(view, + Vector2(808, 360), 100.0, x1), 100.0, + "keyboard passes scale-1 heights through") + var x25 := Transform2D(Vector2(2.5, 0), Vector2(0, 2.5), + Vector2.ZERO) + _expect_true(absf(GateLodge.keyboard_occlusion_for(view, + Vector2(2020, 900), 250.0, x25) - 100.0) < 0.001, + "keyboard converts a non-integer density") + var boxed := Transform2D(Vector2(2, 0), Vector2(0, 2), + Vector2(0, 40)) + _expect_true(absf(GateLodge.keyboard_occlusion_for(view, + Vector2(1616, 800), 200.0, boxed) - 80.0) < 0.001, + "keyboard honors the letterbox offset, not just scale") + _expect_equal(GateLodge.keyboard_occlusion_for(view, + Vector2(2424, 1080), 2000.0, x3), 360.0, + "keyboard taller than the screen clamps to the viewport") + _expect_equal(GateLodge.keyboard_occlusion_for(view, + Vector2(2424, 1080), 0.0, x3), 0.0, + "hidden keyboard occludes nothing") + _expect_equal(GateLodge.keyboard_occlusion_for(view, + Vector2(2424, 1080), -50.0, x3), 0.0, + "absurd keyboard occludes nothing") + var flat := Transform2D(Vector2.ZERO, Vector2.ZERO, + Vector2.ZERO) + _expect_equal(GateLodge.keyboard_occlusion_for(view, + Vector2(2424, 1080), 432.0, flat), 0.0, + "degenerate transform occludes nothing") + _expect_equal(GateLodge.keyboard_lift_for(274.0, 360.0, + 144.0, 120.0, 0.0), 66.0, + "lift takes the full shift with headroom") + _expect_equal(GateLodge.keyboard_lift_for(274.0, 360.0, + 144.0, 40.0, 0.0), 40.0, + "lift parks at the safe top when cramped") + _expect_equal(GateLodge.keyboard_lift_for(274.0, 360.0, + 144.0, 0.0, 0.0), 0.0, + "lift never displaces negatively") + _expect_equal(GateLodge.keyboard_lift_for(200.0, 606.0, + 200.0, 120.0, 0.0), 0.0, + "lift rests when nothing is covered") + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + var report: Dictionary = lodge.call( + "name_form_keyboard_report") + var keys: Array = report.keys() + keys.sort() + # The old single keyboard_height mixed device pixels with + # viewport layout; the report now names each figure's units. + _expect_equal(keys, ["confirm_rect", "field_rect", + "form_visible", "keyboard_height_device", + "keyboard_occlusion", "keyboard_visible", "panel_rect", + "shifted"], "keyboard report carries bounds only") + _expect_false(bool(report["keyboard_visible"]), + "headless reports no keyboard") + _expect_equal(int(report["keyboard_height_device"]), 0, + "headless reports zero device height") + _expect_equal(float(report["keyboard_occlusion"]), 0.0, + "headless reports zero occlusion") + _free_lodge(lodge, fake) + + +## Tall-keyboard name form: the same production `_apply_keyboard_shift` +## hook that runs every live frame adapts the actual form — a short +## lift while it fits, the compact short card when it does not — and +## restores the full card on hide. The real field, error feedback, and +## confirm bounds (never the clamp math alone) stay inside the +## available safe rect with the margin, at phone and tablet framings, +## in every locale, under every feedback text. Headless stages device +## pixels 1:1 with viewport pixels; the math test pins the 3x density. +func _test_name_keyboard_compact() -> void: + var original_size: Vector2i = get_tree().root.size + var original_scale: Vector2i = ( + get_tree().root.content_scale_size) + var original_locale: String = TranslationServer.get_locale() + for framing in [Vector2(808, 360), Vector2(808, 606)]: + var tag: String = "%dx%d" % [ + int(framing.x), int(framing.y)] + get_tree().root.size = Vector2i( + int(framing.x), int(framing.y)) + get_tree().root.content_scale_size = Vector2i( + int(framing.x), int(framing.y)) + await _frames(2) + for locale in LOCALES: + TranslationServer.set_locale(locale) + await _frames(1) + await _check_keyboard_form(tag, locale) + TranslationServer.set_locale(original_locale) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_scale + await _frames(2) + + +func _check_keyboard_form(tag: String, locale: String) -> void: + var label: String = "%s %s" % [tag, locale] + _wipe() + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + await _frames(5) + _tap_speech(lodge) + _tap_speech(lodge) + _tap_speech(lodge) + await _frames(5) + _expect_equal(lodge.lodge_state(), GateLodge.State.NAME, + label + ": reaches the register") + _expect_equal(lodge._camera.process_callback, + Camera2D.CAMERA2D_PROCESS_PHYSICS, + label + ": the lodge camera rides physics") + # Freeze the live hook (headless reads no keyboard and would + # restore); every case below drives the same hook by hand. + lodge.set_process(false) + lodge._name_field.text = "Wanderer" + var view: Rect2 = get_viewport().get_visible_rect() + var xform: Transform2D = ( + get_viewport().get_screen_transform()) + # Headless owns no window (live size reads 0,0), so the tests + # stage the window they resized the viewport to. + var window: Vector2 = view.size + for height in [40.0, 144.0, 180.0, 200.0, 400.0]: + _expect_equal(GateLodge.keyboard_occlusion_for( + view, window, height, xform), + minf(height, view.size.y), + label + ": stages device px 1:1") + var small: float = 40.0 + var mids: Array = [200.0] if tag == "808x606" else [] + var talls: Array = [400.0] if tag == "808x606" \ + else [144.0, 180.0] + for key in ["", "gate.lodge.name_taken", + "gate.lodge.name_offline", "gate.lodge.name_invalid"]: + var feedback: String = "clean" if key.is_empty() else key + lodge._name_status.text = "" if key.is_empty() else \ + GateEntryStrings.text(key) + lodge._seat_visible(lodge._name_panel, "top") + await _frames(3) + var full_rect: Rect2 = ( + lodge._name_panel.get_global_rect()) + var case: String = "%s %s" % [label, feedback] + await _check_keyboard_lifted(lodge, case, view, small) + for mid in mids: + await _check_keyboard_lifted(lodge, case, view, mid) + for tall in talls: + await _check_keyboard_compact_case( + lodge, case, view, tall, full_rect) + var out: Dictionary = lodge._apply_keyboard_shift( + 0.0, view.size) + await _frames(3) + _expect_equal(str(out.get("mode", "")), "restored", + case + ": hiding restores") + var back: Rect2 = lodge._name_panel.get_global_rect() + _expect_true(is_equal_approx(back.position.x, + full_rect.position.x) and is_equal_approx( + back.position.y, full_rect.position.y) + and is_equal_approx(back.size.x, full_rect.size.x) + and is_equal_approx(back.size.y, full_rect.size.y), + case + ": hiding restores the full card " + + "(%s)" % str(back)) + _expect_true(lodge._name_head.visible + and lodge._name_hint.visible, + case + ": hiding restores the chrome") + _expect_equal(lodge._name_field.text, "Wanderer", + case + ": hiding keeps the typed value") + lodge.set_process(true) + await _free_lodge(lodge, fake) + + +## A keyboard the plain lift still clears: full chrome stays, the +## real confirm bottom lands on the margin line, and repeats hold. +func _check_keyboard_lifted(lodge: Node, case: String, view: Rect2, + height: float) -> void: + var staged: Vector2 = view.size + var out: Dictionary = lodge._apply_keyboard_shift( + height, staged) + await _frames(3) + _expect_equal(str(out.get("mode", "")), "lifted", + "%s lift %.0f: lifts, never compacts" % [case, height]) + _expect_true(lodge._name_head.visible + and lodge._name_hint.visible, + "%s lift %.0f: keeps the full chrome" % [case, height]) + var kb_top: float = view.size.y - height + var bottom: float = lodge._name_confirm.get_global_rect().end.y + _expect_true(bottom <= kb_top - 8.0 + 0.5, + "%s lift %.0f: the confirm clears (%.1f)" % [ + case, height, bottom]) + var settled: float = lodge._name_panel.position.y + lodge._apply_keyboard_shift(height, staged) + lodge._apply_keyboard_shift(height, staged) + _expect_equal(lodge._name_panel.position.y, settled, + "%s lift %.0f: the layout holds still" % [case, height]) + + +## A keyboard the plain lift cannot clear: the short card takes over +## and the real field, feedback, and confirm all stay visible above +## it with the margin, holding still across repeats. +func _check_keyboard_compact_case(lodge: Node, case: String, + view: Rect2, height: float, full_rect: Rect2) -> void: + var staged: Vector2 = view.size + var out: Dictionary = lodge._apply_keyboard_shift( + height, staged) + await _frames(3) + _expect_equal(str(out.get("mode", "")), "compact", + "%s tall %.0f: compacts the card" % [case, height]) + _expect_false(lodge._name_head.visible + or lodge._name_hint.visible, + "%s tall %.0f: hides the optional chrome" % [case, height]) + _expect_true(bool(out.get("clears", false)), + "%s tall %.0f: the short card clears" % [case, height]) + _expect_true(lodge._name_panel.size.y < full_rect.size.y, + "%s tall %.0f: the card shrinks" % [case, height]) + var kb_top: float = view.size.y - height + var top: float = lodge._name_panel.position.y + _expect_true(top >= -0.5, + "%s tall %.0f: the card stays onscreen" % [case, height]) + for path in ["_name_field", "_name_status", "_name_confirm"]: + var rect: Rect2 = (lodge.get(path) as Control + ).get_global_rect() + _expect_true(rect.position.y >= top - 0.5 + and rect.end.y <= kb_top - 8.0 + 0.5, + "%s tall %.0f: %s stays usable (%.1f-%.1f)" % [ + case, height, path, rect.position.y, + rect.end.y]) + _expect_equal(lodge._name_field.text, "Wanderer", + "%s tall %.0f: keeps the typed value" % [case, height]) + var settled: float = lodge._name_panel.position.y + lodge._apply_keyboard_shift(height, staged) + lodge._apply_keyboard_shift(height, staged) + _expect_equal(lodge._name_panel.position.y, settled, + "%s tall %.0f: the layout holds still" % [case, height]) + + +## An all-screen keyboard cannot fit even the short card: production +## asks for one dismissal per height (never a repeat storm), a new +## height re-arms, and hiding resets the bound honestly. +func _test_name_keyboard_dismiss() -> void: + var original_size: Vector2i = get_tree().root.size + var original_scale: Vector2i = ( + get_tree().root.content_scale_size) + get_tree().root.size = Vector2i(808, 360) + get_tree().root.content_scale_size = Vector2i(808, 360) + await _frames(2) + _wipe() + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + await _frames(5) + _tap_speech(lodge) + _tap_speech(lodge) + _tap_speech(lodge) + await _frames(5) + lodge.set_process(false) + var staged: Vector2 = get_viewport().get_visible_rect().size + var out: Dictionary = lodge._apply_keyboard_shift(330.0, staged) + _expect_equal(str(out.get("mode", "")), "compact", + "dismiss: an all-screen keyboard compacts") + _expect_false(bool(out.get("clears", true)), + "dismiss: the short card cannot clear it") + _expect_true(bool(out.get("dismissed", false)), + "dismiss: production asks once") + out = lodge._apply_keyboard_shift(330.0, staged) + _expect_false(bool(out.get("dismissed", true)), + "dismiss: the same height never re-asks") + out = lodge._apply_keyboard_shift(335.0, staged) + _expect_true(bool(out.get("dismissed", false)), + "dismiss: a new height re-arms") + out = lodge._apply_keyboard_shift(0.0, staged) + _expect_equal(str(out.get("mode", "")), "restored", + "dismiss: hiding restores") + out = lodge._apply_keyboard_shift(330.0, staged) + _expect_true(bool(out.get("dismissed", false)), + "dismiss: hiding resets the bound") + lodge.set_process(true) + await _free_lodge(lodge, fake) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_scale + await _frames(2) + + +## The greeting must not cover the actors: the hero's and Lumi's live +## opaque silhouettes (real frames through the real canvas transform) +## stay a small gap above every greeting/name-recovery card, at both +## wide viewports and in every locale, and fully inside the viewport. +func _test_greeting_keeps_hero_visible() -> void: + var original_size: Vector2i = get_tree().root.size + var original_scale: Vector2i = ( + get_tree().root.content_scale_size) + var original_locale: String = TranslationServer.get_locale() + for framing in [Vector2(808, 360), Vector2(880, 360)]: + var tag: String = "%dx%d" % [ + int(framing.x), int(framing.y)] + get_tree().root.size = Vector2i( + int(framing.x), int(framing.y)) + get_tree().root.content_scale_size = Vector2i( + int(framing.x), int(framing.y)) + await _frames(2) + var view := Rect2(Vector2.ZERO, + get_viewport().get_visible_rect().size) + _expect_equal(view.size, framing, + "greet: %s stages its viewport" % tag) + for locale in LOCALES: + TranslationServer.set_locale(locale) + await _frames(1) + _wipe() + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + await _frames(5) + # Settle the rendered transform before judging pixels: + # a fresh lodge under physics interpolation reports a + # lagging canvas transform until it is reset, exactly + # what production converges to on its own. + lodge._camera.reset_physics_interpolation() + lodge._player.reset_physics_interpolation() + lodge._lumi.reset_physics_interpolation() + await _frames(1) + await _check_greeting_card(lodge, view, + "%s %s greet_1" % [tag, locale]) + _tap_speech(lodge) + await _frames(2) + await _check_greeting_card(lodge, view, + "%s %s greet_2" % [tag, locale]) + _tap_speech(lodge) + await _frames(2) + await _check_greeting_card(lodge, view, + "%s %s greet_3" % [tag, locale]) + _tap_speech(lodge) + await _frames(3) + lodge._show_busy(GateEntryStrings.text( + "gate.lodge.name_checking")) + await _frames(2) + await _check_greeting_card(lodge, view, + "%s %s name_checking" % [tag, locale]) + await _free_lodge(lodge, fake) + TranslationServer.set_locale(original_locale) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_scale + await _frames(2) + + +## The contact board's pixel audit reads what was rendered: a sane +## four-silhouette board passes, while a huge actor or a missing +## silhouette fails its slot. Boards are synthetic (headless draws +## nothing); geometry per board still runs through the real canvas +## transform of live actors under a real current camera. +func _test_contact_board_audit() -> void: + var original_size: Vector2i = get_tree().root.size + var original_scale: Vector2i = ( + get_tree().root.content_scale_size) + get_tree().root.size = Vector2i(808, 360) + get_tree().root.content_scale_size = Vector2i(808, 360) + await _frames(2) + var root := Node2D.new() + add_child(root) + var actors: Array = [] + var index: int = 0 + for facing in ["front", "rear", "left", "right"]: + var lumi := LumiGuide.new() + lumi.position = Vector2( + 215.0 + index * 126.0, 290.0) + root.add_child(lumi) + lumi.set_facing(facing) + lumi.reset_physics_interpolation() + actors.append(lumi) + index += 1 + var camera := Camera2D.new() + camera.position = Vector2(404, 265) + camera.process_callback = Camera2D.CAMERA2D_PROCESS_PHYSICS + root.add_child(camera) + camera.make_current() + camera.reset_physics_interpolation() + await get_tree().physics_frame + await _frames(2) + var bg: Color = RenderingServer.get_default_clear_color() + var ink := Color(0.02, 0.02, 0.05) \ + if bg.get_luminance() > 0.5 else Color(0.98, 0.97, 0.94) + var review: Node = REVIEW_SCRIPT.new() + var sane: String = "/tmp/lodge-contact-sane-%d.png" % randi() + _paint_board(sane, actors, bg, ink, -1) + (review as Object).set("_failed", 0) + (review as Object).set("_checked", 0) + (review as Object).call( + "_audit_contact_board", sane, actors, + get_viewport().get_canvas_transform()) + _expect_equal(int((review as Object).get("_failed")), 0, + "contact: four sane silhouettes pass") + var huge: String = "/tmp/lodge-contact-huge-%d.png" % randi() + _paint_board(huge, actors, bg, ink, 3) + (review as Object).set("_failed", 0) + (review as Object).set("_checked", 0) + (review as Object).call( + "_audit_contact_board", huge, actors, + get_viewport().get_canvas_transform()) + _expect_true(int((review as Object).get("_failed")) > 0, + "contact: a huge fourth actor fails its slot") + var gone: String = "/tmp/lodge-contact-gone-%d.png" % randi() + _paint_blank_slot(gone, actors, bg, ink, 1) + (review as Object).set("_failed", 0) + (review as Object).set("_checked", 0) + (review as Object).call( + "_audit_contact_board", gone, actors, + get_viewport().get_canvas_transform()) + _expect_true(int((review as Object).get("_failed")) > 0, + "contact: a missing silhouette fails its slot") + (review as Object).free() + for path in [sane, huge, gone]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + root.queue_free() + await get_tree().process_frame + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_scale + await _frames(2) + + +## Paint one synthetic board: every slot gets a 21x44 body plus a +## 16x5 shadow at its actor's live screen feet, except `special`, +## which draws a 120x170 oversized actor instead (or nothing when +## `special` is -1, meaning a fully sane board). +func _paint_board(path: String, actors: Array, bg: Color, + ink: Color, special: int) -> void: + var image := Image.create(808, 360, false, Image.FORMAT_RGBA8) + image.fill(bg) + var index: int = 0 + for actor in actors: + var xf: Transform2D = ( + actor as Node2D).get_global_transform_with_canvas() + var feet: Vector2 = xf * Vector2.ZERO + if index == special: + image.fill_rect(Rect2i(int(feet.x) - 60, + int(feet.y) - 160, 120, 170), ink) + else: + image.fill_rect(Rect2i(int(feet.x) - 10, + int(feet.y) - 44, 21, 44), ink) + image.fill_rect(Rect2i(int(feet.x) - 8, + int(feet.y), 16, 5), ink) + index += 1 + image.save_png(path) + + +## Paint one synthetic board with `blank` left empty. +func _paint_blank_slot(path: String, actors: Array, bg: Color, + ink: Color, blank: int) -> void: + var image := Image.create(808, 360, false, Image.FORMAT_RGBA8) + image.fill(bg) + var index: int = 0 + for actor in actors: + var xf: Transform2D = ( + actor as Node2D).get_global_transform_with_canvas() + var feet: Vector2 = xf * Vector2.ZERO + if index != blank: + image.fill_rect(Rect2i(int(feet.x) - 10, + int(feet.y) - 44, 21, 44), ink) + image.fill_rect(Rect2i(int(feet.x) - 8, + int(feet.y), 16, 5), ink) + index += 1 + image.save_png(path) + + +func _check_greeting_card(lodge: Node, view: Rect2, + tag: String) -> void: + var card: Rect2 = ( + lodge._speech_panel as Control).get_global_rect() + _expect_true((lodge._speech_panel as Control).visible, + "greet: %s shows its card" % tag) + # Every idle frame clears: the hero animates while Lumi talks, + # so the worst frame, not a lucky one, must clear the card. + var animated: AnimatedSprite2D = ( + lodge._player as Node).get_node("Sprite") + var count: int = animated.sprite_frames.get_frame_count( + animated.animation) + _expect_true(count > 0, + "greet: %s animates its hero" % tag) + for index in count: + animated.frame = index + var hero: Rect2 = _drawn_opaque_rect(lodge._player) + _expect_true(hero.has_area(), + "greet: %s draws hero frame %d" % [tag, index]) + _expect_true(view.encloses(hero), + "greet: %s keeps hero frame %d onscreen " + % [tag, index] + "(%s in %s)" % [ + str(hero), str(view)]) + _expect_false(card.intersects(hero.grow(4.0)), + "greet: %s clears hero frame %d by 4px " + % [tag, index] + "(hero %s card %s)" % [ + str(hero), str(card)]) + var lumi: Rect2 = _drawn_opaque_rect(lodge._lumi) + _expect_true(lumi.has_area(), + "greet: %s draws its guide" % tag) + _expect_true(view.encloses(lumi), + "greet: %s keeps the whole guide onscreen " + % tag + "(%s in %s)" % [str(lumi), str(view)]) + _expect_false(card.intersects(lumi.grow(4.0)), + "greet: %s clears the guide by 4px " + % tag + "(lumi %s card %s)" % [ + str(lumi), str(card)]) + + +## Live opaque silhouette of a lodge actor: the current frame's +## opaque pixels through the sprite's real canvas transform, honoring +## centering, offsets, and mirroring. Empty when nothing is opaque. +func _drawn_opaque_rect(actor: Node) -> Rect2: + var sprite: Node2D = actor.get_node("Sprite") as Node2D + var tex: Texture2D = null + var centered: bool = true + var offset := Vector2.ZERO + var flip := Vector2.ZERO + if sprite is AnimatedSprite2D: + var animated := sprite as AnimatedSprite2D + tex = animated.sprite_frames.get_frame_texture( + animated.animation, animated.frame) + offset = animated.offset + flip = Vector2( + 1.0 if animated.flip_h else 0.0, + 1.0 if animated.flip_v else 0.0) + else: + tex = (sprite as Sprite2D).texture + centered = (sprite as Sprite2D).centered + offset = (sprite as Sprite2D).offset + if tex == null: + return Rect2() + var image: Image = tex.get_image() + var box := Rect2i(0, 0, 0, 0) + var found: bool = false + for y in image.get_height(): + for x in image.get_width(): + if image.get_pixel(x, y).a < 0.5: + continue + if not found: + box = Rect2i(x, y, 1, 1) + found = true + else: + box = box.expand(Vector2i(x, y)) + if not found: + return Rect2() + var size := Vector2(image.get_width(), image.get_height()) + if flip.x > 0.5: + box.position.x = int(size.x) - box.end.x + if flip.y > 0.5: + box.position.y = int(size.y) - box.end.y + var origin: Vector2 = -size * 0.5 if centered \ + else Vector2.ZERO + var xf: Transform2D = sprite.get_global_transform_with_canvas() + var scale: Vector2 = xf.get_scale() + return Rect2( + xf * (origin + offset + Vector2(box.position)), + Vector2(box.size) * Vector2( + absf(scale.x), absf(scale.y))) + + +# --- layout ------------------------------------------------------------------- + +## Opaque pixel bounds of a texture at the director's alpha probe +## threshold. Pure image math, no window needed. +func _opaque_box(texture: Texture2D) -> Rect2i: + var image: Image = texture.get_image() + var box := Rect2i(0, 0, 0, 0) + var found: bool = false + for y in image.get_height(): + for x in image.get_width(): + if image.get_pixel(x, y).a < 0.5: + continue + if not found: + box = Rect2i(x, y, 1, 1) + found = true + else: + box = box.expand(Vector2i(x, y)) + return box + + +## The guide's actual drawn body: opaque bounds through the real +## source-to-world conversion and room zoom, feet on her ground point, +## fully inside every judged viewport with the room camera centered. +## Painted boots stay planted: the opaque foot line through the real +## sprite transform moves at most half a world pixel across idle +## phases and facings. The old whole-body bob moved it four. +func _test_lumi_idle_feet_planted() -> void: + var guide := LumiGuide.new() + add_child(guide) + await get_tree().process_frame + guide.set_actor_scale(1.002475) + var total: float = guide.total_scale() + guide.position = Vector2(300, 200) + var feet: Vector2 = guide.position + for facing in ["front", "rear", "left", "right"]: + guide.set_facing(facing) + var sprite: Sprite2D = guide.get_node("Sprite") as Sprite2D + var box: Rect2i = _opaque_box(sprite.texture) + var lows: Array = [] + for quarter in [0.0, 0.25, 0.5, 0.75, 1.0]: + guide._idle_time = LumiGuide.IDLE_PERIOD * quarter + guide._process(0.0) + var foot: Vector2 = guide.position \ + + (sprite.offset + sprite.position + + Vector2(box.get_center().x, + box.position.y + box.size.y)) * total + lows.append(foot.y) + _expect_equal(sprite.position, Vector2.ZERO, + "planted: %s never translates its sprite" % facing) + var spread: float = float(lows.max()) - float(lows.min()) + _expect_true(spread <= 0.5, + "planted: %s foot line holds (%.2f peak to peak)" % [ + facing, spread]) + _expect_equal(guide.position, feet, + "planted: %s keeps its ground anchor" % facing) + guide.queue_free() + await get_tree().process_frame + + +func _test_lumi_world_bounds() -> void: + var hero: Hero = Vault.hero() + var sheet: Image = (hero.idle_sheet as Texture2D).get_image() + var cell: Vector2i = hero.sprite_cell + var top: int = cell.y + var bottom: int = -1 + for y in sheet.get_height(): + for x in sheet.get_width(): + if sheet.get_pixel(x, y).a < 0.5: + continue + top = mini(top, y % cell.y) + bottom = maxi(bottom, y % cell.y) + var hero_body: float = float(bottom - top + 1) * hero.visual_scale + var ratio: float = LumiGuide.WORLD_HEIGHT / hero_body + _expect_true(ratio > 1.4 and ratio < 1.6, + "lumi: %.1f world px stands ~1.5x the %.1f hero body" % [ + LumiGuide.WORLD_HEIGHT, hero_body]) + var guide := LumiGuide.new() + add_child(guide) + await get_tree().process_frame + _expect_true(is_equal_approx(guide.total_scale(), + LumiGuide.WORLD_HEIGHT / LumiGuide.FRAME_HEIGHT), + "lumi: the default scale converts source to world") + var opaque_h: Dictionary = {} + for facing in ["front", "rear", "left", "right"]: + guide.set_facing(facing) + var sprite: Sprite2D = guide.get_node("Sprite") as Sprite2D + var box: Rect2i = _opaque_box(sprite.texture) + opaque_h[facing] = box.size.y + _expect_true(box.size.y > 800, + "lumi: %s keeps its painted height" % facing) + var heights: Array = opaque_h.values() + _expect_true(float(heights.max()) / float(heights.min()) < 1.05, + "lumi: opaque heights agree within 5% across facings") + var boxes: Dictionary = {} + for facing in ["front", "rear", "left", "right"]: + guide.set_facing(facing) + var sheet_sprite: Sprite2D = guide.get_node("Sprite") as Sprite2D + boxes[facing] = _opaque_box(sheet_sprite.texture) + for framing in FRAMINGS: + var tag: String = "%dx%d" % [int(framing.x), int(framing.y)] + var layout: Dictionary = GateLodge.layout_for(framing) + var actor: float = float(layout["actor_scale"]) + guide.set_actor_scale(actor) + var total: float = guide.total_scale() + _expect_true(is_equal_approx(total, + LumiGuide.WORLD_HEIGHT / LumiGuide.FRAME_HEIGHT * actor), + "lumi: %s composes conversion with room zoom" % tag) + _expect_equal(guide.scale, Vector2.ONE * total, + "lumi: %s node scale is uniform" % tag) + guide.position = layout["lumi_pos"] + var view := Rect2(Vector2.ZERO, framing) + for facing in ["front", "rear", "left", "right"]: + guide.set_facing(facing) + var sprite: Sprite2D = guide.get_node("Sprite") as Sprite2D + var size: Vector2 = ( + sprite.texture as Texture2D).get_size() + var feet: Vector2 = guide.position \ + + (sprite.offset + sprite.position + + Vector2(size.x * 0.5, size.y)) * total + _expect_true(feet.distance_to(guide.position) <= 0.5, + "lumi: %s %s plants its feet on the anchor" % [ + tag, facing]) + var drawn := Rect2( + guide.position + sprite.offset * total, + size * total) + _expect_true(view.encloses(drawn), + "lumi: %s %s draws fully inside the room" % [ + tag, facing]) + var box: Rect2i = boxes[facing] + var world_h: float = float(box.size.y) * total + _expect_true(world_h > 38.0 * actor \ + and world_h < 44.0 * actor, + "lumi: %s %s opaque body is %.1f world px" % [ + tag, facing, world_h]) + var shadow: Sprite2D = guide.get_node("Shadow") as Sprite2D + var last_actor: float = float(GateLodge.layout_for( + FRAMINGS[FRAMINGS.size() - 1])["actor_scale"]) + var shadow_world: Vector2 = Vector2( + shadow.texture.get_size().x * shadow.scale.x, + shadow.texture.get_size().y * shadow.scale.y) * guide.scale.x + _expect_true(shadow_world.x > 17.0 * last_actor and shadow_world.x < 21.0 * last_actor, + "lumi: the shadow spans the body (%.1f)" % shadow_world.x) + guide.queue_free() + await get_tree().process_frame + + +func _test_layout_framings() -> void: + _expect_equal(FRAMINGS.size(), 4, "layout: four framings judged") + for framing in FRAMINGS: + var layout: Dictionary = GateLodge.layout_for(framing) + var tag: String = "%dx%d" % [int(framing.x), int(framing.y)] + var wide: bool = bool(layout["wide"]) + _expect_equal(wide, framing.x / framing.y >= 1.7, + "layout: %s picks its plate" % tag) + var plate: Vector2 = Vector2(1881, 836) if wide \ + else Vector2(1448, 1086) + var room: Vector2 = plate * float(layout["room_scale"]) + _expect_true(room.x >= framing.x - 0.5 and room.y >= framing.y - 0.5, + "layout: %s room covers, never bands" % tag) + var walk: Rect2 = layout["walk_rect"] + var desk: Rect2 = layout["desk_rect"] + var gate: Rect2 = layout["gate_rect"] + _expect_true(walk.has_point(layout["hero_pos"]), + "layout: %s hero starts on the walk" % tag) + _expect_true(walk.has_point(layout["lumi_pos"]), + "layout: %s lumi stands on the walk" % tag) + _expect_true(walk.has_point(gate.get_center()), + "layout: %s gate trigger stays reachable" % tag) + _expect_false(desk.has_point(layout["hero_pos"]), + "layout: %s hero starts clear of the desk" % tag) + _expect_false(desk.has_point(layout["lumi_pos"]), + "layout: %s lumi stands clear of the desk" % tag) + var view := Rect2(Vector2.ZERO, framing) + # The desk is an obstacle: it must be present, and may touch + # the cover-crop edge. The gate interaction stays fully shown. + _expect_true(view.intersects(desk), + "layout: %s desk stays onscreen" % tag) + _expect_true(view.encloses(gate), + "layout: %s gate stays onscreen" % tag) + _expect_true(view.has_point(layout["lumi_pos"]), + "layout: %s lumi stays onscreen" % tag) + var wide_base: Dictionary = GateLodge.layout_for(Vector2(808, 360)) + var tablet_base: Dictionary = GateLodge.layout_for(Vector2(808, 606)) + # Cover math: the 2.25:1 plate stands 0.25% proud of 808x360, so + # wide actors run a hair above 1.0 rather than banding the room. + _expect_true(absf(float(wide_base["actor_scale"]) - 1.0) < 0.005, + "layout: wide base runs actors at ~1.0") + _expect_true(absf(float(tablet_base["actor_scale"]) - 1.0) < 0.005, + "layout: tablet base keeps the same world scale") + + +# --- lumi --------------------------------------------------------------------- + +func _test_lumi_rig() -> void: + var lumi: Node = LumiGuide.new() + add_child(lumi) + await get_tree().process_frame + var feet: Vector2 = lumi.position + for facing in ["front", "rear", "left", "right"]: + lumi.set_facing(facing) + _expect_equal(lumi.facing(), facing, + "lumi: facing reads %s" % facing) + var sprite: Sprite2D = lumi.get_node("Sprite") as Sprite2D + var size: Vector2 = (sprite.texture as Texture2D).get_size() + _expect_equal(int(size.y), 869, + "lumi: %s shares the 869px foot line" % facing) + _expect_equal(sprite.offset, Vector2(-size.x * 0.5, -size.y), + "lumi: %s anchors bottom-center" % facing) + _expect_equal(lumi.position, feet, + "lumi: %s swaps without moving her feet" % facing) + lumi.set_facing("sideways") + _expect_equal(lumi.facing(), "right", + "lumi: a bogus facing keeps the last real one") + lumi.position = Vector2(300, 200) + lumi.face_toward(100.0) + _expect_equal(lumi.facing(), "left", "lumi: turns to the left guest") + lumi.face_toward(500.0) + _expect_equal(lumi.facing(), "right", "lumi: turns to the right guest") + lumi.face_toward(305.0) + _expect_equal(lumi.facing(), "front", "lumi: fronts the near guest") + var shadow: Sprite2D = lumi.get_node("Shadow") as Sprite2D + _expect_true(shadow.texture != null, "lumi: the feet cast a shadow") + for _index in 30: + await get_tree().process_frame + _expect_equal(lumi.position, Vector2(300, 200), + "lumi: idle breath never walks her feet") + lumi.queue_free() + await get_tree().process_frame + + +# --- strings -------------------------------------------------------------------- + +func _test_csv_keys() -> void: + _expect_equal(LODGE_KEYS.size(), 33, "csv: thirty-three lodge keys") + var path: String = ProjectSettings.globalize_path( + "res://localization/gate_entry.csv") + var rows: PackedStringArray = FileAccess.get_file_as_string( + path).split("\n") + var found: Dictionary = {} + for row in rows: + if row.is_empty() or row.begins_with("keys,"): + continue + var cells: PackedStringArray = row.split(",") + if not str(cells[0]).begins_with("gate.lodge."): + continue + found[cells[0]] = cells + for key in LODGE_KEYS: + _expect_true(found.has(key), "csv: %s ships" % key) + if not found.has(key): + continue + var cells: PackedStringArray = found[key] + _expect_equal(cells.size(), 6, "csv: %s spans five locales" % key) + if cells.size() != 6: + continue + var marks: int = -1 + for index in range(1, 6): + _expect_false(str(cells[index]).is_empty(), + "csv: %s locale %d filled" % [key, index]) + var count: int = str(cells[index]).count("%s") + if marks < 0: + marks = count + _expect_equal(count, marks, + "csv: %s locale %d keeps its placeholders" % [key, index]) + + +func _test_strings_resolve() -> void: + for key in LODGE_KEYS: + _expect_true(GateEntryStrings.text(key) != key, + "lodge: %s resolves" % key) + + +# --- flow --------------------------------------------------------------------- + +func _test_boot_completed_departs() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.display = "Luna" + fake.intro = true + var lodge: Node = _make_lodge(fake) + await _frames(5) + _expect_equal(lodge.lodge_state(), GateLodge.State.DEPART, + "boot: a completed account departs at once") + _expect_equal(lodge.lodge_account(), FAKE_ID, + "boot: the visit binds its account") + _expect_true(lodge._depart_panel.visible, + "boot: the departure panel shows") + _expect_false(lodge._depart_resume.visible, + "boot: no save means no resume") + _expect_false(Journey.armed, "boot: the lodge arms nothing") + await _free_lodge(lodge, fake) + + +func _test_boot_cold_completed_departs() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.cache_on_load = true + fake.load_reply = {"status": "ok", "display": "Luna", "key": "luna", + "intro_complete": true, "source": "cloud"} + fake.saved = {"has_save": true, "cycle": 3, "zone_index": 1, + "hero_path": "res://resources/heroes/warden.tres"} + var lodge: Node = _make_lodge(fake) + await _frames(5) + _tap_speech(lodge) + _tap_speech(lodge) + _tap_speech(lodge) + await _frames(5) + _expect_equal(lodge.lodge_state(), GateLodge.State.DEPART, + "cold: an acked completed row departs, never practices") + _expect_equal(lodge.lodge_display(), "Luna", + "cold: the recovered handle drives departure") + _expect_false(lodge._speech_panel.visible, + "cold: no practice speech repeats") + _expect_false(lodge._name_panel.visible, + "cold: no second registration asks") + _expect_true(lodge._depart_panel.visible, + "cold: the departure choice shows") + _expect_true(lodge._depart_resume.visible, + "cold: the living save offers resume") + _expect_true(str(lodge._depart_saved.text).contains("3"), + "cold: the saved line survives recovery") + _expect_false(Journey.armed, "cold: recovery arms nothing") + await _free_lodge(lodge, fake) + + +func _test_boot_recovers_claimed_name() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.display = "Luna" + fake.intro = false + var lodge: Node = _make_lodge(fake) + await _frames(5) + _expect_true(lodge._speech_panel.visible, + "recover: the greeting shows") + _expect_equal(lodge._speech_body.text, + GateEntryStrings.text("gate.lodge.named_ok") % "Luna", + "recover: the claimed handle greets by name") + await _free_lodge(lodge, fake) + + +func _test_boot_form_states() -> void: + _wipe() + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + await _frames(5) + _tap_speech(lodge) + _tap_speech(lodge) + _tap_speech(lodge) + await _frames(5) + _expect_equal(lodge.lodge_state(), GateLodge.State.NAME, + "form: an unclaimed account reaches the register") + _expect_true(lodge._name_panel.visible, "form: the form shows") + _expect_equal(lodge._name_field.text, "", + "form: nothing is prefilled, ever") + _expect_equal(lodge._name_status.text, "", + "form: a clean load leaves no status") + await _free_lodge(lodge, fake) + _wipe() + var offline := LodgeFakeHost.new() + offline.load_reply = {"status": "failure", "code": "offline", + "retryable": true} + var limp: Node = _make_lodge(offline) + await _frames(5) + _tap_speech(limp) + _tap_speech(limp) + _tap_speech(limp) + await _frames(5) + _expect_equal(limp.lodge_state(), GateLodge.State.NAME, + "form: a limping load still reaches the register") + _expect_equal(limp._name_status.text, + GateEntryStrings.text("gate.lodge.name_offline"), + "form: the limping load names the outage") + _expect_false(limp._name_confirm.disabled, + "form: the outage never traps the form") + await _free_lodge(limp, offline) + + +func _claim_status(lodge: Node) -> String: + lodge._name_field.text = "Luna" + lodge._on_name_confirm() + await _frames(5) + return lodge._name_status.text + + +func _test_claim_buckets() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.claim_queue = [ + {"status": "conflict", "code": "name-taken"}, + {"status": "failure", "code": "invalid-name"}, + {"status": "failure", "code": "timeout", "retryable": true}, + {"status": "ok", "display": "Luna", "key": "luna"}, + ] + var lodge: Node = _make_lodge(fake) + await _frames(5) + _tap_speech(lodge) + _tap_speech(lodge) + _tap_speech(lodge) + await _frames(5) + _expect_equal(await _claim_status(lodge), + GateEntryStrings.text("gate.lodge.name_taken"), + "claim: a taken name says taken") + _expect_equal(await _claim_status(lodge), + GateEntryStrings.text("gate.lodge.name_invalid"), + "claim: an invalid name says invalid") + _expect_equal(await _claim_status(lodge), + GateEntryStrings.text("gate.lodge.name_offline"), + "claim: a timeout reads as link trouble") + lodge._name_field.text = "Luna" + lodge._on_name_confirm() + await _frames(5) + _expect_equal(lodge.lodge_display(), "Luna", + "claim: the win keeps its handle") + _expect_false(lodge._name_panel.visible, + "claim: the win closes the form") + _expect_false(Journey.armed, "claim: the win arms nothing") + await _free_lodge(lodge, fake) + + +func _wait_state(lodge: Node, state: int, frames: int) -> bool: + for _index in frames: + if lodge.lodge_state() == state: + return true + await get_tree().process_frame + return lodge.lodge_state() == state + + +func _test_full_lesson_to_departure() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.display = "Luna" + var lodge: Node = _make_lodge(fake) + await _frames(5) + _tap_speech(lodge) + await _frames(3) + _expect_equal(lodge._speech_body.text, + GateEntryStrings.text("gate.lodge.move_lesson"), + "lesson: the move lesson reads first") + _tap_speech(lodge) + _expect_true(await _wait_state(lodge, GateLodge.State.MOVE, 10), + "lesson: practice opens the stick step") + _expect_true(lodge._stick.mouse_filter == Control.MOUSE_FILTER_STOP, + "lesson: the stick takes touches while practicing") + lodge.move_override_active = true + lodge.move_override = Vector2.RIGHT + _expect_true(await _wait_state(lodge, GateLodge.State.LINES, 240), + "lesson: real walking teaches the move gate") + lodge.move_override_active = false + _expect_true(bool(Vault.lodge_progress_for_account(FAKE_ID).get( + "move", false)), "lesson: the move gate lands in the vault") + _expect_true(Onboarding.is_done("move"), + "lesson: the practiced move quiets the arena tip") + var onboard_text: String = FileAccess.get_file_as_string( + ProjectSettings.globalize_path("user://lodge_onboarding_test.json")) + _expect_true(onboard_text.contains("move"), + "lesson: the practiced move persists with no armed journey") + _tap_speech(lodge) + _expect_true(await _wait_state(lodge, GateLodge.State.DASH, 10), + "lesson: the dash lesson follows") + _expect_true(lodge._dash_button.visible, + "lesson: the dash button joins for its lesson") + _tap_speech(lodge) + await _frames(3) + lodge._dash_button.emit_signal("pressed") + _expect_true(await _wait_state(lodge, GateLodge.State.LINES, 120), + "lesson: a real dash teaches the dash gate") + _expect_true(bool(Vault.lodge_progress_for_account(FAKE_ID).get( + "dash", false)), "lesson: the dash gate lands in the vault") + _tap_speech(lodge) + _expect_true(await _wait_state(lodge, GateLodge.State.GATE, 10), + "lesson: the gate walk follows") + lodge._player.position = lodge._gate_rect.get_center() + _expect_true(await _wait_state(lodge, GateLodge.State.DEPART, 30), + "lesson: the gate seals into departure") + _expect_true(fake.intro, "lesson: the seal flips the intro bit") + _expect_true(lodge._depart_panel.visible, + "lesson: departure shows after the seal") + _expect_false(Journey.armed, "lesson: the lesson arms nothing") + await _free_lodge(lodge, fake) + + +func _test_seal_retry_and_departure_choice() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.display = "Luna" + fake.seal_queue = [{"status": "failure", "code": "timeout", + "retryable": true}] + fake.saved = {"has_save": true, "cycle": 3, "zone_index": 1, + "hero_path": "res://resources/heroes/warden.tres"} + var lodge: Node = _make_lodge(fake) + var arenas: Array = [] + lodge.request_arena.connect(func(path: String) -> void: arenas.append(path)) + await _frames(5) + _tap_speech(lodge) + await _frames(3) + _tap_speech(lodge) + await _wait_state(lodge, GateLodge.State.MOVE, 10) + lodge.move_override_active = true + lodge.move_override = Vector2.RIGHT + await _wait_state(lodge, GateLodge.State.LINES, 240) + lodge.move_override_active = false + _tap_speech(lodge) + await _wait_state(lodge, GateLodge.State.DASH, 10) + _tap_speech(lodge) + await _frames(3) + lodge._dash_button.emit_signal("pressed") + await _wait_state(lodge, GateLodge.State.LINES, 120) + _tap_speech(lodge) + await _wait_state(lodge, GateLodge.State.GATE, 10) + lodge._player.position = lodge._gate_rect.get_center() + await _frames(10) + _expect_equal(lodge._speech_body.text, + GateEntryStrings.text("gate.lodge.gate_retry"), + "seal: the miss explains itself at the gate") + _tap_speech(lodge) + await _wait_state(lodge, GateLodge.State.GATE, 10) + _expect_true(lodge._gate_armed, "seal: the retry re-arms the gate") + lodge._player.position = lodge._walk_rect.get_center() + await _frames(3) + lodge._player.position = lodge._gate_rect.get_center() + _expect_true(await _wait_state(lodge, GateLodge.State.DEPART, 30), + "seal: the retry seals on re-entry") + _expect_true(lodge._depart_resume.visible, + "depart: a living journey offers resume") + _expect_true(str(lodge._depart_saved.text).contains("3"), + "depart: the saved line names the cycle") + lodge._on_depart_fresh() + _expect_equal(lodge._depart_status.text, + GateEntryStrings.text("gate.lodge.depart_fresh_confirm"), + "depart: fresh over a save asks first") + _expect_true(fake.exit_calls.is_empty(), + "depart: the question plans nothing") + lodge._on_depart_resume() + await _frames(3) + _expect_equal(fake.exit_calls.size(), 1, + "depart: resume exits once") + _expect_equal(bool(fake.exit_calls[0].get("fresh", true)), false, + "depart: resume exits as a resume") + _expect_equal(arenas, + ["res://scenes/gameplay/arena.tscn"], + "depart: the exit names the arena") + await _free_lodge(lodge, fake) + + +func _test_account_loss_retires() -> void: + _wipe() + var fake := LodgeFakeHost.new() + fake.display = "Luna" + var lodge: Node = _make_lodge(fake) + var titles: Array = [] + lodge.request_title.connect(func() -> void: titles.append(true)) + await _frames(5) + fake.public_id = "MB-lodgetest0000000000000000000002" + fake.production_changed.emit(fake.account_state()) + await _frames(3) + _expect_equal(lodge.lodge_state(), GateLodge.State.LOST, + "lost: a moved account retires the visit") + _tap_speech(lodge) + await _frames(3) + _expect_equal(titles.size(), 1, + "lost: the tap walks back to the title") + _expect_equal(fake.cancels, 1, "lost: the visit releases its plan") + await _free_lodge(lodge, fake) + + +func _test_guards_and_push_out() -> void: + _wipe() + Vault.mark_lodge_gate(FAKE_ID, "move") + var fake := LodgeFakeHost.new() + fake.display = "Luna" + var lodge: Node = _make_lodge(fake) + await _frames(5) + _expect_true(lodge.is_in_group("moonlit_dialogue"), + "guard: the visit holds the dialogue line") + var arena_cam: Camera2D = lodge._player.get_node("Cam") as Camera2D + _expect_false(arena_cam.enabled, "guard: the arena camera sleeps") + _expect_equal(get_viewport().get_camera_2d(), lodge._camera, + "guard: the room camera owns the frame") + _expect_true(lodge.find_child("Bullets", true, false) == null, + "guard: the refuge spawns no volleys") + _expect_true(lodge.find_child("Hud", true, false) == null, + "guard: the refuge runs no combat counters") + _tap_speech(lodge) + await _frames(3) + _expect_equal(lodge._speech_body.text, + GateEntryStrings.text("gate.lodge.dash_lesson"), + "guard: a sticky move gate skips its lesson") + _tap_speech(lodge) + await _wait_state(lodge, GateLodge.State.DASH, 10) + _tap_speech(lodge) + await _frames(3) + lodge._player.position = lodge._desk_rect.get_center() + await _frames(3) + _expect_false(lodge._desk_rect.has_point(lodge._player.position), + "guard: the desk slides the hero out") + lodge._player.position = lodge._lumi.position + Vector2(2, 0) + await _frames(3) + _expect_true(lodge._player.position.distance_to( + lodge._lumi.position) >= 20.0, + "guard: lumi holds her ground") + await _free_lodge(lodge, fake) + + +## The desk projection lands on walkable floor: at every judged framing +## plus the wide 880x360 that caught the suite, the desk's center, edge +## midpoints, and corners all exit outside the desk AND inside the walk +## through the real method, and real physics settling holds both. Strip +## samples keep their sliding side; anything above the walk band exits +## below the desk, never off the floor. +func _test_desk_projection_stays_on_walk() -> void: + var original_size: Vector2i = get_tree().root.size + var original_scale: Vector2i = ( + get_tree().root.content_scale_size) + var framings: Array[Vector2] = FRAMINGS.duplicate() + framings.append(Vector2(880, 360)) + for framing in framings: + var tag: String = "%dx%d" % [ + int(framing.x), int(framing.y)] + get_tree().root.size = Vector2i( + int(framing.x), int(framing.y)) + get_tree().root.content_scale_size = Vector2i( + int(framing.x), int(framing.y)) + await _frames(2) + _wipe() + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + await _frames(5) + # Freeze the live loop: each sample drives the real method by + # hand, then real physics ticks settle it without a re-push. + lodge.set_process(false) + var walk: Rect2 = lodge._walk_rect + var desk: Rect2 = lodge._desk_rect + var live: Vector2 = get_viewport().get_visible_rect().size + _expect_equal(live, framing, + "walk: %s stages its viewport" % tag) + var layout: Dictionary = GateLodge.layout_for(live) + _expect_equal(walk, layout["walk_rect"], + "walk: %s seats its live walk" % tag) + _expect_equal(desk, layout["desk_rect"], + "walk: %s seats its live desk" % tag) + var mid: Vector2 = desk.get_center() + var strip_y: float = ( + maxf(walk.position.y, desk.position.y) + desk.end.y + ) * 0.5 + var samples: Array = [ + ["center", mid, "bottom"], + ["top-mid", Vector2(mid.x, desk.position.y + 2.0), + "bottom"], + ["left-mid", Vector2(desk.position.x + 2.0, mid.y), + "bottom"], + ["right-mid", Vector2(desk.end.x - 2.0, mid.y), + "bottom"], + ["bottom-mid", Vector2(mid.x, desk.end.y - 2.0), + "bottom"], + ["left-strip", + Vector2(desk.position.x + 2.0, strip_y), "left"], + ["right-strip", + Vector2(desk.end.x - 2.0, strip_y), "right"], + ["corner-tl", desk.position + Vector2(2, 2), + "bottom"], + ["corner-tr", + Vector2(desk.end.x - 2.0, desk.position.y + 2.0), + "bottom"], + ["corner-bl", + Vector2(desk.position.x + 2.0, desk.end.y - 2.0), + "left"], + ["corner-br", desk.end - Vector2(2, 2), "right"], + ] + for sample in samples: + await _check_desk_sample( + lodge, walk, desk, sample[1], sample[0], + sample[2], tag) + # The live loop holds too: push and clamp agree every frame. + lodge.set_process(true) + lodge._player.position = desk.get_center() + await _frames(3) + _expect_false(desk.has_point(lodge._player.position), + "walk: %s live loop leaves the desk" % tag) + _expect_true(_on_walk(walk, lodge._player.position), + "walk: %s live loop stays on the walk" % tag) + await _free_lodge(lodge, fake) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_scale + await _frames(2) + + +## One desk interior point through the real push-out: the method agrees +## with the pure projection, lands outside the desk on the walk through +## the expected side, and real physics ticks hold both constraints. +func _check_desk_sample(lodge: Node, walk: Rect2, desk: Rect2, + point: Vector2, sample_name: String, side: String, + tag: String) -> void: + var label: String = "%s %s" % [tag, sample_name] + _expect_true(desk.has_point(point), + "walk: %s starts inside the desk" % label) + lodge._player.set_move_input(Vector2.ZERO) + lodge._player.velocity = Vector2.ZERO + lodge._player.position = point + lodge._push_out_of_desk() + var pure: Vector2 = GateLodge.desk_exit_for(point, desk, walk) + _expect_equal(lodge._player.position, pure, + "walk: %s follows the pure projection" % label) + _expect_false(desk.has_point(lodge._player.position), + "walk: %s exits the desk" % label) + _expect_true(_on_walk(walk, lodge._player.position), + "walk: %s lands on the walk" % label) + match side: + "left": + _expect_true(is_equal_approx( + lodge._player.position.x, + desk.position.x - 1.0) and is_equal_approx( + lodge._player.position.y, point.y), + "walk: %s slides out the left side" % label) + "right": + _expect_true(is_equal_approx( + lodge._player.position.x, desk.end.x + 1.0) + and is_equal_approx( + lodge._player.position.y, point.y), + "walk: %s slides out the right side" % label) + _: + _expect_true(is_equal_approx( + lodge._player.position.y, desk.end.y + 1.0) + and is_equal_approx( + lodge._player.position.x, point.x), + "walk: %s drops below the desk" % label) + await get_tree().physics_frame + await get_tree().physics_frame + _expect_false(desk.has_point(lodge._player.position), + "walk: %s settling stays out of the desk" % label) + _expect_true(_on_walk(walk, lodge._player.position), + "walk: %s settling stays on the walk" % label) + + +## Negative control for the desk projection: the pre-fix rule (nearest +## edge, float-equality ties, no walk check) strands the desk center +## off the walk at 880x360, and the real physics clamp then drags it +## back into the desk. The suite PASSES by catching the old rule in +## the act; the `negative-control` labels and print below are the +## deliberate diagnostics, never suite failures. +func _test_desk_negative_control() -> void: + var original_size: Vector2i = get_tree().root.size + var original_scale: Vector2i = ( + get_tree().root.content_scale_size) + get_tree().root.size = Vector2i(880, 360) + get_tree().root.content_scale_size = Vector2i(880, 360) + await _frames(2) + _wipe() + var fake := LodgeFakeHost.new() + var lodge: Node = _make_lodge(fake) + await _frames(5) + lodge.set_process(false) + var walk: Rect2 = lodge._walk_rect + var desk: Rect2 = lodge._desk_rect + _expect_equal(get_viewport().get_visible_rect().size, + Vector2(880, 360), + "negative-control: stages its 880x360 viewport") + lodge._player.set_move_input(Vector2.ZERO) + lodge._player.velocity = Vector2.ZERO + lodge._player.position = _old_desk_exit( + desk.get_center(), desk) + print("negative-control (deliberate, not a suite failure): ", + "880x360 old rule exits desk center ", + str(desk.get_center()), " to ", + str(lodge._player.position), " (on walk: ", + str(_on_walk(walk, lodge._player.position)), ")") + _expect_false(_on_walk(walk, lodge._player.position), + "negative-control: the old rule strands 880x360 off the walk") + await get_tree().physics_frame + await get_tree().physics_frame + print("negative-control (deliberate, not a suite failure): ", + "real clamp settles to ", str(lodge._player.position), + " (back in desk: ", + str(desk.has_point(lodge._player.position)), ")") + _expect_true(desk.has_point(lodge._player.position), + "negative-control: the clamp drags the old exit back inside") + await _free_lodge(lodge, fake) + get_tree().root.size = original_size + get_tree().root.content_scale_size = original_scale + await _frames(2) + + +## The pre-fix projection: nearest desk edge with float-equality ties +## and no walk check. Kept only as the negative control. +func _old_desk_exit(point: Vector2, desk: Rect2) -> Vector2: + var left: float = absf(point.x - desk.position.x) + var right: float = absf(point.x - desk.end.x) + var top: float = absf(point.y - desk.position.y) + var bottom: float = absf(point.y - desk.end.y) + var least: float = minf(minf(left, right), minf(top, bottom)) + if least == left: + return Vector2(desk.position.x - 1.0, point.y) + if least == right: + return Vector2(desk.end.x + 1.0, point.y) + if least == top: + return Vector2(point.x, desk.position.y - 1.0) + return Vector2(point.x, desk.end.y + 1.0) + + +## The projection's unreachable branch still holds its contract: with a +## desk nowhere near the walk (no real framing does this), the clamped +## fallback lands on the walk outside the desk instead of stranding. +func _test_desk_exit_fallback() -> void: + var desk := Rect2(0, 0, 10, 10) + var walk := Rect2(100, 100, 50, 50) + var exit: Vector2 = GateLodge.desk_exit_for( + Vector2(5, 5), desk, walk) + _expect_false(desk.has_point(exit), + "walk: the fallback clears a far desk") + _expect_true(_on_walk(walk, exit), + "walk: the fallback lands on a far walk") + + +## The walk-floor constraint both the projection and the settling must +## hold: inclusive on every side, matching the Player bounds clamp +## rather than Rect2.has_point, which drops the far edge. +func _on_walk(walk: Rect2, point: Vector2) -> bool: + return point.x >= walk.position.x and point.x <= walk.end.x \ + and point.y >= walk.position.y and point.y <= walk.end.y + + +func _report() -> void: + print("gate lodge: %d checks, %d failed" % [_checked, _failed]) + get_tree().quit(1 if _failed > 0 else 0) diff --git a/apps/game/tests/test_gate_lodge.gd.uid b/apps/game/tests/test_gate_lodge.gd.uid new file mode 100644 index 0000000..870d2f4 --- /dev/null +++ b/apps/game/tests/test_gate_lodge.gd.uid @@ -0,0 +1 @@ +uid://dh7dasoiugj0o diff --git a/apps/game/tests/test_gate_lodge.tscn b/apps/game/tests/test_gate_lodge.tscn new file mode 100644 index 0000000..bbcf105 --- /dev/null +++ b/apps/game/tests/test_gate_lodge.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/test_gate_lodge.gd" id="1"] + +[node name="TestGateLodge" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tests/test_journey.gd b/apps/game/tests/test_journey.gd index 0802da5..d3ec2d4 100644 --- a/apps/game/tests/test_journey.gd +++ b/apps/game/tests/test_journey.gd @@ -1,17 +1,18 @@ extends Node -## Journey persistence: checkpoints, resume, death retry, and the episode catalog. +## Journey persistence: checkpoints, resume, sealed defeat, and the episode catalog. ## ## The endless game keeps its run across launches: segment-entry checkpoints -## seal the gate behind the player, death retries the saved segment without -## farming it, and the title offers Continue back through the last gate. The -## opening plays over live combat instead of pausing it, and learned tips -## stay learned. These pin the parts a regression could quietly break: the -## round trip after a fork and a guardian reward, a late cycle past every -## authored episode, hostile saves, backup recovery, failed writes, repeated -## death/retry/launches that must not mint shards, the fresh-journey -## confirmation, the unpaused opening contract, and the story catalog that -## Acts, the chronicle and the arena all read from. +## seal the gate behind the player, death seals the journey terminal without +## farming it, and the title offers Continue back through the last gate of a +## run that is still alive. The opening plays over live combat instead of +## pausing it, and learned tips stay learned. These pin the parts a +## regression could quietly break: the round trip after a fork and a +## guardian reward, a late cycle past every authored episode, hostile saves, +## backup recovery, failed writes, repeated defeat/exits/launches that must +## not mint shards, the fresh-journey confirmation, the unpaused opening +## contract, and the story catalog that Acts, the chronicle and the arena +## all read from. ## ## Uses temp journey/onboarding/chronicle paths under the isolated test HOME, ## so no human save is touched. The Vault and Records autoloads are real but @@ -57,9 +58,9 @@ func _run() -> void: _fresh_paths() _test_failed_write_keeps_previous() _fresh_paths() - await _test_death_retry_no_farming() + await _test_defeat_seals_once_never_retries_free() _fresh_paths() - await _test_result_retry_copy() + await _test_defeat_and_cashout_result_copy() _fresh_paths() await _test_fresh_confirm_required() _fresh_paths() @@ -333,6 +334,17 @@ func _write_text(candidate: String, text: String) -> void: handle.close() +## Raw JSON of one file, bypassing the Journey validator. Empty when the +## file is missing or malformed. +func _read_json_file(candidate: String) -> Dictionary: + if not FileAccess.file_exists(candidate): + return {} + var parser: JSON = JSON.new() + if parser.parse(FileAccess.get_file_as_string(candidate)) != OK: + return {} + return parser.data as Dictionary if parser.data is Dictionary else {} + + func _valid_checkpoint() -> Dictionary: return { "schema_version": 1, @@ -455,10 +467,11 @@ func _test_failed_write_keeps_previous() -> void: "the previous playable save survives the failed write") -# --- death and retry ------------------------------------------------------------------ -## Repeated death, restore and process-launch retries cannot increase banked -## shards or duplicate record and reward settlement. -func _test_death_retry_no_farming() -> void: +# --- death and defeat --------------------------------------------------------------- +## Death seals the journey terminal exactly once: the file carries the ended +## marker at the last seal's id, no free segment retry exists anywhere, and +## repeated finishes, settlements or relaunches grant nothing more. +func _test_defeat_seals_once_never_retries_free() -> void: _begin_human_fresh() var arena: Node2D = ARENA_SCENE.instantiate() as Node2D add_child(arena) @@ -470,45 +483,66 @@ func _test_death_retry_no_farming() -> void: arena.set("_survived", 240.0) _expect_true(bool(arena.call("_journey_checkpoint", true)), "the gate seals") var banked: int = Vault.shards - var awarded: int = int(arena.get("_run_shards_awarded")) - var checkpoint_score: int = int((arena.get("_journey_snapshot") as Dictionary)["settled_score"]) - var checkpoint_stacks: Dictionary = ( - arena.get("_journey_snapshot") as Dictionary)["relic_stacks"] + var sealed_id: int = int( + (arena.get("_journey_snapshot") as Dictionary)["checkpoint_id"]) + var old_journey: String = str(arena.get("_journey_id")) var coins: int = Vault.continue_coins - var retry_action: int = int(arena.ResultAction.RESTART) - - for attempt in 3: - # The defeated segment earns score, loot and time — all of it is lost. - arena.set("_kill_score", int(arena.get("_kill_score")) + 5000) - arena.set("_survived", float(arena.get("_survived")) + 60.0) - var relic_panel: Control = arena.get_node("Ui/Relic") as Control - arena.call("_on_relic_picked", - relic_panel.call("take_named", TOUGH_LIFE), false) - arena.call("_finish", false) - await get_tree().process_frame - _expect_equal(Vault.shards, banked, - "death %d banks no new shards" % (attempt + 1)) - _expect_equal(int(arena.get("_run_shards_awarded")), awarded, - "death %d grants no new reward" % (attempt + 1)) - _expect_true(Records.best_score >= checkpoint_score, - "death %d keeps one record submission" % (attempt + 1)) - var retry: Button = arena.get_node("Ui/Result/Actions/Retry") as Button - _expect_equal(retry.text, tr("RESULT_GATE_RETRY"), - "death %d offers the gate retry" % (attempt + 1)) - arena.call("_on_result_dismissed", retry_action) - await get_tree().process_frame - _expect_false(bool(arena.get("_over")), - "retry %d resumes the run" % (attempt + 1)) - _expect_equal(_stack_counts(arena).size(), (checkpoint_stacks as Dictionary).size(), - "retry %d drops the defeated loot" % (attempt + 1)) - _expect_equal(int(arena.get("_kill_score")), - int((arena.get("_journey_snapshot") as Dictionary)["kill_score"]), - "retry %d restores the checkpoint score" % (attempt + 1)) - _expect_equal(Vault.shards, banked, - "retry %d mints no shards" % (attempt + 1)) - _expect_equal(Vault.continue_coins, coins, "gate retries spend no paid coins") - - # A new process launching into the same gate cannot grant again either. + + # The defeated segment earns score, loot and time; the final failure + # settles the live totals once and seals them into the marker. + arena.set("_kill_score", int(arena.get("_kill_score")) + 5000) + arena.set("_survived", float(arena.get("_survived")) + 60.0) + var relic_panel: Control = arena.get_node("Ui/Relic") as Control + arena.call("_on_relic_picked", + relic_panel.call("take_named", TOUGH_LIFE), false) + var final_total: int = int(arena.call("_journey_score_total")) + var best_before: int = Records.best_score + arena.call("_finish", false) + await get_tree().process_frame + _expect_true(bool(arena.get("_over")), "death ends the run") + _expect_false(arena.has_method("_retry_from_gate"), + "no free gate retry route exists") + _expect_false(arena.has_method("_journey_retry_available"), + "no gate retry availability check exists") + var tombstone: Dictionary = _read_json_file(TEST_PATH) + _expect_true(bool(tombstone.get("ended", false)), + "death seals the terminal marker") + _expect_equal(int(tombstone.get("checkpoint_id", -1)), sealed_id, + "the marker keeps the last seal's id") + _expect_equal(int(tombstone.get("settled_score", -1)), final_total, + "the marker echoes the final live score") + _expect_equal(int(tombstone.get("shards_awarded", -1)), + int(arena.get("_run_shards_awarded")), + "the marker echoes the granted shards") + _expect_false(Journey.has_valid_checkpoint(), + "a sealed defeat offers no Continue") + _expect_true(Journey.summary(Journey.read_checkpoint()).is_empty(), + "a sealed defeat summarizes to nothing") + var retry: Button = arena.get_node("Ui/Result/Actions/Retry") as Button + _expect_equal(retry.text, tr("RESULT_START_OVER"), + "death offers a fresh start, never the defeated segment") + _expect_equal(Vault.continue_coins, coins, "death spends no paid coins") + var banked_final: int = Vault.shards + _expect_true(banked_final >= banked, "the final failure settles once") + _expect_equal(int(arena.get("_board_score")), final_total, + "the result counts the final live score") + _expect_equal(Records.best_score, maxi(best_before, final_total), + "the final score submits to the records") + + # A second finish and a repeated settlement change nothing. + arena.call("_finish", false) + await get_tree().process_frame + _expect_equal(Vault.shards, banked_final, + "a repeated finish banks nothing") + _expect_false(bool(arena.call( + "_persist_finished_result", final_total, + str(arena.get("_board_rank")))), + "a repeated settlement saves no new best") + _expect_equal(Vault.shards, banked_final, + "a repeated settlement banks nothing") + + # A relaunch meets the sealed defeat and starts visibly over: cycle 1, + # a new journey, and no grant for the sealed run. arena.queue_free() await get_tree().process_frame _begin_human_resume() @@ -516,18 +550,24 @@ func _test_death_retry_no_farming() -> void: add_child(relaunched) await get_tree().process_frame await get_tree().process_frame - _expect_equal(int(relaunched.get("_run_shards_awarded")), awarded, - "a relaunch restores the settlement bookkeeping") - relaunched.set("_kill_score", int(relaunched.get("_kill_score")) + 9000) - relaunched.call("_finish", false) - await get_tree().process_frame - _expect_equal(Vault.shards, banked, "a relaunched death banks no new shards") + _expect_true(bool(relaunched.get("_journey_resume_failed")), + "a sealed defeat visibly falls back to fresh") + _expect_equal(int(relaunched.get("_cycle")), 1, + "the fallback starts at cycle 1") + _expect_equal(int(relaunched.get("_zone_index")), 0, + "the fallback starts at zone 0") + _expect_not_equal(str(relaunched.get("_journey_id")), old_journey, + "the fallback begins a new journey") + _expect_equal(Vault.shards, banked_final, + "the fallback grants nothing for the sealed run") + _expect_equal(Vault.continue_coins, coins, + "the fallback spends no paid coins") relaunched.queue_free() await get_tree().process_frame -## Retry says which road it takes: the gate on a loss, a fresh run on a win. -func _test_result_retry_copy() -> void: +## Retry says which road it takes: start over on a loss, a fresh run on a win. +func _test_defeat_and_cashout_result_copy() -> void: _begin_human_fresh() var arena: Node2D = ARENA_SCENE.instantiate() as Node2D add_child(arena) @@ -540,17 +580,33 @@ func _test_result_retry_copy() -> void: arena.call("_finish", false) await get_tree().process_frame var retry: Button = arena.get_node("Ui/Result/Actions/Retry") as Button - _expect_equal(retry.text, tr("RESULT_GATE_RETRY"), - "a lost checkpoint run names the gate retry") - # Leaving the death behind, a cashout keeps the plain fresh-run retry. - arena.call("_retry_from_gate") + _expect_equal(retry.text, tr("RESULT_START_OVER"), + "a lost run names the fresh start") + var lost_continue: Button = arena.get_node( + "Ui/Result/Actions/Continue") as Button + _expect_true(lost_continue.visible, "a lost run offers the paid continue") + _expect_true(bool(_read_json_file(TEST_PATH).get("ended", false)), + "a lost run seals its defeat") + arena.queue_free() + await get_tree().process_frame + # A cashout keeps the plain fresh-run retry and its alive checkpoint. + _begin_human_fresh() + var settled: Node2D = ARENA_SCENE.instantiate() as Node2D + add_child(settled) await get_tree().process_frame - arena.call("_journey_checkpoint", true) - arena.call("_finish", true) await get_tree().process_frame - _expect_equal(retry.text, tr("RESULT_RETRY"), + settled.call("_finish", true) + await get_tree().process_frame + var retry_after_win: Button = settled.get_node( + "Ui/Result/Actions/Retry") as Button + _expect_equal(retry_after_win.text, tr("RESULT_RETRY"), "a settled run keeps the fresh-run retry") - arena.queue_free() + _expect_false((settled.get_node( + "Ui/Result/Actions/Continue") as Button).visible, + "a settled run offers no paid continue") + _expect_true(Journey.has_valid_checkpoint(), + "a settled run keeps its alive checkpoint") + settled.queue_free() await get_tree().process_frame @@ -1370,7 +1426,7 @@ func _test_mixed_eviction_and_garbage_ids() -> void: # --- layout ------------------------------------------------------------------- -## The Continue panel and the gate-retry button fit their copy in all five +## The Continue panel and the start-over button fit their copy in all five ## locales: no clipped text on the title or the result screen. func _test_journey_ui_fits_five_locales() -> void: _expect_equal(Journey.write_checkpoint(_valid_checkpoint()), OK, @@ -1402,13 +1458,15 @@ func _test_journey_ui_fits_five_locales() -> void: score.survived = 320.0 score.level = 6 score.kills = 860 - result.show_result(false, score, false, false, 2, true) + result.show_result(false, score, false, false, 2) await get_tree().process_frame var retry: Button = result.get_node("Actions/Retry") as Button + _expect_equal(retry.text, tr("RESULT_START_OVER"), + "%s loss names the fresh start" % locale) var minimum: Vector2 = retry.get_combined_minimum_size() _expect_true(minimum.x <= retry.size.x + 0.5 and minimum.y <= retry.size.y + 0.5, - "%s gate-retry button fits (%s / %s)" % [locale, minimum, retry.size]) + "%s start-over button fits (%s / %s)" % [locale, minimum, retry.size]) result.queue_free() await get_tree().process_frame @@ -1447,7 +1505,8 @@ func _test_every_journey_key_exists_in_every_locale() -> void: var keys: Array[String] = ["JOURNEY_CONTINUE", "JOURNEY_NEW", "JOURNEY_CONFIRM_TITLE", "JOURNEY_CONFIRM_DESC", "JOURNEY_CONFIRM_ERASE", "JOURNEY_CONFIRM_KEEP", "JOURNEY_SAVE_FAILED", "JOURNEY_RESUME_FAILED", - "JOURNEY_RESUMED", "RESULT_GATE_RETRY"] + "JOURNEY_RESUMED", "RESULT_START_OVER", "RESULT_RETRY", + "RESULT_CONTINUE_COINS", "RESULT_CONTINUE_BUY"] var original: String = TranslationServer.get_locale() for locale in LOCALES: TranslationServer.set_locale(locale) diff --git a/apps/game/tests/test_ladder.gd b/apps/game/tests/test_ladder.gd index 146aeff..b66d9b6 100644 --- a/apps/game/tests/test_ladder.gd +++ b/apps/game/tests/test_ladder.gd @@ -405,6 +405,73 @@ func _run() -> void: _expect_equal(Records.best_score, 50, "retried best-score file restored") _expect_equal(Records.best_rank, "C", "retried best-rank file restored") + # A verified named player records under the account handle with no + # re-ask: the entry shows read-only, submit publishes the stored + # handle locally, and no legacy global upload runs. + var named_panel: Control = PANEL_SCENE.instantiate() as Control + add_child(named_panel) + await get_tree().process_frame + Ladder.entries.clear() + Ladder.set("_submitted_results", {}) + GlobalLadder.set("_submitted_results", {}) + GlobalLadder.set("_doing", "") + var unnamed_run: String = "44444444444444444444444444444444" + named_panel.call("ask", 39, "C", 1, KEEPER, unnamed_run) + (named_panel.get("_name") as LineEdit).text = "typed" + _expect_true(bool(named_panel.call("_on_submit")), + "classic submit still succeeds") + _expect_equal(str((named_panel.get("_last_global_upload") + as Dictionary).get("name", "")), "typed", + "classic submit records the legacy upload") + var named_run_id: String = "22222222222222222222222222222222" + named_panel.call("ask_named", "Luna", 40, "B", 2, KEEPER, + named_run_id) + _expect_equal(str(named_panel.get("_verified_handle")), "Luna", + "named ask stores the verified handle") + _expect_equal((named_panel.get("_name") as LineEdit).text, "Luna", + "named ask shows the handle") + _expect_false((named_panel.get("_name") as LineEdit).editable, + "named ask locks the entry read-only") + (named_panel.get("_name") as LineEdit).text = "forged" + _expect_true(bool(named_panel.call("_on_submit")), + "named submit succeeds") + _expect_equal(str(Ladder.entries[0].get("name", "")), "Luna", + "named submit publishes the stored handle, never the widget") + _expect_true((named_panel.get("_last_global_upload") + as Dictionary).is_empty(), + "named submit starts no legacy upload") + named_panel.call("ask", 41, "B", 2, KEEPER, named_run_id) + _expect_true(str(named_panel.get("_verified_handle")).is_empty(), + "classic ask clears the verified handle") + _expect_true((named_panel.get("_name") as LineEdit).editable, + "classic ask reopens the entry") + named_panel.call("view") + _expect_true(str(named_panel.get("_verified_handle")).is_empty(), + "view clears the verified handle") + # A failed local save keeps the verified pending result for retry. + if FileAccess.file_exists(Ladder.TEMP_SAVE_PATH): + DirAccess.remove_absolute(ladder_temp_absolute) + _expect_equal(DirAccess.make_dir_absolute(ladder_temp_absolute), OK, + "prepares a directory for named save failure") + var named_fail_run: String = "33333333333333333333333333333333" + named_panel.call("ask_named", "Luna", 45, "B", 2, KEEPER, + named_fail_run) + named_panel.call("close") + _expect_true(named_panel.visible, + "named panel stays open when the local save fails") + _expect_equal(str(named_panel.get("_verified_handle")), "Luna", + "named retry keeps the verified handle") + _expect_equal(DirAccess.remove_absolute(ladder_temp_absolute), OK, + "named save path recovered") + _expect_true(bool(named_panel.call("_on_submit")), + "named retry succeeds after path recovery") + _expect_equal(str(Ladder.entries[0].get("name", "")), "Luna", + "named retry records under the handle") + _expect_true((named_panel.get("_pending") as Dictionary).is_empty(), + "named pending clears after the save lands") + named_panel.queue_free() + await get_tree().process_frame + ProjectSettings.set_setting(VERSION_KEY, original_version) Ladder.entries.clear() var absolute: String = ProjectSettings.globalize_path(Ladder.SAVE_PATH) diff --git a/apps/game/tests/test_player_identity.gd b/apps/game/tests/test_player_identity.gd index 34b7be4..19f6ab7 100644 --- a/apps/game/tests/test_player_identity.gd +++ b/apps/game/tests/test_player_identity.gd @@ -2042,10 +2042,11 @@ func _test_ios_export_manifest_links_everything() -> void: _expect_equal(IOS_MANIFEST_SCRIPT.REQUIRED_BUNDLES.size(), 9, "nine privacy bundles required") var systems: PackedStringArray = IOS_MANIFEST_SCRIPT.system_frameworks() - _expect_equal(systems.size(), 7, - "seven system frameworks declared") + _expect_equal(systems.size(), 8, + "eight system frameworks declared") for name in ["AuthenticationServices", "CoreTelephony", "GameKit", - "SafariServices", "Security", "SystemConfiguration", "WebKit"]: + "SafariServices", "Security", "SystemConfiguration", + "UserNotifications", "WebKit"]: _expect_true(systems.has(name + ".framework"), "system framework linked " + name) _expect_equal(IOS_MANIFEST_SCRIPT.linker_flags(), "-ObjC -lz", diff --git a/apps/game/tests/test_production_host.gd b/apps/game/tests/test_production_host.gd index 2b78ea3..f368704 100644 --- a/apps/game/tests/test_production_host.gd +++ b/apps/game/tests/test_production_host.gd @@ -65,6 +65,9 @@ class DelayedSender extends RefCounted: func queue_ok(body: String = "{}") -> void: replies.append({"transport": "ok", "code": 200, "body": body}) + func queue_reply(reply: Dictionary) -> void: + replies.append(reply.duplicate(true)) + func send(method: String, url: String, _headers: Dictionary, _body: String) -> Dictionary: calls.append({"method": method, "url": url}) @@ -89,6 +92,109 @@ class FakeClock extends RefCounted: now += msec +## Recorder stand-in for the cloud coordinator on the four name routes. +## Records every call without touching the wire; `hold_frames` parks the +## reply so a test can retire the account mid-call and observe the stale +## completion. Never emits: the host wires its signals like the real one. +class NameRecorder extends Node: + signal account_changed(snapshot: Dictionary) + signal save_changed(snapshot: Dictionary) + signal conflict_found(info: Dictionary) + signal hall_changed(snapshot: Dictionary) + signal rank_changed(snapshot: Dictionary) + signal attendance_changed(snapshot: Dictionary) + signal dispatched + + var calls: Array = [] + var hold_frames: int = 0 + var tree_node: Node + + func close() -> void: + pass + + # Inert stubs for production paths that can reach the live + # coordinator outside the name routes (snapshots for account state, + # a deferred best-submit from a late save ack). Only the four name + # routes below record; these never touch the wire. + func account_snapshot() -> Dictionary: + return {"state": "unconfigured"} + + func save_snapshot() -> Dictionary: + return {"state": "unconfigured"} + + func hall_snapshot() -> Dictionary: + return {"state": "unregistered", "source": "unregistered", + "rows": []} + + func rank_snapshot() -> Dictionary: + return {"state": "unregistered", "source": "unregistered", + "rank": 0, "score": 0} + + func conflict_snapshot() -> Dictionary: + return {} + + func submit_current_best() -> Dictionary: + return {"status": "cancelled", "code": "recorder", + "retryable": false} + + func refresh_rank() -> Dictionary: + return {"status": "cancelled", "code": "recorder", + "retryable": false} + + func refresh_board(_limit: int = 20) -> Dictionary: + return {"status": "cancelled", "code": "recorder", + "retryable": false} + + func claim_adventurer_name(display: String) -> Dictionary: + calls.append({"route": "claim", "display": display}) + dispatched.emit() + await _hold() + return {"status": "ok", "display": display, + "key": str(display).to_lower(), "intro_complete": false, + "cached": true, "backfilled": false, + "backfill_code": "no-row"} + + func fetch_adventurer_name() -> Dictionary: + calls.append({"route": "load"}) + dispatched.emit() + await _hold() + return {"status": "ok", "display": "Alpha", "key": "alpha", + "intro_complete": false, "cached": true, "source": "cloud"} + + func complete_intro() -> Dictionary: + calls.append({"route": "intro"}) + dispatched.emit() + await _hold() + return {"status": "ok", "display": "Alpha", "key": "alpha", + "intro_complete": true, "cached": true, "source": "cloud"} + + func backfill_hall_name() -> Dictionary: + calls.append({"route": "backfill"}) + dispatched.emit() + await _hold() + return {"status": "ok", "code": "no-row", "backfilled": false} + + func claim_attendance() -> Dictionary: + calls.append({"route": "attendance"}) + dispatched.emit() + await _hold() + return {"status": "granted", + "receipt": "attendance:stub:1:stub", "coins": 2, + "granted": true, "duplicate": false, + "next_eligible_utc": "2026-10-07T22:00:00Z", + "remaining_seconds": 43200} + + func attendance_snapshot() -> Dictionary: + return {"state": "unregistered", "source": "none", + "public_id": "", "receipt": "", + "last_claim_utc": "", "next_eligible_utc": "", + "remaining_seconds": -1} + + func _hold() -> void: + for _index in hold_frames: + await tree_node.get_tree().process_frame + + class RoutedSender extends RefCounted: var calls: Array = [] var delay_frames: int = 3 @@ -197,6 +303,48 @@ func _run() -> void: await _test_restart_keeps_id() await _test_corrupt_identity_refuses_readiness() await _test_canonical_adoption_durable() + await _test_adoption_rekey_failure_stays_guest_then_retries() + await _test_legacy_paid_adoption_recovers() + await _test_adoption_occupied_canonical_preserves_guest() + await _test_adoption_crash_between_rekey_and_move() + await _test_adoption_target_receipt_without_source_receipt() + await _test_adoption_occupied_target_without_source_receipt() + await _test_claim_name_caches_and_backfills_nothing() + await _test_claim_taken_keeps_identity() + await _test_local_guest_cannot_claim() + await _test_linking_keeps_claimed_name() + await _test_intro_complete_and_reload() + await _test_claim_backfills_existing_best() + await _test_submit_best_carries_handle() + await _test_claim_restore_keeps_completion_bit() + await _test_name_wrappers_cancel_on_token_switch() + await _test_claim_token_failure_sends_nothing() + await _test_claim_token_timeout_sends_nothing() + await _test_claim_shutdown_mid_wait_cancels() + await _test_claim_stale_completion_cancels() + await _test_claim_retirement_mid_wait_cancels() + await _test_claim_cancelled_across_failed_deletion() + await _test_claim_cancelled_by_earlier_failed_deletion() + await _test_claim_refused_while_deletion_in_flight() + await _test_attendance_first_claim_grants_two() + await _test_attendance_cooldown_grants_nothing() + await _test_attendance_same_install_recovers_after_uncertain_ack() + await _test_attendance_wallet_failure_recovers() + await _test_attendance_request_skips_and_double_tap() + await _test_attendance_receipt_flush_and_suppression() + await _test_attendance_foreground_and_offline_cache() + await _test_attendance_link_keeps_record() + await _test_attendance_cancelled_across_failed_deletion() + await _test_attendance_refused_while_deletion_in_flight() + await _test_attendance_backfills_before_advance() + await _test_attendance_other_install_advance_carries_prev() + await _test_attendance_cooldown_backfills_carried_prev() + await _test_attendance_backfill_replay_is_idempotent() + await _test_attendance_uncertain_advance_recovers() + await _test_attendance_ambiguous_backfill_skips_and_advances() + await _test_attendance_pruned_return_advances() + await _test_submit_best_pushes_hud_rank() + await _test_hall_named_rows_reach_panel() await _test_adoption_conflict_preserves() await _test_bindings_have_no_secrets() await _test_offline_guest_labeled_local() @@ -209,6 +357,15 @@ func _run() -> void: await _test_fresh_guest_on_signout_preserves_slot() await _test_new_save_confirmation() await _test_resume_needs_save() + await _test_defeat_offers_no_resume() + await _test_stuck_revive_needs_fresh_confirmation() + await _test_lodge_local_guest_skips() + await _test_lodge_unnamed_cloud_blocked() + await _test_lodge_named_unfinished_recovers() + await _test_lodge_completed_skips() + await _test_lodge_exit_guards() + await _test_lodge_exit_ok_and_confirm() + await _test_lodge_exit_defeat_never_resumes() await _test_double_start_refused() await _test_wrong_account_entry_refused() await _test_continue_reward_safety() @@ -368,6 +525,23 @@ func _await_ready(host: Node, frames: int = 240) -> Dictionary: return coord.account_snapshot() +## Wait until the live coordinator's reservation leaves `reserving` by any +## outcome — ready, offline, conflict, or error. For failure-path tests +## that must observe the error snapshot instead of a ready one. +func _await_reserve_settled(host: Node, frames: int = 120) -> Dictionary: + for _index in frames: + var coord: Node = host.get("_coordinator") as Node + if coord != null: + var snapshot: Dictionary = coord.account_snapshot() + if str(snapshot.get("state", "")) != "reserving": + return snapshot + await get_tree().process_frame + var coord: Node = host.get("_coordinator") as Node + if coord == null: + return {} + return coord.account_snapshot() + + ## Wait until the host's initial cloud check stops fetching: resolved, ## failed, or never started. Returns the final account state. func _settle_restore(host: Node, frames: int = 240) -> Dictionary: @@ -412,149 +586,2485 @@ func _make_parts_with_sender(sender: RefCounted, "account": account, "coord": coord} -## True once the host's initial cloud check is fetching. -func _await_restore_pending(host: Node, frames: int = 240) -> bool: - for _index in frames: - if bool((host.account_state() as Dictionary).get( - "restore_pending", false)): - return true - await get_tree().process_frame - return false +## True once the host's initial cloud check is fetching. +func _await_restore_pending(host: Node, frames: int = 240) -> bool: + for _index in frames: + if bool((host.account_state() as Dictionary).get( + "restore_pending", false)): + return true + await get_tree().process_frame + return false + + +## True once at least `count` checkpoint reads have been sent. +func _await_checkpoint_sent(sender: RefCounted, count: int = 1, + frames: int = 240) -> bool: + for _index in frames: + if _checkpoint_calls(sender).size() >= count: + return true + await get_tree().process_frame + return false + + +## True once the routed sender has no delayed call still awaiting. Tests +## drain this before freeing parts, so no `send()` resumes on a freed +## double after the host is gone. +func _await_sender_idle(sender: RoutedSender, frames: int = 240) -> bool: + for _index in frames: + if sender.in_flight <= 0: + return true + await get_tree().process_frame + return false + + +## Re-emit the live coordinator's ready snapshot, the way a same-account +## duplicate callback arrives. Uses the host's live coordinator: the +## injected one is retired on first configure, so the parts dict copy is +## already freed by the time any duplicate could arrive. +func _emit_duplicate_ready(host: Node) -> void: + var live: Node = host.get("_coordinator") as Node + live.account_changed.emit(live.account_snapshot()) + + +func _test_boot_mints_id_before_play() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var state: Dictionary = (parts["host"] as Node).startup() + _expect_true(bool(state.get("ready", false)), "boot: identity ready") + _expect_true(ACCOUNT_SCRIPT.is_valid_public_id( + str(state.get("public_id", ""))), "boot: durable MB id minted") + _expect_true(FileAccess.file_exists(ID_PATH), "boot: id saved to disk") + _expect_equal(str(state.get("source", "")), "local", + "boot: guest starts local") + await _free_parts(parts) + + +func _test_restart_keeps_id() -> void: + _wipe_all() + var first: Dictionary = _make_parts() + var id_a: String = str(((first["host"] as Node).startup() + ).get("public_id", "")) + await _free_parts(first) + var second: Dictionary = _make_parts() + var id_b: String = str(((second["host"] as Node).startup() + ).get("public_id", "")) + _expect_equal(id_b, id_a, "restart: same durable id") + await _free_parts(second) + + +func _test_corrupt_identity_refuses_readiness() -> void: + _wipe_all() + var writer: FileAccess = FileAccess.open(ID_PATH, FileAccess.WRITE) + writer.store_string("not an identity file {{{") + writer.close() + var parts: Dictionary = _make_parts() + var state: Dictionary = (parts["host"] as Node).startup() + # A corrupt file recovers with a fresh durable id, never a half id. + _expect_true(bool(state.get("ready", false)), + "corrupt: recovered to ready") + _expect_true(ACCOUNT_SCRIPT.is_valid_public_id( + str(state.get("public_id", ""))), + "corrupt: recovered id is valid") + _expect_true(bool(((parts["account"] as Node).current_state() + ).get("recovered_from_corrupt", false)), + "corrupt: recovery flagged") + await _free_parts(parts) + # An unwritable disk refuses readiness and advertises nothing. + _wipe_all() + var fake: Node = FAKE_ADAPTER_SCRIPT.new() + var account: Node = ACCOUNT_SCRIPT.new() + account.setup(fake, "user://no_such_dir_anywhere/player_identity.cfg", + "user://no_such_dir_anywhere/player_bindings.cfg") + var coord: Node = COORD_SCRIPT.new() as Node + var sender: RefCounted = FAKE_SENDER_SCRIPT.new() + var host: Node = HOST_SCRIPT.new() as Node + add_child(host) + host.inject_services({"account": account, "adapter": fake, + "sender": sender, "coordinator": coord, "vault": Vault}) + _watch(host) + var broken: Dictionary = host.startup() + _expect_false(bool(broken.get("ready", false)), + "unwritable: readiness refused") + _expect_true(str(broken.get("public_id", "")).is_empty(), + "unwritable: no id advertised") + _expect_equal(str(broken.get("source", "")), "unready", + "unwritable: source unready") + _expect_equal(str((host.plan_entry(true, true) as Dictionary).get( + "code", "")), "identity_not_ready", + "unwritable: entry refused without a durable id") + await _free_parts({"host": host}) + + +func _test_canonical_adoption_durable() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + Vault.continue_coins = 1 + Vault.save_vault() + _seal_and_journal(guest, "adopt-j-1", 2, 3) + Journey.use_account(guest) + # Blocked installs keep the receipt pending past the first status + # read, so the adoption below must carry it onto the canonical slot + # instead of settling it in place. Slot moves are renames and still + # run under the fault. + Journey.install_fault = Journey.InstallFault.FAIL_ALL + # A UID that already owns another id: the canonical one wins. + sender.queue_ok(_profile_body(UID_A, CANON_C)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host.begin_guest() + var adopted: Dictionary = await _settle_call( + host, "account_state", CANON_C) + _expect_equal(str(adopted.get("public_id", "")), CANON_C, + "adopt: canonical id wins") + _expect_equal(str(Vault.scoped_continue_txn().get("owner", "")), CANON_C, + "adopt: the guest receipt rekeys onto the canonical slot") + _expect_equal(int(Vault.scoped_continue_txn().get("checkpoint_id", 0)), + 3, "adopt: the rekeyed receipt names the paid seal") + Journey.install_fault = Journey.InstallFault.NONE + _expect_equal(Vault.recover_paid_continue(), "recovered", + "adopt: the rekeyed receipt still settles") + _expect_equal(Vault.continue_coins, 0, + "adopt: the rekeyed settle charges nothing more") + _expect_equal(int(Journey.read_checkpoint().get("checkpoint_id", 0)), 3, + "adopt: the canonical slot holds the paid revive") + _expect_equal((host.account_state() as Dictionary).get( + "cloud_uid", ""), UID_A, "adopt: cloud session kept") + var account: Node = parts["account"] + _expect_equal(account.public_id_for_uid(UID_A), CANON_C, + "adopt: uid binding recorded") + _expect_true((_fired["error"] as Array).is_empty(), + "adopt: successful configure raises no error") + _expect_true(guest != CANON_C, "adopt: guest really differed") + await _free_parts(parts) + # Relaunch keeps the adopted canonical id. + var relaunched: Dictionary = _make_parts() + var state: Dictionary = (relaunched["host"] as Node).startup() + _expect_equal(str(state.get("public_id", "")), CANON_C, + "adopt: canonical id survives restart") + await _free_parts(relaunched) + + +## A wallet failure during canonical adoption keeps the guest scope and +## reports the failed move instead of adopting over a stranded receipt. +## After the fault clears and the account restarts, the retry adopts and +## the one paid revive lands under the canonical slot with no second +## debit, no foreign hook, and no orphan receipt. +func _test_adoption_rekey_failure_stays_guest_then_retries() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + Vault.continue_coins = 1 + Vault.save_vault() + _seal_and_journal(guest, "fault-j-1", 5, 6) + Journey.use_account(guest) + var hooked: Array = [] + var hook := func(info: Dictionary) -> void: hooked.append(info) + Journey.subscribe_stable_checkpoint(hook) + var vault_tmp: String = ProjectSettings.globalize_path( + Vault.TEMP_SAVE_PATH) + _occupy_dir(vault_tmp) + sender.queue_ok(_profile_body(UID_A, CANON_C)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host.begin_guest() + var faulted: Dictionary = await _await_reserve_settled(host) + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), guest, + "fault-adopt: the failed move stays on the guest") + _expect_equal(str(faulted.get("state", "")), "error", + "fault-adopt: the failed move reports error, not ready") + _expect_equal(str(faulted.get("code", "")), "slot-move-failed", + "fault-adopt: the error names the failed move") + var coord: Node = host.get("_coordinator") as Node + _expect_equal(str((coord.save_snapshot() as Dictionary).get( + "code", "")), "slot-move-failed", + "fault-adopt: the save snapshot carries the failure") + _expect_true(FileAccess.file_exists( + Journey.account_main_path(guest)), + "fault-adopt: the guest main stays in place") + _expect_false(FileAccess.file_exists( + Journey.account_main_path(CANON_C)), + "fault-adopt: nothing moves into the canonical slot") + _expect_equal(_txn_owner_cid(guest), 6, + "fault-adopt: the receipt stays owned by the guest") + _expect_equal(Vault.continue_coins, 0, + "fault-adopt: the one debit stands, no second charge") + var gate: Dictionary = host.saved_gate_summary() + _expect_false(bool(gate.get("revive_stuck", true)), + "fault-adopt: ordinary gate reads stay unstuck") + + # The restart: fault cleared, wallet reloaded, fresh host on the same + # storage. The retry adopts and the paid seal lands canonically. + _release_dir(vault_tmp) + Vault.load_vault() + _expect_equal(Vault.continue_coins, 0, + "fault-adopt: the reload keeps the single debit") + _expect_equal(_txn_owner_cid(guest), 6, + "fault-adopt: the reload keeps the guest receipt") + await _free_parts(parts) + var restarted: Dictionary = _make_parts() + var host2: Node = restarted["host"] + var fake2: Node = restarted["fake"] + var sender2: RefCounted = restarted["sender"] + var state2: Dictionary = (host2 as Node).startup() + _expect_equal(str(state2.get("public_id", "")), guest, + "fault-adopt: the restart restores the guest") + # The restart's own status reads deliver the seal that landed during + # the fault (its ack was what the broken wallet held back), so the + # retry below carries landed bytes rather than a pending receipt — + # the crash-window case covers a receipt still in flight. + var landed: Dictionary = Journey.read_checkpoint() + _expect_false(Journey.is_ended(landed), + "fault-adopt: the restart kept the landed seal") + _expect_equal(int(landed.get("checkpoint_id", 0)), 6, + "fault-adopt: the landed seal is the correct original") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "fault-adopt: the eager delivery clears the journal") + _expect_equal(Vault.recover_paid_continue(), "none", + "fault-adopt: nothing pends after the eager delivery") + Journey.subscribe_stable_checkpoint(hook) + sender2.queue_ok(_profile_body(UID_A, CANON_C)) + sender2.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake2.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host2.begin_guest() + var adopted: Dictionary = await _settle_call( + host2, "account_state", CANON_C) + _expect_equal(str(adopted.get("public_id", "")), CANON_C, + "fault-adopt: the retry adopts the canonical id") + var canon: Dictionary = Journey.read_checkpoint() + _expect_false(Journey.is_ended(canon), + "fault-adopt: the canonical slot holds the alive revive") + _expect_equal(int(canon.get("checkpoint_id", 0)), 6, + "fault-adopt: the revive is the correct original seal") + _expect_equal(Vault.continue_coins, 0, + "fault-adopt: the retry charges nothing more") + _expect_true(_txn_owner_cid(guest) == 0 + and _txn_owner_cid(CANON_C) == 0, + "fault-adopt: no orphan receipt survives") + for note in hooked: + var heard: String = str((note as Dictionary).get("path", "")) + _expect_true(heard == Journey.account_main_path(guest) + or heard == Journey.account_main_path(CANON_C), + "fault-adopt: every hook stays within the adopting slots") + var seventh: Dictionary = _valid_checkpoint("fault-j-1", 7, 0) + _expect_equal(Journey.write_checkpoint(seventh), OK, + "fault-adopt: post-recovery play seals") + _expect_false(hooked.is_empty(), + "fault-adopt: the recovery notified at least once") + if not hooked.is_empty(): + _expect_equal(str((hooked.back() as Dictionary).get("path", "")), + Journey.account_main_path(CANON_C), + "fault-adopt: routing follows the adoption") + Journey.unsubscribe_stable_checkpoint(hook) + await _free_parts(restarted) + + +## A legacy receipt adopts with its slot's files: the debit rekeys onto +## the owned account before the save migrates, then settles there. Under +## a wallet failure the migration waits — legacy bytes and receipt stay +## jointly in place — and the next startup converges without recharging. +func _test_legacy_paid_adoption_recovers() -> void: + # Blocked installs keep each receipt pending past the status reads + # around it, so the adoption — not an eager settle — carries it. + _wipe_all() + _write_legacy_pair("legacy-j-1", 5) + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + Journey.use_account("") + Vault.continue_coins = 1 + Vault.save_vault() + _journal_revive("legacy-j-1", 6) + Journey.install_fault = Journey.InstallFault.FAIL_ALL + var state: Dictionary = (host as Node).startup() + var guest: String = str(state.get("public_id", "")) + _expect_false(FileAccess.file_exists(Journey.DEFAULT_PATH), + "legacy-adopt: the legacy main migrates away") + _expect_equal(_txn_owner_cid(guest), 6, + "legacy-adopt: the receipt rekeys onto the owned slot") + Journey.install_fault = Journey.InstallFault.NONE + _expect_equal(Vault.recover_paid_continue(), "recovered", + "legacy-adopt: the owned receipt settles") + _expect_equal(int(Journey.read_checkpoint().get("checkpoint_id", 0)), + 6, "legacy-adopt: the revive is the correct original seal") + _expect_equal(Vault.continue_coins, 0, + "legacy-adopt: the settle charges nothing more") + await _free_parts(parts) + + # The fault half: a broken wallet holds the migration jointly — the + # receipt and its bytes stay in the legacy scope together, so even a + # rotation before the retry cannot split them across two guests. + # Blocked installs keep the receipt itself pending (rather than + # landed-but-unacked) so the retry carries receipt and bytes together; + # the joint hold below is still the wallet's doing — file moves are + # raw operations the install block does not stop. + _wipe_all() + _write_legacy_pair("legacy-j-2", 5) + var held: Dictionary = _make_parts() + var held_host: Node = held["host"] + Journey.use_account("") + Vault.continue_coins = 1 + Vault.save_vault() + _journal_revive("legacy-j-2", 6) + Journey.install_fault = Journey.InstallFault.FAIL_ALL + var vault_tmp: String = ProjectSettings.globalize_path( + Vault.TEMP_SAVE_PATH) + _occupy_dir(vault_tmp) + var held_state: Dictionary = (held_host as Node).startup() + var held_guest: String = str(held_state.get("public_id", "")) + _expect_true(FileAccess.file_exists(Journey.DEFAULT_PATH), + "legacy-adopt: the failed rekey holds the migration") + _expect_equal(_txn_owner_cid(""), 6, + "legacy-adopt: the receipt stays legacy-owned") + _expect_false(FileAccess.file_exists( + Journey.account_main_path(held_guest)), + "legacy-adopt: the owned slot stays empty meanwhile") + _expect_equal(str(((held_host as Node).account_state() + as Dictionary).get("legacy_hold", "")), "legacy-adopt-held", + "legacy-adopt: the hold carries a truthful retry code") + (held_host as Node).sign_out() + var rotated: String = str(((held_host as Node).account_state() + as Dictionary).get("public_id", "")) + _expect_true(rotated != held_guest and not rotated.is_empty(), + "legacy-adopt: the rotation mints the next guest") + _expect_true(FileAccess.file_exists(Journey.DEFAULT_PATH), + "legacy-adopt: the rotation moves nothing while held") + _expect_equal(_txn_owner_cid(""), 6, + "legacy-adopt: the rotation keeps the legacy receipt") + _release_dir(vault_tmp) + Vault.load_vault() + _expect_equal(Vault.continue_coins, 0, + "legacy-adopt: the reload keeps the single debit") + _expect_equal(_txn_owner_cid(""), 6, + "legacy-adopt: the reload keeps the legacy receipt") + await _free_parts(held) + var retry: Dictionary = _make_parts() + var retry_state: Dictionary = (retry["host"] as Node).startup() + _expect_equal(str(retry_state.get("public_id", "")), rotated, + "legacy-adopt: the retry restores the rotated guest") + _expect_true(str(retry_state.get("legacy_hold", "")).is_empty(), + "legacy-adopt: the retry clears the hold code") + _expect_false(FileAccess.file_exists(Journey.DEFAULT_PATH), + "legacy-adopt: the retry migrates the save") + _expect_equal(_txn_owner_cid(rotated), 6, + "legacy-adopt: the retry rekeys the receipt with its bytes") + Journey.install_fault = Journey.InstallFault.NONE + _expect_equal(Vault.recover_paid_continue(), "recovered", + "legacy-adopt: the retry settles without recharging") + _expect_equal(Vault.continue_coins, 0, + "legacy-adopt: exactly one debit across the fault") + _expect_false(FileAccess.file_exists( + Journey.account_main_path(held_guest)), + "legacy-adopt: no split-brain copy lands in the first guest") + await _free_parts(retry) + + +## A canonical slot that already holds a journey keeps it: adoption still +## lands, the unrelated slot stays byte-identical, and the guest receipt +## is retained (deferred) rather than rekeyed or dropped. +func _test_adoption_occupied_canonical_preserves_guest() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + Vault.continue_coins = 1 + Vault.save_vault() + _seal_and_journal(guest, "occ-j-1", 5, 6) + Journey.use_account(CANON_C) + _write_checkpoint({"journey_id": "canon-j-9", "checkpoint_id": 4, + "cycle": 2}) + var canon_before: String = FileAccess.get_file_as_string( + Journey.account_main_path(CANON_C)) + Journey.use_account(guest) + # Blocked installs keep the receipt pending past the first status + # read, so the adoption meets it instead of settling it in place. + Journey.install_fault = Journey.InstallFault.FAIL_ALL + sender.queue_ok(_profile_body(UID_A, CANON_C)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host.begin_guest() + var adopted: Dictionary = await _settle_call( + host, "account_state", CANON_C) + _expect_equal(str(adopted.get("public_id", "")), CANON_C, + "occupied: adoption still lands") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_main_path(CANON_C)), canon_before, + "occupied: the canonical slot stays byte-identical") + _expect_true(FileAccess.file_exists( + Journey.account_main_path(guest)), + "occupied: the guest slot stays in place") + _expect_equal(_txn_owner_cid(guest), 6, + "occupied: the guest receipt is retained") + _expect_equal(Vault.recover_paid_continue(), "deferred", + "occupied: the retained receipt defers, never imports") + _expect_equal(Vault.continue_coins, 0, + "occupied: the one debit stands") + Journey.install_fault = Journey.InstallFault.NONE + Journey.use_account(guest) + _expect_equal(Vault.recover_paid_continue(), "recovered", + "occupied: the retained receipt still settles in its scope") + _expect_equal(Vault.continue_coins, 0, + "occupied: the late settle charges nothing more") + await _free_parts(parts) + + +## The rekey-first crash window converges: the receipt already owns the +## canonical slot while its files still sit in the guest slot. Only the +## durable state survives the death; the next adoption completes the move +## and the paid seal lands with no second debit. +func _test_adoption_crash_between_rekey_and_move() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + Vault.continue_coins = 1 + Vault.save_vault() + _seal_and_journal(guest, "crash-j-1", 5, 6) + _expect_true(Vault.rekey_continue_txn_owner(guest, CANON_C), + "crash-window: the rekey lands first") + Vault.load_vault() + _expect_equal(_txn_owner_cid(CANON_C), 6, + "crash-window: only the rekeyed receipt survives the death") + _expect_true(FileAccess.file_exists( + Journey.account_main_path(guest)), + "crash-window: the files never moved") + _expect_equal(Vault.continue_coins, 0, + "crash-window: the death charges nothing") + # Blocked installs keep the receipt pending past the status reads + # around it, so the explicit recovery — not an eager settle — lands it. + Journey.install_fault = Journey.InstallFault.FAIL_ALL + sender.queue_ok(_profile_body(UID_A, CANON_C)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host.begin_guest() + var adopted: Dictionary = await _settle_call( + host, "account_state", CANON_C) + _expect_equal(str(adopted.get("public_id", "")), CANON_C, + "crash-window: adoption completes the move") + Journey.install_fault = Journey.InstallFault.NONE + _expect_equal(Vault.recover_paid_continue(), "recovered", + "crash-window: the paid seal materializes canonically") + _expect_equal(int(Journey.read_checkpoint().get("checkpoint_id", 0)), + 6, "crash-window: the revive is the correct original seal") + _expect_equal(Vault.continue_coins, 0, + "crash-window: the materialize charges nothing more") + _expect_true(_txn_owner_cid(guest) == 0 + and _txn_owner_cid(CANON_C) == 0, + "crash-window: no orphan receipt survives") + await _free_parts(parts) + + +## A canonical target holding its own pending paid receipt is occupied +## even when the source carries no receipt: the adoption keeps the +## unrelated source bytes out, and the target's exact acknowledged seal +## settles from the journal with no second debit. +func _test_adoption_target_receipt_without_source_receipt() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + # Target C's acknowledged receipt, journaled through the real API, + # with no surviving target files: a supported paid recovery state. + Journey.use_account(CANON_C) + Vault.continue_coins = 1 + Vault.save_vault() + var paid: Dictionary = _valid_checkpoint("d1", 6, 0) + _expect_true(Vault.begin_continue_txn("d1", 6, + JSON.stringify(paid)), "target-kept: the target debit journals") + _wipe_slot(CANON_C) + _expect_false(FileAccess.file_exists( + Journey.account_main_path(CANON_C)), + "target-kept: the target holds no files") + # Source A's unrelated living journey, carrying no receipt. + Journey.use_account(guest) + var living: Dictionary = _valid_checkpoint("d1-other", 2, 0) + _expect_equal(Journey.write_checkpoint(living), OK, + "target-kept: the source journey seals") + var source_main: String = FileAccess.get_file_as_string( + Journey.account_main_path(guest)) + var source_backup: String = "" + if FileAccess.file_exists(Journey.account_backup_path(guest)): + source_backup = FileAccess.get_file_as_string( + Journey.account_backup_path(guest)) + # Only durable state crosses the restart: the reload and fresh parts + # below prove the receipt, not test memory, drives the recovery. + Vault.load_vault() + _expect_equal(_txn_owner_cid(CANON_C), 6, + "target-kept: the reload retains the target receipt") + _expect_equal(Vault.continue_coins, 0, + "target-kept: the reload keeps the single debit") + await _free_parts(parts) + var restarted: Dictionary = _make_parts() + var host2: Node = restarted["host"] + var fake2: Node = restarted["fake"] + var sender2: RefCounted = restarted["sender"] + var state2: Dictionary = (host2 as Node).startup() + _expect_equal(str(state2.get("public_id", "")), guest, + "target-kept: the restart restores the source guest") + _expect_equal(_txn_owner_cid(CANON_C), 6, + "target-kept: the restart retains the target receipt") + sender2.queue_ok(_profile_body(UID_A, CANON_C)) + sender2.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake2.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host2.begin_guest() + var adopted: Dictionary = await _settle_call( + host2, "account_state", CANON_C) + _expect_equal(str(adopted.get("public_id", "")), CANON_C, + "target-kept: the adoption still lands") + _expect_equal(Vault.recover_paid_continue(), "none", + "target-kept: the adoption settles the receipt eagerly") + var restored: Dictionary = Journey.read_checkpoint() + var want: Variant = JSON.parse_string(JSON.stringify(paid)) + _expect_equal(restored, want, + "target-kept: the target's exact acknowledged seal restores") + _expect_true(_txn_owner_cid(guest) == 0 + and _txn_owner_cid(CANON_C) == 0, + "target-kept: no orphan receipt survives") + _expect_equal(Vault.continue_coins, 0, + "target-kept: the recovery charges nothing more") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_main_path(guest)), source_main, + "target-kept: the source main is retained byte-identical") + if source_backup.is_empty(): + _expect_false(FileAccess.file_exists( + Journey.account_backup_path(guest)), + "target-kept: no source backup appears") + else: + _expect_equal(FileAccess.get_file_as_string( + Journey.account_backup_path(guest)), source_backup, + "target-kept: the source backup is retained byte-identical") + await _free_parts(restarted) + + +## A canonical target holding its own journey is occupied even when the +## source carries no receipt: neither side moves, neither loses a byte. +func _test_adoption_occupied_target_without_source_receipt() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + Journey.use_account(CANON_C) + var owned: Dictionary = _valid_checkpoint("canon-own", 4, 0) + _expect_equal(Journey.write_checkpoint(owned), OK, + "bare-occupied: the target journey seals") + var canon_main: String = FileAccess.get_file_as_string( + Journey.account_main_path(CANON_C)) + Journey.use_account(guest) + var living: Dictionary = _valid_checkpoint("guest-own", 2, 0) + _expect_equal(Journey.write_checkpoint(living), OK, + "bare-occupied: the source journey seals") + var source_main: String = FileAccess.get_file_as_string( + Journey.account_main_path(guest)) + Vault.load_vault() + await _free_parts(parts) + var restarted: Dictionary = _make_parts() + var host2: Node = restarted["host"] + var fake2: Node = restarted["fake"] + var sender2: RefCounted = restarted["sender"] + var state2: Dictionary = (host2 as Node).startup() + _expect_equal(str(state2.get("public_id", "")), guest, + "bare-occupied: the restart restores the source guest") + sender2.queue_ok(_profile_body(UID_A, CANON_C)) + sender2.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake2.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host2.begin_guest() + var adopted: Dictionary = await _settle_call( + host2, "account_state", CANON_C) + _expect_equal(str(adopted.get("public_id", "")), CANON_C, + "bare-occupied: the adoption still lands") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_main_path(CANON_C)), canon_main, + "bare-occupied: the target slot stays byte-identical") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_main_path(guest)), source_main, + "bare-occupied: the source slot stays byte-identical") + _expect_equal(Vault.recover_paid_continue(), "none", + "bare-occupied: no receipt pends anywhere") + _expect_true(_txn_owner_cid(guest) == 0 + and _txn_owner_cid(CANON_C) == 0, + "bare-occupied: no orphan receipt survives") + await _free_parts(restarted) + + +## Adopt a fresh guest to the canonical id through the real route. +## Returns the parts plus the guest id; the caller owns freeing. +func _adopt_guest_to_canonical(extra: Dictionary = {}) -> Dictionary: + _wipe_all() + _clear_name_cache() + _clear_attendance_cache() + var parts: Dictionary = _make_parts(null, extra) + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + sender.queue_ok(_profile_body(UID_A, CANON_C)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + fake.guest_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} + host.begin_guest() + await _settle_call(host, "account_state", CANON_C) + parts["guest"] = guest + return parts + + +## Swap the live coordinator for a recorder without moving the account: +## the configured UID stays, so only the instance retires. A narrow +## white-box seam: re-injection would churn the account signals and free +## the retired coordinator mid-test, while production retirement keeps +## late replies observable. The retired node stays parented and is freed +## with the host. +func _swap_coordinator(parts: Dictionary, recorder: NameRecorder) -> void: + recorder.tree_node = self + var host: Node = parts["host"] + host.add_child(recorder) + host.set("_coordinator", recorder) + host._wire_signals() + parts["coord"] = recorder + + +## Call one name route, awaited. Single-line calls: a split never parses. +func _call_name_route(host: Node, route: String) -> Dictionary: + match route: + "claim": + return await host.claim_adventurer_name("Alpha") + "load": + return await host.load_adventurer_name() + "intro": + return await host.mark_intro_complete() + return await host.backfill_hall_name() + + +## Retire the live coordinator to `recorder` mid-flight, proving the +## request under test is parked at the token barrier first. Runs from a +## timer or a recorder signal while the test awaits the wrapper: parking +## the wrapper coroutine itself is impossible, since an un-awaited async +## call is a hard error. +func _swap_to_b(parts: Dictionary, recorder: NameRecorder, + label: String) -> void: + var host: Node = parts["host"] + _expect_true((host.get("_token_waiters") as Array).size() > 0, + "%s: the call parks at the barrier" % label) + _swap_coordinator(parts, recorder) + # The account side of the retirement: production moves the configured + # UID through retire/reconfigure, which would also fail the waiters. + host.set("_configured_uid", UID_B) + + +## Hand the live slot to `recorder` for account B without parking +## assertions: the stale-completion swap runs on dispatch, past the token. +func _adopt_b(parts: Dictionary, recorder: NameRecorder) -> void: + _swap_coordinator(parts, recorder) + (parts["host"] as Node).set("_configured_uid", UID_B) + + +## Shut the host down mid-wait, proving the request under test parked at +## the token barrier first. +func _shutdown_mid_wait(parts: Dictionary) -> void: + var host: Node = parts["host"] + _expect_true((host.get("_token_waiters") as Array).size() > 0, + "shutdown-wait: the claim parks at the barrier") + host.shutdown() + + +## Begin the real account deletion from a timer while the test awaits +## another wrapper. Signal-driven, since an un-awaited async call is a +## hard error; the terminal result lands in `box["r"]` for settling. +func _begin_deletion_async(parts: Dictionary, box: Dictionary) -> void: + box["r"] = await (parts["host"] as Node).delete_current_account() + + +## Prove the deletion run holds its ticket with both the name request and +## the deletion parked at the shared token barrier. +func _assert_deletion_barrier(parts: Dictionary) -> void: + var host: Node = parts["host"] + _expect_false((host.get("_deletion_ticket") as Dictionary).is_empty(), + "del-barrier: the deletion run holds its ticket") + _expect_equal((host.get("_token_waiters") as Array).size(), 2, + "del-barrier: claim and deletion share the barrier") + + +## Fail a real deletion fast mid-wait: prove the name request parked +## first, hand the deletion a sync-ok token so it never shares the +## barrier, and run it to its terminal failure on the same account. +func _fail_deletion_fast(parts: Dictionary, box: Dictionary) -> void: + var host: Node = parts["host"] + _expect_equal((host.get("_token_waiters") as Array).size(), 1, + "del-gen: the claim parks before deletion begins") + (parts["fake"] as Node).token_receipt = {"status": "ok", + "id_token": "tok-ok", "token_expires_at": 4102444800000} + box["r"] = await host.delete_current_account() + + +## Bounded wait for a detached run's terminal result. +func _await_box_key(box: Dictionary, key: String, + frames: int = 180) -> bool: + for _index in frames: + if box.has(key): + return true + await get_tree().process_frame + return box.has(key) + + +## Retire through the production path mid-flight, then hand the live slot +## to `recorder` with the UID still dropped, the deletion/startup shape. +func _retire_to_recorder(parts: Dictionary, + recorder: NameRecorder) -> void: + var host: Node = parts["host"] + _expect_true((host.get("_token_waiters") as Array).size() > 0, + "retire-wait: the call parks at the barrier") + # Read before the production retire frees the live recorder. + var retired: NameRecorder = parts["coord"] as NameRecorder + _expect_true(retired.calls.is_empty(), + "retire-wait: nothing dispatched before the token") + host._retire_coordinator() + _expect_equal(str(host.get("_configured_uid")), "", + "retire-wait: the production path drops the configured UID") + _swap_coordinator(parts, recorder) + + +## Drop the cached token so the next wrapper must refresh through the +## scripted adapter receipt (pending holds the barrier, an error fails +## it). Narrow white-box seam: production drops this only on account +## moves, which would also retire the request under test. +func _drop_cached_token(host: Node) -> void: + host.set("_id_token", "") + + +## Complete the held token wait through the production signal, as a +## refreshed native token would. +func _release_token_ok(parts: Dictionary) -> void: + (parts["account"] as Node).emit_signal("id_token_ready", { + "id_token": "tok-held-ok", "token_expires_at": 4102444800000}) + + +func _name_row_body(display: String, key: String, + intro_complete: bool) -> String: + return JSON.stringify({ + "name": "projects/x/databases/(default)/documents/mb_adventurers_v1/%s" + % CANON_C, + "fields": { + "public_id": {"stringValue": CANON_C}, + "uid": {"stringValue": UID_A}, + "name_key": {"stringValue": key}, + "display": {"stringValue": display}, + "intro_complete": {"booleanValue": intro_complete}, + "schema": {"integerValue": "1"}, + "created_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + "updated_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + }, + }) + + +func _hall_own_body(score: int, hero: String, cycles: int, + display: String = "") -> String: + var fields: Dictionary = { + "public_id": {"stringValue": CANON_C}, + "hero": {"stringValue": hero}, + "score": {"integerValue": str(score)}, + "cycles": {"integerValue": str(cycles)}, + "release": {"stringValue": "4.0.0"}, + "schema": {"integerValue": "1"}, + "updated_at": {"timestampValue": "2026-10-01T00:00:00Z"}, + } + if not display.is_empty(): + fields["display"] = {"stringValue": display} + return JSON.stringify({ + "name": "projects/x/databases/(default)/documents/mb_hall_v1/%s" + % CANON_C, + "fields": fields, + }) + + +## The verified-name cache is a shared durable store like the vault +## file: name tests reset it coming and going so neither earlier tests +## nor later deletion tests observe a leaked handle. +func _clear_name_cache() -> void: + Vault.verified_names = {} + + +func _clear_attendance_cache() -> void: + Vault.attendance_marks = {} + Vault.attendance_floor = 0 + Vault.attendance_next = {} + for key in (Vault.continue_coin_grants as Dictionary).keys(): + if str(key).begins_with("attendance:"): + (Vault.continue_coin_grants as Dictionary).erase(key) + + +func _commit_with(sender: RefCounted, needle: String) -> Dictionary: + for call in (sender.calls as Array): + var entry: Dictionary = call + if str(entry.get("url", "")).contains("documents:commit") \ + and str(entry.get("body", "")).contains(needle): + return JSON.parse_string(str(entry.get("body", ""))) + return {} + + +func _test_claim_name_caches_and_backfills_nothing() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "name-claim: the claim succeeds") + _expect_equal(str(claimed.get("display", "")), "Luna", + "name-claim: the display comes back") + _expect_equal(str(claimed.get("key", "")), "luna", + "name-claim: the immutable key comes back") + _expect_true(bool(claimed.get("cached", false)), + "name-claim: the verified handle caches") + _expect_false(bool(claimed.get("backfilled", true)), + "name-claim: no best row means no backfill write") + _expect_equal(str(claimed.get("backfill_code", "")), "no-row", + "name-claim: the empty backfill names no-row") + _expect_equal((host as Node).verified_display_name(), "Luna", + "name-claim: the live handle reads synchronously") + _expect_equal(str((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("key", "")), "luna", + "name-claim: the cache keys by public ID") + var commit: Dictionary = _commit_with(sender, "mb_names_v1") + _expect_equal((commit.get("writes", []) as Array).size(), 2, + "name-claim: the commit carries both halves") + _expect_true(str(JSON.stringify(commit)).contains( + "mb_adventurers_v1"), + "name-claim: the adventurer half rides along") + _clear_name_cache() + await _free_parts(parts) + + +func _test_claim_taken_keeps_identity() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_reply({"transport": "ok", "code": 409, "body": "{}"}) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "conflict", + "name-taken: the taken name conflicts") + _expect_equal(str(claimed.get("code", "")), "name-taken", + "name-taken: the conflict names name-taken") + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), CANON_C, + "name-taken: the loser keeps its canonical ID") + _expect_true((Vault.verified_name_for_account(CANON_C) + as Dictionary).is_empty(), + "name-taken: the loser caches nothing") + _expect_equal((host as Node).verified_display_name(), "", + "name-taken: the loser reads unnamed") + _clear_name_cache() + await _free_parts(parts) + + +func _test_local_guest_cannot_claim() -> void: + _wipe_all() + _clear_name_cache() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + host.startup() + var guest: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + var refused_claim: Dictionary = await (host as Node + ).claim_adventurer_name("Luna") + _expect_equal(str(refused_claim.get("code", "")), "local-guest", + "name-local: claim without a session refuses") + var refused_load: Dictionary = await (host as Node + ).load_adventurer_name() + _expect_equal(str(refused_load.get("code", "")), "local-guest", + "name-local: load without a session refuses") + var refused_intro: Dictionary = await (host as Node + ).mark_intro_complete() + _expect_equal(str(refused_intro.get("code", "")), "local-guest", + "name-local: intro without a session refuses") + _expect_true((Vault.verified_name_for_account(guest) + as Dictionary).is_empty(), + "name-local: offline work certifies no name") + _clear_name_cache() + await _free_parts(parts) + + +func _test_linking_keeps_claimed_name() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + _write_checkpoint({"cycle": 1, "journey_id": "link-name-j-1"}) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "name-link: the guest claims first") + var journey_before: String = FileAccess.get_file_as_string( + Journey.account_main_path(CANON_C)) + fake.link_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "google"}} + var linked: Dictionary = host.link_provider("google") + await _frames(2) + _expect_equal(str(linked.get("status", "")), "ok", + "name-link: the link succeeds") + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), CANON_C, + "name-link: the link keeps the canonical ID") + _expect_equal((host as Node).verified_display_name(), "Luna", + "name-link: the link keeps the verified handle") + _expect_equal(FileAccess.get_file_as_string( + Journey.account_main_path(CANON_C)), journey_before, + "name-link: the link keeps the journey bytes") + host.sign_out() + var rotated: String = str(((host as Node).account_state() + as Dictionary).get("public_id", "")) + _expect_true(rotated != CANON_C and not rotated.is_empty(), + "name-link: sign-out rotates") + _expect_equal((host as Node).verified_display_name(), "", + "name-link: the rotated guest reads unnamed") + _expect_equal(str((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("display", "")), "Luna", + "name-link: rotation never shares the old handle") + _clear_name_cache() + await _free_parts(parts) + + +func _test_intro_complete_and_reload() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "name-intro: the claim succeeds") + sender.queue_ok(_name_row_body("Luna", "luna", false)) + sender.queue_ok("{\"writeResults\": [{}]}") + var done: Dictionary = await host.mark_intro_complete() + _expect_equal(str(done.get("status", "")), "ok", + "name-intro: the bit flips") + _expect_true(bool((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("intro_complete", false)), + "name-intro: the cache flips with it") + Vault.load_vault() + _expect_equal(str((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("display", "")), "Luna", + "name-intro: the reload keeps the handle") + _expect_true(bool((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("intro_complete", false)), + "name-intro: the reload keeps the bit") + sender.queue_ok(_name_row_body("Luna", "luna", true)) + var loaded: Dictionary = await host.load_adventurer_name() + _expect_equal(str(loaded.get("status", "")), "ok", + "name-intro: the reload restores from the server too") + _expect_equal(str(loaded.get("display", "")), "Luna", + "name-intro: the restore names the same handle") + _clear_name_cache() + await _free_parts(parts) + + +func _test_claim_backfills_existing_best() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_ok(_hall_own_body(500, + "res://resources/heroes/keeper.tres", 7)) + sender.queue_ok(_hall_own_body(500, + "res://resources/heroes/keeper.tres", 7)) + sender.queue_ok("{\"writeResults\": [{}]}") + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "name-backfill: the claim succeeds") + _expect_true(bool(claimed.get("backfilled", false)), + "name-backfill: the existing best backfills") + var commit: Dictionary = _commit_with(sender, "mb_hall_v1") + var fields: Dictionary = ((commit.get("writes", []) as Array)[0] + as Dictionary).get("update", {}).get("fields", {}) + _expect_equal(str(fields.get("score", {}).get("integerValue", "")), + "500", "name-backfill: the backfill keeps the best score") + _expect_equal(str(fields.get("hero", {}).get("stringValue", "")), + "res://resources/heroes/keeper.tres", + "name-backfill: the backfill keeps the saved hero") + _expect_equal(str(fields.get("display", {}).get("stringValue", "")), + "Luna", "name-backfill: the backfill attaches the handle") + _clear_name_cache() + await _free_parts(parts) + + +func _test_submit_best_carries_handle() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "name-submit: the claim succeeds") + _write_checkpoint({"cycle": 2, "journey_id": "submit-name-j-1"}) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}]}") + var coord: Node = host.get("_coordinator") as Node + var submitted: Dictionary = await coord.call("submit_current_best") + _expect_equal(str(submitted.get("status", "")), "ok", + "name-submit: the best submits") + var commit: Dictionary = _commit_with(sender, "mb_hall_v1") + var fields: Dictionary = ((commit.get("writes", []) as Array)[0] + as Dictionary).get("update", {}).get("fields", {}) + _expect_equal(str(fields.get("display", {}).get("stringValue", "")), + "Luna", "name-submit: the production write carries the handle") + _clear_name_cache() + await _free_parts(parts) + + +## A completed name restore keeps the true tutorial bit through a claim of +## a different ask and a local reload; a later stale incomplete same-name +## response carries its bytes honestly but cannot regress the durable bit. +func _test_claim_restore_keeps_completion_bit() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "name-bit: the claim succeeds") + _expect_false(bool(claimed.get("intro_complete", true)), + "name-bit: the fresh claim starts incomplete") + sender.queue_ok(_name_row_body("Luna", "luna", false)) + sender.queue_ok("{\"writeResults\": [{}]}") + var done: Dictionary = await host.mark_intro_complete() + _expect_equal(str(done.get("status", "")), "ok", + "name-bit: the bit flips") + sender.queue_ok(_name_row_body("Luna", "luna", true)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var restored: Dictionary = await host.claim_adventurer_name("Different") + _expect_equal(str(restored.get("status", "")), "ok", + "name-bit: the restore succeeds") + _expect_equal(str(restored.get("display", "")), "Luna", + "name-bit: the restore keeps the canonical handle") + _expect_true(bool(restored.get("intro_complete", false)), + "name-bit: the restore returns the real completion bit") + Vault.load_vault() + _expect_true(bool((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("intro_complete", false)), + "name-bit: the reload keeps the restored bit") + sender.queue_ok(_name_row_body("Luna", "luna", false)) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var stale: Dictionary = await host.claim_adventurer_name("Different") + _expect_equal(str(stale.get("status", "")), "ok", + "name-bit: the stale same-name claim still restores") + _expect_false(bool(stale.get("intro_complete", true)), + "name-bit: the stale result carries its own bytes") + _expect_true(bool((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("intro_complete", false)), + "name-bit: the stale response never regresses the cache") + Vault.load_vault() + _expect_true(bool((Vault.verified_name_for_account(CANON_C) + as Dictionary).get("intro_complete", false)), + "name-bit: the reload still keeps the bit") + _clear_name_cache() + await _free_parts(parts) + + +## All four name wrappers pin to the initiating account: a token wait +## that outlives a switch to B cancels instead of dispatching onto B. +func _test_name_wrappers_cancel_on_token_switch() -> void: + for route in ["claim", "load", "intro", "backfill"]: + await _run_token_switch_case(route) + + +func _run_token_switch_case(route: String) -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + var rec_b: NameRecorder = NameRecorder.new() + var label: String = "token-switch/%s" % route + # The swap lands strictly before the token release; the timer + # callbacks prove the barrier parked the call first. + get_tree().create_timer(0.1).timeout.connect( + _swap_to_b.bind(parts, rec_b, label)) + get_tree().create_timer(0.2).timeout.connect( + _release_token_ok.bind(parts)) + var result: Dictionary = await _call_name_route(host, route) + _expect_equal(str(result.get("status", "")), "cancelled", + "token-switch/%s: the retired request cancels" % route) + _expect_equal(str(result.get("code", "")), "account-retired", + "token-switch/%s: the cancel names the retirement" % route) + _expect_true(rec_a.calls.is_empty(), + "token-switch/%s: nothing dispatched before the token" % route) + _expect_true(rec_b.calls.is_empty(), + "token-switch/%s: the next account receives nothing" % route) + _clear_name_cache() + await _free_parts(parts) + + +## A failed token refresh sends no name mutation: the claim fails with +## the token code and the live coordinator stays silent. +func _test_claim_token_failure_sends_nothing() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "error", "code": "token_error"} + var failed: Dictionary = await host.claim_adventurer_name("Alpha") + _expect_equal(str(failed.get("status", "")), "failure", + "token-fail: the claim fails with the token") + _expect_equal(str(failed.get("code", "")), "token_error", + "token-fail: the token code passes through") + _expect_true(rec_a.calls.is_empty(), + "token-fail: no mutation dispatches without a token") + _clear_name_cache() + await _free_parts(parts) + + +## A token wait that times out fails the same way: no dispatch, no name. +func _test_claim_token_timeout_sends_nothing() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical( + {"token_wait_seconds": 0.05}) + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + var timed_out: Dictionary = await host.claim_adventurer_name("Alpha") + _expect_equal(str(timed_out.get("status", "")), "failure", + "token-timeout: the claim fails with the token") + _expect_equal(str(timed_out.get("code", "")), "token_timeout", + "token-timeout: the timeout code passes through") + _expect_true(rec_a.calls.is_empty(), + "token-timeout: no mutation dispatches without a token") + _clear_name_cache() + await _free_parts(parts) + + +## Shutdown during the token wait cancels the parked claim; the retired +## coordinator receives nothing. +func _test_claim_shutdown_mid_wait_cancels() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + get_tree().create_timer(0.1).timeout.connect( + _shutdown_mid_wait.bind(parts)) + var result: Dictionary = await _call_name_route(host, "claim") + _expect_equal(str(result.get("status", "")), "cancelled", + "shutdown-wait: the parked claim cancels") + _expect_equal(str(result.get("code", "")), "host-closed", + "shutdown-wait: the cancel names the closed host") + _expect_true(rec_a.calls.is_empty(), + "shutdown-wait: the retired coordinator receives nothing") + _clear_name_cache() + await _free_parts(parts) + + +## A coordinator reply that lands after the account retired is a stale +## completion: the wrapper cancels instead of adopting it, and the next +## account receives nothing. +func _test_claim_stale_completion_cancels() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var rec_a: NameRecorder = NameRecorder.new() + rec_a.hold_frames = 8 + _swap_coordinator(parts, rec_a) + var rec_b: NameRecorder = NameRecorder.new() + # The swap runs synchronously on dispatch, inside A's held reply. + rec_a.dispatched.connect(_adopt_b.bind(parts, rec_b)) + var changed_before: int = (_fired["changed"] as Array).size() + var result: Dictionary = await _call_name_route(host, "claim") + _expect_equal(rec_a.calls.size(), 1, + "stale-complete: the live coordinator receives the claim") + _expect_equal(str(result.get("status", "")), "cancelled", + "stale-complete: the late reply cancels") + _expect_equal(str(result.get("code", "")), "account-retired", + "stale-complete: the cancel names the retirement") + _expect_true(rec_b.calls.is_empty(), + "stale-complete: the next account receives nothing") + _expect_equal((_fired["changed"] as Array).size(), changed_before, + "stale-complete: the stale reply emits nothing") + _clear_name_cache() + await _free_parts(parts) + + +## Retirement through the production path (the deletion/startup shape) +## cancels a parked claim; the next coordinator stays silent. +func _test_claim_retirement_mid_wait_cancels() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + var rec_b: NameRecorder = NameRecorder.new() + get_tree().create_timer(0.1).timeout.connect( + _retire_to_recorder.bind(parts, rec_b)) + get_tree().create_timer(0.2).timeout.connect( + _release_token_ok.bind(parts)) + var result: Dictionary = await _call_name_route(host, "claim") + _expect_equal(str(result.get("status", "")), "cancelled", + "retire-wait: the retired request cancels") + _expect_equal(str(result.get("code", "")), "account-retired", + "retire-wait: the cancel names the retirement") + _expect_true(rec_b.calls.is_empty(), + "retire-wait: the next coordinator receives nothing") + _clear_name_cache() + await _free_parts(parts) + + +## A claim begun before deletion stays cancelled even when the deletion +## later fails on the same account and coordinator: the pinned deletion +## generation retires it either way. A genuinely new request afterwards +## proceeds on the fresh generation. +func _test_claim_cancelled_across_failed_deletion() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + # Settle the account-level pending without releasing the host waiter, + # so the real deletion can begin while the claim still waits. + fake.auto_error = {"code": "token_error"} + _queue_unclaimed_adventurer(sender) + sender.queue_reply({"transport": "ok", "code": 403, "body": "{}"}) + var del_box: Dictionary = {} + get_tree().create_timer(0.1).timeout.connect( + _begin_deletion_async.bind(parts, del_box)) + get_tree().create_timer(0.15).timeout.connect( + _assert_deletion_barrier.bind(parts)) + get_tree().create_timer(0.2).timeout.connect( + _release_token_ok.bind(parts)) + var result: Dictionary = await _call_name_route(host, "claim") + _expect_equal(str(result.get("status", "")), "cancelled", + "del-cancel: the pre-deletion claim cancels") + _expect_equal(str(result.get("code", "")), "account-retired", + "del-cancel: the cancel names the retirement") + _expect_true(rec_a.calls.is_empty(), + "del-cancel: nothing dispatches during deletion") + _expect_true(await _await_box_key(del_box, "r"), + "del-cancel: the deletion run settles") + var deleted: Dictionary = del_box["r"] + _expect_equal(str(deleted.get("status", "")), "failure", + "del-cancel: the denied commit fails the run") + _expect_equal(str(deleted.get("code", "")), "permission-denied", + "del-cancel: the denial code passes through") + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), CANON_C, + "del-cancel: the failed run keeps the same account") + var retry: Dictionary = await host.claim_adventurer_name("Beta") + _expect_equal(str(retry.get("status", "")), "ok", + "del-cancel: a new request proceeds after failed deletion") + _expect_equal(rec_a.calls.size(), 1, + "del-cancel: the new request dispatches once") + _clear_name_cache() + await _free_parts(parts) + + +## A claim still parked when an earlier deletion already failed on the +## same account and coordinator stays cancelled: the pinned deletion +## generation retires it even though no ticket is in flight at resume. +func _test_claim_cancelled_by_earlier_failed_deletion() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + fake.auto_error = {"code": "token_error"} + _queue_unclaimed_adventurer(sender) + sender.queue_reply({"transport": "ok", "code": 403, "body": "{}"}) + var del_box: Dictionary = {} + get_tree().create_timer(0.05).timeout.connect( + _fail_deletion_fast.bind(parts, del_box)) + get_tree().create_timer(0.3).timeout.connect( + _release_token_ok.bind(parts)) + var result: Dictionary = await _call_name_route(host, "claim") + _expect_equal(str(result.get("status", "")), "cancelled", + "del-gen: the pre-deletion claim cancels") + _expect_equal(str(result.get("code", "")), "account-retired", + "del-gen: the cancel names the retirement") + _expect_true(rec_a.calls.is_empty(), + "del-gen: nothing dispatches after the failed run") + _expect_true((host.get("_deletion_ticket") as Dictionary).is_empty(), + "del-gen: no ticket is in flight at resume") + _expect_true(await _await_box_key(del_box, "r"), + "del-gen: the deletion run settles") + _expect_equal(str((del_box["r"] as Dictionary).get("status", "")), + "failure", "del-gen: the denied commit fails the run") + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), CANON_C, + "del-gen: the failed run keeps the same account") + _clear_name_cache() + await _free_parts(parts) + + +## A new request started while deletion is in flight refuses at the sync +## guard instead of queueing behind the destructive run. +func _test_claim_refused_while_deletion_in_flight() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + var delayed: DelayedSender = DelayedSender.new() + delayed.tree_node = self + delayed.delay_frames = 45 + _queue_unclaimed_adventurer(delayed) + delayed.queue_ok(_commit_ack_body()) + fake.delete_receipt = {"status": "ok"} + host.set("_sender", delayed) + var del_box: Dictionary = {} + get_tree().create_timer(0.05).timeout.connect( + _begin_deletion_async.bind(parts, del_box)) + var commit_seen: bool = false + for _index in 180: + for call in (delayed.calls as Array): + if str((call as Dictionary).get("url", "")).contains( + "documents:commit"): + commit_seen = true + break + if commit_seen: + break + await get_tree().process_frame + _expect_true(commit_seen, + "del-refuse: the deletion commit goes outstanding") + var refused: Dictionary = await host.claim_adventurer_name("Alpha") + _expect_equal(str(refused.get("status", "")), "failure", + "del-refuse: the claim refuses during deletion") + _expect_equal(str(refused.get("code", "")), "deletion-in-flight", + "del-refuse: the refusal names the flight") + _expect_true(rec_a.calls.is_empty(), + "del-refuse: nothing dispatches during deletion") + _expect_true(await _await_box_key(del_box, "r", 240), + "del-refuse: the deletion run settles") + _expect_equal(str((del_box["r"] as Dictionary).get("status", "")), + "ok", "del-refuse: the held deletion completes") + _expect_true(str((host.account_state() as Dictionary).get( + "public_id", "")) != CANON_C, + "del-refuse: the completed run rotates the account") + _clear_name_cache() + await _free_parts(parts) + + +const ATT_NOW: int = 1791367200 # 2026-10-07T10:00:00Z. + + +## Bare HUD stand-in: records attendance receipts without a scene. Carries +## the rank calls too, so a leaked stub can never break a later rank test. +class AttHudStub extends Node: + var announced: Array = [] + + func announce(text: String, _color: Color) -> void: + announced.append(text) + + func clear_cloud_rank() -> void: + pass + + func set_cloud_rank(_text: String) -> void: + pass + + +## Bare dialogue stand-in: reports open so the receipt gate drops. +class AttDialogueStub extends Node: + var open: bool = true + + func is_open() -> bool: + return open + + +func _att_rfc(seconds: int) -> String: + return Time.get_datetime_string_from_unix_time(maxi(seconds, 0)) \ + .replace(" ", "T") + "Z" + + +func _att_missing_body(read_time: String = "") -> String: + var stamp: String = read_time + if stamp.is_empty(): + stamp = _att_rfc(ATT_NOW) + return JSON.stringify([{"missing": "mb_attendance_v1/x", + "readTime": stamp}]) + + +func _att_row_body(last_claim: String, install: String, + read_time: String = "", update_time: String = "", + prev_claim: String = "", prev_install: String = "") -> String: + var stamp: String = read_time + if stamp.is_empty(): + stamp = _att_rfc(ATT_NOW) + var swapped: String = update_time + if swapped.is_empty(): + swapped = last_claim + var fields: Dictionary = { + "public_id": {"stringValue": CANON_C}, + "uid": {"stringValue": UID_A}, + "install_id": {"stringValue": install}, + "last_claim_at": {"timestampValue": last_claim}, + "schema": {"integerValue": "1"}, + } + if not prev_claim.is_empty(): + fields["prev_claim_at"] = {"timestampValue": prev_claim} + if not prev_install.is_empty(): + fields["prev_install_id"] = {"stringValue": prev_install} + return JSON.stringify([{ + "found": { + "name": "projects/p/databases/(default)/documents/mb_attendance_v1/x", + "fields": fields, + "updateTime": swapped, + }, + "readTime": stamp, + }]) + + +## Every attendance commit write this sender carried, oldest first, parsed +## from the recorded request bodies. +func _att_commit_writes(sender: RefCounted) -> Array: + var writes: Array = [] + for call in (sender.calls as Array): + if not str((call as Dictionary).get("url", "")).contains( + "documents:commit"): + continue + var body: Variant = JSON.parse_string( + str((call as Dictionary).get("body", ""))) + if typeof(body) != TYPE_DICTIONARY: + continue + var each: Array = (body as Dictionary).get("writes", []) + if each.is_empty(): + continue + writes.append(each[0]) + return writes + + +func _att_commit_body(commit_time: String) -> String: + return JSON.stringify({"writeResults": [{}], + "commitTime": commit_time}) + + +## First attendance claim over the real stack: a missing server row plus an +## acknowledged commit grants exactly two coins with a stable receipt, a +## live twelve-hour deadline, one signal, and a queued HUD receipt. Reload +## keeps the coins and the key together. +func _test_attendance_first_claim_grants_two() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var fired: Array = [] + host.production_attendance.connect( + func(snap: Dictionary) -> void: fired.append( + (snap as Dictionary).duplicate())) + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var calls_before: int = (sender.calls as Array).size() + var grants_before: int = ( + Vault.continue_coin_grants as Dictionary).size() + sender.queue_ok(_att_missing_body()) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var result: Dictionary = await host.claim_attendance() + var receipt: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + _expect_equal(str(result.get("status", "")), "granted", + "att-first: the claim grants") + _expect_equal(int(result.get("coins", 0)), 2, + "att-first: the grant is exactly two") + _expect_equal(str(result.get("receipt", "")), receipt, + "att-first: the receipt names the claim") + _expect_true(bool(result.get("granted", false)), + "att-first: the wallet took the coins") + _expect_false(bool(result.get("duplicate", true)), + "att-first: the first grant is not a duplicate") + _expect_equal(str(result.get("next_eligible_utc", "")), + _att_rfc(ATT_NOW + 43200), + "att-first: the deadline is twelve hours out") + _expect_equal(int(result.get("remaining_seconds", -1)), 43200, + "att-first: the full cooldown remains") + _expect_equal(Vault.continue_coins, before + 2, + "att-first: the balance rises by two") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + receipt, 0)), 2, + "att-first: the receipt lands with the coins") + _expect_equal((Vault.continue_coin_grants as Dictionary).size(), + grants_before + 1, + "att-first: one new grant entry only") + _expect_equal((sender.calls as Array).size() - calls_before, 2, + "att-first: one read plus one commit") + _expect_equal(fired.size(), 1, + "att-first: one attendance signal fires") + var first: Dictionary = (fired[0] as Dictionary) \ + if not fired.is_empty() else {} + _expect_equal(str(first.get("receipt", "")), receipt, + "att-first: the signal carries the receipt") + var view: Dictionary = host.attendance_view() + _expect_equal(str(view.get("state", "")), "ready", + "att-first: the view is live") + _expect_equal(str(view.get("source", "")), "live", + "att-first: the view is server-confirmed") + _expect_equal(str(view.get("next_eligible_utc", "")), + _att_rfc(ATT_NOW + 43200), + "att-first: the view carries the deadline") + Vault.load_vault() + _expect_equal(Vault.continue_coins, before + 2, + "att-first: the reload keeps the coins") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + receipt, 0)), 2, + "att-first: the reload keeps the key") + await _free_parts(parts) + + +## A row claimed eleven hours ago on another install cools down: no commit, +## no coins, no receipt, and the live view names the remaining hour. +func _test_attendance_cooldown_grants_nothing() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var before: int = Vault.continue_coins + var calls_before: int = (sender.calls as Array).size() + var grants_before: int = ( + Vault.continue_coin_grants as Dictionary).size() + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW - 39600), + "install-other-9")) + var result: Dictionary = await host.request_attendance() + _expect_equal(str(result.get("status", "")), "cooldown", + "att-cool: the repeat cools down") + _expect_equal(int(result.get("remaining_seconds", -1)), 3600, + "att-cool: one hour remains") + _expect_equal(str(result.get("next_eligible_utc", "")), + _att_rfc(ATT_NOW + 3600), + "att-cool: the deadline names the hour") + _expect_false(bool(result.get("granted", false)), + "att-cool: nothing grants") + _expect_equal(Vault.continue_coins, before, + "att-cool: the balance holds") + _expect_equal((Vault.continue_coin_grants as Dictionary).size(), + grants_before, + "att-cool: no grant entry appears") + _expect_equal((sender.calls as Array).size() - calls_before, 1, + "att-cool: the read alone goes out") + _expect_true(str(host.get("_attendance_receipt_pending")).is_empty(), + "att-cool: no receipt queues") + var view: Dictionary = host.attendance_view() + _expect_equal(str(view.get("source", "")), "live", + "att-cool: the view is server-confirmed") + _expect_equal(int(view.get("remaining_seconds", -1)), 3600, + "att-cool: the view names the hour") + await _free_parts(parts) + + +## An acknowledged commit without a timestamp stays uncertain: no coins +## until the re-read proves the row is ours, then one top-up lands. +func _test_attendance_same_install_recovers_after_uncertain_ack() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + sender.queue_ok(_att_missing_body()) + sender.queue_ok("{}") + var uncertain: Dictionary = await host.claim_attendance() + _expect_equal(str(uncertain.get("status", "")), "failure", + "att-uncertain: the timeless commit fails") + _expect_equal(str(uncertain.get("code", "")), + "attendance-uncertain-ack", + "att-uncertain: the code names the ack") + _expect_equal(Vault.continue_coins, before, + "att-uncertain: no coins land early") + var interim: Dictionary = host.attendance_view() + _expect_equal(str(interim.get("state", "")), "offline", + "att-uncertain: the interim view admits the gap") + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW), install)) + var again: Dictionary = await host.claim_attendance() + var receipt: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + _expect_equal(str(again.get("status", "")), "already-claimed", + "att-uncertain: the re-read settles") + _expect_true(bool(again.get("granted", false)), + "att-uncertain: the top-up lands") + _expect_equal(str(again.get("receipt", "")), receipt, + "att-uncertain: the receipt matches the row") + _expect_equal(Vault.continue_coins, before + 2, + "att-uncertain: exactly two arrive") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + receipt, 0)), 2, + "att-uncertain: the key lands with the coins") + await _free_parts(parts) + + +## A blocked wallet temp file fails the local grant without losing the +## server claim: nothing lands, and the retry after repair grants once +## under the same receipt. +func _test_attendance_wallet_failure_recovers() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var receipt: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + sender.queue_ok(_att_missing_body()) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var temp: String = ProjectSettings.globalize_path( + Vault.TEMP_SAVE_PATH) + DirAccess.make_dir_recursive_absolute(temp) + var failed: Dictionary = await host.claim_attendance() + DirAccess.remove_absolute(temp) + _expect_equal(str(failed.get("status", "")), "failure", + "att-wallet: the blocked grant fails") + _expect_equal(str(failed.get("code", "")), "local-grant-failed", + "att-wallet: the code names the wallet") + _expect_true(bool(failed.get("server_claimed", false)), + "att-wallet: the server claim survives") + _expect_equal(str(failed.get("receipt", "")), receipt, + "att-wallet: the receipt names the claim") + _expect_equal(Vault.continue_coins, before, + "att-wallet: the balance holds") + _expect_false((Vault.continue_coin_grants as Dictionary).has( + receipt), + "att-wallet: no key lands") + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW), install)) + var again: Dictionary = await host.claim_attendance() + _expect_equal(str(again.get("status", "")), "already-claimed", + "att-wallet: the retry settles") + _expect_true(bool(again.get("granted", false)), + "att-wallet: the retry grants") + _expect_equal(str(again.get("receipt", "")), receipt, + "att-wallet: the retry reuses the receipt") + _expect_equal(Vault.continue_coins, before + 2, + "att-wallet: exactly two arrive once") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + receipt, 0)), 2, + "att-wallet: the key lands with the coins") + await _free_parts(parts) + + +## Entry checks never block play: local guests, deletion, restore, and +## overlap skip without touching the wire, and a real double tap grants +## once from a single read plus commit. +func _test_attendance_request_skips_and_double_tap() -> void: + _wipe_all() + _clear_name_cache() + _clear_attendance_cache() + var guest_parts: Dictionary = _make_parts() + var guest_host: Node = guest_parts["host"] + var guest_fake: Node = guest_parts["fake"] + var guest_sender: RefCounted = guest_parts["sender"] + guest_fake.guest_receipt = {"status": "error", "code": "network_error", + "retryable": true} + guest_host.startup() + guest_host.begin_guest() + var skipped: Dictionary = await guest_host.request_attendance() + _expect_equal(str(skipped.get("status", "")), "skipped", + "att-skip: the local guest skips") + _expect_equal(str(skipped.get("code", "")), "local-guest", + "att-skip: the skip names the guest") + _expect_true((guest_sender.calls as Array).is_empty(), + "att-skip: the local guest sends nothing") + await _free_parts(guest_parts) + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var calls_before: int = (sender.calls as Array).size() + host.set("_deletion_ticket", {"generation": 7}) + var del_skip: Dictionary = await host.request_attendance() + _expect_equal(str(del_skip.get("code", "")), "deletion-in-flight", + "att-skip: deletion skips") + host.set("_deletion_ticket", {}) + host.set("_restore_state", "checking") + var restore_skip: Dictionary = await host.request_attendance() + _expect_equal(str(restore_skip.get("code", "")), "restore-pending", + "att-skip: restore skips") + host.set("_restore_state", "") + host.set("_attendance_in_flight", true) + var busy_skip: Dictionary = await host.request_attendance() + _expect_equal(str(busy_skip.get("code", "")), "in-flight", + "att-skip: overlap skips") + host.set("_attendance_in_flight", false) + _expect_equal((sender.calls as Array).size(), calls_before, + "att-skip: the skips send nothing") + sender.queue_ok(_att_missing_body()) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var before: int = Vault.continue_coins + var tap_before: int = (sender.calls as Array).size() + # A detached wrapper coroutine cannot park on this Godot build, so the + # first tap parks at the token barrier in the test while a timer fires + # the second tap mid-flight; the release then lets the first land. + _drop_cached_token(host) + var fake: Node = parts["fake"] + fake.token_receipt = {"status": "pending"} + var tap_box: Dictionary = {} + get_tree().create_timer(0.05).timeout.connect( + _request_attendance_async.bind(parts, tap_box)) + get_tree().create_timer(0.15).timeout.connect( + _release_token_ok.bind(parts)) + var first: Dictionary = await host.request_attendance() + _expect_equal(str(first.get("status", "")), "granted", + "att-tap: the first tap lands") + _expect_true(await _await_box_key(tap_box, "r"), + "att-tap: the second tap settles") + var dup: Dictionary = tap_box["r"] + _expect_equal(str(dup.get("status", "")), "skipped", + "att-tap: the second tap skips") + _expect_equal(str(dup.get("code", "")), "in-flight", + "att-tap: the skip names the flight") + _expect_equal(Vault.continue_coins, before + 2, + "att-tap: exactly two arrive") + _expect_equal((sender.calls as Array).size() - tap_before, 2, + "att-tap: one read plus one commit") + await _free_parts(parts) + + +## Fire one entry check from a timer while the test holds another: the +## parked call cannot run detached, so the timer carries the overlap. +func _request_attendance_async(parts: Dictionary, + box: Dictionary) -> void: + var host: Node = parts["host"] + box["r"] = await host.request_attendance() + + +## A granted entry claim queues a five-language receipt; the HUD shows it +## once, while an IME field or an open NPC dialogue drops the overlay and +## the wallet keeps the coins. +func _test_attendance_receipt_flush_and_suppression() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var before: int = Vault.continue_coins + sender.queue_ok(_att_missing_body()) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var result: Dictionary = await host.request_attendance() + _expect_equal(str(result.get("status", "")), "granted", + "att-receipt: the entry claim grants") + var pending: String = str(host.get("_attendance_receipt_pending")) + _expect_false(pending.is_empty(), + "att-receipt: the grant queues a receipt") + _expect_true(pending.contains("+2"), + "att-receipt: the receipt names two coins") + var hud: AttHudStub = AttHudStub.new() + hud.add_to_group("moonlit_hud") + add_child(hud) + host.call("_flush_attendance_receipt") + _expect_equal((hud.announced as Array).size(), 1, + "att-receipt: the HUD shows it once") + var shown: String = str((hud.announced as Array)[0]) \ + if not (hud.announced as Array).is_empty() else "" + _expect_equal(shown, pending, + "att-receipt: the HUD shows the queued text") + _expect_true(str(host.get("_attendance_receipt_pending")).is_empty(), + "att-receipt: the flush clears the queue") + var field: LineEdit = LineEdit.new() + add_child(field) + field.grab_focus() + await _frames(1) + host.set("_attendance_receipt_pending", "att-queued-ime") + host.call("_flush_attendance_receipt") + _expect_equal((hud.announced as Array).size(), 1, + "att-receipt: the IME focus drops the overlay") + _expect_true(str(host.get("_attendance_receipt_pending")).is_empty(), + "att-receipt: the dropped queue clears") + _expect_equal(Vault.continue_coins, before + 2, + "att-receipt: the wallet keeps the coins") + field.release_focus() + field.queue_free() + var talk: AttDialogueStub = AttDialogueStub.new() + talk.add_to_group("moonlit_dialogue") + add_child(talk) + host.set("_attendance_receipt_pending", "att-queued-talk") + host.call("_flush_attendance_receipt") + _expect_equal((hud.announced as Array).size(), 1, + "att-receipt: the open dialogue drops the overlay") + _expect_true(str(host.get("_attendance_receipt_pending")).is_empty(), + "att-receipt: the dialogue drop clears") + hud.queue_free() + talk.queue_free() + await _free_parts(parts) + + +## A foreground return re-checks without granting again, and an outage +## grants nothing while the live deadline survives for reminders. +func _test_attendance_foreground_and_offline_cache() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + sender.queue_ok(_att_missing_body()) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var entered: Dictionary = await host.request_attendance() + _expect_equal(str(entered.get("status", "")), "granted", + "att-fore: the entry claim grants") + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW), install)) + var again: Dictionary = await host.notify_foreground_return() + _expect_equal(str(again.get("status", "")), "already-claimed", + "att-fore: the return re-checks") + _expect_false(bool(again.get("granted", true)), + "att-fore: no new coins land") + _expect_equal(str(again.get("receipt", "")), + str(entered.get("receipt", "")), + "att-fore: the re-check names the same receipt") + _expect_equal(Vault.continue_coins, before + 2, + "att-fore: the balance holds") + sender.queue_reply({"transport": "offline", "code": 0, "body": ""}) + var off: Dictionary = await host.request_attendance() + _expect_equal(str(off.get("status", "")), "offline", + "att-fore: the outage stays offline") + _expect_equal(Vault.continue_coins, before + 2, + "att-fore: the outage grants nothing") + var view: Dictionary = host.attendance_view() + _expect_equal(str(view.get("source", "")), "live", + "att-fore: the live view survives") + _expect_equal(str(view.get("next_eligible_utc", "")), + _att_rfc(ATT_NOW + 43200), + "att-fore: the deadline survives") + await _free_parts(parts) + + +## Linking a provider keeps the attendance record on the same canonical +## ID without a second grant; signing out rotates to a guest with no +## cached deadline. +func _test_attendance_link_keeps_record() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + sender.queue_ok(_att_missing_body()) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var result: Dictionary = await host.claim_attendance() + _expect_equal(str(result.get("status", "")), "granted", + "att-link: the guest claims first") + fake.link_receipt = {"status": "ok", "session": { + "kind": "cloud", "uid": UID_A, "provider": "google"}} + var linked: Dictionary = host.link_provider("google") + await _frames(2) + _expect_equal(str(linked.get("status", "")), "ok", + "att-link: the link succeeds") + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), CANON_C, + "att-link: the link keeps the canonical ID") + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW), install)) + var again: Dictionary = await host.claim_attendance() + _expect_equal(str(again.get("status", "")), "already-claimed", + "att-link: the link keeps the record") + _expect_equal(Vault.continue_coins, before + 2, + "att-link: no second grant lands") + host.sign_out() + var rotated: String = str((host.account_state() as Dictionary).get( + "public_id", "")) + _expect_true(rotated != CANON_C and not rotated.is_empty(), + "att-link: sign-out rotates") + _expect_true((Vault.attendance_next_for_account(rotated) + as Dictionary).is_empty(), + "att-link: the rotated guest reads no deadline") + await _free_parts(parts) + + +## An attendance claim parked before deletion stays cancelled even though +## the deletion fails on the same account; a genuinely new request after +## the failure proceeds. +func _test_attendance_cancelled_across_failed_deletion() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var sender: RefCounted = parts["sender"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + _drop_cached_token(host) + fake.token_receipt = {"status": "pending"} + fake.auto_error = {"code": "token_error"} + _queue_unclaimed_adventurer(sender) + sender.queue_reply({"transport": "ok", "code": 403, "body": "{}"}) + var del_box: Dictionary = {} + get_tree().create_timer(0.1).timeout.connect( + _begin_deletion_async.bind(parts, del_box)) + get_tree().create_timer(0.15).timeout.connect( + _assert_deletion_barrier.bind(parts)) + get_tree().create_timer(0.2).timeout.connect( + _release_token_ok.bind(parts)) + var result: Dictionary = await host.claim_attendance() + _expect_equal(str(result.get("status", "")), "cancelled", + "att-del: the pre-deletion claim cancels") + _expect_equal(str(result.get("code", "")), "account-retired", + "att-del: the cancel names the retirement") + _expect_true(rec_a.calls.is_empty(), + "att-del: nothing dispatches during deletion") + _expect_true(await _await_box_key(del_box, "r"), + "att-del: the deletion run settles") + var deleted: Dictionary = del_box["r"] + _expect_equal(str(deleted.get("status", "")), "failure", + "att-del: the denied commit fails the run") + _expect_equal(str((host.account_state() as Dictionary).get( + "public_id", "")), CANON_C, + "att-del: the failed run keeps the same account") + var retry: Dictionary = await host.claim_attendance() + _expect_equal(str(retry.get("status", "")), "granted", + "att-del: a new request proceeds after failed deletion") + _expect_equal(rec_a.calls.size(), 1, + "att-del: the new request dispatches once") + await _free_parts(parts) + + +## A fresh attendance claim refuses while a deletion commit is in flight +## and dispatches nothing; the held deletion still completes. +func _test_attendance_refused_while_deletion_in_flight() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var fake: Node = parts["fake"] + var rec_a: NameRecorder = NameRecorder.new() + _swap_coordinator(parts, rec_a) + var delayed: DelayedSender = DelayedSender.new() + delayed.tree_node = self + delayed.delay_frames = 45 + _queue_unclaimed_adventurer(delayed) + delayed.queue_ok(_commit_ack_body()) + fake.delete_receipt = {"status": "ok"} + host.set("_sender", delayed) + var del_box: Dictionary = {} + get_tree().create_timer(0.05).timeout.connect( + _begin_deletion_async.bind(parts, del_box)) + var commit_seen: bool = false + for _index in 180: + for call in (delayed.calls as Array): + if str((call as Dictionary).get("url", "")).contains( + "documents:commit"): + commit_seen = true + break + if commit_seen: + break + await get_tree().process_frame + _expect_true(commit_seen, + "att-refuse: the deletion commit goes outstanding") + var refused: Dictionary = await host.claim_attendance() + _expect_equal(str(refused.get("status", "")), "failure", + "att-refuse: the claim refuses during deletion") + _expect_equal(str(refused.get("code", "")), "deletion-in-flight", + "att-refuse: the refusal names the flight") + _expect_true(rec_a.calls.is_empty(), + "att-refuse: nothing dispatches during deletion") + _expect_true(await _await_box_key(del_box, "r", 240), + "att-refuse: the deletion run settles") + _expect_equal(str((del_box["r"] as Dictionary).get("status", "")), + "ok", "att-refuse: the held deletion completes") + await _free_parts(parts) + + +## A server-acknowledged reward lost to a blocked wallet save recovers +## before the next eligible period advances: the old two coins land under +## their own receipt, the new claim lands under the next, and the advance +## commit carries the old stamp byte for byte with its swap intact. +func _test_attendance_backfills_before_advance() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var stamp: String = "2026-10-06T21:00:00.75Z" + var first: int = ATT_NOW - 46800 + var swapped: String = "2026-10-06T21:05:00.125Z" + var old_key: String = "attendance:%s:%d:%s" % [CANON_C, first, + install] + var new_key: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + sender.queue_ok(_att_missing_body(stamp)) + sender.queue_ok(_att_commit_body(stamp)) + var temp: String = ProjectSettings.globalize_path( + Vault.TEMP_SAVE_PATH) + DirAccess.make_dir_recursive_absolute(temp) + var failed: Dictionary = await host.request_attendance() + DirAccess.remove_absolute(temp) + _expect_equal(str(failed.get("status", "")), "failure", + "att-back: the blocked first grant fails") + _expect_equal(str(failed.get("receipt", "")), old_key, + "att-back: the failure names the earned receipt") + _expect_equal(Vault.continue_coins, before, + "att-back: nothing lands while blocked") + _expect_true(str(host.get("_attendance_receipt_pending")).is_empty(), + "att-back: no receipt queues for nothing") + Vault.load_vault() + _expect_equal(Vault.continue_coins, before, + "att-back: the restart keeps the honest balance") + sender.queue_ok(_att_row_body(stamp, install, _att_rfc(ATT_NOW), + swapped)) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var advanced: Dictionary = await host.request_attendance() + _expect_equal(str(advanced.get("status", "")), "granted", + "att-back: the next period advances") + _expect_true(bool(advanced.get("backfilled", false)), + "att-back: the old reward backfills first") + _expect_equal(str(advanced.get("backfilled_receipt", "")), old_key, + "att-back: the backfill names the old receipt") + _expect_equal(str(advanced.get("receipt", "")), new_key, + "att-back: the advance names the new receipt") + _expect_equal(Vault.continue_coins, before + 4, + "att-back: both earned visits land") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + old_key, 0)), 2, + "att-back: the old key lands with its coins") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + new_key, 0)), 2, + "att-back: the new key lands with its coins") + _expect_true(str(host.get("_attendance_receipt_pending")).contains( + "+4"), "att-back: the receipt names all four coins") + var writes: Array = _att_commit_writes(sender) + _expect_equal(writes.size(), 2, + "att-back: one create plus one advance commit") + var create: Dictionary = writes[0] + _expect_true(not ((create.get("update", {}) as Dictionary).get( + "fields", {}) as Dictionary).has("prev_claim_at"), + "att-back: the create carries no previous stamp") + var advance: Dictionary = writes[1] + _expect_equal(str((advance.get("currentDocument", {}) as Dictionary + ).get("updateTime", "")), swapped, + "att-back: the advance swaps on the read updateTime") + var fields: Dictionary = (advance.get("update", {}) as Dictionary + ).get("fields", {}) + _expect_equal(str((fields.get("prev_claim_at", {}) as Dictionary + ).get("timestampValue", "")), stamp, + "att-back: the advance carries the old stamp exactly") + _expect_equal(str((fields.get("prev_install_id", {}) as Dictionary + ).get("stringValue", "")), install, + "att-back: the advance carries the old install") + var view: Dictionary = host.attendance_view() + _expect_equal(str(view.get("receipt", "")), new_key, + "att-back: the live view names the newest receipt") + await _free_parts(parts) -## True once at least `count` checkpoint reads have been sent. -func _await_checkpoint_sent(sender: RefCounted, count: int = 1, - frames: int = 240) -> bool: - for _index in frames: - if _checkpoint_calls(sender).size() >= count: - return true - await get_tree().process_frame - return false +## Another install's eligible row advances without granting its reward +## into this wallet: only the new claim lands, carrying the foreign +## stamp and install so the earning install can still recover. +func _test_attendance_other_install_advance_carries_prev() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var old: String = _att_rfc(ATT_NOW - 46800) + var other: String = "install-other-9" + sender.queue_ok(_att_row_body(old, other)) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var advanced: Dictionary = await host.request_attendance() + _expect_equal(str(advanced.get("status", "")), "granted", + "att-xback: the foreign row advances") + _expect_false(bool(advanced.get("backfilled", true)), + "att-xback: the foreign reward never backfills here") + _expect_equal(Vault.continue_coins, before + 2, + "att-xback: only the new claim lands") + _expect_false((Vault.continue_coin_grants as Dictionary).has( + "attendance:%s:%d:%s" % [CANON_C, ATT_NOW - 46800, other]), + "att-xback: no foreign key lands") + var writes: Array = _att_commit_writes(sender) + _expect_equal(writes.size(), 1, + "att-xback: one advance commit goes out") + var fields: Dictionary = ((writes[0] as Dictionary).get("update", {}) + as Dictionary).get("fields", {}) + _expect_equal(str((fields.get("prev_claim_at", {}) as Dictionary + ).get("timestampValue", "")), old, + "att-xback: the advance carries the foreign stamp") + _expect_equal(str((fields.get("prev_install_id", {}) as Dictionary + ).get("stringValue", "")), other, + "att-xback: the advance carries the foreign install") + _expect_true(str(host.get("_attendance_receipt_pending")).contains( + "+2"), "att-xback: the receipt names two coins") + _expect_equal(str((fields.get("install_id", {}) as Dictionary + ).get("stringValue", "")), install, + "att-xback: the advance binds the receiving install") + await _free_parts(parts) -## True once the routed sender has no delayed call still awaiting. Tests -## drain this before freeing parts, so no `send()` resumes on a freed -## double after the host is gone. -func _await_sender_idle(sender: RoutedSender, frames: int = 240) -> bool: - for _index in frames: - if sender.in_flight <= 0: - return true - await get_tree().process_frame - return false +## A held foreign row carrying this install's previous stamp backfills +## it without committing: the earned two land, the cooldown stands, and +## the receipt shows for the backfill alone. +func _test_attendance_cooldown_backfills_carried_prev() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var calls_before: int = (sender.calls as Array).size() + var last: String = _att_rfc(ATT_NOW - 3600) + var prev: String = _att_rfc(ATT_NOW - 46800) + var old_key: String = "attendance:%s:%d:%s" % [CANON_C, + ATT_NOW - 46800, install] + sender.queue_ok(_att_row_body(last, "install-other-9", + _att_rfc(ATT_NOW), "", prev, install)) + var held: Dictionary = await host.request_attendance() + _expect_equal(str(held.get("status", "")), "cooldown", + "att-prev: the foreign period holds") + _expect_true(bool(held.get("backfilled", false)), + "att-prev: the carried reward backfills") + _expect_equal(str(held.get("backfilled_receipt", "")), old_key, + "att-prev: the backfill names the old receipt") + _expect_equal(Vault.continue_coins, before + 2, + "att-prev: exactly the earned two land") + _expect_equal((sender.calls as Array).size() - calls_before, 1, + "att-prev: the read alone goes out") + _expect_true(str(host.get("_attendance_receipt_pending")).contains( + "+2"), "att-prev: the receipt shows the backfill") + var view: Dictionary = host.attendance_view() + _expect_equal(str(view.get("receipt", "")), old_key, + "att-prev: the live view names the backfilled receipt") + await _free_parts(parts) -## Re-emit the live coordinator's ready snapshot, the way a same-account -## duplicate callback arrives. Uses the host's live coordinator: the -## injected one is retired on first configure, so the parts dict copy is -## already freed by the time any duplicate could arrive. -func _emit_duplicate_ready(host: Node) -> void: - var live: Node = host.get("_coordinator") as Node - live.account_changed.emit(live.account_snapshot()) +## Re-reading the same carried row backfills once: the second visit +## finds the receipt applied, grants nothing more, and the restart +## between keeps the key with its coins. +func _test_attendance_backfill_replay_is_idempotent() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var last: String = _att_rfc(ATT_NOW - 3600) + var prev: String = _att_rfc(ATT_NOW - 46800) + var old_key: String = "attendance:%s:%d:%s" % [CANON_C, + ATT_NOW - 46800, install] + sender.queue_ok(_att_row_body(last, "install-other-9", + _att_rfc(ATT_NOW), "", prev, install)) + var held: Dictionary = await host.request_attendance() + _expect_true(bool(held.get("backfilled", false)), + "att-replay: the first visit backfills") + _expect_equal(Vault.continue_coins, before + 2, + "att-replay: the earned two land") + Vault.load_vault() + _expect_equal(Vault.continue_coins, before + 2, + "att-replay: the restart keeps the backfill") + sender.queue_ok(_att_row_body(last, "install-other-9", + _att_rfc(ATT_NOW), "", prev, install)) + var again: Dictionary = await host.request_attendance() + _expect_equal(str(again.get("status", "")), "cooldown", + "att-replay: the second visit still holds") + _expect_false(bool(again.get("backfilled", true)), + "att-replay: the second visit backfills nothing") + _expect_equal(Vault.continue_coins, before + 2, + "att-replay: the replay adds nothing") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + old_key, 0)), 2, + "att-replay: the key lands exactly once") + await _free_parts(parts) -func _test_boot_mints_id_before_play() -> void: - _wipe_all() - var parts: Dictionary = _make_parts() - var state: Dictionary = (parts["host"] as Node).startup() - _expect_true(bool(state.get("ready", false)), "boot: identity ready") - _expect_true(ACCOUNT_SCRIPT.is_valid_public_id( - str(state.get("public_id", ""))), "boot: durable MB id minted") - _expect_true(FileAccess.file_exists(ID_PATH), "boot: id saved to disk") - _expect_equal(str(state.get("source", "")), "local", - "boot: guest starts local") +## An uncertain advance commit still backfills first: the old two land +## with their receipt while the new claim stays unknown, and the next +## visit tops the new claim up without ever doubling the old. +func _test_attendance_uncertain_advance_recovers() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var stamp: String = _att_rfc(ATT_NOW - 46800) + var old_key: String = "attendance:%s:%d:%s" % [CANON_C, + ATT_NOW - 46800, install] + var new_key: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + sender.queue_ok(_att_missing_body(stamp)) + sender.queue_ok(_att_commit_body(stamp)) + var temp: String = ProjectSettings.globalize_path( + Vault.TEMP_SAVE_PATH) + DirAccess.make_dir_recursive_absolute(temp) + var failed: Dictionary = await host.request_attendance() + DirAccess.remove_absolute(temp) + _expect_equal(str(failed.get("code", "")), "local-grant-failed", + "att-unadv: the blocked first grant fails") + sender.queue_ok(_att_row_body(stamp, install)) + sender.queue_ok("{}") + var uncertain: Dictionary = await host.request_attendance() + _expect_equal(str(uncertain.get("code", "")), + "attendance-uncertain-ack", + "att-unadv: the timeless advance stays uncertain") + _expect_true(bool(uncertain.get("backfilled", false)), + "att-unadv: the old reward backfills anyway") + _expect_equal(Vault.continue_coins, before + 2, + "att-unadv: the old two land alone") + _expect_true(str(host.get("_attendance_receipt_pending")).contains( + "+2"), "att-unadv: the receipt names the backfill") + host.set("_attendance_receipt_pending", "") + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW), install, + _att_rfc(ATT_NOW + 3600))) + var topped: Dictionary = await host.request_attendance() + _expect_equal(str(topped.get("status", "")), "already-claimed", + "att-unadv: the re-read settles the new claim") + _expect_true(bool(topped.get("granted", false)), + "att-unadv: the new claim tops up") + _expect_equal(str(topped.get("receipt", "")), new_key, + "att-unadv: the top-up names the new receipt") + _expect_equal(Vault.continue_coins, before + 4, + "att-unadv: both earned visits land") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + old_key, 0)), 2, + "att-unadv: the old key never doubles") + Vault.load_vault() + _expect_equal(Vault.continue_coins, before + 4, + "att-unadv: the restart keeps both visits") await _free_parts(parts) -func _test_restart_keeps_id() -> void: - _wipe_all() - var first: Dictionary = _make_parts() - var id_a: String = str(((first["host"] as Node).startup() - ).get("public_id", "")) - await _free_parts(first) - var second: Dictionary = _make_parts() - var id_b: String = str(((second["host"] as Node).startup() - ).get("public_id", "")) - _expect_equal(id_b, id_a, "restart: same durable id") - await _free_parts(second) +## A backfill refused by the eviction floor is skipped, never repaid, +## while the genuine new claim still advances: the old receipt lands +## nothing, the new commit lands two, and the result names the skipped +## receipt. Retired history never freezes future rewards. +func _test_attendance_ambiguous_backfill_skips_and_advances() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + var before: int = Vault.continue_coins + var stamp: String = _att_rfc(ATT_NOW - 46800) + var old_key: String = "attendance:%s:%d:%s" % [CANON_C, + ATT_NOW - 46800, install] + var new_key: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + # Narrow white-box seam: the floor normally folds from real + # evictions (vault suite and the pruned-return test below); + # pinning it here isolates the coordinator's skip mapping. + Vault.attendance_floor = ATT_NOW + sender.queue_ok(_att_row_body(stamp, install)) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var advanced: Dictionary = await host.request_attendance() + Vault.attendance_floor = 0 + _expect_equal(str(advanced.get("status", "")), "granted", + "att-amb: the new claim still advances") + _expect_false(bool(advanced.get("backfilled", true)), + "att-amb: the old receipt never backfills") + _expect_true(bool(advanced.get("backfill_skipped", false)), + "att-amb: the skip is reported") + _expect_equal(str(advanced.get("skipped_receipt", "")), old_key, + "att-amb: the skip names the old receipt") + _expect_equal(str(advanced.get("receipt", "")), new_key, + "att-amb: the grant names the new receipt") + _expect_equal(Vault.continue_coins, before + 2, + "att-amb: only the new two land") + _expect_false((Vault.continue_coin_grants as Dictionary).has( + old_key), + "att-amb: the old key never lands") + _expect_true(str(host.get("_attendance_receipt_pending")).contains( + "+2"), "att-amb: the receipt names the new two") + sender.queue_ok(_att_row_body(_att_rfc(ATT_NOW), install, + _att_rfc(ATT_NOW + 3600))) + var again: Dictionary = await host.request_attendance() + _expect_equal(str(again.get("status", "")), "already-claimed", + "att-amb: the next visit settles the new claim") + _expect_false(bool(again.get("backfill_skipped", true)), + "att-amb: the covered stamp stays silent") + _expect_equal(Vault.continue_coins, before + 2, + "att-amb: the settle adds nothing more") + await _free_parts(parts) -func _test_corrupt_identity_refuses_readiness() -> void: - _wipe_all() - var writer: FileAccess = FileAccess.open(ID_PATH, FileAccess.WRITE) - writer.store_string("not an identity file {{{") - writer.close() - var parts: Dictionary = _make_parts() - var state: Dictionary = (parts["host"] as Node).startup() - # A corrupt file recovers with a fresh durable id, never a half id. - _expect_true(bool(state.get("ready", false)), - "corrupt: recovered to ready") - _expect_true(ACCOUNT_SCRIPT.is_valid_public_id( - str(state.get("public_id", ""))), - "corrupt: recovered id is valid") - _expect_true(bool(((parts["account"] as Node).current_state() - ).get("recovered_from_corrupt", false)), - "corrupt: recovery flagged") +## Ten owners claim on one install, the first owner's mark and receipt +## prune, and that owner returns after twelve hours: the forgotten old +## receipt is refused without repayment while the eligible new claim +## commits and pays. Purchased keys stay intact throughout. +func _test_attendance_pruned_return_advances() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var install: String = Vault.ensure_install_id() + _expect_true(bool(Vault.grant_continue_coins(5, "store-order-9")), + "att-prune: the purchased key lands") + var first: int = ATT_NOW - 46800 + var old_key: String = "attendance:%s:%d:%s" % [CANON_C, first, + install] + var new_key: String = "attendance:%s:%d:%s" % [CANON_C, ATT_NOW, + install] + sender.queue_ok(_att_missing_body(_att_rfc(first))) + sender.queue_ok(_att_commit_body(_att_rfc(first))) + var opened: Dictionary = await host.request_attendance() + _expect_equal(str(opened.get("status", "")), "granted", + "att-prune: the first claim lands") + # Nine further owners grant through the real vault path; the first + # owner's mark and key prune past the bounds. + for index in 9: + var owner: String = "MB-prune-%d" % index + var step: Dictionary = Vault.grant_attendance_coins(owner, + first + 60 * (index + 1), install, true) + _expect_true(bool(step.get("granted", false)), + "att-prune: owner %d grants" % index) + _expect_false((Vault.attendance_marks as Dictionary).has(CANON_C), + "att-prune: the first mark prunes") + _expect_false((Vault.continue_coin_grants as Dictionary).has( + old_key), + "att-prune: the first key prunes") + Vault.load_vault() + var before: int = Vault.continue_coins + sender.queue_ok(_att_row_body(_att_rfc(first), install)) + sender.queue_ok(_att_commit_body(_att_rfc(ATT_NOW))) + var returned: Dictionary = await host.request_attendance() + _expect_equal(str(returned.get("status", "")), "granted", + "att-prune: the return advances") + _expect_false(bool(returned.get("backfilled", true)), + "att-prune: the forgotten receipt is never repaid") + _expect_true(bool(returned.get("backfill_skipped", false)), + "att-prune: the skip is reported") + _expect_equal(str(returned.get("skipped_receipt", "")), old_key, + "att-prune: the skip names the old receipt") + _expect_equal(Vault.continue_coins, before + 2, + "att-prune: exactly the new two land") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + new_key, 0)), 2, + "att-prune: the new key lands with its coins") + _expect_equal(int((Vault.continue_coin_grants as Dictionary).get( + "store-order-9", 0)), 5, + "att-prune: the purchase stays intact") await _free_parts(parts) - # An unwritable disk refuses readiness and advertises nothing. - _wipe_all() - var fake: Node = FAKE_ADAPTER_SCRIPT.new() - var account: Node = ACCOUNT_SCRIPT.new() - account.setup(fake, "user://no_such_dir_anywhere/player_identity.cfg", - "user://no_such_dir_anywhere/player_bindings.cfg") - var coord: Node = COORD_SCRIPT.new() as Node - var sender: RefCounted = FAKE_SENDER_SCRIPT.new() - var host: Node = HOST_SCRIPT.new() as Node - add_child(host) - host.inject_services({"account": account, "adapter": fake, - "sender": sender, "coordinator": coord, "vault": Vault}) - _watch(host) - var broken: Dictionary = host.startup() - _expect_false(bool(broken.get("ready", false)), - "unwritable: readiness refused") - _expect_true(str(broken.get("public_id", "")).is_empty(), - "unwritable: no id advertised") - _expect_equal(str(broken.get("source", "")), "unready", - "unwritable: source unready") - _expect_equal(str((host.plan_entry(true, true) as Dictionary).get( - "code", "")), "identity_not_ready", - "unwritable: entry refused without a durable id") - await _free_parts({"host": host}) -func _test_canonical_adoption_durable() -> void: - _wipe_all() - var parts: Dictionary = _make_parts() +## Best completion reaches HUD standing again: after a submit, the Arena +## rank chip shows the refreshed standing without another rank pull. +func _test_submit_best_pushes_hud_rank() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() var host: Node = parts["host"] - var fake: Node = parts["fake"] var sender: RefCounted = parts["sender"] - host.startup() - var guest: String = str((host.account_state() as Dictionary).get( - "public_id", "")) - # A UID that already owns another id: the canonical one wins. - sender.queue_ok(_profile_body(UID_A, CANON_C)) sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) - fake.guest_receipt = {"status": "ok", "session": { - "kind": "cloud", "uid": UID_A, "provider": "anonymous"}} - host.begin_guest() - var adopted: Dictionary = await _settle_call( - host, "account_state", CANON_C) - _expect_equal(str(adopted.get("public_id", "")), CANON_C, - "adopt: canonical id wins") - _expect_equal((host.account_state() as Dictionary).get( - "cloud_uid", ""), UID_A, "adopt: cloud session kept") - var account: Node = parts["account"] - _expect_equal(account.public_id_for_uid(UID_A), CANON_C, - "adopt: uid binding recorded") - _expect_true((_fired["error"] as Array).is_empty(), - "adopt: successful configure raises no error") - _expect_true(guest != CANON_C, "adopt: guest really differed") + sender.queue_ok("{\"writeResults\": [{}, {}]}") + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + var claimed: Dictionary = await host.claim_adventurer_name("Luna") + _expect_equal(str(claimed.get("status", "")), "ok", + "submit-hud: the claim succeeds") + sender.queue_ok(_own_row_body(CANON_C, 7000)) + sender.queue_ok(_rank_body(1)) + var view: Dictionary = await host.request_rank() + _expect_equal(int(view.get("rank", 0)), 2, + "submit-hud: the standing refreshes") + var hud: Control = HUD_SCENE.instantiate() as Control + add_child(hud) + await _frames(2) + var rank: Label = hud.get_node("RightPanel/Row/Rank") as Label + # A late rank push can light the chip before the submit runs; clear + # it and prove a quiet window first, so only the submit can relight. + hud.clear_cloud_rank() + await _frames(5) + _expect_false(rank.visible, "submit-hud: the cleared chip stays dark") + _write_checkpoint({"cycle": 2, "journey_id": "submit-hud-j-1"}) + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + sender.queue_ok("{\"writeResults\": [{}]}") + await host._submit_best() + _expect_true(rank.visible and rank.text.begins_with("#2 · "), + "submit-hud: the submit pushes the standing to the chip") + var commit: Dictionary = _commit_with(sender, "mb_hall_v1") + var live: Node = host.get("_coordinator") as Node + var expected: Dictionary = live._derive_hall_row( + Journey.read_checkpoint()) + var fields: Dictionary = ((commit.get("writes", []) as Array)[0] + as Dictionary).get("update", {}).get("fields", {}) + _expect_equal(str(fields.get("display", {}).get("stringValue", "")), + "Luna", "submit-hud: the write carries the verified handle") + _expect_equal(str(fields.get("hero", {}).get("stringValue", "")), + str(expected.get("hero", "")), + "submit-hud: the write carries the saved hero") + _expect_equal(int(fields.get("score", {}).get("integerValue", "0")), + int(expected.get("score", 0)), + "submit-hud: the write carries the saved score") + _expect_equal(int(fields.get("cycles", {}).get("integerValue", "0")), + int(expected.get("cycles", 0)), + "submit-hud: the write carries the saved cycles") + hud.queue_free() + _clear_name_cache() + await _free_parts(parts) + + +## Verified handles travel the production board path into the real panel: +## maximum-length Korean and Japanese handles read on the headline, the +## stable MB ID keeps its own line, and unnamed rows keep the honest +## rank-and-score fallback. +func _test_hall_named_rows_reach_panel() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var sender: RefCounted = parts["sender"] + var korean12: String = "달빛기사단달빛기사단달빛" + var japanese12: String = "ルミールミールミールミー" + _expect_equal(korean12.length(), 12, "panel: the Korean handle is max") + _expect_equal(japanese12.length(), 12, + "panel: the Japanese handle is max") + sender.reset() + sender.queue_ok(_board_body([ + [OTHER_ID, Vault.HEROES[0], 9000, korean12], + [CANON_D, "warden", 8000, japanese12], + [CANON_C, Vault.HEROES[0], 7000], + ])) + var view: Dictionary = await host.request_hall() + var rows: Array = view.get("rows", []) + _expect_equal(rows.size(), 3, "panel: three board rows mapped") + _expect_equal(str((rows[0] as Dictionary).get("display", "")), korean12, + "panel: the mapper keeps the Korean handle") + _expect_equal(str((rows[2] as Dictionary).get("display", "")), "", + "panel: the unnamed row maps no handle") + var panel: GateHallPanel = GateHallPanel.new() + add_child(panel) + await _frames(1) + panel.show_rows(rows) + _expect_equal(panel.row_count(), 3, "panel: three real rows render") + var first: Label = panel.get_node( + "Card/Stack/Rows/RowsBox/HallRow0/Line/Middle/Headline") as Label + _expect_equal(first.text, "#1 · 9000 · " + korean12, + "panel: the Korean handle reads on the headline") + var first_id: Label = panel.get_node( + "Card/Stack/Rows/RowsBox/HallRow0/Line/Middle/IdLine") as Label + _expect_equal(first_id.text, OTHER_ID, + "panel: the stable ID keeps its own line") + var second: Label = panel.get_node( + "Card/Stack/Rows/RowsBox/HallRow1/Line/Middle/Headline") as Label + _expect_equal(second.text, "#2 · 8000 · " + japanese12, + "panel: the Japanese handle reads on the headline") + var third: Label = panel.get_node( + "Card/Stack/Rows/RowsBox/HallRow2/Line/Middle/Headline") as Label + _expect_equal(third.text, "#3 · 7000", + "panel: the unnamed row keeps its honest fallback") + var row_box: Control = panel.get_node( + "Card/Stack/Rows/RowsBox/HallRow0") as Control + _expect_equal(row_box.custom_minimum_size.y, + GateHallPanel.ROW_MIN_HEIGHT, + "panel: the named row keeps the row height") + panel.queue_free() await _free_parts(parts) - # Relaunch keeps the adopted canonical id. - var relaunched: Dictionary = _make_parts() - var state: Dictionary = (relaunched["host"] as Node).startup() - _expect_equal(str(state.get("public_id", "")), CANON_C, - "adopt: canonical id survives restart") - await _free_parts(relaunched) func _test_adoption_conflict_preserves() -> void: @@ -767,10 +3277,19 @@ func _test_token_retirement_on_switch() -> void: await _settle_call(host, "account_state", CANON_C) _expect_equal(host.supply_token(), "fake-id-token", "token: live token supplied") + Vault.continue_coins = 1 + Vault.save_vault() + _seal_and_journal(CANON_C, "token-j-1", 2, 3) + _expect_equal(Vault.continue_coins, 0, + "token: the debit journals before sign-out") var fetches: int = (fake.calls as Array).count("get_id_token") host.sign_out() _expect_true(host.supply_token().is_empty(), "token: retired on sign-out") + _expect_equal(Vault.recover_paid_continue(), "deferred", + "token: sign-out defers the old receipt instead of dropping it") + _expect_equal(_txn_owner_cid(CANON_C), 3, + "token: the old receipt survives the rotation") fake.guest_receipt = {"status": "ok", "session": { "kind": "cloud", "uid": UID_B, "provider": "anonymous"}} sender.reset() @@ -782,6 +3301,14 @@ func _test_token_retirement_on_switch() -> void: "token: refreshed for the new account") _expect_equal(host.supply_token(), "fake-id-token", "token: new account serves its own token") + _expect_equal(Vault.recover_paid_continue(), "deferred", + "token: the refresh keeps deferring the old receipt") + _expect_equal(_txn_owner_cid(CANON_C), 3, + "token: the refresh leaves the old receipt alone") + _expect_equal(_txn_owner_cid(CANON_D), 0, + "token: the new scope journals nothing of its own") + _expect_true(bool(Vault.clear_continue_txn_for_owner(CANON_C)), + "token: cleanup drops the retained receipt") await _free_parts(parts) @@ -903,6 +3430,246 @@ func _test_resume_needs_save() -> void: await _free_parts(parts) +## A sealed defeat offers no resume: the saved-gate summary reports no save, +## planning a resume is refused like an empty slot, and a fresh expedition +## needs no erase confirmation because nothing resumable waits. +func _test_defeat_offers_no_resume() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + host.startup() + _write_checkpoint({"cycle": 4, "journey_id": "defeat-j-1", + "ended": true}) + _expect_false(bool((host.saved_gate_summary() as Dictionary).get( + "has_save", true)), "defeat: summary reports no save") + var refused: Dictionary = host.plan_entry(false, true) + _expect_equal(str(refused.get("code", "")), "no_save", + "defeat: resume refused for a sealed run") + _expect_false(Journey.armed, "defeat: nothing armed by the refusal") + var fresh: Dictionary = host.plan_entry(true, false) + _expect_equal(str(fresh.get("status", "")), "ok", + "defeat: fresh over a sealed run needs no confirmation") + _expect_equal(Journey.pending, Journey.Pending.FRESH, + "defeat: fresh pending") + await _free_parts(parts) + + +## A paid revive stranded behind failing writes is not a save, but a fresh +## expedition over it still asks first — the paid coin is only ever +## abandoned explicitly. Once the writes heal, the same entry recovers the +## paid seal and resumes it without another charge. +func _test_stuck_revive_needs_fresh_confirmation() -> void: + _wipe_all() + _release_dir(ProjectSettings.globalize_path(Journey.path + ".tmp")) + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + host.startup() + _write_checkpoint({"cycle": 4, "journey_id": "stuck-j-1", + "checkpoint_id": 2, "ended": true}) + Vault.continue_coins = 2 + Vault.continue_txn = {} + var seal: Dictionary = _valid_checkpoint("stuck-j-1", 3, 9) + _expect_true(Vault.begin_continue_txn("stuck-j-1", 3, + JSON.stringify(seal)), "stuck: the debit journals its seal") + _occupy_dir(ProjectSettings.globalize_path(Journey.path + ".tmp")) + var summary: Dictionary = host.saved_gate_summary() + _expect_false(bool(summary.get("has_save", true)), + "stuck: summary reports no save") + _expect_true(bool(summary.get("revive_stuck", false)), + "stuck: summary names the stuck revive") + var refused: Dictionary = host.plan_entry(false, true) + _expect_equal(str(refused.get("code", "")), "no_save", + "stuck: resume refused while the seal cannot land") + var needs_confirm: Dictionary = host.plan_entry(true, false) + _expect_equal(str(needs_confirm.get("code", "")), "needs_confirmation", + "stuck: fresh over a stuck revive asks first") + _release_dir(ProjectSettings.globalize_path(Journey.path + ".tmp")) + var planned: Dictionary = host.plan_entry(false, true) + _expect_equal(str(planned.get("status", "")), "ok", + "stuck: resume plans after recovery") + _expect_true(Journey.has_valid_checkpoint(), + "stuck: the recovered seal is resumable") + _expect_equal(Vault.continue_coins, 1, + "stuck: recovery charges nothing more") + _expect_true((Vault.continue_txn as Dictionary).is_empty(), + "stuck: recovery clears the journal") + await _free_parts(parts) + + +## Occupy a path as a directory so the next write there fails. Always paired +## with `_release_dir`, and released again at the next test start. +func _occupy_dir(absolute: String) -> void: + if FileAccess.file_exists(absolute): + DirAccess.remove_absolute(absolute) + DirAccess.make_dir_absolute(absolute) + + +func _release_dir(absolute: String) -> void: + if DirAccess.dir_exists_absolute(absolute): + DirAccess.remove_absolute(absolute) + + +func _test_lodge_local_guest_skips() -> void: + _wipe_all() + var parts: Dictionary = _make_parts() + var host: Node = parts["host"] + host.startup() + _expect_false(host.needs_lodge_lesson(), + "lodge: a local guest settles no claim") + var planned: Dictionary = host.plan_entry(true, true) + _expect_equal(str(planned.get("status", "")), "ok", + "lodge: a local guest still enters the arena") + _expect_true(str(planned.get("arena", "")).ends_with("arena.tscn"), + "lodge: the local plan names the arena") + host.cancel_entry_plan() + await _free_parts(parts) + + +func _test_lodge_unnamed_cloud_blocked() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + _expect_true(host.needs_lodge_lesson(), + "lodge: an unnamed cloud account needs the lesson") + var refused: Dictionary = host.plan_entry(true, true) + _expect_equal(str(refused.get("code", "")), "needs_lodge", + "lodge: the arena refuses before the lesson") + _expect_false(Journey.armed, "lodge: the refusal arms nothing") + var visit: Dictionary = host.plan_lodge_entry() + _expect_equal(str(visit.get("status", "")), "ok", + "lodge: the visit plans") + _expect_true(str(visit.get("lodge", "")).ends_with("gate_lodge.tscn"), + "lodge: the visit names the lodge scene") + _expect_true(bool(visit.get("needs_name", false)), + "lodge: the visit reports the missing name") + _expect_false(Journey.armed, "lodge: the visit arms no journey") + _expect_equal(Journey.pending, Journey.Pending.NONE, + "lodge: the visit pends nothing") + var again: Dictionary = host.plan_lodge_entry() + _expect_equal(str(again.get("code", "")), "entry_in_flight", + "lodge: a second visit refuses while one holds") + host.cancel_entry_plan() + _expect_false(Journey.armed, "lodge: the cancel arms nothing") + await _free_parts(parts) + + +func _test_lodge_named_unfinished_recovers() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", false), + "lodge: the unfinished handle caches") + _expect_true(host.needs_lodge_lesson(), + "lodge: a named unfinished account still needs the lesson") + _expect_equal(host.verified_display_name(), "Luna", + "lodge: the claimed handle recovers synchronously") + var visit: Dictionary = host.plan_lodge_entry() + _expect_equal(str(visit.get("status", "")), "ok", + "lodge: the unfinished visit plans") + _expect_false(bool(visit.get("needs_name", true)), + "lodge: the visit reports the name settled") + host.cancel_entry_plan() + _clear_name_cache() + await _free_parts(parts) + + +func _test_lodge_completed_skips() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", true), + "lodge: the completed handle caches") + _expect_false(host.needs_lodge_lesson(), + "lodge: a completed account skips the lesson") + var visit: Dictionary = host.plan_lodge_entry() + _expect_equal(str(visit.get("code", "")), "lodge_complete", + "lodge: a completed account cannot plan a visit") + var planned: Dictionary = host.plan_entry(true, true) + _expect_equal(str(planned.get("status", "")), "ok", + "lodge: a completed account enters the arena directly") + host.cancel_entry_plan() + _clear_name_cache() + await _free_parts(parts) + + +func _test_lodge_exit_guards() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + var naked: Dictionary = host.plan_lodge_exit(true, true) + _expect_equal(str(naked.get("code", "")), "no_lodge_plan", + "lodge: no exit without a live visit") + var visit: Dictionary = host.plan_lodge_entry() + _expect_equal(str(visit.get("status", "")), "ok", + "lodge: the guarded visit plans") + var early: Dictionary = host.plan_lodge_exit(true, true) + _expect_equal(str(early.get("code", "")), "needs_lodge", + "lodge: an exit before the seal still refuses") + _expect_false(Journey.armed, "lodge: the early exit arms nothing") + var held: Dictionary = host.plan_lodge_entry() + _expect_equal(str(held.get("code", "")), "entry_in_flight", + "lodge: the refused exit keeps the visit hold") + host.cancel_entry_plan() + await _free_parts(parts) + + +func _test_lodge_exit_ok_and_confirm() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", true), + "lodge: the departing handle caches") + _write_checkpoint({"cycle": 3, "journey_id": "lodge-j-1"}) + var visit: Dictionary = host.plan_lodge_entry() + _expect_equal(str(visit.get("code", "")), "lodge_complete", + "lodge: a completed account cannot plan a visit") + _clear_name_cache() + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", false), + "lodge: the exit test reopens the lesson bit") + visit = host.plan_lodge_entry() + _expect_equal(str(visit.get("status", "")), "ok", + "lodge: the exit visit plans") + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", true), + "lodge: the seal lands before departure") + var unconfirmed: Dictionary = host.plan_lodge_exit(true, false) + _expect_equal(str(unconfirmed.get("code", "")), "needs_confirmation", + "lodge: fresh over a save still asks first") + var held: Dictionary = host.plan_lodge_entry() + _expect_equal(str(held.get("code", "")), "entry_in_flight", + "lodge: the refused exit keeps the visit hold") + var resume: Dictionary = host.plan_lodge_exit(false, true) + _expect_equal(str(resume.get("status", "")), "ok", + "lodge: the sealed resume departs") + _expect_true(str(resume.get("arena", "")).ends_with("arena.tscn"), + "lodge: the departure names the arena") + _expect_true(host.confirm_entry_account(CANON_C), + "lodge: the departure confirms the same account") + _expect_true(Journey.armed, "lodge: the departure arms the journey") + _expect_equal(Journey.pending, Journey.Pending.RESUME, + "lodge: the resume pends") + host.cancel_entry_plan() + _clear_name_cache() + await _free_parts(parts) + + +func _test_lodge_exit_defeat_never_resumes() -> void: + var parts: Dictionary = await _adopt_guest_to_canonical() + var host: Node = parts["host"] + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", false), + "lodge: the defeated visit caches its handle") + _write_checkpoint({"cycle": 4, "journey_id": "lodge-defeat-j-1", + "ended": true}) + var visit: Dictionary = host.plan_lodge_entry() + _expect_equal(str(visit.get("status", "")), "ok", + "lodge: the defeated visit plans") + _expect_true(Vault.cache_verified_name(CANON_C, "Luna", "luna", true), + "lodge: the seal lands after the defeat") + var resume: Dictionary = host.plan_lodge_exit(false, true) + _expect_equal(str(resume.get("code", "")), "no_save", + "lodge: the sealed run never resumes from the lodge") + var fresh: Dictionary = host.plan_lodge_exit(true, false) + _expect_equal(str(fresh.get("status", "")), "ok", + "lodge: fresh over a sealed run needs no confirmation") + host.cancel_entry_plan() + _clear_name_cache() + await _free_parts(parts) + + func _test_double_start_refused() -> void: _wipe_all() var parts: Dictionary = _make_parts() @@ -2231,21 +4998,34 @@ func _expected_commit_documents(uid: String, public_id: String) -> Array: return [ "%s/mb_hall_v1/%s" % [root, public_id], "%s/mb_checkpoints_v1/%s" % [root, uid], + "%s/mb_attendance_v1/%s" % [root, public_id], "%s/mb_reservations_v1/%s" % [root, public_id], "%s/mb_profiles_v1/%s" % [root, uid], ] -## Asserts the wire holds the atomic four-row deletion commit: a single -## POST to `documents:commit` carrying exactly the four owned deletes with -## no preconditions. Sequential or incomplete shapes fail here. +## Queues the name-resolution lookup an unnamed account's deletion +## performs before its atomic commit: no adventurer row exists. +func _queue_unclaimed_adventurer(sender: RefCounted) -> void: + sender.queue_reply({"transport": "ok", "code": 404, "body": "{}"}) + + +## Asserts the wire holds one name lookup plus the atomic four-row +## deletion commit: a single POST to `documents:commit` carrying exactly +## the four owned deletes with no preconditions. Sequential or incomplete +## shapes fail here. func _expect_commit_shape(sender: RefCounted, uid: String, public_id: String, label: String) -> void: var calls: Array = sender.calls - _expect_equal(calls.size(), 1, label + ": one commit, never four deletes") - if calls.is_empty(): + _expect_equal(calls.size(), 2, + label + ": one lookup plus one commit, never five deletes") + if calls.size() < 2: return - var call: Dictionary = calls[0] + _expect_equal(str((calls[0] as Dictionary).get("method", "")), "GET", + label + ": deletion resolves the name first") + _expect_true(str((calls[0] as Dictionary).get("url", "")).contains( + "mb_adventurers_v1"), label + ": lookup reads the adventurer row") + var call: Dictionary = calls[1] _expect_equal(str(call.get("method", "")), "POST", label + ": deletion commits via POST") _expect_true(str(call.get("url", "")).contains("documents:commit"), @@ -2256,15 +5036,15 @@ func _expect_commit_shape(sender: RefCounted, uid: String, if typeof(parsed) != TYPE_DICTIONARY: return var writes: Array = (parsed as Dictionary).get("writes", []) - _expect_equal(writes.size(), 4, - label + ": commit carries all four deletes") + _expect_equal(writes.size(), 5, + label + ": commit carries all five deletes") var names: Array = [] for write in writes: names.append(str((write as Dictionary).get("delete", ""))) _expect_false((write as Dictionary).has("currentDocument"), label + ": no preconditions, missing rows are no-ops") _expect_equal(names, _expected_commit_documents(uid, public_id), - label + ": exact four owned documents in plan order") + label + ": exact five owned documents in plan order") _expect_no_delete_calls(sender, label) @@ -2303,10 +5083,19 @@ func _test_delete_pending_success_cleans_up() -> void: await _sign_in_cloud(parts, UID_A, CANON_C) _write_checkpoint({"cycle": 3, "journey_id": "gone-j-1"}) _write_decoy_slot() + Vault.continue_coins = 2 + Vault.save_vault() + _seal_and_journal(CANON_C, "gone-j-1", 4, 5) + Journey.use_account(OTHER_ID) + _seal_and_journal(OTHER_ID, "spared-j-1", 2, 3) + Journey.use_account(CANON_C) + _expect_equal(Vault.continue_coins, 0, + "delete: both scopes journal before deletion") var shards_before: int = Vault.shards var settled_before: Array = (Vault.settled_journeys as Array).duplicate() await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2332,8 +5121,10 @@ func _test_delete_pending_success_cleans_up() -> void: "delete: seam records the acknowledgement") var planned: Dictionary = HOST_SCRIPT.delete_commit_request( UID_A, CANON_C) - _expect_equal(str(planned.get("body", "")), - str(((sender.calls as Array)[0] as Dictionary).get("body", "")), + var wire_calls: Array = sender.calls as Array + _expect_equal(str(planned.get("body", "")), str( + (wire_calls[wire_calls.size() - 1] as Dictionary).get( + "body", "")), "delete: seam body matches the wire bytes") _expect_false(FileAccess.file_exists( Journey.account_main_path(CANON_C)), @@ -2346,6 +5137,18 @@ func _test_delete_pending_success_cleans_up() -> void: _expect_true(FileAccess.file_exists( Journey.account_main_path(OTHER_ID)), "delete: other account slot untouched") + Journey.use_account(OTHER_ID) + _expect_equal(int(Vault.scoped_continue_txn().get("checkpoint_id", 0)), + 3, "delete: the spared scope keeps its receipt") + _expect_equal(Vault.recover_paid_continue(), "recovered", + "delete: the spared receipt still settles") + _expect_equal(Vault.continue_coins, 0, + "delete: the spared settle charges nothing more") + Journey.use_account(CANON_C) + _expect_true(Vault.scoped_continue_txn().is_empty(), + "delete: the deleted scope keeps no receipt") + _expect_equal(Vault.continue_coins, 0, + "delete: two debits total, none refunded, none doubled") _expect_equal(Vault.shards, shards_before, "delete: vault shards untouched") _expect_equal(Vault.settled_journeys, settled_before, @@ -2366,6 +5169,7 @@ func _test_delete_commit_denied_stops() -> void: _write_checkpoint({"cycle": 2, "journey_id": "stay-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_reply({"transport": "ok", "code": 403, "body": "{}"}) var result: Dictionary = await host.delete_current_account() _expect_equal(str(result.get("status", "")), "failure", @@ -2375,8 +5179,8 @@ func _test_delete_commit_denied_stops() -> void: _expect_equal(str(result.get("failed_step", "")), "commit", "delete-fail: names the refused atomic commit") _expect_equal(result.get("remaining", []), - ["hall", "checkpoint", "reservation", "profile"], - "delete-fail: all four steps remain for a clean retry") + ["hall", "checkpoint", "attendance", "reservation", "profile"], + "delete-fail: all five steps remain for a clean retry") _expect_commit_shape(sender, UID_A, CANON_C, "delete-fail") _expect_false((fake.calls as Array).has("delete_account"), "delete-fail: native never called after a cloud refusal") @@ -2392,6 +5196,7 @@ func _test_delete_commit_denied_stops() -> void: "delete-fail: seam records the denial") # A truthful retry succeeds once the commit acknowledges. sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "ok"} var retry: Dictionary = await host.delete_current_account() @@ -2435,6 +5240,7 @@ func _test_delete_commit_timeout_preserves() -> void: "delete-timeout-commit: seam records the timeout") # The retry repeats the identical four-row shape and finishes. sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "ok"} var retry: Dictionary = await host.delete_current_account() @@ -2455,6 +5261,7 @@ func _test_delete_native_sync_error_preserves() -> void: _write_checkpoint({"cycle": 2, "journey_id": "sync-err-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "error", "code": "native_misfire", "retryable": true} @@ -2473,6 +5280,7 @@ func _test_delete_native_sync_error_preserves() -> void: # rows (missing rows are server no-ops) and finishes. var first_documents: Array = _commit_documents(sender) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "ok"} var retry: Dictionary = await host.delete_current_account() @@ -2494,6 +5302,7 @@ func _test_delete_native_sync_cancel_preserves() -> void: _write_checkpoint({"cycle": 1, "journey_id": "sync-cancel-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) # The native sheet dismissed before anything began: a sync # cancellation after the acknowledged commit. @@ -2521,6 +5330,7 @@ func _test_delete_duplicate_taps_single_commit() -> void: _write_checkpoint({"cycle": 1, "journey_id": "double-tap-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2528,7 +5338,7 @@ func _test_delete_duplicate_taps_single_commit() -> void: var second: Dictionary = await host.delete_current_account() _expect_equal(str(second.get("code", "")), "deletion_in_flight", "delete-taps: second tap retired while the first owns the op") - _expect_equal((sender.calls as Array).size(), 1, + _expect_equal((sender.calls as Array).size(), 2, "delete-taps: duplicate tap sends no second commit") fake.complete_session(_pending_request_id(fake), {"kind": "local_guest"}) @@ -2554,6 +5364,7 @@ func _test_delete_native_error_preserves_and_retries() -> void: _write_checkpoint({"cycle": 2, "journey_id": "reauth-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2584,6 +5395,7 @@ func _test_delete_native_error_preserves_and_retries() -> void: host.begin_guest() await _frames(5) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "ok"} var retry: Dictionary = await host.delete_current_account() @@ -2605,6 +5417,7 @@ func _test_delete_cancel_preserves() -> void: _write_checkpoint({"cycle": 1, "journey_id": "cancel-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2632,6 +5445,7 @@ func _test_delete_draining_then_success() -> void: await _sign_in_cloud(parts, UID_A, CANON_C) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} fake.cancel_status = "draining" @@ -2662,6 +5476,7 @@ func _test_delete_rejects_switch_and_start() -> void: _write_checkpoint({"cycle": 1, "journey_id": "locked-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2707,6 +5522,7 @@ func _test_delete_account_move_aborts() -> void: await _await_ready(host) await _frames(30) delayed.reset() + _queue_unclaimed_adventurer(delayed) delayed.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2720,11 +5536,11 @@ func _test_delete_account_move_aborts() -> void: _expect_equal((delayed.calls as Array).size(), 1, "delete-move: moved account sends nothing more") var only_call: Dictionary = (delayed.calls as Array)[0] - _expect_equal(str(only_call.get("method", "")), "POST", - "delete-move: the single in-flight call is the commit") + _expect_equal(str(only_call.get("method", "")), "GET", + "delete-move: the single in-flight call is the lookup") _expect_true(str(only_call.get("url", "")).contains( - "documents:commit"), - "delete-move: in-flight call hits the commit endpoint") + "mb_adventurers_v1"), + "delete-move: in-flight call reads the adventurer row") _expect_false((fake.calls as Array).has("delete_account"), "delete-move: native never runs for a moved account") _expect_true(FileAccess.file_exists( @@ -2747,6 +5563,7 @@ func _test_delete_timeout_preserves_and_late_completion_safe() -> void: _write_checkpoint({"cycle": 1, "journey_id": "timeout-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var started_msec: int = Time.get_ticks_msec() @@ -2788,6 +5605,7 @@ func _test_delete_frames_before_deadline_harmless() -> void: await _sign_in_cloud(parts, UID_A, CANON_C) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2816,6 +5634,7 @@ func _test_delete_success_just_before_deadline() -> void: _write_checkpoint({"cycle": 2, "journey_id": "near-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -2847,6 +5666,7 @@ func _test_delete_late_terminal_safe_for_next_run() -> void: _write_checkpoint({"cycle": 1, "journey_id": "retry-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var first_box: Dictionary = _launch_delete(host) @@ -2869,6 +5689,7 @@ func _test_delete_late_terminal_safe_for_next_run() -> void: # A truthful retry starts a new generation with its own native wait. # Its commit repeats the acknowledged one over already absent rows. var first_documents: Array = _commit_documents(sender) + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) var box: Dictionary = _launch_delete(host) await _frames(5) @@ -2891,8 +5712,8 @@ func _test_delete_late_terminal_safe_for_next_run() -> void: "delete-retry: genuine terminal finishes the retry") _expect_equal((fake.calls as Array).count("delete_account"), 2, "delete-retry: exactly two native attempts ran") - _expect_equal((sender.calls as Array).size(), 2, - "delete-retry: one commit per run, nothing more") + _expect_equal((sender.calls as Array).size(), 4, + "delete-retry: one lookup plus one commit per run, nothing more") _expect_equal(_commit_documents(sender), first_documents, "delete-retry: retry repeats the identical four deletes") await _free_parts(parts) @@ -2910,6 +5731,7 @@ func _test_delete_expired_run_deletes_no_other_account() -> void: _write_checkpoint({"cycle": 1, "journey_id": "other-j-1"}) await _frames(20) sender.reset() + _queue_unclaimed_adventurer(sender) sender.queue_ok(_commit_ack_body()) fake.delete_receipt = {"status": "pending"} var box: Dictionary = _launch_delete(host) @@ -3500,6 +6322,8 @@ func _test_restore_blocks_fresh_until_remote_read() -> void: host.begin_guest() var pending: bool = await _await_restore_pending(host) _expect_true(pending, "restore-block: empty slot owns the check") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var refused: Dictionary = host.plan_entry(true, false) _expect_equal(str(refused.get("code", "")), "cloud_restore_pending", "restore-block: fresh refused while checking") @@ -3519,6 +6343,7 @@ func _test_restore_blocks_fresh_until_remote_read() -> void: _expect_equal(Journey.pending, Journey.Pending.RESUME, "restore-block: resume pends") await _await_sender_idle(routed) + _clear_name_cache() await _free_parts(parts) @@ -3635,6 +6460,8 @@ func _test_restore_not_found_unlocks_fresh() -> void: "restore-404: authoritative empty is not a failure") _expect_true((_fired["error"] as Array).is_empty(), "restore-404: no error on clean empty") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-404: fresh unlocks after not-found") @@ -3649,6 +6476,7 @@ func _test_restore_not_found_unlocks_fresh() -> void: _expect_false((gate.get_node("Content/StatusCard/Ready/StartRow/Resume" ) as Button).visible, "restore-404: no Continue without a save") entry.queue_free() + _clear_name_cache() await _free_parts(parts) @@ -3773,6 +6601,8 @@ func _test_restore_explicit_offline() -> void: ) as Label).text, GateEntryStrings.text("gate.save.offline_unknown"), "restore-decide: card says the save is unchecked") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-decide: fresh unlocks after the decision") @@ -3788,6 +6618,7 @@ func _test_restore_explicit_offline() -> void: _expect_false(bool(clean.get("restore_failed", true)), "restore-decide: no failure after clean resolve") entry.queue_free() + _clear_name_cache() await _free_parts(parts) @@ -3878,10 +6709,13 @@ func _test_restore_switch_retires_late_result() -> void: "restore-switch: A's late valid bytes install nowhere") _expect_true(Journey.read_checkpoint().is_empty(), "restore-switch: B slot honestly empty after not-found") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_D, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-switch: B plans fresh after its own resolve") await _await_sender_idle(routed) + _clear_name_cache() await _free_parts(parts) @@ -3907,10 +6741,13 @@ func _test_restore_duplicate_ready_restarts_nothing() -> void: await _frames(10) _expect_equal(_checkpoint_calls(sender).size(), pulls, "restore-dupe: resolved duplicate sends no pull") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-dupe: entry stays unlocked after duplicate") host.cancel_entry_plan() + _clear_name_cache() await _free_parts(parts) # While failed, a duplicate neither re-checks nor clears the failure. _wipe_all() @@ -3992,6 +6829,8 @@ func _test_restore_local_resume_needs_no_cloud() -> void: codes.append(str((err as Dictionary).get("code", ""))) _expect_false(codes.has("save_check_failed"), "restore-local: the check never ran over a local save") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(false, true) _expect_equal(str(planned.get("status", "")), "ok", "restore-local: resume plans at once") @@ -3999,6 +6838,7 @@ func _test_restore_local_resume_needs_no_cloud() -> void: "restore-local: resume pends") _expect_equal(int((host.saved_gate_summary() as Dictionary).get( "cycle", 0)), 4, "restore-local: local cycle intact") + _clear_name_cache() await _free_parts(parts) # A local-only guest never checks at all, even fully offline. _wipe_all() @@ -4054,6 +6894,8 @@ func _test_restore_timeout_is_bounded() -> void: "restore-timeout: elapsed expiry fails honestly") _expect_equal(str(failed.get("restore_code", "")), "restore_timeout", "restore-timeout: reason names the timeout") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var refused: Dictionary = host.plan_entry(true, false) _expect_equal(str(refused.get("code", "")), "cloud_restore_unresolved", "restore-timeout: fresh refused while unresolved") @@ -4089,6 +6931,7 @@ func _test_restore_timeout_is_bounded() -> void: var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-timeout: fresh plans after the new resolve") + _clear_name_cache() await _free_parts(parts) @@ -4138,11 +6981,14 @@ func _test_restore_claim_identity_never_reused() -> void: hung.release_checkpoint_call(2, {"transport": "ok", "code": 404, "body": "{}"}) await _settle_restore(host) + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-claim: newer resolve unlocks fresh") _expect_equal(_checkpoint_calls(hung).size(), 2, "restore-claim: one pull per attempt") + _clear_name_cache() await _free_parts(parts) @@ -4275,12 +7121,15 @@ func _test_restore_relogin_starts_new_generation() -> void: hung.release_checkpoint_call(2, {"transport": "ok", "code": 404, "body": "{}"}) await _settle_restore(host) + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-relogin: newer resolve unlocks fresh") _expect_true((gate.get_node("Content/StatusCard/Ready") as Control ).visible, "restore-relogin: newer resolve shows the card") entry.queue_free() + _clear_name_cache() await _free_parts(parts) @@ -4329,6 +7178,8 @@ func _test_restore_timeout_offline_before_orphan() -> void: "restore-timed-offline: offline decision recorded") _expect_true((gate.get_node("Content/StatusCard/Ready") as Control ).visible, "restore-timed-offline: offline shows the card") + # Settled handle: restore tests judge the restore gate, not the lodge. + Vault.cache_verified_name(CANON_C, "Luna", "luna", true) var planned: Dictionary = host.plan_entry(true, false) _expect_equal(str(planned.get("status", "")), "ok", "restore-timed-offline: fresh plans while held") @@ -4350,6 +7201,7 @@ func _test_restore_timeout_offline_before_orphan() -> void: _expect_equal(_checkpoint_calls(hung).size(), 1, "restore-timed-offline: only the retired pull was sent") entry.queue_free() + _clear_name_cache() await _free_parts(parts) @@ -4448,6 +7300,62 @@ func _write_checkpoint(extra: Dictionary) -> void: _expect_equal(error, OK, "helper: checkpoint writes") +## Checkpoint id of one owner's journaled entry, flat or parked, or 0 +## when the owner holds nothing. Reads without switching the scope. +func _txn_owner_cid(owner: String) -> int: + if not (Vault.continue_txn as Dictionary).is_empty() \ + and str((Vault.continue_txn as Dictionary).get("owner", "")) \ + == owner: + return int((Vault.continue_txn as Dictionary).get( + "checkpoint_id", 0)) + var parked: Variant = (Vault.get("continue_txn_parked") as Dictionary + ).get(owner) + if parked is Dictionary: + return int((parked as Dictionary).get("checkpoint_id", 0)) + return 0 + + +## Seal a defeat and journal its paid revive in one scope, through the +## real API. The caller owns the active account before and after. +func _seal_and_journal(owner: String, journey: String, seal_id: int, + revive_id: int) -> void: + Journey.use_account(owner) + var alive: Dictionary = _valid_checkpoint(journey, seal_id, 0) + _expect_equal(Journey.write_checkpoint(alive), OK, + "helper: the alive seal writes") + var sealed: Dictionary = alive.duplicate(true) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "helper: the defeat seals") + var revive: Dictionary = _valid_checkpoint(journey, revive_id, 0) + _expect_true(Vault.begin_continue_txn(journey, revive_id, + JSON.stringify(revive)), "helper: the debit journals") + + +## Seal a legacy defeat's file pair without journaling. Split from the +## debit because the coordinator swap inside `_make_parts` settles any +## journal already pending in the active scope; legacy tests plant the +## files first, build parts, then journal just before the startup under +## test. +func _write_legacy_pair(journey: String, seal_id: int) -> void: + Journey.use_account("") + var alive: Dictionary = _valid_checkpoint(journey, seal_id, 0) + _expect_equal(Journey.write_checkpoint(alive), OK, + "helper: the legacy seal writes") + var sealed: Dictionary = alive.duplicate(true) + sealed["ended"] = true + _expect_equal(Journey.write_checkpoint(sealed), OK, + "helper: the legacy defeat seals") + + +## Journal one paid revive in the currently active scope, through the +## real API. The caller funds the coin first. +func _journal_revive(journey: String, revive_id: int) -> void: + var revive: Dictionary = _valid_checkpoint(journey, revive_id, 0) + _expect_true(Vault.begin_continue_txn(journey, revive_id, + JSON.stringify(revive)), "helper: the debit journals") + + func _valid_checkpoint(journey: Variant, checkpoint_id: int, shards: int, extra: Dictionary = {}) -> Dictionary: var checkpoint: Dictionary = { @@ -4510,14 +7418,17 @@ func _remote_body(uid: String, revision: int, payload: String, func _board_body(rows: Array) -> String: var entries: Array = [] for row in rows: - entries.append({"document": {"fields": { + var fields: Dictionary = { "public_id": {"stringValue": str((row as Array)[0])}, "hero": {"stringValue": str((row as Array)[1])}, "score": {"integerValue": str(int((row as Array)[2]))}, "cycles": {"integerValue": "3"}, "release": {"stringValue": "4.0.0"}, "updated_at": {"timestampValue": "2026-10-01T00:00:00Z"}, - }}}) + } + if (row as Array).size() > 3: + fields["display"] = {"stringValue": str((row as Array)[3])} + entries.append({"document": {"fields": fields}}) return JSON.stringify(entries) diff --git a/apps/game/tests/test_result_layout.gd b/apps/game/tests/test_result_layout.gd index 2e0d338..7fa1545 100644 --- a/apps/game/tests/test_result_layout.gd +++ b/apps/game/tests/test_result_layout.gd @@ -160,7 +160,7 @@ func _check_hero_plate() -> void: score.cycles = 8 var panel: Control = RESULT_SCENE.instantiate() as Control add_child(panel) - panel.show_result(true, score, false, false, 3, false, + panel.show_result(true, score, false, false, 3, "res://resources/heroes/keeper.tres") await get_tree().process_frame var body := panel.get_node("HeroBody") as TextureRect diff --git a/apps/game/tests/test_result_route.gd b/apps/game/tests/test_result_route.gd index 5cffdd8..4deac4d 100644 --- a/apps/game/tests/test_result_route.gd +++ b/apps/game/tests/test_result_route.gd @@ -26,6 +26,7 @@ func _ready() -> void: _test_board_then_shrine() _test_board_then_restart() _test_record_button() + _test_named_record_button() _test_without_board() await _test_store_handoff_ends_current_run() await _test_title_handoff() @@ -259,6 +260,42 @@ func _test_record_button() -> void: _free_route_parts(parts) +func _test_named_record_button() -> void: + var parts: Dictionary = _route_parts(32100) + var arena: Node = parts["arena"] + var result: Control = parts["result"] + var ladder: Control = parts["ladder"] + var account: String = "MB-route-named-00000000000000001" + Journey.use_account(account) + Vault.verified_names = {} + Vault.cache_verified_name(account, "Luna", "luna", true) + result.visible = true + arena.request_record() + _expect_equal(ladder.ask_named_count, 1, + "named record routes to the verified handle without a re-ask") + _expect_equal(ladder.last_handle, "Luna", + "named record carries the account handle") + _expect_equal(ladder.ask_count, 0, + "named record never opens the typed name") + arena.complete_ladder() + # An account switch mid-result re-reads the new scope: the other + # account has no cached handle, so it gets the classic typed name. + var stranger: String = "MB-route-stranger-0000000000002" + Journey.use_account(stranger) + result.visible = true + arena.request_record() + _expect_equal(ladder.ask_count, 1, + "switched account falls back to the typed name") + _expect_equal(ladder.ask_named_count, 1, + "switched account issues no second named ask") + _expect_equal(str((Vault.verified_name_for_account(account) + as Dictionary).get("display", "")), "Luna", + "the switch never moves the other handle") + Vault.verified_names = {} + Journey.use_account("") + _free_route_parts(parts) + + func _test_without_board() -> void: Ladder.entries.clear() for i in Ladder.KEEP: diff --git a/apps/game/tests/test_vault.gd b/apps/game/tests/test_vault.gd index 4fc2ba9..d179953 100644 --- a/apps/game/tests/test_vault.gd +++ b/apps/game/tests/test_vault.gd @@ -44,6 +44,13 @@ func _init() -> void: _test_hero_purchase() _test_hero_source_grant_and_revoke() _test_continue_coin_grant_atomicity() + _test_continue_txn_atomicity() + _test_prepare_receipt_move() + _test_lodge_progress() + _test_verified_name_cache() + _test_verified_name_intro_monotone() + _test_attendance_wallet() + _test_attendance_cross_owner_prune() _test_score_settlement() _test_versioned_primary_beats_backup() _test_backup_recovery() @@ -63,6 +70,10 @@ func _new_vault() -> Node: return VAULT_SCRIPT.new() as Node +func _orphan_node_count() -> int: + return int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)) + + func _test_hero_visual_assets() -> void: for path in [WARDEN, DANCER, KEEPER]: var hero: Hero = load(path) as Hero @@ -179,6 +190,544 @@ func _test_boon_purchase() -> void: vault.free() +## A paid continue journals its debit and exact revive seal in one atomic +## save: the balance and the journal land together, a retry of the same +## attempt charges nothing more, a failed save changes neither, and a new +## journey clears the moot journal (restoring it when its own save fails). +func _test_continue_txn_atomicity() -> void: + var orphans_before: int = _orphan_node_count() + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + vault.continue_coins = 2 + _expect_true(vault.begin_continue_txn(7, 3, "{\"seal\":true}"), + "begin journals the debit and its seal") + _expect_equal(vault.continue_coins, 1, "begin debits exactly one coin") + _expect_equal(int(vault.continue_txn.get("checkpoint_id", 0)), 3, + "begin names the revive seal") + vault.continue_coins = 0 + _expect_true(vault.begin_continue_txn(7, 3, "{\"seal\":true}"), + "a retry reuses the journaled attempt") + _expect_equal(vault.continue_coins, 0, + "a retry charges nothing more") + _expect_false(vault.begin_continue_txn(7, 4, "{\"seal\":true}"), + "a different attempt needs its own coin") + _expect_false(vault.begin_continue_txn(0, 4, "{\"seal\":true}"), + "a bad journey id is refused") + _expect_false(vault.begin_continue_txn(7, -1, "{\"seal\":true}"), + "a bad seal id is refused") + _expect_false(vault.begin_continue_txn(7, 4, ""), + "an empty seal is refused") + + # The debit and the journal persist across a reload together. + _remove_save_target() + var first: Node = _new_vault() + first.load_vault() + first.continue_coins = 2 + _expect_true(first.begin_continue_txn("rABC", 5, "{\"seal\":5}"), + "a string journey begins") + var second: Node = _new_vault() + second.load_vault() + _expect_equal(second.continue_coins, 1, "a reload keeps the debit") + _expect_equal(str(second.continue_txn.get("journey_id", "")), "rABC", + "a reload keeps the journal journey") + _expect_equal(int(second.continue_txn.get("checkpoint_id", 0)), 5, + "a reload keeps the journal seal id") + _expect_equal(str(second.continue_txn.get("seal", "")), "{\"seal\":5}", + "a reload keeps the journal seal") + _expect_true(second.ack_continue_txn(), "ack clears the journal") + _expect_true((second.continue_txn as Dictionary).is_empty(), + "the journal stays cleared in memory") + var third: Node = _new_vault() + third.load_vault() + _expect_true((third.continue_txn as Dictionary).is_empty(), + "a reload keeps the ack") + _expect_true(third.ack_continue_txn(), "acking nothing acks true") + + # A malformed journal drops on load instead of forging a revive. + for bad_txn in [{"journey_id": "", "checkpoint_id": 5, "seal": "x"}, + {"journey_id": 7, "checkpoint_id": "5", "seal": "x"}, + {"journey_id": 7, "checkpoint_id": 5, "seal": 9}]: + _remove_save_target() + var seed: ConfigFile = ConfigFile.new() + seed.set_value("vault", "schema_version", 5) + seed.set_value("vault", "continue_txn", bad_txn) + seed.save(_save_absolute) + var loaded: Node = _new_vault() + loaded.load_vault() + _expect_true((loaded.continue_txn as Dictionary).is_empty(), + "a malformed journal drops on load: %s" % str(bad_txn)) + loaded.free() + + # A failing save reverts the debit and the journal together, and a + # failing ack keeps the journal for the next recovery. + _remove_save_target() + var failing: Node = FAILING_VAULT_SCRIPT.new() as Node + failing.load_vault() + _expect_false(failing.begin_continue_txn(9, 1, "{\"s\":1}"), + "begin fails when the save fails") + _expect_equal(failing.continue_coins, 2, + "a failed begin keeps the balance") + _expect_true((failing.continue_txn as Dictionary).is_empty(), + "a failed begin journals nothing") + failing.continue_txn = { + "journey_id": 9, "checkpoint_id": 1, "seal": "{\"s\":1}"} + _expect_false(failing.ack_continue_txn(), + "ack fails when the save fails") + _expect_false((failing.continue_txn as Dictionary).is_empty(), + "a failed ack keeps the journal") + + # A new journey clears the moot journal — unless its own save fails, + # which restores it like the sequence. + _remove_save_target() + var fresh: Node = _new_vault() + fresh.load_vault() + fresh.continue_coins = 2 + _expect_true(fresh.begin_continue_txn(11, 2, "{\"s\":2}"), + "the old journey journals first") + _expect_true(fresh.begin_journey() is int, "the new journey issues") + _expect_true((fresh.continue_txn as Dictionary).is_empty(), + "a new journey clears the journal") + var failing_fresh: Node = FAILING_VAULT_SCRIPT.new() as Node + failing_fresh.load_vault() + failing_fresh.continue_txn = { + "journey_id": 11, "checkpoint_id": 2, "seal": "{\"s\":2}"} + _expect_true(failing_fresh.begin_journey() is String, + "a failed issue falls back without saving") + _expect_false((failing_fresh.continue_txn as Dictionary).is_empty(), + "a failed issue restores the journal") + vault.free() + first.free() + second.free() + third.free() + failing.free() + fresh.free() + failing_fresh.free() + _expect_equal(_orphan_node_count(), orphans_before, + "continue txn frees every temporary vault") + + +## Moving a receipt ahead of its slot's files reports one honest outcome: +## `ready` (nothing pending, or the rekey landed), `kept` (the target +## holds its own receipt or journey — the source stays), or `failed` (the +## rekey could not save — the caller must not move files). +func _test_prepare_receipt_move() -> void: + var orphans_before: int = _orphan_node_count() + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + _expect_equal(str(vault.call("prepare_receipt_move", "", "MB-x")), + "ready", "prepare: nothing pending is ready") + vault.continue_coins = 2 + _expect_true(vault.begin_continue_txn(7, 3, "{\"seal\":true}"), + "prepare: the debit journals") + # Instance vaults journal under the global legacy scope here. + _expect_equal(str(vault.call("prepare_receipt_move", "", "MB-x")), + "ready", "prepare: the rekey onto an empty slot lands") + _expect_equal(str(vault.continue_txn.get("owner", "")), "MB-x", + "prepare: the entry now owns the target") + _expect_equal(vault.continue_coins, 1, + "prepare: the rekey charges nothing") + var reloaded: Node = _new_vault() + reloaded.load_vault() + _expect_equal(str(reloaded.continue_txn.get("owner", "")), "MB-x", + "prepare: the rekey survives reload") + + # A target holding its own receipt keeps both entries in place. + reloaded.continue_txn_parked = {"MB-x": { + "owner": "MB-x", "journey_id": 7, "checkpoint_id": 3, + "seal": "{\"seal\":true}"}} + reloaded.continue_txn = {"owner": "", "journey_id": 9, + "checkpoint_id": 1, "seal": "{\"s\":1}"} + _expect_equal(str(reloaded.call("prepare_receipt_move", "", "MB-x")), + "kept", "prepare: an owned target keeps the source") + _expect_equal(str(reloaded.continue_txn.get("owner", "")), "", + "prepare: the kept entry stays put") + + # A target holding a journey but no receipt is kept too: settling + # against a foreign journey would stale-clear the receipt. + reloaded.continue_txn_parked = {} + var occupant: String = ProjectSettings.globalize_path( + Journey.account_main_path("MB-y")) + var writer: FileAccess = FileAccess.open( + Journey.account_main_path("MB-y"), FileAccess.WRITE) + writer.store_string("{\"slot\":true}") + writer.close() + _expect_equal(str(reloaded.call("prepare_receipt_move", "", "MB-y")), + "kept", "prepare: a journey-occupied target keeps the source") + DirAccess.remove_absolute(occupant) + _expect_equal(str(reloaded.call("prepare_receipt_move", "", "MB-y")), + "ready", "prepare: the freed slot adopts afterwards") + + # A target holding its own receipt or journey is occupied even when + # the source carries no receipt: importing unrelated source bytes + # over it would stale-clear the target's paid recovery. + reloaded.continue_txn = {} + reloaded.continue_txn_parked = {"MB-x": { + "owner": "MB-x", "journey_id": 7, "checkpoint_id": 3, + "seal": "{\"seal\":true}"}} + _expect_equal(str(reloaded.call( + "prepare_receipt_move", "MB-s", "MB-x")), "kept", + "prepare: a receipt-held target keeps a receiptless source") + reloaded.continue_txn_parked = {} + writer = FileAccess.open( + Journey.account_main_path("MB-y"), FileAccess.WRITE) + writer.store_string("{\"slot\":true}") + writer.close() + _expect_equal(str(reloaded.call( + "prepare_receipt_move", "MB-s", "MB-y")), "kept", + "prepare: a journey-held target keeps a receiptless source") + DirAccess.remove_absolute(occupant) + _expect_equal(str(reloaded.call( + "prepare_receipt_move", "MB-s", "MB-y")), "ready", + "prepare: the freed target takes a receiptless source") + + # A rekey that cannot save fails loudly and restores the entry. + _remove_save_target() + var failing: Node = FAILING_VAULT_SCRIPT.new() as Node + failing.load_vault() + failing.continue_txn = {"owner": "", "journey_id": 9, + "checkpoint_id": 1, "seal": "{\"s\":1}"} + _expect_equal(str(failing.call("prepare_receipt_move", "", "MB-z")), + "failed", "prepare: an unsavable rekey fails") + _expect_equal(str(failing.continue_txn.get("owner", "")), "", + "prepare: the failed rekey restores the owner") + vault.free() + reloaded.free() + failing.free() + _expect_equal(_orphan_node_count(), orphans_before, + "prepare frees every temporary vault") + + +func _test_lodge_progress() -> void: + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + _expect_equal(vault.call("lodge_progress_for_account", "MB-1"), + {"move": false, "dash": false}, + "lodge: gates start unlearned") + _expect_false(bool(vault.call("mark_lodge_gate", "MB-1", "swim")), + "lodge: unknown gates refuse without a save") + _expect_false(bool(vault.call("mark_lodge_gate", "", "move")), + "lodge: an empty scope refuses") + _expect_true(bool(vault.call("mark_lodge_gate", "MB-1", "move")), + "lodge: the move gate marks") + _expect_true(bool(vault.call("mark_lodge_gate", "MB-1", "move")), + "lodge: re-practice stays true") + _expect_equal(vault.call("lodge_progress_for_account", "MB-1"), + {"move": true, "dash": false}, + "lodge: the practiced gate reads back") + var reloaded: Node = _new_vault() + reloaded.load_vault() + _expect_equal(reloaded.call("lodge_progress_for_account", "MB-1"), + {"move": true, "dash": false}, + "lodge: the gate survives reload") + _expect_true(bool(vault.call("mark_lodge_gate", "MB-1", "dash")), + "lodge: the dash gate marks") + for index in 10: + _expect_true(bool(vault.call("mark_lodge_gate", + "MB-g%d" % index, "move")), + "lodge: scope %d marks" % index) + var scoped: Dictionary = vault.get("lodge_progress") as Dictionary + _expect_equal(scoped.size(), 8, "lodge: the scopes stay bounded") + _expect_false(scoped.has("MB-1"), "lodge: the oldest scope evicts") + _expect_false(scoped.has("MB-g0"), "lodge: eviction runs oldest-first") + _expect_true(scoped.has("MB-g2"), "lodge: newer scopes survive") + _expect_true(scoped.has("MB-g9"), "lodge: the newest scope survives") + _expect_true(bool(vault.call("clear_lodge_for_owner", "MB-g9")), + "lodge: one scope clears") + _expect_equal(vault.call("lodge_progress_for_account", "MB-g9"), + {"move": false, "dash": false}, + "lodge: the cleared scope reads unlearned") + _expect_true((vault.get("lodge_progress") as Dictionary).has("MB-g8"), + "lodge: the clear keeps every other scope") + vault.set("lodge_progress", {"MB-x": "garbage", "": {"move": true}}) + vault.save_vault() + var scrubbed: Node = _new_vault() + scrubbed.load_vault() + _expect_false((scrubbed.get("lodge_progress") as Dictionary).has( + "MB-x"), "lodge: a malformed entry drops on load") + vault.free() + reloaded.free() + scrubbed.free() + _remove_save_target() + + +func _test_verified_name_cache() -> void: + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + _expect_true((vault.call("verified_name_for_account", "MB-x") + as Dictionary).is_empty(), "cache: unknown account reads empty") + _expect_true(bool(vault.call("cache_verified_name", "MB-x", + "Luna", "luna", false)), "cache: the verified handle stores") + _expect_equal(str((vault.call("verified_name_for_account", "MB-x") + as Dictionary).get("display", "")), "Luna", + "cache: the owning scope reads its handle") + _expect_true((vault.call("verified_name_for_account", "MB-y") + as Dictionary).is_empty(), + "cache: separate accounts never share") + _expect_false(bool(vault.call("cache_verified_name", "MB-y", + "Luna", "bob", false)), + "cache: a mismatched key refuses") + _expect_false(bool(vault.call("cache_verified_name", "MB-y", + "x", "x", false)), "cache: a short display refuses") + _expect_true(bool(vault.call("cache_verified_name", "MB-x", + "Luna", "luna", true)), + "cache: the intro flip re-stores") + _expect_true(bool((vault.call("verified_name_for_account", "MB-x") + as Dictionary).get("intro_complete", false)), + "cache: the intro flip reads back") + for index in 10: + _expect_true(bool(vault.call("cache_verified_name", + "MB-fill-%d" % index, "Name%d" % index, + "name%d" % index, false)), + "cache: fill entry stores") + _expect_true(int((vault.get("verified_names") as Dictionary).size()) + <= 8, "cache: the bound holds") + _expect_true((vault.call("verified_name_for_account", "MB-x") + as Dictionary).is_empty(), + "cache: the oldest entry evicts first") + var reloaded: Node = _new_vault() + reloaded.load_vault() + _expect_equal(str((reloaded.call("verified_name_for_account", + "MB-fill-9") as Dictionary).get("display", "")), "Name9", + "cache: handles survive reload") + _expect_true(bool(reloaded.call( + "clear_verified_name_for_owner", "MB-fill-9")), + "cache: the owner clears") + _expect_true((reloaded.call("verified_name_for_account", + "MB-fill-9") as Dictionary).is_empty(), + "cache: the cleared owner reads empty") + _expect_equal(str((reloaded.call("verified_name_for_account", + "MB-fill-8") as Dictionary).get("display", "")), "Name8", + "cache: clearing drops only the deleted scope") + reloaded.free() + vault.free() + + +func _test_verified_name_intro_monotone() -> void: + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + _expect_true(bool(vault.call("cache_verified_name", "MB-m", + "Luna", "luna", true)), "monotone: the completion stores") + _expect_true(bool(vault.call("cache_verified_name", "MB-m", + "Luna", "luna", false)), + "monotone: a late incomplete same-name write still lands") + _expect_true(bool((vault.call("verified_name_for_account", "MB-m") + as Dictionary).get("intro_complete", false)), + "monotone: the stored completion never regresses") + _expect_true(bool(vault.call("cache_verified_name", "MB-n", + "Luna", "luna", false)), + "monotone: an unrelated account starts incomplete") + _expect_false(bool((vault.call("verified_name_for_account", "MB-n") + as Dictionary).get("intro_complete", true)), + "monotone: the new scope reads incomplete") + var reloaded: Node = _new_vault() + reloaded.load_vault() + _expect_true(bool((reloaded.call("verified_name_for_account", "MB-m") + as Dictionary).get("intro_complete", false)), + "monotone: the completion survives reload") + reloaded.free() + vault.free() + + +func _test_attendance_wallet() -> void: + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + var install: String = str(vault.call("ensure_install_id")) + _expect_equal(install.length(), 32, "attend: the install id is 32 hex") + _expect_equal(str(vault.call("ensure_install_id")), install, + "attend: the install id is stable") + var before: int = int(vault.continue_coins) + var first: Dictionary = vault.call("grant_attendance_coins", + "MB-m", 1000000, install) + _expect_equal(str(first.get("status", "")), "ok", + "attend: the first grant lands") + _expect_true(bool(first.get("granted", false)), + "attend: the first grant is new") + _expect_equal(int(vault.continue_coins), before + 2, + "attend: the grant adds exactly two") + var key: String = str(first.get("key", "")) + _expect_true(key.begins_with("attendance:MB-m:1000000:"), + "attend: the receipt key derives from row plus install") + var again: Dictionary = vault.call("grant_attendance_coins", + "MB-m", 1000000, install) + _expect_true(bool(again.get("duplicate", false)), + "attend: the same key grants idempotently") + _expect_equal(int(vault.continue_coins), before + 2, + "attend: the duplicate adds nothing") + var older: Dictionary = vault.call("grant_attendance_coins", + "MB-m", 999000, install) + _expect_true(bool(older.get("duplicate", false)), + "attend: an older key hits the watermark") + _expect_equal(int(vault.continue_coins), before + 2, + "attend: the watermark adds nothing") + var sibling: Dictionary = vault.call("grant_attendance_coins", + "MB-n", 1000000, install) + _expect_true(bool(sibling.get("granted", false)), + "attend: another account grants on its own mark") + _expect_true(bool(vault.call("cache_attendance_next", "MB-m", + "2026-10-08T02:00:00Z", 3600, 1000000)), + "attend: the deadline caches") + _expect_equal(str((vault.call("attendance_next_for_account", "MB-m") + as Dictionary).get("next_utc", "")), "2026-10-08T02:00:00Z", + "attend: the cached deadline reads back") + var reloaded: Node = _new_vault() + reloaded.load_vault() + _expect_equal(str(reloaded.call("ensure_install_id")), install, + "attend: the install id survives reload") + _expect_equal(int(reloaded.continue_coins), before + 4, + "attend: the balances survive reload") + _expect_true((reloaded.get("continue_coin_grants") + as Dictionary).has(key), + "attend: the receipt survives with its coins") + _expect_true(bool((reloaded.call("grant_attendance_coins", + "MB-m", 999000, install) as Dictionary).get("duplicate", false)), + "attend: the watermark survives reload") + reloaded.free() + + # Bounded replay: purchases never evict, pruned receipts never regrant. + _expect_true(bool(vault.call("grant_continue_coins", 5, + "store-order-1")), "attend: the purchased key lands") + for index in 12: + var stamp: int = 2000000 + index * 43200 + var step: Dictionary = vault.call("grant_attendance_coins", + "MB-m", stamp, install) + _expect_true(bool(step.get("granted", false)), + "attend: period %d grants" % index) + var grants: Dictionary = vault.get( + "continue_coin_grants") as Dictionary + _expect_true(grants.has("store-order-1"), + "attend: pruning never evicts a purchase") + _expect_equal(int(grants["store-order-1"]), 5, + "attend: the purchase keeps its count") + var kept: int = 0 + for grant_key in grants: + if str(grant_key).begins_with("attendance:"): + kept += 1 + _expect_equal(kept, 8, "attend: only the newest eight receipts stay") + _expect_true(not grants.has(key), + "attend: the oldest receipt prunes away") + var reprised: Dictionary = vault.call("grant_attendance_coins", + "MB-m", 1000000, install) + _expect_true(bool(reprised.get("duplicate", false)), + "attend: the pruned receipt never grants again") + + # Failed saves grant nothing and keep nothing half-written. + _remove_save_target() + var failing: Node = FAILING_VAULT_SCRIPT.new() as Node + failing.load_vault() + _expect_equal(str(failing.call("ensure_install_id")), "", + "attend: an undurable install stays hidden") + var failed_before: int = int(failing.continue_coins) + var denied: Dictionary = failing.call("grant_attendance_coins", + "MB-m", 3000000, install) + _expect_equal(str(denied.get("status", "")), "failure", + "attend: an unsavable grant fails") + _expect_equal(int(failing.continue_coins), failed_before, + "attend: the failed grant keeps the balance") + _expect_true((failing.get("continue_coin_grants") + as Dictionary).is_empty(), + "attend: the failed grant keeps no key") + failing.free() + + # Deletion clears one scope's marks and deadline, never its receipts. + _expect_true(bool(vault.call("clear_attendance_for_owner", "MB-m")), + "attend: the owner clears") + _expect_true((vault.get("attendance_marks") as Dictionary).has( + "MB-n"), "attend: the sibling mark survives") + _expect_true((vault.call("attendance_next_for_account", "MB-m") + as Dictionary).is_empty(), + "attend: the cleared deadline reads empty") + vault.free() + + +## Ten owners grant one minute apart, then the vault reloads: the first +## owner's identical old receipt must never grant again even though its +## key and mark both pruned, while a genuine new account, a new period, +## and a same-second fresh tie still grant. The purchased receipt stays. +func _test_attendance_cross_owner_prune() -> void: + _remove_save_target() + var vault: Node = _new_vault() + vault.load_vault() + var install: String = str(vault.call("ensure_install_id")) + _expect_true(bool(vault.call("grant_continue_coins", 5, + "store-order-9")), "attend-x: the purchased key lands") + var before: int = int(vault.continue_coins) + var base: int = 1791000000 + for index in 10: + var owner: String = "MB-x%d" % index + var step: Dictionary = vault.call("grant_attendance_coins", + owner, base + index * 60, install) + _expect_true(bool(step.get("granted", false)), + "attend-x: owner %d grants" % index) + _expect_equal(int(vault.continue_coins), before + 20, + "attend-x: ten owners add twenty") + var reloaded: Node = _new_vault() + reloaded.load_vault() + _expect_equal(int(reloaded.continue_coins), before + 20, + "attend-x: the balances survive reload") + _expect_equal(int((reloaded.get("continue_coin_grants") + as Dictionary).get("store-order-9", 0)), 5, + "attend-x: the purchase survives reload") + var replay: Dictionary = reloaded.call("grant_attendance_coins", + "MB-x0", base, install) + _expect_equal(str(replay.get("status", "")), "failure", + "attend-x: the evicted receipt never grants again") + _expect_equal(str(replay.get("code", "")), + "attendance-replay-ambiguous", + "attend-x: the refusal names the eviction") + _expect_equal(int(reloaded.continue_coins), before + 20, + "attend-x: the replay adds nothing") + var fresh: Dictionary = reloaded.call("grant_attendance_coins", + "MB-new", base + 600, install) + _expect_true(bool(fresh.get("granted", false)), + "attend-x: a genuine new account grants") + var period: Dictionary = reloaded.call("grant_attendance_coins", + "MB-x9", base + 540 + 43200, install) + _expect_true(bool(period.get("granted", false)), + "attend-x: a genuine new period grants") + # A blocked save rolls the eviction back with the balance and keys. + DirAccess.make_dir_recursive_absolute(_temp_absolute) + var blocked: Dictionary = vault.call("grant_attendance_coins", + "MB-blocked", base + 600, install) + DirAccess.remove_absolute(_temp_absolute) + _expect_equal(str(blocked.get("status", "")), "failure", + "attend-x: the blocked grant fails") + _expect_equal(int(vault.continue_coins), before + 20, + "attend-x: the blocked grant keeps the balance") + _expect_equal(int(vault.get("attendance_floor")), base + 60, + "attend-x: the failed eviction leaves the floor") + var strict_tie: Dictionary = reloaded.call( + "grant_attendance_coins", "MB-tie", base, install, false) + _expect_equal(str(strict_tie.get("status", "")), "failure", + "attend-x: an uncertain same-second tie fails closed") + _expect_equal(int(reloaded.continue_coins), before + 24, + "attend-x: only the genuine grants land") + var tie: Dictionary = reloaded.call( + "grant_attendance_coins", "MB-tie", base, install, true) + _expect_true(bool(tie.get("granted", false)), + "attend-x: a fresh same-second tie grants") + _expect_equal(int(reloaded.continue_coins), before + 26, + "attend-x: the fresh tie adds two") + var tie_again: Dictionary = reloaded.call( + "grant_attendance_coins", "MB-tie", base, install, true) + _expect_true(bool(tie_again.get("duplicate", false)), + "attend-x: the fresh tie stays idempotent") + _expect_true(bool(reloaded.call("attendance_receipt_applied", + "MB-x9", base + 540 + 43200, install)), + "attend-x: the applied receipt reads applied") + _expect_false(bool(reloaded.call("attendance_receipt_applied", + "MB-x0", base, install)), + "attend-x: the evicted receipt reads unapplied") + _expect_false(bool(reloaded.call("attendance_receipt_applied", + "MB-ghost", base, install)), + "attend-x: the unknown receipt reads unapplied") + reloaded.free() + vault.free() func _test_score_settlement() -> void: _remove_save_target() var vault: Node = _new_vault() diff --git a/apps/game/tools/build_lodge_assets.py b/apps/game/tools/build_lodge_assets.py new file mode 100644 index 0000000..bc0a080 --- /dev/null +++ b/apps/game/tools/build_lodge_assets.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Pack the gate-lodge room plates and Lumi turnaround into runtime PNGs. + +Reads the approved raw sources under notes/workflow/muse/art/gate-chamber/ +(read-only; that tree is director-owned) and writes deterministic RGBA +runtime PNGs under assets/custom/. Every facing keeps its source +proportions: one common top/bottom crop line, one runtime scale, and a +shared foot anchor, instead of independently filling each bounding box. + +Re-running on the same sources produces byte-identical outputs; --check +verifies that without writing. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from PIL import Image + +from pixel_canvas import Canvas + +TOOL_DIR = Path(__file__).resolve().parent +GAME_ROOT = TOOL_DIR.parent +REPO_ROOT = GAME_ROOT.parents[1] +SOURCE_ROOT = REPO_ROOT / "notes/workflow/muse/art/gate-chamber" +ROOM_ROOT = GAME_ROOT / "assets/custom/world/lodge" +LUMI_ROOT = GAME_ROOT / "assets/custom/actors/lumi" + +# Measured Lumi figure boxes (x0, x1, top, bottom, inclusive) on the raw +# 1774x887 turnaround at alpha threshold 128, matching the director's +# alpha probe (visible heights 853/850/851/852, bottoms 867/866/866/867). +# Order on the sheet: front, rear, left, right. +LUMI_FIGURES = { + "front": (67, 385, 14, 866), + "rear": (514, 833, 16, 865), + "left": (968, 1213, 15, 865), + "right": (1418, 1648, 15, 866), +} +LUMI_ORDER = ("front", "rear", "left", "right") + +# Transparent margin kept around each silhouette for filtering. The crop +# top/bottom are common to all facings so every packed frame shares one +# height and one foot line; widths stay per-facing so profiles are not +# stretched to the front silhouette. +CROP_PAD = 8 +CROP_TOP = min(box[2] for box in LUMI_FIGURES.values()) - CROP_PAD +CROP_BOTTOM_EXCL = max(box[3] for box in LUMI_FIGURES.values()) + 1 + CROP_PAD + +# Bust crop on the front column: head plus shoulders for the small name +# form portrait. Centered on the front figure's horizontal middle. +BUST_HALF_WIDTH = 130 +BUST_HEIGHT = 320 + + +def _canvas_from_rgba(image: Image.Image) -> Canvas: + rgba = image.convert("RGBA") + canvas = Canvas(rgba.width, rgba.height) + canvas.pixels[:] = rgba.tobytes() + return canvas + + +def _lumi_crop_box(name: str) -> tuple[int, int, int, int]: + x0, x1, _top, _bottom = LUMI_FIGURES[name] + return (x0 - CROP_PAD, CROP_TOP, x1 + 1 + CROP_PAD, CROP_BOTTOM_EXCL) + + +def _bust_crop_box() -> tuple[int, int, int, int]: + x0, x1, _top, _bottom = LUMI_FIGURES["front"] + center = (x0 + x1) // 2 + return ( + center - BUST_HALF_WIDTH, + CROP_TOP, + center + BUST_HALF_WIDTH, + CROP_TOP + BUST_HEIGHT, + ) + + +def build_outputs() -> dict[Path, bytes]: + """Pack every runtime PNG in memory. Pure: writes nothing.""" + missing = [ + name + for name in ( + "gate-chamber-room.png", + "gate-chamber-room-tablet.png", + "lumi-turnaround.png", + ) + if not (SOURCE_ROOT / name).exists() + ] + if missing: + raise SystemExit( + "lodge art sources missing under %s: %s" + % (SOURCE_ROOT, ", ".join(missing)) + ) + outputs: dict[Path, bytes] = {} + wide = Image.open(SOURCE_ROOT / "gate-chamber-room.png") + outputs[ROOM_ROOT / "room_wide.png"] = _canvas_from_rgba(wide).to_png() + tablet = Image.open(SOURCE_ROOT / "gate-chamber-room-tablet.png") + outputs[ROOM_ROOT / "room_tablet.png"] = _canvas_from_rgba(tablet).to_png() + sheet = Image.open(SOURCE_ROOT / "lumi-turnaround.png") + for name in LUMI_ORDER: + facing = sheet.crop(_lumi_crop_box(name)) + outputs[LUMI_ROOT / ("%s.png" % name)] = _canvas_from_rgba( + facing + ).to_png() + bust = sheet.crop(_bust_crop_box()) + outputs[LUMI_ROOT / "bust.png"] = _canvas_from_rgba(bust).to_png() + return outputs + + +def measure_sheet() -> dict[str, tuple[int, int]]: + """Visible figure height and bottom row per facing at alpha >= 128.""" + sheet = Image.open(SOURCE_ROOT / "lumi-turnaround.png").convert("RGBA") + width, height = sheet.size + alpha = sheet.split()[3] + loader = alpha.load() + measured: dict[str, tuple[int, int]] = {} + for name in LUMI_ORDER: + x0, x1, _top, _bottom = LUMI_FIGURES[name] + top, bottom = height, -1 + for y in range(height): + found = 0 + for x in range(x0 - CROP_PAD, x1 + 1 + CROP_PAD): + if 0 <= x < width and loader[x, y] >= 128: + found += 1 + if found > 3: + break + if found > 3: + top = min(top, y) + bottom = max(bottom, y) + measured[name] = (bottom - top + 1, bottom) + return measured + + +def pack_all(*, check: bool) -> None: + outputs = build_outputs() + measured = measure_sheet() + heights = [size for size, _bottom in measured.values()] + spread = (max(heights) - min(heights)) / float(min(heights)) + if spread >= 0.02: + raise SystemExit( + "lumi facing heights disagree: %s (spread %.3f)" + % (measured, spread) + ) + problems: list[str] = [] + for path, payload in outputs.items(): + if check: + if not path.exists(): + problems.append("%s: missing" % path) + elif path.read_bytes() != payload: + problems.append("%s: bytes differ" % path) + else: + if path.exists() and path.read_bytes() == payload: + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + for name in LUMI_ORDER: + size, bottom = measured[name] + print( + "lumi %s: visible height %d bottom %d" % (name, size, bottom) + ) + print( + "lodge pack: %d outputs height-spread %.4f" + % (len(outputs), spread) + ) + if problems: + raise SystemExit("\n".join(problems)) + print( + "lodge assets %s" % ("check: outputs current" if check else "packed") + ) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="verify packed outputs match the sources without writing", + ) + args = parser.parse_args(argv) + try: + pack_all(check=args.check) + except SystemExit as exit: + message = str(exit) + if message: + print(message, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/apps/game/tools/build_store_graphics.py b/apps/game/tools/build_store_graphics.py index 71365eb..d817e08 100644 --- a/apps/game/tools/build_store_graphics.py +++ b/apps/game/tools/build_store_graphics.py @@ -518,6 +518,7 @@ "analytics.json", "analytics.json.tmp", "analytics_consent.revoked", + "attendance_reminders.disabled", "chronicle.json", "chronicle.json.tmp", "iap_entitlements.cfg", diff --git a/apps/game/tools/custom_asset_contracts.json b/apps/game/tools/custom_asset_contracts.json index 051d372..62d4744 100644 --- a/apps/game/tools/custom_asset_contracts.json +++ b/apps/game/tools/custom_asset_contracts.json @@ -4611,6 +4611,83 @@ "cell_height": 168, "allow_blank_frames": false } + }, + { + "id": "lodge.room.wide", + "category": "lodge", + "status": "final", + "path": "assets/custom/world/lodge/room_wide.png", + "width": 1881, + "height": 836, + "alpha_mode": "opaque", + "max_visible_rgb_colors": 152681, + "max_alpha_levels": 1 + }, + { + "id": "lodge.room.tablet", + "category": "lodge", + "status": "final", + "path": "assets/custom/world/lodge/room_tablet.png", + "width": 1448, + "height": 1086, + "alpha_mode": "opaque", + "max_visible_rgb_colors": 154114, + "max_alpha_levels": 1 + }, + { + "id": "lodge.lumi.front", + "category": "lodge", + "status": "final", + "path": "assets/custom/actors/lumi/front.png", + "width": 335, + "height": 869, + "alpha_mode": "graded", + "max_visible_rgb_colors": 57570, + "max_alpha_levels": 256 + }, + { + "id": "lodge.lumi.rear", + "category": "lodge", + "status": "final", + "path": "assets/custom/actors/lumi/rear.png", + "width": 336, + "height": 869, + "alpha_mode": "graded", + "max_visible_rgb_colors": 43556, + "max_alpha_levels": 256 + }, + { + "id": "lodge.lumi.left", + "category": "lodge", + "status": "final", + "path": "assets/custom/actors/lumi/left.png", + "width": 262, + "height": 869, + "alpha_mode": "graded", + "max_visible_rgb_colors": 51673, + "max_alpha_levels": 256 + }, + { + "id": "lodge.lumi.right", + "category": "lodge", + "status": "final", + "path": "assets/custom/actors/lumi/right.png", + "width": 247, + "height": 869, + "alpha_mode": "graded", + "max_visible_rgb_colors": 49474, + "max_alpha_levels": 256 + }, + { + "id": "lodge.lumi.bust", + "category": "lodge", + "status": "final", + "path": "assets/custom/actors/lumi/bust.png", + "width": 260, + "height": 320, + "alpha_mode": "graded", + "max_visible_rgb_colors": 30284, + "max_alpha_levels": 256 } ] } diff --git a/apps/game/tools/lodge_review.gd b/apps/game/tools/lodge_review.gd new file mode 100644 index 0000000..a3e14b6 --- /dev/null +++ b/apps/game/tools/lodge_review.gd @@ -0,0 +1,554 @@ +extends Node + +## Gate lodge QA harness: validate headless, render windowed. +## +## The director renders on a real display; this sandbox cannot. Modes: +## - validate (headless): layout math plus panel seating, safe-area, and +## clipping audits for every framing x locale x dialogue state. This +## mode runs here and exits nonzero on any violation. +## - room (windowed): one PNG of the lodge in a framing/locale/state. +## - contact (windowed): one PNG with the four Lumi facings side by side, +## plus a pixel audit of the four captured silhouettes. +## - clip (windowed): scripted real-time lesson (walk, dash, neutral +## stop, gate seal), up to 600 sim frames with at most 120 saved PNGs. +## Prints the observed phases and exits nonzero unless all complete. +## +## Run: `pnpm godot:isolated [--windowed] res://tools/lodge_review.tscn -- +## mode=room framing=808x360 locale=en state=depart out=builds/lodge-qa` +## Windowed modes need `--resolution WxH` matching the framing. All modes +## use isolated user files, so the human record stays untouched. +## +## The fake host and scripted inputs below are harness-only: normal +## production never sees a test nickname, fake rows, or driven movement. + +const LODGE_SCENE: PackedScene = preload( + "res://scenes/gameplay/gate_lodge.tscn") +const FRAMINGS: Dictionary = { + "808x360": Vector2(808, 360), + "840x360": Vector2(840, 360), + "808x606": Vector2(808, 606), +} +const LOCALES: Array[String] = ["ko", "en", "ja", "zh_CN", "zh_TW"] +const STATES: Array[String] = ["greet", "name", "move", "gate", "depart"] +## Real-time lesson budget with a bounded saved selection: at most 120 +## output frames no matter how long the lesson runs. +const CLIP_SIM_FRAMES: int = 600 +const CLIP_EVERY: int = 5 +const CLIP_OUTPUT_BUDGET: int = 120 +const FAKE_ID: String = "MB-lodgereview00000000000000000001" + +var _failed: int = 0 +var _checked: int = 0 + + +class ReviewHost extends Node: + signal production_changed(state: Dictionary) + + var display: String = "Luna" + var intro: bool = false + var claim_ok: bool = true + var seal_ok: bool = true + var saved: Dictionary = {"has_save": false} + + func account_state() -> Dictionary: + return {"public_id": FAKE_ID, + "deletion_in_flight": false} + + func needs_lodge_lesson() -> bool: + return display.is_empty() or not intro + + func verified_display_name() -> String: + return display + + func load_adventurer_name() -> Dictionary: + await get_tree().process_frame + if display.is_empty(): + return {"status": "failure", "code": "adventurer-not-found", + "retryable": false} + return {"status": "ok", "display": display, "key": "luna", + "intro_complete": intro, "source": "cloud"} + + func claim_adventurer_name(text: String) -> Dictionary: + await get_tree().process_frame + if not claim_ok: + return {"status": "failure", "code": "offline", + "retryable": true} + display = text + return {"status": "ok", "display": text, "key": "key"} + + func mark_intro_complete() -> Dictionary: + await get_tree().process_frame + if not seal_ok: + return {"status": "failure", "code": "timeout", + "retryable": true} + intro = true + return {"status": "ok"} + + func saved_gate_summary() -> Dictionary: + return saved.duplicate(true) + + func plan_lodge_exit(fresh: bool, _confirmed: bool) -> Dictionary: + return {"status": "ok", + "arena": "res://scenes/gameplay/arena.tscn", + "fresh": fresh, "account_id": FAKE_ID} + + func confirm_entry_account(account_id: String) -> bool: + return account_id == FAKE_ID + + func cancel_entry_plan() -> void: + pass + + +func _ready() -> void: + GateEntryStrings.ensure_loaded() + _run.call_deferred() + + +func _args() -> Dictionary: + var out: Dictionary = {"mode": "validate", "framing": "808x360", + "locale": "en", "state": "depart", "out": "builds/lodge-qa", + "kb": "0"} + for arg in OS.get_cmdline_user_args(): + var pair: PackedStringArray = str(arg).split("=", true, 1) + if pair.size() == 2 and out.has(pair[0]): + out[pair[0]] = pair[1] + return out + + +func _run() -> void: + var args: Dictionary = _args() + match str(args["mode"]): + "validate": + await _run_validate() + "room": + await _run_room(args) + "contact": + await _run_contact(args) + "clip": + await _run_clip(args) + _: + printerr("lodge review: unknown mode ", args["mode"]) + get_tree().quit(2) + + +# --- validate (headless) ------------------------------------------------------ + +## Every framing x locale x state: panels seat inside the safe area, the +## keyboard-clear form stays high, and no label clips its text. +func _run_validate() -> void: + for framing in FRAMINGS: + _audit_framing(str(framing), FRAMINGS[framing]) + for locale in LOCALES: + TranslationServer.set_locale(locale) + for state in STATES: + await _audit_state(locale, state) + print("lodge review: %d checks, %d failed" % [_checked, _failed]) + get_tree().quit(1 if _failed > 0 else 0) + + +func _audit_framing(tag: String, size: Vector2) -> void: + var layout: Dictionary = GateLodge.layout_for(size) + var view := Rect2(Vector2.ZERO, size) + _check(view.encloses(layout["gate_rect"] as Rect2), + "%s: the gate stays reachable" % tag) + _check(view.has_point(layout["lumi_pos"]), + "%s: lumi stays onscreen" % tag) + _check(view.has_point(layout["hero_pos"]), + "%s: the hero starts onscreen" % tag) + var walk: Rect2 = layout["walk_rect"] + _check(walk.has_point(layout["hero_pos"]) + and walk.has_point(layout["lumi_pos"]) + and walk.has_point((layout["gate_rect"] as Rect2).get_center()), + "%s: hero, lumi, and gate share the walk" % tag) + + +func _audit_state(locale: String, state: String) -> void: + var tag: String = "%s/%s" % [locale, state] + Vault.load_vault() + Vault.clear_lodge_for_owner(FAKE_ID) + var fake := ReviewHost.new() + fake.display = "" if state == "greet" or state == "name" else "Luna" + fake.saved = {"has_save": true, "cycle": 12, "zone_index": 7, + "hero_path": "res://resources/heroes/keeper.tres"} + if state == "gate": + Vault.mark_lodge_gate(FAKE_ID, "move") + Vault.mark_lodge_gate(FAKE_ID, "dash") + add_child(fake) + var lodge: Node = LODGE_SCENE.instantiate() + lodge.host_override = fake + lodge.embedded = true + add_child(lodge) + await _drive_to(lodge, fake, state) + await get_tree().process_frame + await get_tree().process_frame + _audit_panels(lodge, tag) + _audit_guide(lodge, tag) + if state == "name" and lodge.lodge_state() == GateLodge.State.NAME: + for key in ["gate.lodge.name_taken", "gate.lodge.name_invalid", + "gate.lodge.name_offline", "gate.lodge.name_checking"]: + lodge._name_status.text = GateEntryStrings.text(key) + await get_tree().process_frame + await get_tree().process_frame + _audit_panels(lodge, "%s/%s" % [tag, key.get_slice(".", 2)]) + lodge.queue_free() + fake.queue_free() + await get_tree().process_frame + + +## Walk the real flow to a state: taps are the same calls a finger makes. +func _drive_to(lodge: Node, fake: ReviewHost, state: String) -> void: + for _index in 8: + await get_tree().process_frame + if state == "greet": + return + for _tap in 3: + lodge._advance_speech() + await get_tree().process_frame + for _index in 6: + await get_tree().process_frame + if state == "name": + return + if lodge.lodge_state() == GateLodge.State.NAME: + lodge._name_field.text = "Luna" + lodge._on_name_confirm() + for _index in 6: + await get_tree().process_frame + lodge._advance_speech() + await get_tree().process_frame + if state == "move": + lodge._advance_speech() + await get_tree().process_frame + return + for _index in 4: + await get_tree().process_frame + if state == "gate": + lodge._advance_speech() + for _index in 4: + await get_tree().process_frame + return + if state == "depart": + fake.intro = true + lodge._enter_departure() + for _index in 4: + await get_tree().process_frame + + +## Shortest-viewport height budgets: the name form seats at y 58 on a +## 360-tall screen, speech floats low, departure centers. Real laid-out +## heights must fit the 808x360 framing every locale reaches. +const HEIGHT_BUDGETS: Dictionary = { + "Ui/Speech": 240.0, + "Ui/NameForm": 296.0, + "Ui/Departure": 320.0, +} + + +func _audit_panels(lodge: Node, tag: String) -> void: + var view: Vector2 = get_viewport().get_visible_rect().size + var full := Rect2(Vector2.ZERO, view) + var safe: Rect2 = Screen.viewport_safe_rect(get_viewport()) + if not safe.has_area(): + safe = full + for path in ["Ui/TopBar", "Ui/Speech", "Ui/NameForm", "Ui/Departure"]: + var panel: Control = lodge.get_node_or_null(path) as Control + if panel == null or not panel.visible: + continue + var inside: bool = safe.encloses(panel.get_global_rect()) + if not inside: + print("RECT ", tag, " ", path, " panel=", + panel.get_global_rect(), " safe=", safe, + " view=", full, " min=", + panel.get_combined_minimum_size()) + for kid in panel.find_children("*", "Control", true, false): + var c: Control = kid as Control + print(" kid ", c.name, " ", c.get_class(), + " min=", c.get_combined_minimum_size(), + " rect=", c.get_rect(), + " text=", str(c.get("text")).left(40)) + _check(inside, "%s: %s sits inside the safe area" % [tag, path]) + if HEIGHT_BUDGETS.has(path): + _check(panel.size.y <= float(HEIGHT_BUDGETS[path]), + "%s: %s fits the 360-tall budget (%.0f)" % [ + tag, path, panel.size.y]) + for label in panel.find_children("*", "Label", true, false): + _audit_label(label, tag, path) + + +## The guide draws through the source-to-world conversion times the +## room zoom: catch a raw unscaled sprite even without rendering. +func _audit_guide(lodge: Node, tag: String) -> void: + var want: float = LumiGuide.WORLD_HEIGHT / LumiGuide.FRAME_HEIGHT \ + * lodge._actor_scale + var got: Vector2 = lodge._lumi.scale + _check(is_equal_approx(got.x, want) and is_equal_approx(got.y, want), + "%s: lumi converts source px to world (%.4f)" % [tag, got.x]) + var sprite: Sprite2D = lodge._lumi.get_node("Sprite") as Sprite2D + var size: Vector2 = (sprite.texture as Texture2D).get_size() + _check(is_equal_approx(size.y * got.y, + LumiGuide.WORLD_HEIGHT * lodge._actor_scale), + "%s: lumi stands %.0f world px tall" % [tag, size.y * got.y]) + + +func _audit_label(label: Label, tag: String, path: String) -> void: + if str(label.text).is_empty(): + return + _check(label.size.x > 0.0 and label.size.y > 0.0, + "%s: %s lays its label out" % [tag, path]) + if label.clip_text and label.max_lines_visible > 0: + _check(label.get_line_count() <= label.max_lines_visible, + "%s: %s keeps every line" % [tag, path]) + + +func _check(value: bool, label: String) -> void: + _checked += 1 + if not value: + _failed += 1 + printerr("FAIL lodge review: ", label) + + +# --- windowed capture (director) ----------------------------------------------- + +func _swap_locale(locale: String) -> void: + TranslationServer.set_locale(locale) + await get_tree().process_frame + + +func _capture(viewport: Viewport, path: String) -> void: + await RenderingServer.frame_post_draw + var image: Image = viewport.get_texture().get_image() + var file: String = ProjectSettings.globalize_path(path) + DirAccess.make_dir_recursive_absolute(file.get_base_dir()) + if image.save_png(file) != OK: + printerr("lodge review: could not write ", file) + get_tree().quit(2) + + +func _run_room(args: Dictionary) -> void: + await _swap_locale(str(args["locale"])) + Vault.load_vault() + var fake := ReviewHost.new() + fake.display = "" if str(args["state"]) in ["greet", "name"] else "Luna" + add_child(fake) + var lodge: Node = LODGE_SCENE.instantiate() + lodge.host_override = fake + lodge.embedded = true + add_child(lodge) + await _drive_to(lodge, fake, str(args["state"])) + await get_tree().process_frame + await get_tree().process_frame + # A synthetic keyboard stages the adapted form for inspection + # through the same production hook the tests drive: device + # pixels in, live window and transform converting. This is a + # staged occlusion, never a native keyboard delivery. The live + # hook freezes first, or its zero-height frames would restore. + var kb: float = maxf(float(str(args["kb"])), 0.0) + var suffix: String = "" + if kb > 0.0: + lodge.set_process(false) + var outcome: Dictionary = lodge._apply_keyboard_shift(kb) + await get_tree().process_frame + await get_tree().process_frame + print("lodge review: synthetic keyboard ", + JSON.stringify(outcome)) + suffix = "-kb%d" % int(kb) + await _capture(get_viewport(), "%s/room-%s-%s-%s%s.png" % [ + str(args["out"]), str(args["framing"]), str(args["locale"]), + str(args["state"]), suffix]) + if lodge.has_method("name_form_keyboard_report"): + print("lodge review: keyboard ", + JSON.stringify(lodge.call( + "name_form_keyboard_report"))) + print("lodge review: room captured") + get_tree().quit(0) + + +func _run_contact(args: Dictionary) -> void: + var root := Node2D.new() + add_child(root) + var actors: Array = [] + var index: int = 0 + for facing in ["front", "rear", "left", "right"]: + var lumi := LumiGuide.new() + lumi.position = Vector2(215.0 + index * 126.0, 290.0) + root.add_child(lumi) + lumi.set_facing(facing) + lumi.reset_physics_interpolation() + actors.append(lumi) + index += 1 + var camera := Camera2D.new() + camera.position = Vector2(404, 265) + # Physics callback before tree entry: the project interpolates + # physics, and the idle default warns on every capture. + camera.process_callback = Camera2D.CAMERA2D_PROCESS_PHYSICS + # Seat first, then make current: the reverse order errors, and + # the capture must render the settled interpolation, not the + # first tick's lagging transform that once drew the last actor + # huge and half outside the board. + root.add_child(camera) + camera.make_current() + camera.reset_physics_interpolation() + await get_tree().physics_frame + await get_tree().process_frame + await get_tree().process_frame + var path: String = "%s/contact-lumi.png" % str(args["out"]) + await _capture(get_viewport(), path) + _audit_contact_board(path, actors, + get_viewport().get_canvas_transform()) + print("lodge review: contact captured") + get_tree().quit(1 if _failed > 0 else 0) + + +## Judge the captured board's pixels, not the pre-draw transforms: +## each of the four slots must show one sanely-sized silhouette near +## its actor's feet. The board background is the opaque clear color, +## so silhouettes read as pixels far from the corner color. +func _audit_contact_board(path: String, actors: Array, + canvas: Transform2D) -> void: + var file: String = ProjectSettings.globalize_path(path) + if not FileAccess.file_exists(path): + _check(false, "contact: the board was written") + return + var image: Image = Image.load_from_file(file) as Image + if image == null or image.is_empty(): + _check(false, "contact: the board holds pixels") + return + var corners: Array = [ + image.get_pixel(4, 4), + image.get_pixel(image.get_width() - 5, 4), + image.get_pixel(4, image.get_height() - 5), + image.get_pixel( + image.get_width() - 5, image.get_height() - 5), + ] + # The background is whatever most corners agree on, so one stray + # oversized actor cannot elect itself the background. + corners.sort_custom(func(a: Color, b: Color) -> bool: + return a.to_html() < b.to_html()) + var bg: Color = corners[1] + var names: Array = ["front", "rear", "left", "right"] + var index: int = 0 + for actor in actors: + var feet: Vector2 = ( + actor as Node2D).get_global_transform_with_canvas() \ + * Vector2.ZERO + # The canvas unit is the camera/viewport scale, never the + # actor's own node scale (a Lumi stands 0.048 of her own + # source pixels tall; that is not the board's ruler). + var unit: float = absf(canvas.get_scale().y) + if unit <= 0.001: + unit = 1.0 + var slot := Rect2( + feet + Vector2(-63, -64) * unit, + Vector2(126, 76) * unit) + var clip: Rect2 = slot.intersection(Rect2( + Vector2.ZERO, image.get_size())) + var found := Rect2(Vector2.ZERO, Vector2.ZERO) + var drew: bool = false + for y in range(int(clip.position.y), int(clip.end.y)): + for x in range(int(clip.position.x), int(clip.end.x)): + var c: Color = image.get_pixel(x, y) + var drift: float = (c.r - bg.r) * (c.r - bg.r) \ + + (c.g - bg.g) * (c.g - bg.g) \ + + (c.b - bg.b) * (c.b - bg.b) + if drift < 0.02: + continue + var at := Rect2(Vector2(x, y), Vector2.ONE) + found = at if not drew else found.merge(at) + drew = true + var tag: String = "contact: %s" % str(names[index]) + _check(drew, "%s draws a silhouette" % tag) + if not drew: + index += 1 + continue + var tall: float = found.size.y / unit + var wide: float = found.size.x / unit + var low: float = (found.end.y - feet.y) / unit + print("contact: %s at %s size %s (%.1f x %.1f world)" % [ + str(names[index]), str(found.position), + str(found.size), wide, tall]) + _check(tall >= 30.0 and tall <= 60.0, + "%s stands a sane height (%.1f)" % [tag, tall]) + _check(wide >= 6.0 and wide <= 60.0, + "%s spans a sane width (%.1f)" % [tag, wide]) + _check(low >= -4.0 and low <= 12.0, + "%s plants its feet near the anchor (%.1f)" % [ + tag, low]) + index += 1 + + +func _run_clip(args: Dictionary) -> void: + await _swap_locale(str(args["locale"])) + Vault.load_vault() + Vault.clear_lodge_for_owner(FAKE_ID) + var fake := ReviewHost.new() + add_child(fake) + var lodge: Node = LODGE_SCENE.instantiate() + lodge.host_override = fake + lodge.embedded = true + add_child(lodge) + var phases: Dictionary = {"moved": 0.0, "dashed": false, + "stopped": false, "depart": false, "dash_sent": false, + "stop_frames": 0, "depart_frames": 0, + "last_pos": Vector2.ZERO, "begun": false} + var saved_pngs: int = 0 + var frame: int = 0 + var done: bool = false + while frame < CLIP_SIM_FRAMES and not done: + await get_tree().process_frame + done = _clip_drive(lodge, frame, phases) + if frame % CLIP_EVERY == 0 \ + and saved_pngs < CLIP_OUTPUT_BUDGET: + await _capture(get_viewport(), "%s/clip-%03d.png" % [ + str(args["out"]), saved_pngs]) + saved_pngs += 1 + frame += 1 + phases["depart"] = lodge.lodge_state() == GateLodge.State.DEPART + print("lodge review: clip moved=%.1f dashed=%s stopped=%s " % [ + float(phases["moved"]), str(phases["dashed"]), + str(phases["stopped"])] + "depart=%s sim=%d saved=%d" % [ + str(phases["depart"]), frame, saved_pngs]) + var complete: bool = float(phases["moved"]) >= 60.0 \ + and bool(phases["dashed"]) and bool(phases["stopped"]) \ + and bool(phases["depart"]) + get_tree().quit(0 if complete else 1) + + +## Script the real lesson: tap words through, walk right, dash, stop +## neutral, then steer to the gate until the seal lands. Returns true a +## few frames after departure so the choice is on film. +func _clip_drive(lodge: Node, frame: int, phases: Dictionary) -> bool: + if lodge._speech_panel.visible and not lodge._speech_locked: + lodge._advance_speech() + var player: Node = lodge._player + if not bool(phases["begun"]): + phases["begun"] = frame >= 6 + phases["last_pos"] = player.position + if frame == 6: + lodge.move_override_active = true + lodge.move_override = Vector2.RIGHT + return false + phases["moved"] = float(phases["moved"]) \ + + player.position.distance_to(phases["last_pos"]) + phases["last_pos"] = player.position + if player.is_dashing(): + phases["dashed"] = true + var state: int = lodge.lodge_state() + if state == GateLodge.State.DASH \ + and lodge._speech_locked and not bool(phases["dash_sent"]): + phases["dash_sent"] = true + lodge._dash_button.emit_signal("pressed") + elif state == GateLodge.State.GATE and not bool(phases["stopped"]): + lodge.move_override = Vector2.ZERO + if player.velocity.length() < 8.0 and not player.is_dashing(): + phases["stop_frames"] = int(phases["stop_frames"]) + 1 + if int(phases["stop_frames"]) >= 10: + phases["stopped"] = true + elif state == GateLodge.State.GATE: + var gate: Rect2 = lodge._gate_rect + var to_gate: Vector2 = gate.get_center() - player.position + if to_gate.length() > 1.0: + lodge.move_override = to_gate.normalized() + elif state == GateLodge.State.DEPART: + lodge.move_override_active = false + phases["depart_frames"] = int(phases["depart_frames"]) + 1 + return int(phases["depart_frames"]) >= 6 + return false diff --git a/apps/game/tools/lodge_review.gd.uid b/apps/game/tools/lodge_review.gd.uid new file mode 100644 index 0000000..09a4a11 --- /dev/null +++ b/apps/game/tools/lodge_review.gd.uid @@ -0,0 +1 @@ +uid://bqrq1sq8e2nsw diff --git a/apps/game/tools/lodge_review.tscn b/apps/game/tools/lodge_review.tscn new file mode 100644 index 0000000..3b880b7 --- /dev/null +++ b/apps/game/tools/lodge_review.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/lodge_review.gd" id="1"] + +[node name="LodgeReview" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tools/reminder_review.gd b/apps/game/tools/reminder_review.gd new file mode 100644 index 0000000..36ffcff --- /dev/null +++ b/apps/game/tools/reminder_review.gd @@ -0,0 +1,201 @@ +extends Node + +## Isolated reminder QA harness (director-run, native device only). +## +## Drives the production MoonlitIdentity bridge against a disposable +## account: short test delivery, real launch action, pending +## diagnostics, and cancellation. The debug schedule never touches the +## twelve-hour production rule or any wallet: it fires one short +## non-repeating delivery and the guards stay the same (OS permission, +## channel, foreground suppression all apply, so background the app +## before the fire time to see the notification). +## +## Usage (Android test device, isolated account): +## -- res://tools/reminder_review.tscn -- mode=ui delay=60 +## Headless validate (no native side, asserts honest unsupported): +## pnpm godot:isolated res://tools/reminder_review.tscn -- mode=validate + +signal _outcome_arrived(outcome: Dictionary) + +const BRIDGE_PATH: String = "/root/MoonlitIdentity" + +var _outcome: Dictionary = {} +var _got_outcome: bool = false + + +func _ready() -> void: + var args: Dictionary = _args() + if str(args["mode"]) == "validate": + _validate() + return + if DisplayServer.get_name() == "headless": + printerr("reminder review: ui mode needs a display; " + + "use mode=validate headless") + get_tree().quit(2) + return + _build_ui(float(args["delay"])) + + +func _args() -> Dictionary: + var args: Dictionary = {"mode": "ui", "delay": 60.0} + for arg in OS.get_cmdline_user_args(): + var text: String = str(arg) + if "=" in text: + var key: String = text.get_slice("=", 0) + if args.has(key): + args[key] = text.get_slice("=", 1) + return args + + +func _bridge() -> Node: + return get_node_or_null(BRIDGE_PATH) + + +func _validate() -> void: + var failed: int = 0 + var bridge: Node = _bridge() + if bridge == null: + print("reminder review: no bridge autoload (bare tree)") + get_tree().quit(0) + return + for method in ["reminder_status", "reminder_request_permission", + "reminder_schedule", "reminder_cancel", + "reminder_open_settings", "reminder_pending_diagnostics", + "reminder_debug_schedule"]: + if not bridge.has_method(method): + printerr("reminder review: bridge lacks ", method) + failed += 1 + var status: Dictionary = bridge.call("reminder_status") + if str(status.get("status", "")) != "unsupported": + printerr("reminder review: headless status must be " + + "unsupported, got ", status) + failed += 1 + var debug: Dictionary = bridge.call( + "reminder_debug_schedule", 30.0) + if str(debug.get("status", "")) != "unsupported": + printerr("reminder review: headless debug must be " + + "unsupported, got ", debug) + failed += 1 + if failed > 0: + get_tree().quit(1) + return + print("reminder review: validate ok (honest unsupported)") + get_tree().quit(0) + + +func _build_ui(delay: float) -> void: + var layer := CanvasLayer.new() + add_child(layer) + var box := VBoxContainer.new() + box.set_anchors_preset(Control.PRESET_CENTER) + box.position = Vector2(-160, -140) + box.custom_minimum_size = Vector2(320, 280) + box.add_theme_constant_override("separation", 8) + layer.add_child(box) + var log := Label.new() + log.name = "Log" + log.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + log.custom_minimum_size = Vector2(320, 120) + box.add_child(log) + _add_button(box, "Status", _on_status.bind(log)) + _add_button(box, "Request permission", + _on_permission.bind(log)) + _add_button(box, "Schedule test (%ds)" % int(delay), + _on_debug.bind(log, delay)) + _add_button(box, "Pending diagnostics", + _on_pending.bind(log)) + _add_button(box, "Cancel all", _on_cancel.bind(log)) + _log(log, "reminder QA ready; use an isolated test account") + + +func _add_button(box: VBoxContainer, text: String, + handler: Callable) -> void: + var button := Button.new() + button.text = text + button.custom_minimum_size = Vector2(320, 28) + button.pressed.connect(handler) + box.add_child(button) + + +func _log(log: Label, text: String) -> void: + log.text = text + print("reminder review: ", text) + + +func _on_status(log: Label) -> void: + var bridge: Node = _bridge() + if bridge == null: + _log(log, "no bridge") + return + _log(log, JSON.stringify(bridge.call("reminder_status"))) + + +func _on_permission(log: Label) -> void: + var bridge: Node = _bridge() + if bridge == null: + _log(log, "no bridge") + return + _log(log, "requesting; answer the OS sheet") + var receipt: Dictionary = bridge.call( + "reminder_request_permission") + if str(receipt.get("status", "")) != "pending": + _log(log, JSON.stringify(receipt)) + return + var outcome: Dictionary = await _await_outcome(bridge, + str(receipt.get("request_id", ""))) + _log(log, JSON.stringify(outcome)) + + +func _on_debug(log: Label, delay: float) -> void: + var bridge: Node = _bridge() + if bridge == null: + _log(log, "no bridge") + return + var receipt: Dictionary = bridge.call( + "reminder_debug_schedule", delay) + _log(log, JSON.stringify(receipt) + + " — background the app to see it") + + +func _on_pending(log: Label) -> void: + var bridge: Node = _bridge() + if bridge == null: + _log(log, "no bridge") + return + var receipt: Dictionary = bridge.call( + "reminder_pending_diagnostics") + if str(receipt.get("status", "")) != "pending": + _log(log, JSON.stringify(receipt)) + return + var outcome: Dictionary = await _await_outcome(bridge, + str(receipt.get("request_id", ""))) + _log(log, JSON.stringify(outcome)) + + +func _on_cancel(log: Label) -> void: + var bridge: Node = _bridge() + if bridge == null: + _log(log, "no bridge") + return + _log(log, JSON.stringify(bridge.call("reminder_cancel"))) + + +func _await_outcome(bridge: Node, + request_id: String) -> Dictionary: + _got_outcome = false + _outcome = {} + var handler: Callable = func(outcome: Dictionary) -> void: + if str(outcome.get("request_id", "")) == request_id: + _outcome = outcome + _got_outcome = true + bridge.connect("request_completed", handler) + var waited: float = 0.0 + while not _got_outcome and waited < 65.0: + await get_tree().process_frame + waited += get_process_delta_time() + if bridge.is_connected("request_completed", handler): + bridge.disconnect("request_completed", handler) + if not _got_outcome: + return {"status": "error", "code": "qa_timeout", + "request_id": request_id} + return _outcome diff --git a/apps/game/tools/reminder_review.gd.uid b/apps/game/tools/reminder_review.gd.uid new file mode 100644 index 0000000..0a56bb8 --- /dev/null +++ b/apps/game/tools/reminder_review.gd.uid @@ -0,0 +1 @@ +uid://cywvj6sv27iy5 diff --git a/apps/game/tools/reminder_review.tscn b/apps/game/tools/reminder_review.tscn new file mode 100644 index 0000000..1b67a01 --- /dev/null +++ b/apps/game/tools/reminder_review.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/reminder_review.gd" id="1"] + +[node name="ReminderReview" type="Node"] +script = ExtResource("1") diff --git a/apps/game/tools/run_regression_tests.mjs b/apps/game/tools/run_regression_tests.mjs index 8dff66c..33513f3 100644 --- a/apps/game/tools/run_regression_tests.mjs +++ b/apps/game/tools/run_regression_tests.mjs @@ -79,9 +79,14 @@ const checks = [ ['Terrain cycling and loot wiring', ['res://tests/test_terrain_integration.tscn'], true, false], ['Beacon and cycle two-way choice modal', ['res://tests/test_run_choice_panel.tscn'], true, false], ['Story acts, chronicle, and per-hero voice', ['res://tests/test_story_structure.tscn'], true, false], - ['Journey checkpoints, gate retry, and story episodes', ['res://tests/test_journey.tscn'], true, false], + ['Journey checkpoints, sealed defeat, and story episodes', ['res://tests/test_journey.tscn'], true, false], + ['Sealed defeat: no free retry, transactional coin continue', ['res://tests/test_defeat_terminal.tscn'], true, false], + ['Crash boundaries: failed primary, journaled debit, stuck revive', ['res://tests/test_crash_boundaries.tscn'], true, false], + ['Account recovery: owned pending revive, deferred foreign scope', ['res://tests/test_account_recovery.tscn'], true, false], ['Cloud transport bounds and statuses', ['--script', 'res://tests/test_cloud_transport.gd'], true, false], ['Cloud identity registration and restore', ['--script', 'res://tests/test_cloud_identity.gd'], true, false], + ['Cloud adventurer names: claim, restore, intro bit', ['--script', 'res://tests/test_cloud_name.gd'], true, false], + ['Cloud attendance: server-time cooldown claims', ['--script', 'res://tests/test_cloud_attendance.gd'], true, false], ['Cloud checkpoint queue, guards, and conflicts', ['--script', 'res://tests/test_cloud_checkpoint.gd'], true, false], ['Cloud Hall best score, board, and rank', ['--script', 'res://tests/test_cloud_hall.gd'], true, false], ['Cloud account deletion order and local store', ['--script', 'res://tests/test_cloud_account.gd'], true, false], @@ -89,6 +94,8 @@ const checks = [ ['Production host entry, accounts, and HUD rank', ['res://tests/test_production_host.tscn'], true, false], ['Moon gate entry layout in every locale', ['res://tests/test_gate_entry_layout.tscn'], true, false], ['Moon gate entry states, panels, and motion', ['res://tests/test_gate_entry_state.tscn'], true, false], + ['Gate lodge lesson, Lumi rig, and departure', ['res://tests/test_gate_lodge.tscn'], true, false], + ['Attendance reminders: intent, schedule, settings', ['res://tests/test_attendance_reminders.tscn'], true, false], ['Forecourt party walks its ground routes, sheets, and clearance', ['res://tests/test_forecourt_party.tscn'], true, false], ['Forecourt stride follows measured travel', ['res://tests/test_forecourt_cadence.tscn'], true, false], ['Moon gate loading: first paint, cancel, honest errors', ['res://tests/test_gate_loading.tscn'], true, false], diff --git a/apps/game/tools/shot_journey.gd b/apps/game/tools/shot_journey.gd index ebbd37f..ef20b3a 100644 --- a/apps/game/tools/shot_journey.gd +++ b/apps/game/tools/shot_journey.gd @@ -1,14 +1,16 @@ extends Node -## Foreground review harness for the journey: fresh start, resume, death -## retry, and a later gate. +## Foreground review harness for the journey: fresh start, resume, sealed +## defeat with and without coins, and a later gate. ## ## Windowed, for the director to look at and play. The menu buttons swap the -## four states live; the fresh-guidance state is playable from the title tap -## through the first unpaused instruction. +## states live; the fresh-guidance state is playable from the title tap +## through the first unpaused instruction, and both defeat states show the +## real production result screen with working buttons. ## ## pnpm game -- res://tools/shot_journey.tscn -- menu -## pnpm game -- res://tools/shot_journey.tscn -- retry locale=ko +## pnpm game -- res://tools/shot_journey.tscn -- defeat locale=ko +## pnpm game -- res://tools/shot_journey.tscn -- defeat_broke ## ## Headless validate mode builds every state and asserts its invariants: ## @@ -16,7 +18,9 @@ extends Node ## ## Journey, onboarding and chronicle paths point at a throwaway folder, so ## the harness never reads or writes a real save. Lives in `tools/`, which -## `_runtime_fingerprint()` excludes. +## `_runtime_fingerprint()` excludes. Coin counts on the defeat states are +## staged in memory after the settlement lands, so the broke state never +## persists a zeroed balance; buttons clicked in review settle for real. const ARENA_SCENE: PackedScene = preload("res://scenes/gameplay/arena.tscn") const TITLE_SCENE: PackedScene = preload("res://scenes/menus/title_menu.tscn") @@ -28,6 +32,9 @@ const RELICS: Array[String] = [ var _stage: Node = null var _menu: VBoxContainer = null +## Real coin balance snapshotted while a defeat state stages its review +## count. Negative when nothing is staged. +var _real_coins: int = -1 func _ready() -> void: @@ -99,7 +106,12 @@ func _sample_checkpoint() -> Dictionary: } +func _exit_tree() -> void: + _restore_coins() + + func _clear_stage() -> void: + _restore_coins() Journey.disarm() RunEntry.from_title = false if _stage != null and is_instance_valid(_stage): @@ -115,8 +127,10 @@ func _show_state(state: String) -> void: _show_title(false) "resume": _show_title(true) - "retry": - await _show_retry() + "defeat": + await _show_defeat(false) + "defeat_broke": + await _show_defeat(true) "late": await _show_late_gate() "guidance": @@ -146,7 +160,13 @@ func _show_guidance() -> void: get_tree().root.add_child.call_deferred(_stage) -func _show_retry() -> void: +## A real defeat on the production result screen: resume the sample gate, +## earn a little past it, then fall. The loss seals the journey terminal +## and offers a paid continue plus a fresh start. `broke` stages a zero +## coin balance in memory after the settlement lands, so the panel shows +## the go-buy road; leaving the state restores the real balance before any +## later save can persist the staged count. +func _show_defeat(broke: bool) -> void: Journey.write_checkpoint(_sample_checkpoint()) Journey.begin_resume() RunEntry.mark_from_title() @@ -154,10 +174,24 @@ func _show_retry() -> void: get_tree().root.add_child(_stage) await get_tree().process_frame await get_tree().process_frame - # Earn a little past the gate, then fall: the lost segment grants - # nothing and the result offers the gate retry. _stage.set("_kill_score", int(_stage.get("_kill_score")) + 2000) _stage.call("_finish", false) + _stage_coins(0 if broke else maxi(Vault.continue_coins, 2)) + (_stage.get_node("Ui/Result") as Control).call("_refresh_continue") + + +## Stage a review coin count in memory, snapshotting the real balance first +## so leaving the state (or the harness) restores it before any later save. +func _stage_coins(want: int) -> void: + if _real_coins < 0: + _real_coins = Vault.continue_coins + Vault.continue_coins = want + + +func _restore_coins() -> void: + if _real_coins >= 0: + Vault.continue_coins = _real_coins + _real_coins = -1 func _show_late_gate() -> void: @@ -185,7 +219,8 @@ func _build_menu() -> void: _menu.position = Vector2(8, 8) _menu.add_theme_constant_override("separation", 4) get_tree().root.add_child.call_deferred(_menu) - var states: Array[String] = ["fresh", "resume", "guidance", "retry", "late"] + var states: Array[String] = [ + "fresh", "resume", "guidance", "defeat", "defeat_broke", "late"] for state in states: var button := Button.new() button.text = state @@ -199,7 +234,9 @@ func _validate() -> void: var failed: int = 0 failed += await _validate_title(false, "fresh title hides Continue") failed += await _validate_title(true, "resume title shows Continue") - failed += await _validate_retry() + failed += await _validate_defeat_title() + failed += await _validate_defeat(false) + failed += await _validate_defeat(true) failed += await _validate_late_gate() failed += await _validate_guidance() if failed > 0: @@ -230,24 +267,50 @@ func _validate_title(with_save: bool, label: String) -> int: return 0 if ok else 1 -func _validate_retry() -> int: +func _validate_defeat_title() -> int: + _clear_stage() + var sealed: Dictionary = _sample_checkpoint() + sealed["ended"] = true + Journey.write_checkpoint(sealed) + var title: Control = TITLE_SCENE.instantiate() as Control + add_child(title) + await get_tree().process_frame + await get_tree().process_frame + var panel: Control = title.get_node("Ui/Screen/JourneyPanel") as Control + var ok: bool = not panel.visible and not Journey.has_valid_checkpoint() + title.queue_free() + await get_tree().process_frame + print(("PASS " if ok else "FAIL ") + "sealed title hides Continue") + return 0 if ok else 1 + + +func _validate_defeat(broke: bool) -> int: _clear_stage() await get_tree().process_frame - await _show_retry() + await _show_defeat(broke) await get_tree().process_frame var retry: Button = _stage.get_node("Ui/Result/Actions/Retry") as Button - # The result counts the checkpoint, not the defeated segment's +2000. - var snapshot: Dictionary = _stage.get("_journey_snapshot") - var clamped := Score.new() - clamped.cycles = maxi(int(snapshot["cycle"]) - 1, 0) - clamped.beacons = int(snapshot["lit_count"]) - clamped.survived = float(snapshot["survived"]) - clamped.level = int(snapshot["level"]) - clamped.kills = int(snapshot["kill_score"]) + var again: Button = _stage.get_node("Ui/Result/Actions/Continue") as Button + # The result counts the final live state, defeated segment's +2000 + # included, and the journey file carries the terminal marker. + var live := Score.new() + live.cycles = maxi(int(_stage.get("_cycle")) - 1, 0) + live.beacons = int(_stage.get("_lit_count")) + live.survived = float(_stage.get("_survived")) + live.level = int(_stage.get("_level")) + live.kills = int(_stage.get("_kill_score")) + var want_continue: String = tr("RESULT_CONTINUE_BUY") if broke \ + else tr("RESULT_CONTINUE_COINS") % 2 + var sealed: Dictionary = Journey.read_checkpoint() var ok: bool = bool(_stage.get("_over")) \ - and retry.text == tr("RESULT_GATE_RETRY") \ - and int(_stage.get("_board_score")) == clamped.total() - print(("PASS " if ok else "FAIL ") + "death retry offers the saved gate") + and retry.text == tr("RESULT_START_OVER") \ + and again.visible and again.text == want_continue \ + and int(_stage.get("_board_score")) == live.total() \ + and bool(sealed.get("ended", false)) \ + and not Journey.has_valid_checkpoint() + print(("PASS " if ok else "FAIL ") + + ("broke defeat offers the shop road" if broke + else "defeat seals terminal with a coin road")) return 0 if ok else 1 diff --git a/apps/game/tools/shot_ui.gd b/apps/game/tools/shot_ui.gd index 69c2e4b..c2cbde3 100644 --- a/apps/game/tools/shot_ui.gd +++ b/apps/game/tools/shot_ui.gd @@ -312,10 +312,10 @@ func _open(id: String, node: Node) -> void: node.call("open_cycle", 3) "result_lose": node.call("show_result", false, _sample_score(2), true, true, - -1, false, KEEPER) + -1, KEEPER) "result_win": node.call("show_result", true, _sample_score(9), true, false, - 3, false, KEEPER) + 3, KEEPER) "ladder": node.call("view") "hero": diff --git a/apps/player-care/content/en.mjs b/apps/player-care/content/en.mjs index 2b9eb80..4881ee6 100644 --- a/apps/player-care/content/en.mjs +++ b/apps/player-care/content/en.mjs @@ -79,6 +79,8 @@ export const CONTENT = {
  • Saves land on the device first; uploads follow in the background.
  • Saves never carry purchase or balance records.
  • Downloads are fully checked before install, and a differing cloud copy waits for you to choose which side to keep.
  • +
  • Signed-in accounts also hold one private attendance record (last claim time) that paces the free continue-coin reward; it never appears in the Hall.
  • +
  • On phones, Settings can switch on a local reminder when the next attendance coins are ready. The notice is scheduled on the device from the last claim time and carries neither account references, tokens, nor personal data; it grants no coins by itself and is cancelled entirely by turning it off in Settings. Notices repeat roughly every twelve hours while away, up to 48 scheduled notices that refresh whenever the game is opened.
  • `, }, { @@ -86,9 +88,10 @@ export const CONTENT = { num: '5', h: 'Hall board', html: `
      -
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, and update time.
    • +
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, update time, and — once claimed — the account's public game handle.
    • Board reads are public; only the owning account may write its entry. Scores only increase, and standing is derived from scores so equal scores share one rank.
    • -
    • No name, email, account reference, token, or save appears in a Hall entry.
    • +
    • Each account may claim one permanent public handle of 2–12 letters, digits, or Chinese/Japanese/Korean characters. Claiming needs a connection; afterwards the handle shows on the account's Hall entry on every device. Handles cannot be renamed or moved to another account.
    • +
    • The handle is a game name, not a real name, and it is the only name in a Hall entry. No email, account reference, token, or save appears in one.
    `, }, { @@ -96,11 +99,11 @@ export const CONTENT = { num: '6', h: 'Deletion', html: `
      -
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • +
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, claimed name and adventurer record, attendance record, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • On iOS, deleting an Apple-linked account may ask you to confirm again through the Apple sign-in sheet.
    • Signing out ends the sign-in session and switches to a fresh guest ID; it deletes nothing on the device or in the cloud.
    • Deleting your cloud account removes its cloud records and sign-in, its local journey save files, and its on-device account binding; other accounts' local saves and separate on-device files such as purchases, Vault, and settings remain.
    • -
    • Account, checkpoint, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • +
    • Account, name, checkpoint, attendance, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • You can also request access, correction, or deletion of your data by email at ${SUPPORT_EMAIL}. Include your player ID.
    • Support requests are matched to your records through your player ID; deletion requests that concern purchase records are forwarded to the purchase-verification processor.
    `, @@ -112,7 +115,7 @@ export const CONTENT = { html: `
    • Guest play works offline as a device-only guest and is never shown as a registered cloud account.
    • If background sign-up fails, or the build has no sign-in setup, play continues anyway. Builds without sign-in setup make no registration attempt and show no error.
    • -
    • Sign-in, cloud saves, the Hall, the store, and purchase verification need an internet connection.
    • +
    • Sign-in, cloud saves, the Hall, attendance claims, the store, and purchase verification need an internet connection.
    `, }, { @@ -225,6 +228,8 @@ export const CONTENT = { html: `
    • Core play and local records work offline as a guest.
    • Loading store products, purchasing, purchase verification, sign-in, cloud saves, and the Hall need an internet connection.
    • +
    • Returning after twelve hours grants two continue coins; claiming needs a connection, and offline play continues without the grant.
    • +
    • On phones, Settings can switch on a local reminder when the next two coins are ready; turning it off cancels it. The notice never grants coins by itself.
    • There are no ads.
    `, }, diff --git a/apps/player-care/content/ja.mjs b/apps/player-care/content/ja.mjs index 129353c..17deb23 100644 --- a/apps/player-care/content/ja.mjs +++ b/apps/player-care/content/ja.mjs @@ -76,6 +76,8 @@ export const CONTENT = {
  • 保存は端末が先で、送信は後から追います。
  • 保存に購入・残高の記録は入りません。
  • 受信は十分に検査してから入り、異なるクラウド写しはどちらを残すか選択を待ちます。
  • +
  • ログイン済みアカウントは無料コンティニューコイン報酬の間隔を保つ非公開の出席記録(最後の受取時刻)も一つ持ちます。殿堂には決して表示されません。
  • +
  • スマートフォンでは、次の出席コインが受け取れるときに知らせるローカル通知を設定でオンにできます。通知は最後の出席時刻をもとに端末内で予約され、アカウント情報・トークン・個人情報を含まず、コインを直接付与することもありません。設定でオフにすると予約はすべて取り消されます。通知は留守の間おおむね12時間ごとに繰り返され、最大24日分が予約されてゲームを開くたびに更新されます。
  • `, }, { @@ -83,9 +85,10 @@ export const CONTENT = { num: '5', h: '殿堂', html: `
      -
    • 殿堂はプレイヤーIDごとに最良記録一つ(プレイヤーID、英雄、スコア、進行度、アプリ版、更新時刻)を保ちます。
    • +
    • 殿堂はプレイヤーIDごとに最良記録一つ(プレイヤーID、英雄、スコア、進行度、アプリ版、更新時刻、そして登録済みならアカウントの公開ゲーム名)を保ちます。
    • 読み取りは公開で、書き込みは所有アカウントのみ、スコアは上がる一方で、順位はスコアから求めるため同点は同順位になります。
    • -
    • 殿堂の記録に名前、メール、アカウント参照、トークン、保存は入りません。
    • +
    • アカウントごとに英字・数字・中日韓文字2〜12字の永続公開名を一つ登録できます。登録には接続が必要で、登録後はどの端末の殿堂記録にもその名が表示されます。改名や他のアカウントへの移し替えはできません。
    • +
    • この名はゲーム用の名であり本名ではなく、殿堂の記録に入る唯一の名です。メール、アカウント参照、トークン、保存は入りません。
    `, }, { @@ -93,11 +96,11 @@ export const CONTENT = { num: '6', h: '削除', html: `
      -
    • クラウドアカウントはゲームのアカウント設定から消します。削除は殿堂記録、クラウド保存、予約、プロフィールを一つの検証済み手順で先にまとめて消し、その確認後にログイン自体を消します。
    • +
    • クラウドアカウントはゲームのアカウント設定から消します。削除は殿堂記録、登録済みの名と冒険者記録、出席記録、クラウド保存、予約、プロフィールを一つの検証済み手順で先にまとめて消し、その確認後にログイン自体を消します。
    • iOSでApple連携のアカウントを消すときはAppleログイン画面での再確認を求めることがあります。
    • 出るとログインの続きは終わり新しいゲストIDに替わります。端末とクラウドのどちらからも何も消しません。
    • クラウドアカウントを消すとクラウドの記録とログインに加え、そのアカウントのローカルな旅の保存と端末内のアカウントの結び付けも消えます。他のアカウントのローカル保存や購入・Vault・設定などの別の端末内ファイルは残ります。
    • -
    • アカウント・チェックポイント・殿堂の記録に、自動で消える期限はありません。アカウントを消すまで残ります。
    • +
    • アカウント・名・チェックポイント・出席・殿堂の記録に、自動で消える期限はありません。アカウントを消すまで残ります。
    • ${SUPPORT_EMAIL}へのメールでも自分の情報の開示・訂正・削除を求められます。プレイヤーIDを添えてください。
    • 問合せはプレイヤーIDで記録と照らします。購入記録にかかる削除の求めは購入確認サービスへ渡します。
    `, @@ -109,7 +112,7 @@ export const CONTENT = { html: `
    • ゲストプレイは端末専用ゲストとしてオフラインでも動き、クラウド登録済みとして表示されません。
    • 裏側の加入に失敗しても、ログイン設定のないビルドでも、プレイは続きます。ログイン設定のないビルドは登録を試みず、誤りも出しません。
    • -
    • ログイン、クラウド保存、殿堂、ストア、購入確認にはインターネット接続が必要です。
    • +
    • ログイン、クラウド保存、殿堂、出席受取、ストア、購入確認にはインターネット接続が必要です。
    `, }, { @@ -221,6 +224,8 @@ export const CONTENT = { html: `
    • 基本プレイとローカル記録はゲストとしてオフラインでも動きます。
    • 商品の読み込み、購入、購入確認、ログイン、クラウド保存と殿堂にはインターネット接続が必要です。
    • +
    • 十二時間ぶりの帰還でコンティニューコインを二枚贈ります。受取には接続が必要で、圏外でも遊びはそのまま続けられます。
    • +
    • スマートフォンでは、次の2コインが受け取れるときに知らせるローカル通知を設定でオンにでき、オフにすると取り消されます。通知自体がコインを付与することはありません。
    • 広告はありません。
    `, }, diff --git a/apps/player-care/content/ko.mjs b/apps/player-care/content/ko.mjs index 0836f8b..7351e6e 100644 --- a/apps/player-care/content/ko.mjs +++ b/apps/player-care/content/ko.mjs @@ -76,6 +76,8 @@ export const CONTENT = {
  • 저장은 기기에 먼저 되고 업로드는 뒤따릅니다.
  • 저장에 구매·잔액 기록은 들어가지 않습니다.
  • 다운로드는 완전히 검사한 뒤 설치되고, 다른 클라우드 사본은 어느 쪽을 쓸지 선택을 기다립니다.
  • +
  • 로그인한 계정은 무료 이어하기 코인 보상의 간격을 재는 비공개 출석 기록(마지막 수령 시각)도 하나 가집니다. 전당에는 절대 표시되지 않습니다.
  • +
  • 휴대전화에서는 다음 출석 코인을 받을 수 있을 때 알려주는 로컬 알림을 설정에서 켤 수 있습니다. 알림은 마지막 출석 시각을 기준으로 기기에서 예약되며 계정 정보·토큰·개인정보를 담지 않고 코인을 직접 지급하지도 않습니다. 설정에서 끄면 예약된 알림이 모두 취소됩니다. 알림은 자리를 비운 동안 대략 열두 시간 간격으로 반복되며 최대 24일치가 예약되고 게임을 열 때마다 새로고침됩니다.
  • `, }, { @@ -83,9 +85,10 @@ export const CONTENT = { num: '5', h: '전당', html: `
      -
    • 전당은 플레이어 ID마다 최고 기록 하나(플레이어 ID, 영웅, 점수, 진행도, 앱 버전, 갱신 시각)를 보관합니다.
    • +
    • 전당은 플레이어 ID마다 최고 기록 하나(플레이어 ID, 영웅, 점수, 진행도, 앱 버전, 갱신 시각, 그리고 등록한 경우 계정의 공개 게임 별명)를 보관합니다.
    • 읽기는 공개이며 쓰기는 소유 계정만 가능하고, 점수는 올라가기만 하며 순위는 점수에서 계산되어 동점은 같은 순위를 공유합니다.
    • -
    • 전당 기록에 이름, 이메일, 계정 참조, 토큰, 저장은 들어가지 않습니다.
    • +
    • 계정마다 영문·숫자·중일한 문자 2~12자로 된 영구 공개 별명을 하나 등록할 수 있습니다. 등록에는 연결이 필요하며, 등록 후에는 모든 기기의 전당 기록에 별명이 표시됩니다. 별명은 바꿀 수도 다른 계정으로 옮길 수도 없습니다.
    • +
    • 별명은 게임 이름이지 실명이 아니며, 전당 기록에 들어가는 유일한 이름입니다. 이메일, 계정 참조, 토큰, 저장은 들어가지 않습니다.
    `, }, { @@ -93,11 +96,11 @@ export const CONTENT = { num: '6', h: '삭제', html: `
      -
    • 클라우드 계정은 게임의 계정 설정에서 삭제합니다. 삭제는 전당 기록, 클라우드 저장, 예약, 프로필을 하나의 검증된 단계로 먼저 함께 지우고, 그 확인 뒤에 로그인 자체를 삭제합니다.
    • +
    • 클라우드 계정은 게임의 계정 설정에서 삭제합니다. 삭제는 전당 기록, 등록한 별명과 모험가 기록, 출석 기록, 클라우드 저장, 예약, 프로필을 하나의 검증된 단계로 먼저 함께 지우고, 그 확인 뒤에 로그인 자체를 삭제합니다.
    • iOS에서 Apple 연결 계정을 삭제할 때는 Apple 로그인 화면으로 다시 확인할 수 있습니다.
    • 로그아웃하면 로그인 세션이 끝나고 새 게스트 ID로 바뀝니다. 기기와 클라우드 어디에서도 아무것도 지우지 않습니다.
    • 클라우드 계정을 삭제하면 클라우드 기록과 로그인은 물론 그 계정의 로컬 여정 저장 파일과 기기 안의 계정 연결도 지워집니다. 다른 계정의 로컬 저장과 구매·Vault·설정 같은 별도의 기기 파일은 남습니다.
    • -
    • 계정·체크포인트·전당 기록에는 자동으로 지워지는 기한이 없습니다. 계정을 삭제하기 전까지 남습니다.
    • +
    • 계정·별명·체크포인트·출석·전당 기록에는 자동으로 지워지는 기한이 없습니다. 계정을 삭제하기 전까지 남습니다.
    • ${SUPPORT_EMAIL}로 이메일하면 내 정보의 열람·정정·삭제를 요청할 수 있습니다. 플레이어 ID를 함께 보내 주세요.
    • 고객지원 요청은 플레이어 ID로 기록과 대조합니다. 구매 기록에 관한 삭제 요청은 구매 검증 처리 업체에 전달됩니다.
    `, @@ -109,7 +112,7 @@ export const CONTENT = { html: `
    • 게스트 플레이는 기기 전용 게스트로 오프라인에서도 되며 클라우드 등록 계정으로 표시되지 않습니다.
    • 백그라운드 가입이 실패하거나 로그인 설정이 없는 빌드에서도 플레이는 계속됩니다. 로그인 설정이 없는 빌드는 등록을 시도하지 않고 오류도 내지 않습니다.
    • -
    • 로그인, 클라우드 저장, 전당, 상점, 구매 검증에는 인터넷 연결이 필요합니다.
    • +
    • 로그인, 클라우드 저장, 전당, 출석 수령, 상점, 구매 검증에는 인터넷 연결이 필요합니다.
    `, }, { @@ -221,6 +224,8 @@ export const CONTENT = { html: `
    • 기본 플레이와 로컬 기록은 게스트로 오프라인에서도 됩니다.
    • 상품 조회, 구매, 구매 검증, 로그인, 클라우드 저장, 전당에는 인터넷 연결이 필요합니다.
    • +
    • 열두 시간이 지나 돌아오면 이어하기 코인 2개를 받습니다. 수령에는 연결이 필요하고, 오프라인에서도 플레이는 그대로 이어집니다.
    • +
    • 휴대전화에서는 다음 2코인을 받을 수 있을 때 알려주는 로컬 알림을 설정에서 켤 수 있으며 끄면 취소됩니다. 알림 자체가 코인을 지급하지는 않습니다.
    • 광고는 없습니다.
    `, }, diff --git a/apps/player-care/content/zh-Hans.mjs b/apps/player-care/content/zh-Hans.mjs index 12ab993..c2daed0 100644 --- a/apps/player-care/content/zh-Hans.mjs +++ b/apps/player-care/content/zh-Hans.mjs @@ -76,6 +76,8 @@ export const CONTENT = {
  • 先落盘再后台上传。
  • 存档不含购买或余额记录。
  • 下载须通过完整检查才会安装;不一致的云端副本等待玩家选择保留哪一侧。
  • +
  • 已登录账号还持有一条非公开出勤记录(上次领取时间),用于限定免费继续金币奖励的间隔;它绝不会显示在殿堂中。
  • +
  • 在手机上,可在设置中开启本地提醒,在下次签到币可领取时通知您。通知根据上次签到时间在设备上安排,不包含账号信息、令牌或个人数据,也不会直接发放金币。在设置中关闭后,所有已安排的提醒都会取消。通知在您离开期间大约每十二小时重复一次,最多安排24天的通知,每次打开游戏时都会刷新。
  • `, }, { @@ -83,9 +85,10 @@ export const CONTENT = { num: '5', h: '殿堂榜', html: `
      -
    • 殿堂按玩家 ID 保存一条最佳记录(玩家 ID、英雄、分数、进度、应用版本、更新时间)。
    • +
    • 殿堂按玩家 ID 保存一条最佳记录(玩家 ID、英雄、分数、进度、应用版本、更新时间,以及已认领时的账号公开游戏名)。
    • 读取公开,仅归属账号可写,分数只增不减,名次由分数推导,同分同名次。
    • -
    • 殿堂记录不含姓名、邮箱、账号引用、令牌或存档。
    • +
    • 每个账号可认领一个 2–12 字的永久公开名:字母、数字或中日韩文字。认领需要联网;认领后,该名会显示在所有设备的殿堂记录中。公开名不可改名,也不可转移到其他账号。
    • +
    • 该名是游戏名而非真实姓名,是殿堂记录中唯一的名称。殿堂记录不含邮箱、账号引用、令牌或存档。
    `, }, { @@ -93,11 +96,11 @@ export const CONTENT = { num: '6', h: '删除', html: `
      -
    • 在游戏的账号设置中删除云账号。删除先以一次已验证的步骤同时清除殿堂记录、云存档、预留与资料;确认后再删除登录本身。
    • +
    • 在游戏的账号设置中删除云账号。删除先以一次已验证的步骤同时清除殿堂记录、已认领的名称与冒险者记录、出勤记录、云存档、预留与资料;确认后再删除登录本身。
    • iOS 上删除 Apple 关联账号时,可能要求重开 Apple 登录页重新确认。
    • 退出登录会结束登录会话并切换到全新游客 ID;在设备或云端都不删除任何内容。
    • 删除云账号会清除其云端记录与登录,以及其本地旅程存档文件和设备上的账号绑定;其他账号的本地存档,以及购买、Vault、设置等单独的设备文件会继续保留。
    • -
    • 账号、检查点与殿堂记录没有预设的自动到期:保留至账号删除为止。
    • +
    • 账号、名称、检查点、出勤与殿堂记录没有预设的自动到期:保留至账号删除为止。
    • 也可以发邮件到 ${SUPPORT_EMAIL},请求查阅、更正或删除本人信息。请附上玩家 ID。
    • 支持请求经玩家 ID 与记录核对;涉及购买记录的删除请求会转交给购买验证处理方。
    `, @@ -109,7 +112,7 @@ export const CONTENT = { html: `
    • 游客可离线以纯本机游客游玩,不会显示为已注册云账号。
    • 后台注册失败或版本未配置登录,游玩都会继续。未配置登录的版本不尝试注册,也不报错。
    • -
    • 登录、云存档、殿堂、商店与购买验证需要网络连接。
    • +
    • 登录、云存档、殿堂、出勤领取、商店与购买验证需要网络连接。
    `, }, { @@ -221,6 +224,8 @@ export const CONTENT = { html: `
    • 基础玩法与本地记录可以游客身份离线使用。
    • 加载商品、购买与购买验证、登录、云存档与殿堂需要网络连接。
    • +
    • 相隔十二小时后回归可领取两枚继续金币;领取需要联网,离线也可继续游玩,只是领不到奖励。
    • +
    • 在手机上,可在设置中开启本地提醒,在下次2枚金币可领取时通知您;关闭后即取消。通知本身不会发放金币。
    • 游戏没有广告。
    `, }, diff --git a/apps/player-care/content/zh-Hant.mjs b/apps/player-care/content/zh-Hant.mjs index 412fd36..9961536 100644 --- a/apps/player-care/content/zh-Hant.mjs +++ b/apps/player-care/content/zh-Hant.mjs @@ -76,6 +76,8 @@ export const CONTENT = {
  • 先落盤再後台上傳。
  • 存檔不含購買或餘額紀錄。
  • 下載須通過完整檢查才會安裝;不一致的雲端副本等待玩家選擇保留哪一側。
  • +
  • 已登入帳號還持有一條非公開出勤紀錄(上次領取時間),用於限定免費繼續金幣獎勵的間隔;它絕不會顯示在殿堂中。
  • +
  • 在手機上,可在設定中開啟本地提醒,在下次簽到幣可領取時通知您。通知根據上次簽到時間在裝置上安排,不包含帳號資訊、權杖或個人資料,也不會直接發放金幣。在設定中關閉後,所有已安排的提醒都會取消。通知在您離開期間大約每十二小時重複一次,最多安排24天的通知,每次開啟遊戲時都會重新整理。
  • `, }, { @@ -83,9 +85,10 @@ export const CONTENT = { num: '5', h: '殿堂榜', html: `
      -
    • 殿堂按玩家 ID 保存一條最佳紀錄(玩家 ID、英雄、分數、進度、應用版本、更新時間)。
    • +
    • 殿堂按玩家 ID 保存一條最佳紀錄(玩家 ID、英雄、分數、進度、應用版本、更新時間,以及已認領時的帳號公開遊戲名)。
    • 讀取公開,僅歸屬帳號可寫,分數只增不減,名次由分數推導,同分同名次。
    • -
    • 殿堂紀錄不含姓名、信箱、帳號參照、權杖或存檔。
    • +
    • 每個帳號可認領一個 2–12 字的永久公開名:字母、數字或中日韓文字。認領需要連線;認領後,該名會顯示在所有裝置的殿堂紀錄中。公開名不可改名,也不可轉移到其他帳號。
    • +
    • 該名是遊戲名而非真實姓名,是殿堂紀錄中唯一的名稱。殿堂紀錄不含信箱、帳號參照、權杖或存檔。
    `, }, { @@ -93,11 +96,11 @@ export const CONTENT = { num: '6', h: '刪除', html: `
      -
    • 在遊戲的帳號設定中刪除雲帳號。刪除先以一次已驗證的步驟同時清除殿堂紀錄、雲存檔、預留與資料;確認後再刪除登入本身。
    • +
    • 在遊戲的帳號設定中刪除雲帳號。刪除先以一次已驗證的步驟同時清除殿堂紀錄、已認領的名稱與冒險者紀錄、出勤紀錄、雲存檔、預留與資料;確認後再刪除登入本身。
    • iOS 上刪除 Apple 關聯帳號時,可能要求重開 Apple 登入頁重新確認。
    • 登出會結束登入工作階段並切換到全新訪客 ID;在裝置或雲端都不刪除任何內容。
    • 刪除雲帳號會清除其雲端紀錄與登入,以及其本地旅程存檔檔案和裝置上的帳號綁定;其他帳號的本地存檔,以及購買、Vault、設定等單獨的裝置檔案會繼續保留。
    • -
    • 帳號、檢查點與殿堂紀錄沒有預設的自動到期:保留至帳號刪除為止。
    • +
    • 帳號、名稱、檢查點、出勤與殿堂紀錄沒有預設的自動到期:保留至帳號刪除為止。
    • 也可以寫信到 ${SUPPORT_EMAIL},請求查閱、更正或刪除本人資訊。請附上玩家 ID。
    • 支援請求經玩家 ID 與紀錄核對;涉及購買紀錄的刪除請求會轉交給購買驗證處理方。
    `, @@ -109,7 +112,7 @@ export const CONTENT = { html: `
    • 訪客可離線以純本機訪客遊玩,不會顯示為已註冊雲帳號。
    • 後台註冊失敗或版本未設定登入,遊玩都會繼續。未設定登入的版本不嘗試註冊,也不報錯。
    • -
    • 登入、雲存檔、殿堂、商店與購買驗證需要網路連線。
    • +
    • 登入、雲存檔、殿堂、出勤領取、商店與購買驗證需要網路連線。
    `, }, { @@ -221,6 +224,8 @@ export const CONTENT = { html: `
    • 基礎玩法與本機紀錄,訪客身分可離線使用。
    • 載入商品、購買與購買驗證、登入、雲存檔與殿堂需要網路連線。
    • +
    • 相隔十二小時後回歸可領取兩枚繼續金幣;領取需要連線,離線也可繼續遊玩,只是領不到獎勵。
    • +
    • 在手機上,可在設定中開啟本地提醒,在下次2枚金幣可領取時通知您;關閉後即取消。通知本身不會發放金幣。
    • 遊戲沒有廣告。
    `, }, diff --git a/apps/player-care/dist/en/privacy.html b/apps/player-care/dist/en/privacy.html index c49c8fa..40d2649 100644 --- a/apps/player-care/dist/en/privacy.html +++ b/apps/player-care/dist/en/privacy.html @@ -65,20 +65,23 @@

    Privacy Policy — Moonlit Beacon

  • Saves land on the device first; uploads follow in the background.
  • Saves never carry purchase or balance records.
  • Downloads are fully checked before install, and a differing cloud copy waits for you to choose which side to keep.
  • +
  • Signed-in accounts also hold one private attendance record (last claim time) that paces the free continue-coin reward; it never appears in the Hall.
  • +
  • On phones, Settings can switch on a local reminder when the next attendance coins are ready. The notice is scheduled on the device from the last claim time and carries neither account references, tokens, nor personal data; it grants no coins by itself and is cancelled entirely by turning it off in Settings. Notices repeat roughly every twelve hours while away, up to 48 scheduled notices that refresh whenever the game is opened.
  • Hall board

      -
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, and update time.
    • +
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, update time, and — once claimed — the account's public game handle.
    • Board reads are public; only the owning account may write its entry. Scores only increase, and standing is derived from scores so equal scores share one rank.
    • -
    • No name, email, account reference, token, or save appears in a Hall entry.
    • +
    • Each account may claim one permanent public handle of 2–12 letters, digits, or Chinese/Japanese/Korean characters. Claiming needs a connection; afterwards the handle shows on the account's Hall entry on every device. Handles cannot be renamed or moved to another account.
    • +
    • The handle is a game name, not a real name, and it is the only name in a Hall entry. No email, account reference, token, or save appears in one.

    Deletion

      -
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • +
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, claimed name and adventurer record, attendance record, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • On iOS, deleting an Apple-linked account may ask you to confirm again through the Apple sign-in sheet.
    • Signing out ends the sign-in session and switches to a fresh guest ID; it deletes nothing on the device or in the cloud.
    • Deleting your cloud account removes its cloud records and sign-in, its local journey save files, and its on-device account binding; other accounts' local saves and separate on-device files such as purchases, Vault, and settings remain.
    • -
    • Account, checkpoint, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • +
    • Account, name, checkpoint, attendance, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • You can also request access, correction, or deletion of your data by email at hyo@hyo.dev. Include your player ID.
    • Support requests are matched to your records through your player ID; deletion requests that concern purchase records are forwarded to the purchase-verification processor.
    @@ -86,7 +89,7 @@

    Privacy Policy — Moonlit Beacon

    • Guest play works offline as a device-only guest and is never shown as a registered cloud account.
    • If background sign-up fails, or the build has no sign-in setup, play continues anyway. Builds without sign-in setup make no registration attempt and show no error.
    • -
    • Sign-in, cloud saves, the Hall, the store, and purchase verification need an internet connection.
    • +
    • Sign-in, cloud saves, the Hall, attendance claims, the store, and purchase verification need an internet connection.

    Analytics

    Optional gameplay analytics stays off unless two switches agree: the shipped build's export configuration and your explicit consent in Settings, which defaults to off.

    diff --git a/apps/player-care/dist/en/privacy/index.html b/apps/player-care/dist/en/privacy/index.html index c49c8fa..40d2649 100644 --- a/apps/player-care/dist/en/privacy/index.html +++ b/apps/player-care/dist/en/privacy/index.html @@ -65,20 +65,23 @@

    Privacy Policy — Moonlit Beacon

  • Saves land on the device first; uploads follow in the background.
  • Saves never carry purchase or balance records.
  • Downloads are fully checked before install, and a differing cloud copy waits for you to choose which side to keep.
  • +
  • Signed-in accounts also hold one private attendance record (last claim time) that paces the free continue-coin reward; it never appears in the Hall.
  • +
  • On phones, Settings can switch on a local reminder when the next attendance coins are ready. The notice is scheduled on the device from the last claim time and carries neither account references, tokens, nor personal data; it grants no coins by itself and is cancelled entirely by turning it off in Settings. Notices repeat roughly every twelve hours while away, up to 48 scheduled notices that refresh whenever the game is opened.
  • Hall board

      -
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, and update time.
    • +
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, update time, and — once claimed — the account's public game handle.
    • Board reads are public; only the owning account may write its entry. Scores only increase, and standing is derived from scores so equal scores share one rank.
    • -
    • No name, email, account reference, token, or save appears in a Hall entry.
    • +
    • Each account may claim one permanent public handle of 2–12 letters, digits, or Chinese/Japanese/Korean characters. Claiming needs a connection; afterwards the handle shows on the account's Hall entry on every device. Handles cannot be renamed or moved to another account.
    • +
    • The handle is a game name, not a real name, and it is the only name in a Hall entry. No email, account reference, token, or save appears in one.

    Deletion

      -
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • +
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, claimed name and adventurer record, attendance record, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • On iOS, deleting an Apple-linked account may ask you to confirm again through the Apple sign-in sheet.
    • Signing out ends the sign-in session and switches to a fresh guest ID; it deletes nothing on the device or in the cloud.
    • Deleting your cloud account removes its cloud records and sign-in, its local journey save files, and its on-device account binding; other accounts' local saves and separate on-device files such as purchases, Vault, and settings remain.
    • -
    • Account, checkpoint, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • +
    • Account, name, checkpoint, attendance, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • You can also request access, correction, or deletion of your data by email at hyo@hyo.dev. Include your player ID.
    • Support requests are matched to your records through your player ID; deletion requests that concern purchase records are forwarded to the purchase-verification processor.
    @@ -86,7 +89,7 @@

    Privacy Policy — Moonlit Beacon

    • Guest play works offline as a device-only guest and is never shown as a registered cloud account.
    • If background sign-up fails, or the build has no sign-in setup, play continues anyway. Builds without sign-in setup make no registration attempt and show no error.
    • -
    • Sign-in, cloud saves, the Hall, the store, and purchase verification need an internet connection.
    • +
    • Sign-in, cloud saves, the Hall, attendance claims, the store, and purchase verification need an internet connection.

    Analytics

    Optional gameplay analytics stays off unless two switches agree: the shipped build's export configuration and your explicit consent in Settings, which defaults to off.

    diff --git a/apps/player-care/dist/en/support.html b/apps/player-care/dist/en/support.html index 8d244e1..9be7581 100644 --- a/apps/player-care/dist/en/support.html +++ b/apps/player-care/dist/en/support.html @@ -93,6 +93,8 @@

    Support — Moonlit Beacon

    • Core play and local records work offline as a guest.
    • Loading store products, purchasing, purchase verification, sign-in, cloud saves, and the Hall need an internet connection.
    • +
    • Returning after twelve hours grants two continue coins; claiming needs a connection, and offline play continues without the grant.
    • +
    • On phones, Settings can switch on a local reminder when the next two coins are ready; turning it off cancels it. The notice never grants coins by itself.
    • There are no ads.

    Privacy

    diff --git a/apps/player-care/dist/en/support/index.html b/apps/player-care/dist/en/support/index.html index 8d244e1..9be7581 100644 --- a/apps/player-care/dist/en/support/index.html +++ b/apps/player-care/dist/en/support/index.html @@ -93,6 +93,8 @@

    Support — Moonlit Beacon

    • Core play and local records work offline as a guest.
    • Loading store products, purchasing, purchase verification, sign-in, cloud saves, and the Hall need an internet connection.
    • +
    • Returning after twelve hours grants two continue coins; claiming needs a connection, and offline play continues without the grant.
    • +
    • On phones, Settings can switch on a local reminder when the next two coins are ready; turning it off cancels it. The notice never grants coins by itself.
    • There are no ads.

    Privacy

    diff --git a/apps/player-care/dist/ja/privacy.html b/apps/player-care/dist/ja/privacy.html index 0e043e3..4c88098 100644 --- a/apps/player-care/dist/ja/privacy.html +++ b/apps/player-care/dist/ja/privacy.html @@ -65,20 +65,23 @@

    プライバシーポリシー — 月明かりの烽火

  • 保存は端末が先で、送信は後から追います。
  • 保存に購入・残高の記録は入りません。
  • 受信は十分に検査してから入り、異なるクラウド写しはどちらを残すか選択を待ちます。
  • +
  • ログイン済みアカウントは無料コンティニューコイン報酬の間隔を保つ非公開の出席記録(最後の受取時刻)も一つ持ちます。殿堂には決して表示されません。
  • +
  • スマートフォンでは、次の出席コインが受け取れるときに知らせるローカル通知を設定でオンにできます。通知は最後の出席時刻をもとに端末内で予約され、アカウント情報・トークン・個人情報を含まず、コインを直接付与することもありません。設定でオフにすると予約はすべて取り消されます。通知は留守の間おおむね12時間ごとに繰り返され、最大24日分が予約されてゲームを開くたびに更新されます。
  • 殿堂

      -
    • 殿堂はプレイヤーIDごとに最良記録一つ(プレイヤーID、英雄、スコア、進行度、アプリ版、更新時刻)を保ちます。
    • +
    • 殿堂はプレイヤーIDごとに最良記録一つ(プレイヤーID、英雄、スコア、進行度、アプリ版、更新時刻、そして登録済みならアカウントの公開ゲーム名)を保ちます。
    • 読み取りは公開で、書き込みは所有アカウントのみ、スコアは上がる一方で、順位はスコアから求めるため同点は同順位になります。
    • -
    • 殿堂の記録に名前、メール、アカウント参照、トークン、保存は入りません。
    • +
    • アカウントごとに英字・数字・中日韓文字2〜12字の永続公開名を一つ登録できます。登録には接続が必要で、登録後はどの端末の殿堂記録にもその名が表示されます。改名や他のアカウントへの移し替えはできません。
    • +
    • この名はゲーム用の名であり本名ではなく、殿堂の記録に入る唯一の名です。メール、アカウント参照、トークン、保存は入りません。

    削除

      -
    • クラウドアカウントはゲームのアカウント設定から消します。削除は殿堂記録、クラウド保存、予約、プロフィールを一つの検証済み手順で先にまとめて消し、その確認後にログイン自体を消します。
    • +
    • クラウドアカウントはゲームのアカウント設定から消します。削除は殿堂記録、登録済みの名と冒険者記録、出席記録、クラウド保存、予約、プロフィールを一つの検証済み手順で先にまとめて消し、その確認後にログイン自体を消します。
    • iOSでApple連携のアカウントを消すときはAppleログイン画面での再確認を求めることがあります。
    • 出るとログインの続きは終わり新しいゲストIDに替わります。端末とクラウドのどちらからも何も消しません。
    • クラウドアカウントを消すとクラウドの記録とログインに加え、そのアカウントのローカルな旅の保存と端末内のアカウントの結び付けも消えます。他のアカウントのローカル保存や購入・Vault・設定などの別の端末内ファイルは残ります。
    • -
    • アカウント・チェックポイント・殿堂の記録に、自動で消える期限はありません。アカウントを消すまで残ります。
    • +
    • アカウント・名・チェックポイント・出席・殿堂の記録に、自動で消える期限はありません。アカウントを消すまで残ります。
    • hyo@hyo.devへのメールでも自分の情報の開示・訂正・削除を求められます。プレイヤーIDを添えてください。
    • 問合せはプレイヤーIDで記録と照らします。購入記録にかかる削除の求めは購入確認サービスへ渡します。
    @@ -86,7 +89,7 @@

    プライバシーポリシー — 月明かりの烽火

    • ゲストプレイは端末専用ゲストとしてオフラインでも動き、クラウド登録済みとして表示されません。
    • 裏側の加入に失敗しても、ログイン設定のないビルドでも、プレイは続きます。ログイン設定のないビルドは登録を試みず、誤りも出しません。
    • -
    • ログイン、クラウド保存、殿堂、ストア、購入確認にはインターネット接続が必要です。
    • +
    • ログイン、クラウド保存、殿堂、出席受取、ストア、購入確認にはインターネット接続が必要です。

    分析

    任意のゲームプレイ分析は、出荷ビルドの書出し設定と初期停止の設定明示同意の両方がそろわない限り停止のままです。

    diff --git a/apps/player-care/dist/ja/privacy/index.html b/apps/player-care/dist/ja/privacy/index.html index 0e043e3..4c88098 100644 --- a/apps/player-care/dist/ja/privacy/index.html +++ b/apps/player-care/dist/ja/privacy/index.html @@ -65,20 +65,23 @@

    プライバシーポリシー — 月明かりの烽火

  • 保存は端末が先で、送信は後から追います。
  • 保存に購入・残高の記録は入りません。
  • 受信は十分に検査してから入り、異なるクラウド写しはどちらを残すか選択を待ちます。
  • +
  • ログイン済みアカウントは無料コンティニューコイン報酬の間隔を保つ非公開の出席記録(最後の受取時刻)も一つ持ちます。殿堂には決して表示されません。
  • +
  • スマートフォンでは、次の出席コインが受け取れるときに知らせるローカル通知を設定でオンにできます。通知は最後の出席時刻をもとに端末内で予約され、アカウント情報・トークン・個人情報を含まず、コインを直接付与することもありません。設定でオフにすると予約はすべて取り消されます。通知は留守の間おおむね12時間ごとに繰り返され、最大24日分が予約されてゲームを開くたびに更新されます。
  • 殿堂

      -
    • 殿堂はプレイヤーIDごとに最良記録一つ(プレイヤーID、英雄、スコア、進行度、アプリ版、更新時刻)を保ちます。
    • +
    • 殿堂はプレイヤーIDごとに最良記録一つ(プレイヤーID、英雄、スコア、進行度、アプリ版、更新時刻、そして登録済みならアカウントの公開ゲーム名)を保ちます。
    • 読み取りは公開で、書き込みは所有アカウントのみ、スコアは上がる一方で、順位はスコアから求めるため同点は同順位になります。
    • -
    • 殿堂の記録に名前、メール、アカウント参照、トークン、保存は入りません。
    • +
    • アカウントごとに英字・数字・中日韓文字2〜12字の永続公開名を一つ登録できます。登録には接続が必要で、登録後はどの端末の殿堂記録にもその名が表示されます。改名や他のアカウントへの移し替えはできません。
    • +
    • この名はゲーム用の名であり本名ではなく、殿堂の記録に入る唯一の名です。メール、アカウント参照、トークン、保存は入りません。

    削除

      -
    • クラウドアカウントはゲームのアカウント設定から消します。削除は殿堂記録、クラウド保存、予約、プロフィールを一つの検証済み手順で先にまとめて消し、その確認後にログイン自体を消します。
    • +
    • クラウドアカウントはゲームのアカウント設定から消します。削除は殿堂記録、登録済みの名と冒険者記録、出席記録、クラウド保存、予約、プロフィールを一つの検証済み手順で先にまとめて消し、その確認後にログイン自体を消します。
    • iOSでApple連携のアカウントを消すときはAppleログイン画面での再確認を求めることがあります。
    • 出るとログインの続きは終わり新しいゲストIDに替わります。端末とクラウドのどちらからも何も消しません。
    • クラウドアカウントを消すとクラウドの記録とログインに加え、そのアカウントのローカルな旅の保存と端末内のアカウントの結び付けも消えます。他のアカウントのローカル保存や購入・Vault・設定などの別の端末内ファイルは残ります。
    • -
    • アカウント・チェックポイント・殿堂の記録に、自動で消える期限はありません。アカウントを消すまで残ります。
    • +
    • アカウント・名・チェックポイント・出席・殿堂の記録に、自動で消える期限はありません。アカウントを消すまで残ります。
    • hyo@hyo.devへのメールでも自分の情報の開示・訂正・削除を求められます。プレイヤーIDを添えてください。
    • 問合せはプレイヤーIDで記録と照らします。購入記録にかかる削除の求めは購入確認サービスへ渡します。
    @@ -86,7 +89,7 @@

    プライバシーポリシー — 月明かりの烽火

    • ゲストプレイは端末専用ゲストとしてオフラインでも動き、クラウド登録済みとして表示されません。
    • 裏側の加入に失敗しても、ログイン設定のないビルドでも、プレイは続きます。ログイン設定のないビルドは登録を試みず、誤りも出しません。
    • -
    • ログイン、クラウド保存、殿堂、ストア、購入確認にはインターネット接続が必要です。
    • +
    • ログイン、クラウド保存、殿堂、出席受取、ストア、購入確認にはインターネット接続が必要です。

    分析

    任意のゲームプレイ分析は、出荷ビルドの書出し設定と初期停止の設定明示同意の両方がそろわない限り停止のままです。

    diff --git a/apps/player-care/dist/ja/support.html b/apps/player-care/dist/ja/support.html index 52ee141..117b5c9 100644 --- a/apps/player-care/dist/ja/support.html +++ b/apps/player-care/dist/ja/support.html @@ -93,6 +93,8 @@

    サポート — 月明かりの烽火

    • 基本プレイとローカル記録はゲストとしてオフラインでも動きます。
    • 商品の読み込み、購入、購入確認、ログイン、クラウド保存と殿堂にはインターネット接続が必要です。
    • +
    • 十二時間ぶりの帰還でコンティニューコインを二枚贈ります。受取には接続が必要で、圏外でも遊びはそのまま続けられます。
    • +
    • スマートフォンでは、次の2コインが受け取れるときに知らせるローカル通知を設定でオンにでき、オフにすると取り消されます。通知自体がコインを付与することはありません。
    • 広告はありません。

    個人情報

    diff --git a/apps/player-care/dist/ja/support/index.html b/apps/player-care/dist/ja/support/index.html index 52ee141..117b5c9 100644 --- a/apps/player-care/dist/ja/support/index.html +++ b/apps/player-care/dist/ja/support/index.html @@ -93,6 +93,8 @@

    サポート — 月明かりの烽火

    • 基本プレイとローカル記録はゲストとしてオフラインでも動きます。
    • 商品の読み込み、購入、購入確認、ログイン、クラウド保存と殿堂にはインターネット接続が必要です。
    • +
    • 十二時間ぶりの帰還でコンティニューコインを二枚贈ります。受取には接続が必要で、圏外でも遊びはそのまま続けられます。
    • +
    • スマートフォンでは、次の2コインが受け取れるときに知らせるローカル通知を設定でオンにでき、オフにすると取り消されます。通知自体がコインを付与することはありません。
    • 広告はありません。

    個人情報

    diff --git a/apps/player-care/dist/ko/privacy.html b/apps/player-care/dist/ko/privacy.html index eb452c8..ea7c03f 100644 --- a/apps/player-care/dist/ko/privacy.html +++ b/apps/player-care/dist/ko/privacy.html @@ -65,20 +65,23 @@

    개인정보처리방침 — 달빛 봉화

  • 저장은 기기에 먼저 되고 업로드는 뒤따릅니다.
  • 저장에 구매·잔액 기록은 들어가지 않습니다.
  • 다운로드는 완전히 검사한 뒤 설치되고, 다른 클라우드 사본은 어느 쪽을 쓸지 선택을 기다립니다.
  • +
  • 로그인한 계정은 무료 이어하기 코인 보상의 간격을 재는 비공개 출석 기록(마지막 수령 시각)도 하나 가집니다. 전당에는 절대 표시되지 않습니다.
  • +
  • 휴대전화에서는 다음 출석 코인을 받을 수 있을 때 알려주는 로컬 알림을 설정에서 켤 수 있습니다. 알림은 마지막 출석 시각을 기준으로 기기에서 예약되며 계정 정보·토큰·개인정보를 담지 않고 코인을 직접 지급하지도 않습니다. 설정에서 끄면 예약된 알림이 모두 취소됩니다. 알림은 자리를 비운 동안 대략 열두 시간 간격으로 반복되며 최대 24일치가 예약되고 게임을 열 때마다 새로고침됩니다.
  • 전당

      -
    • 전당은 플레이어 ID마다 최고 기록 하나(플레이어 ID, 영웅, 점수, 진행도, 앱 버전, 갱신 시각)를 보관합니다.
    • +
    • 전당은 플레이어 ID마다 최고 기록 하나(플레이어 ID, 영웅, 점수, 진행도, 앱 버전, 갱신 시각, 그리고 등록한 경우 계정의 공개 게임 별명)를 보관합니다.
    • 읽기는 공개이며 쓰기는 소유 계정만 가능하고, 점수는 올라가기만 하며 순위는 점수에서 계산되어 동점은 같은 순위를 공유합니다.
    • -
    • 전당 기록에 이름, 이메일, 계정 참조, 토큰, 저장은 들어가지 않습니다.
    • +
    • 계정마다 영문·숫자·중일한 문자 2~12자로 된 영구 공개 별명을 하나 등록할 수 있습니다. 등록에는 연결이 필요하며, 등록 후에는 모든 기기의 전당 기록에 별명이 표시됩니다. 별명은 바꿀 수도 다른 계정으로 옮길 수도 없습니다.
    • +
    • 별명은 게임 이름이지 실명이 아니며, 전당 기록에 들어가는 유일한 이름입니다. 이메일, 계정 참조, 토큰, 저장은 들어가지 않습니다.

    삭제

      -
    • 클라우드 계정은 게임의 계정 설정에서 삭제합니다. 삭제는 전당 기록, 클라우드 저장, 예약, 프로필을 하나의 검증된 단계로 먼저 함께 지우고, 그 확인 뒤에 로그인 자체를 삭제합니다.
    • +
    • 클라우드 계정은 게임의 계정 설정에서 삭제합니다. 삭제는 전당 기록, 등록한 별명과 모험가 기록, 출석 기록, 클라우드 저장, 예약, 프로필을 하나의 검증된 단계로 먼저 함께 지우고, 그 확인 뒤에 로그인 자체를 삭제합니다.
    • iOS에서 Apple 연결 계정을 삭제할 때는 Apple 로그인 화면으로 다시 확인할 수 있습니다.
    • 로그아웃하면 로그인 세션이 끝나고 새 게스트 ID로 바뀝니다. 기기와 클라우드 어디에서도 아무것도 지우지 않습니다.
    • 클라우드 계정을 삭제하면 클라우드 기록과 로그인은 물론 그 계정의 로컬 여정 저장 파일과 기기 안의 계정 연결도 지워집니다. 다른 계정의 로컬 저장과 구매·Vault·설정 같은 별도의 기기 파일은 남습니다.
    • -
    • 계정·체크포인트·전당 기록에는 자동으로 지워지는 기한이 없습니다. 계정을 삭제하기 전까지 남습니다.
    • +
    • 계정·별명·체크포인트·출석·전당 기록에는 자동으로 지워지는 기한이 없습니다. 계정을 삭제하기 전까지 남습니다.
    • hyo@hyo.dev로 이메일하면 내 정보의 열람·정정·삭제를 요청할 수 있습니다. 플레이어 ID를 함께 보내 주세요.
    • 고객지원 요청은 플레이어 ID로 기록과 대조합니다. 구매 기록에 관한 삭제 요청은 구매 검증 처리 업체에 전달됩니다.
    @@ -86,7 +89,7 @@

    개인정보처리방침 — 달빛 봉화

    • 게스트 플레이는 기기 전용 게스트로 오프라인에서도 되며 클라우드 등록 계정으로 표시되지 않습니다.
    • 백그라운드 가입이 실패하거나 로그인 설정이 없는 빌드에서도 플레이는 계속됩니다. 로그인 설정이 없는 빌드는 등록을 시도하지 않고 오류도 내지 않습니다.
    • -
    • 로그인, 클라우드 저장, 전당, 상점, 구매 검증에는 인터넷 연결이 필요합니다.
    • +
    • 로그인, 클라우드 저장, 전당, 출석 수령, 상점, 구매 검증에는 인터넷 연결이 필요합니다.

    분석

    선택형 게임플레이 분석은 출시 빌드의 내보내기 설정과 기본 꺼짐인 설정 명시 동의가 모두 맞아야 동작하며, 그 전까지는 꺼져 있습니다.

    diff --git a/apps/player-care/dist/ko/privacy/index.html b/apps/player-care/dist/ko/privacy/index.html index eb452c8..ea7c03f 100644 --- a/apps/player-care/dist/ko/privacy/index.html +++ b/apps/player-care/dist/ko/privacy/index.html @@ -65,20 +65,23 @@

    개인정보처리방침 — 달빛 봉화

  • 저장은 기기에 먼저 되고 업로드는 뒤따릅니다.
  • 저장에 구매·잔액 기록은 들어가지 않습니다.
  • 다운로드는 완전히 검사한 뒤 설치되고, 다른 클라우드 사본은 어느 쪽을 쓸지 선택을 기다립니다.
  • +
  • 로그인한 계정은 무료 이어하기 코인 보상의 간격을 재는 비공개 출석 기록(마지막 수령 시각)도 하나 가집니다. 전당에는 절대 표시되지 않습니다.
  • +
  • 휴대전화에서는 다음 출석 코인을 받을 수 있을 때 알려주는 로컬 알림을 설정에서 켤 수 있습니다. 알림은 마지막 출석 시각을 기준으로 기기에서 예약되며 계정 정보·토큰·개인정보를 담지 않고 코인을 직접 지급하지도 않습니다. 설정에서 끄면 예약된 알림이 모두 취소됩니다. 알림은 자리를 비운 동안 대략 열두 시간 간격으로 반복되며 최대 24일치가 예약되고 게임을 열 때마다 새로고침됩니다.
  • 전당

      -
    • 전당은 플레이어 ID마다 최고 기록 하나(플레이어 ID, 영웅, 점수, 진행도, 앱 버전, 갱신 시각)를 보관합니다.
    • +
    • 전당은 플레이어 ID마다 최고 기록 하나(플레이어 ID, 영웅, 점수, 진행도, 앱 버전, 갱신 시각, 그리고 등록한 경우 계정의 공개 게임 별명)를 보관합니다.
    • 읽기는 공개이며 쓰기는 소유 계정만 가능하고, 점수는 올라가기만 하며 순위는 점수에서 계산되어 동점은 같은 순위를 공유합니다.
    • -
    • 전당 기록에 이름, 이메일, 계정 참조, 토큰, 저장은 들어가지 않습니다.
    • +
    • 계정마다 영문·숫자·중일한 문자 2~12자로 된 영구 공개 별명을 하나 등록할 수 있습니다. 등록에는 연결이 필요하며, 등록 후에는 모든 기기의 전당 기록에 별명이 표시됩니다. 별명은 바꿀 수도 다른 계정으로 옮길 수도 없습니다.
    • +
    • 별명은 게임 이름이지 실명이 아니며, 전당 기록에 들어가는 유일한 이름입니다. 이메일, 계정 참조, 토큰, 저장은 들어가지 않습니다.

    삭제

      -
    • 클라우드 계정은 게임의 계정 설정에서 삭제합니다. 삭제는 전당 기록, 클라우드 저장, 예약, 프로필을 하나의 검증된 단계로 먼저 함께 지우고, 그 확인 뒤에 로그인 자체를 삭제합니다.
    • +
    • 클라우드 계정은 게임의 계정 설정에서 삭제합니다. 삭제는 전당 기록, 등록한 별명과 모험가 기록, 출석 기록, 클라우드 저장, 예약, 프로필을 하나의 검증된 단계로 먼저 함께 지우고, 그 확인 뒤에 로그인 자체를 삭제합니다.
    • iOS에서 Apple 연결 계정을 삭제할 때는 Apple 로그인 화면으로 다시 확인할 수 있습니다.
    • 로그아웃하면 로그인 세션이 끝나고 새 게스트 ID로 바뀝니다. 기기와 클라우드 어디에서도 아무것도 지우지 않습니다.
    • 클라우드 계정을 삭제하면 클라우드 기록과 로그인은 물론 그 계정의 로컬 여정 저장 파일과 기기 안의 계정 연결도 지워집니다. 다른 계정의 로컬 저장과 구매·Vault·설정 같은 별도의 기기 파일은 남습니다.
    • -
    • 계정·체크포인트·전당 기록에는 자동으로 지워지는 기한이 없습니다. 계정을 삭제하기 전까지 남습니다.
    • +
    • 계정·별명·체크포인트·출석·전당 기록에는 자동으로 지워지는 기한이 없습니다. 계정을 삭제하기 전까지 남습니다.
    • hyo@hyo.dev로 이메일하면 내 정보의 열람·정정·삭제를 요청할 수 있습니다. 플레이어 ID를 함께 보내 주세요.
    • 고객지원 요청은 플레이어 ID로 기록과 대조합니다. 구매 기록에 관한 삭제 요청은 구매 검증 처리 업체에 전달됩니다.
    @@ -86,7 +89,7 @@

    개인정보처리방침 — 달빛 봉화

    • 게스트 플레이는 기기 전용 게스트로 오프라인에서도 되며 클라우드 등록 계정으로 표시되지 않습니다.
    • 백그라운드 가입이 실패하거나 로그인 설정이 없는 빌드에서도 플레이는 계속됩니다. 로그인 설정이 없는 빌드는 등록을 시도하지 않고 오류도 내지 않습니다.
    • -
    • 로그인, 클라우드 저장, 전당, 상점, 구매 검증에는 인터넷 연결이 필요합니다.
    • +
    • 로그인, 클라우드 저장, 전당, 출석 수령, 상점, 구매 검증에는 인터넷 연결이 필요합니다.

    분석

    선택형 게임플레이 분석은 출시 빌드의 내보내기 설정과 기본 꺼짐인 설정 명시 동의가 모두 맞아야 동작하며, 그 전까지는 꺼져 있습니다.

    diff --git a/apps/player-care/dist/ko/support.html b/apps/player-care/dist/ko/support.html index 0dfbb80..80cccb4 100644 --- a/apps/player-care/dist/ko/support.html +++ b/apps/player-care/dist/ko/support.html @@ -93,6 +93,8 @@

    고객지원 — 달빛 봉화

    • 기본 플레이와 로컬 기록은 게스트로 오프라인에서도 됩니다.
    • 상품 조회, 구매, 구매 검증, 로그인, 클라우드 저장, 전당에는 인터넷 연결이 필요합니다.
    • +
    • 열두 시간이 지나 돌아오면 이어하기 코인 2개를 받습니다. 수령에는 연결이 필요하고, 오프라인에서도 플레이는 그대로 이어집니다.
    • +
    • 휴대전화에서는 다음 2코인을 받을 수 있을 때 알려주는 로컬 알림을 설정에서 켤 수 있으며 끄면 취소됩니다. 알림 자체가 코인을 지급하지는 않습니다.
    • 광고는 없습니다.

    개인정보

    diff --git a/apps/player-care/dist/ko/support/index.html b/apps/player-care/dist/ko/support/index.html index 0dfbb80..80cccb4 100644 --- a/apps/player-care/dist/ko/support/index.html +++ b/apps/player-care/dist/ko/support/index.html @@ -93,6 +93,8 @@

    고객지원 — 달빛 봉화

    • 기본 플레이와 로컬 기록은 게스트로 오프라인에서도 됩니다.
    • 상품 조회, 구매, 구매 검증, 로그인, 클라우드 저장, 전당에는 인터넷 연결이 필요합니다.
    • +
    • 열두 시간이 지나 돌아오면 이어하기 코인 2개를 받습니다. 수령에는 연결이 필요하고, 오프라인에서도 플레이는 그대로 이어집니다.
    • +
    • 휴대전화에서는 다음 2코인을 받을 수 있을 때 알려주는 로컬 알림을 설정에서 켤 수 있으며 끄면 취소됩니다. 알림 자체가 코인을 지급하지는 않습니다.
    • 광고는 없습니다.

    개인정보

    diff --git a/apps/player-care/dist/privacy.html b/apps/player-care/dist/privacy.html index 1acbc80..31f8031 100644 --- a/apps/player-care/dist/privacy.html +++ b/apps/player-care/dist/privacy.html @@ -65,20 +65,23 @@

    Privacy Policy — Moonlit Beacon

  • Saves land on the device first; uploads follow in the background.
  • Saves never carry purchase or balance records.
  • Downloads are fully checked before install, and a differing cloud copy waits for you to choose which side to keep.
  • +
  • Signed-in accounts also hold one private attendance record (last claim time) that paces the free continue-coin reward; it never appears in the Hall.
  • +
  • On phones, Settings can switch on a local reminder when the next attendance coins are ready. The notice is scheduled on the device from the last claim time and carries neither account references, tokens, nor personal data; it grants no coins by itself and is cancelled entirely by turning it off in Settings. Notices repeat roughly every twelve hours while away, up to 48 scheduled notices that refresh whenever the game is opened.
  • Hall board

      -
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, and update time.
    • +
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, update time, and — once claimed — the account's public game handle.
    • Board reads are public; only the owning account may write its entry. Scores only increase, and standing is derived from scores so equal scores share one rank.
    • -
    • No name, email, account reference, token, or save appears in a Hall entry.
    • +
    • Each account may claim one permanent public handle of 2–12 letters, digits, or Chinese/Japanese/Korean characters. Claiming needs a connection; afterwards the handle shows on the account's Hall entry on every device. Handles cannot be renamed or moved to another account.
    • +
    • The handle is a game name, not a real name, and it is the only name in a Hall entry. No email, account reference, token, or save appears in one.

    Deletion

      -
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • +
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, claimed name and adventurer record, attendance record, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • On iOS, deleting an Apple-linked account may ask you to confirm again through the Apple sign-in sheet.
    • Signing out ends the sign-in session and switches to a fresh guest ID; it deletes nothing on the device or in the cloud.
    • Deleting your cloud account removes its cloud records and sign-in, its local journey save files, and its on-device account binding; other accounts' local saves and separate on-device files such as purchases, Vault, and settings remain.
    • -
    • Account, checkpoint, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • +
    • Account, name, checkpoint, attendance, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • You can also request access, correction, or deletion of your data by email at hyo@hyo.dev. Include your player ID.
    • Support requests are matched to your records through your player ID; deletion requests that concern purchase records are forwarded to the purchase-verification processor.
    @@ -86,7 +89,7 @@

    Privacy Policy — Moonlit Beacon

    • Guest play works offline as a device-only guest and is never shown as a registered cloud account.
    • If background sign-up fails, or the build has no sign-in setup, play continues anyway. Builds without sign-in setup make no registration attempt and show no error.
    • -
    • Sign-in, cloud saves, the Hall, the store, and purchase verification need an internet connection.
    • +
    • Sign-in, cloud saves, the Hall, attendance claims, the store, and purchase verification need an internet connection.

    Analytics

    Optional gameplay analytics stays off unless two switches agree: the shipped build's export configuration and your explicit consent in Settings, which defaults to off.

    diff --git a/apps/player-care/dist/privacy/index.html b/apps/player-care/dist/privacy/index.html index 1acbc80..31f8031 100644 --- a/apps/player-care/dist/privacy/index.html +++ b/apps/player-care/dist/privacy/index.html @@ -65,20 +65,23 @@

    Privacy Policy — Moonlit Beacon

  • Saves land on the device first; uploads follow in the background.
  • Saves never carry purchase or balance records.
  • Downloads are fully checked before install, and a differing cloud copy waits for you to choose which side to keep.
  • +
  • Signed-in accounts also hold one private attendance record (last claim time) that paces the free continue-coin reward; it never appears in the Hall.
  • +
  • On phones, Settings can switch on a local reminder when the next attendance coins are ready. The notice is scheduled on the device from the last claim time and carries neither account references, tokens, nor personal data; it grants no coins by itself and is cancelled entirely by turning it off in Settings. Notices repeat roughly every twelve hours while away, up to 48 scheduled notices that refresh whenever the game is opened.
  • Hall board

      -
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, and update time.
    • +
    • The Hall keeps one best entry per player ID: player ID, hero, score, progress, app version, update time, and — once claimed — the account's public game handle.
    • Board reads are public; only the owning account may write its entry. Scores only increase, and standing is derived from scores so equal scores share one rank.
    • -
    • No name, email, account reference, token, or save appears in a Hall entry.
    • +
    • Each account may claim one permanent public handle of 2–12 letters, digits, or Chinese/Japanese/Korean characters. Claiming needs a connection; afterwards the handle shows on the account's Hall entry on every device. Handles cannot be renamed or moved to another account.
    • +
    • The handle is a game name, not a real name, and it is the only name in a Hall entry. No email, account reference, token, or save appears in one.

    Deletion

      -
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • +
    • Delete your cloud account from the game's account settings. Deletion first removes its Hall entry, claimed name and adventurer record, attendance record, cloud save, reservation, and profile together in one verified step; only after that confirmation is the sign-in itself deleted.
    • On iOS, deleting an Apple-linked account may ask you to confirm again through the Apple sign-in sheet.
    • Signing out ends the sign-in session and switches to a fresh guest ID; it deletes nothing on the device or in the cloud.
    • Deleting your cloud account removes its cloud records and sign-in, its local journey save files, and its on-device account binding; other accounts' local saves and separate on-device files such as purchases, Vault, and settings remain.
    • -
    • Account, checkpoint, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • +
    • Account, name, checkpoint, attendance, and Hall records have no configured automatic expiry: they remain until the account is deleted.
    • You can also request access, correction, or deletion of your data by email at hyo@hyo.dev. Include your player ID.
    • Support requests are matched to your records through your player ID; deletion requests that concern purchase records are forwarded to the purchase-verification processor.
    @@ -86,7 +89,7 @@

    Privacy Policy — Moonlit Beacon

    • Guest play works offline as a device-only guest and is never shown as a registered cloud account.
    • If background sign-up fails, or the build has no sign-in setup, play continues anyway. Builds without sign-in setup make no registration attempt and show no error.
    • -
    • Sign-in, cloud saves, the Hall, the store, and purchase verification need an internet connection.
    • +
    • Sign-in, cloud saves, the Hall, attendance claims, the store, and purchase verification need an internet connection.

    Analytics

    Optional gameplay analytics stays off unless two switches agree: the shipped build's export configuration and your explicit consent in Settings, which defaults to off.

    diff --git a/apps/player-care/dist/support.html b/apps/player-care/dist/support.html index eac0bab..2bc8117 100644 --- a/apps/player-care/dist/support.html +++ b/apps/player-care/dist/support.html @@ -93,6 +93,8 @@

    Support — Moonlit Beacon

    • Core play and local records work offline as a guest.
    • Loading store products, purchasing, purchase verification, sign-in, cloud saves, and the Hall need an internet connection.
    • +
    • Returning after twelve hours grants two continue coins; claiming needs a connection, and offline play continues without the grant.
    • +
    • On phones, Settings can switch on a local reminder when the next two coins are ready; turning it off cancels it. The notice never grants coins by itself.
    • There are no ads.

    Privacy

    diff --git a/apps/player-care/dist/support/index.html b/apps/player-care/dist/support/index.html index eac0bab..2bc8117 100644 --- a/apps/player-care/dist/support/index.html +++ b/apps/player-care/dist/support/index.html @@ -93,6 +93,8 @@

    Support — Moonlit Beacon

    • Core play and local records work offline as a guest.
    • Loading store products, purchasing, purchase verification, sign-in, cloud saves, and the Hall need an internet connection.
    • +
    • Returning after twelve hours grants two continue coins; claiming needs a connection, and offline play continues without the grant.
    • +
    • On phones, Settings can switch on a local reminder when the next two coins are ready; turning it off cancels it. The notice never grants coins by itself.
    • There are no ads.

    Privacy

    diff --git a/apps/player-care/dist/zh-Hans/privacy.html b/apps/player-care/dist/zh-Hans/privacy.html index ecc7e05..49a1a8d 100644 --- a/apps/player-care/dist/zh-Hans/privacy.html +++ b/apps/player-care/dist/zh-Hans/privacy.html @@ -65,20 +65,23 @@

    隐私政策 — 月光烽火

  • 先落盘再后台上传。
  • 存档不含购买或余额记录。
  • 下载须通过完整检查才会安装;不一致的云端副本等待玩家选择保留哪一侧。
  • +
  • 已登录账号还持有一条非公开出勤记录(上次领取时间),用于限定免费继续金币奖励的间隔;它绝不会显示在殿堂中。
  • +
  • 在手机上,可在设置中开启本地提醒,在下次签到币可领取时通知您。通知根据上次签到时间在设备上安排,不包含账号信息、令牌或个人数据,也不会直接发放金币。在设置中关闭后,所有已安排的提醒都会取消。通知在您离开期间大约每十二小时重复一次,最多安排24天的通知,每次打开游戏时都会刷新。
  • 殿堂榜

      -
    • 殿堂按玩家 ID 保存一条最佳记录(玩家 ID、英雄、分数、进度、应用版本、更新时间)。
    • +
    • 殿堂按玩家 ID 保存一条最佳记录(玩家 ID、英雄、分数、进度、应用版本、更新时间,以及已认领时的账号公开游戏名)。
    • 读取公开,仅归属账号可写,分数只增不减,名次由分数推导,同分同名次。
    • -
    • 殿堂记录不含姓名、邮箱、账号引用、令牌或存档。
    • +
    • 每个账号可认领一个 2–12 字的永久公开名:字母、数字或中日韩文字。认领需要联网;认领后,该名会显示在所有设备的殿堂记录中。公开名不可改名,也不可转移到其他账号。
    • +
    • 该名是游戏名而非真实姓名,是殿堂记录中唯一的名称。殿堂记录不含邮箱、账号引用、令牌或存档。

    删除

      -
    • 在游戏的账号设置中删除云账号。删除先以一次已验证的步骤同时清除殿堂记录、云存档、预留与资料;确认后再删除登录本身。
    • +
    • 在游戏的账号设置中删除云账号。删除先以一次已验证的步骤同时清除殿堂记录、已认领的名称与冒险者记录、出勤记录、云存档、预留与资料;确认后再删除登录本身。
    • iOS 上删除 Apple 关联账号时,可能要求重开 Apple 登录页重新确认。
    • 退出登录会结束登录会话并切换到全新游客 ID;在设备或云端都不删除任何内容。
    • 删除云账号会清除其云端记录与登录,以及其本地旅程存档文件和设备上的账号绑定;其他账号的本地存档,以及购买、Vault、设置等单独的设备文件会继续保留。
    • -
    • 账号、检查点与殿堂记录没有预设的自动到期:保留至账号删除为止。
    • +
    • 账号、名称、检查点、出勤与殿堂记录没有预设的自动到期:保留至账号删除为止。
    • 也可以发邮件到 hyo@hyo.dev,请求查阅、更正或删除本人信息。请附上玩家 ID。
    • 支持请求经玩家 ID 与记录核对;涉及购买记录的删除请求会转交给购买验证处理方。
    @@ -86,7 +89,7 @@

    隐私政策 — 月光烽火

    • 游客可离线以纯本机游客游玩,不会显示为已注册云账号。
    • 后台注册失败或版本未配置登录,游玩都会继续。未配置登录的版本不尝试注册,也不报错。
    • -
    • 登录、云存档、殿堂、商店与购买验证需要网络连接。
    • +
    • 登录、云存档、殿堂、出勤领取、商店与购买验证需要网络连接。

    分析

    可选的玩法分析须同时满足出货版本的导出配置与默认关闭的设置明示同意才会启用,否则保持关闭。

    diff --git a/apps/player-care/dist/zh-Hans/privacy/index.html b/apps/player-care/dist/zh-Hans/privacy/index.html index ecc7e05..49a1a8d 100644 --- a/apps/player-care/dist/zh-Hans/privacy/index.html +++ b/apps/player-care/dist/zh-Hans/privacy/index.html @@ -65,20 +65,23 @@

    隐私政策 — 月光烽火

  • 先落盘再后台上传。
  • 存档不含购买或余额记录。
  • 下载须通过完整检查才会安装;不一致的云端副本等待玩家选择保留哪一侧。
  • +
  • 已登录账号还持有一条非公开出勤记录(上次领取时间),用于限定免费继续金币奖励的间隔;它绝不会显示在殿堂中。
  • +
  • 在手机上,可在设置中开启本地提醒,在下次签到币可领取时通知您。通知根据上次签到时间在设备上安排,不包含账号信息、令牌或个人数据,也不会直接发放金币。在设置中关闭后,所有已安排的提醒都会取消。通知在您离开期间大约每十二小时重复一次,最多安排24天的通知,每次打开游戏时都会刷新。
  • 殿堂榜

      -
    • 殿堂按玩家 ID 保存一条最佳记录(玩家 ID、英雄、分数、进度、应用版本、更新时间)。
    • +
    • 殿堂按玩家 ID 保存一条最佳记录(玩家 ID、英雄、分数、进度、应用版本、更新时间,以及已认领时的账号公开游戏名)。
    • 读取公开,仅归属账号可写,分数只增不减,名次由分数推导,同分同名次。
    • -
    • 殿堂记录不含姓名、邮箱、账号引用、令牌或存档。
    • +
    • 每个账号可认领一个 2–12 字的永久公开名:字母、数字或中日韩文字。认领需要联网;认领后,该名会显示在所有设备的殿堂记录中。公开名不可改名,也不可转移到其他账号。
    • +
    • 该名是游戏名而非真实姓名,是殿堂记录中唯一的名称。殿堂记录不含邮箱、账号引用、令牌或存档。

    删除

      -
    • 在游戏的账号设置中删除云账号。删除先以一次已验证的步骤同时清除殿堂记录、云存档、预留与资料;确认后再删除登录本身。
    • +
    • 在游戏的账号设置中删除云账号。删除先以一次已验证的步骤同时清除殿堂记录、已认领的名称与冒险者记录、出勤记录、云存档、预留与资料;确认后再删除登录本身。
    • iOS 上删除 Apple 关联账号时,可能要求重开 Apple 登录页重新确认。
    • 退出登录会结束登录会话并切换到全新游客 ID;在设备或云端都不删除任何内容。
    • 删除云账号会清除其云端记录与登录,以及其本地旅程存档文件和设备上的账号绑定;其他账号的本地存档,以及购买、Vault、设置等单独的设备文件会继续保留。
    • -
    • 账号、检查点与殿堂记录没有预设的自动到期:保留至账号删除为止。
    • +
    • 账号、名称、检查点、出勤与殿堂记录没有预设的自动到期:保留至账号删除为止。
    • 也可以发邮件到 hyo@hyo.dev,请求查阅、更正或删除本人信息。请附上玩家 ID。
    • 支持请求经玩家 ID 与记录核对;涉及购买记录的删除请求会转交给购买验证处理方。
    @@ -86,7 +89,7 @@

    隐私政策 — 月光烽火

    • 游客可离线以纯本机游客游玩,不会显示为已注册云账号。
    • 后台注册失败或版本未配置登录,游玩都会继续。未配置登录的版本不尝试注册,也不报错。
    • -
    • 登录、云存档、殿堂、商店与购买验证需要网络连接。
    • +
    • 登录、云存档、殿堂、出勤领取、商店与购买验证需要网络连接。

    分析

    可选的玩法分析须同时满足出货版本的导出配置与默认关闭的设置明示同意才会启用,否则保持关闭。

    diff --git a/apps/player-care/dist/zh-Hans/support.html b/apps/player-care/dist/zh-Hans/support.html index f4bc984..edb1e1f 100644 --- a/apps/player-care/dist/zh-Hans/support.html +++ b/apps/player-care/dist/zh-Hans/support.html @@ -93,6 +93,8 @@

    支持 — 月光烽火

    • 基础玩法与本地记录可以游客身份离线使用。
    • 加载商品、购买与购买验证、登录、云存档与殿堂需要网络连接。
    • +
    • 相隔十二小时后回归可领取两枚继续金币;领取需要联网,离线也可继续游玩,只是领不到奖励。
    • +
    • 在手机上,可在设置中开启本地提醒,在下次2枚金币可领取时通知您;关闭后即取消。通知本身不会发放金币。
    • 游戏没有广告。

    隐私

    diff --git a/apps/player-care/dist/zh-Hans/support/index.html b/apps/player-care/dist/zh-Hans/support/index.html index f4bc984..edb1e1f 100644 --- a/apps/player-care/dist/zh-Hans/support/index.html +++ b/apps/player-care/dist/zh-Hans/support/index.html @@ -93,6 +93,8 @@

    支持 — 月光烽火

    • 基础玩法与本地记录可以游客身份离线使用。
    • 加载商品、购买与购买验证、登录、云存档与殿堂需要网络连接。
    • +
    • 相隔十二小时后回归可领取两枚继续金币;领取需要联网,离线也可继续游玩,只是领不到奖励。
    • +
    • 在手机上,可在设置中开启本地提醒,在下次2枚金币可领取时通知您;关闭后即取消。通知本身不会发放金币。
    • 游戏没有广告。

    隐私

    diff --git a/apps/player-care/dist/zh-Hant/privacy.html b/apps/player-care/dist/zh-Hant/privacy.html index 518dd18..2a471ed 100644 --- a/apps/player-care/dist/zh-Hant/privacy.html +++ b/apps/player-care/dist/zh-Hant/privacy.html @@ -65,20 +65,23 @@

    隱私權政策 — 月光烽火

  • 先落盤再後台上傳。
  • 存檔不含購買或餘額紀錄。
  • 下載須通過完整檢查才會安裝;不一致的雲端副本等待玩家選擇保留哪一側。
  • +
  • 已登入帳號還持有一條非公開出勤紀錄(上次領取時間),用於限定免費繼續金幣獎勵的間隔;它絕不會顯示在殿堂中。
  • +
  • 在手機上,可在設定中開啟本地提醒,在下次簽到幣可領取時通知您。通知根據上次簽到時間在裝置上安排,不包含帳號資訊、權杖或個人資料,也不會直接發放金幣。在設定中關閉後,所有已安排的提醒都會取消。通知在您離開期間大約每十二小時重複一次,最多安排24天的通知,每次開啟遊戲時都會重新整理。
  • 殿堂榜

      -
    • 殿堂按玩家 ID 保存一條最佳紀錄(玩家 ID、英雄、分數、進度、應用版本、更新時間)。
    • +
    • 殿堂按玩家 ID 保存一條最佳紀錄(玩家 ID、英雄、分數、進度、應用版本、更新時間,以及已認領時的帳號公開遊戲名)。
    • 讀取公開,僅歸屬帳號可寫,分數只增不減,名次由分數推導,同分同名次。
    • -
    • 殿堂紀錄不含姓名、信箱、帳號參照、權杖或存檔。
    • +
    • 每個帳號可認領一個 2–12 字的永久公開名:字母、數字或中日韓文字。認領需要連線;認領後,該名會顯示在所有裝置的殿堂紀錄中。公開名不可改名,也不可轉移到其他帳號。
    • +
    • 該名是遊戲名而非真實姓名,是殿堂紀錄中唯一的名稱。殿堂紀錄不含信箱、帳號參照、權杖或存檔。

    刪除

      -
    • 在遊戲的帳號設定中刪除雲帳號。刪除先以一次已驗證的步驟同時清除殿堂紀錄、雲存檔、預留與資料;確認後再刪除登入本身。
    • +
    • 在遊戲的帳號設定中刪除雲帳號。刪除先以一次已驗證的步驟同時清除殿堂紀錄、已認領的名稱與冒險者紀錄、出勤紀錄、雲存檔、預留與資料;確認後再刪除登入本身。
    • iOS 上刪除 Apple 關聯帳號時,可能要求重開 Apple 登入頁重新確認。
    • 登出會結束登入工作階段並切換到全新訪客 ID;在裝置或雲端都不刪除任何內容。
    • 刪除雲帳號會清除其雲端紀錄與登入,以及其本地旅程存檔檔案和裝置上的帳號綁定;其他帳號的本地存檔,以及購買、Vault、設定等單獨的裝置檔案會繼續保留。
    • -
    • 帳號、檢查點與殿堂紀錄沒有預設的自動到期:保留至帳號刪除為止。
    • +
    • 帳號、名稱、檢查點、出勤與殿堂紀錄沒有預設的自動到期:保留至帳號刪除為止。
    • 也可以寫信到 hyo@hyo.dev,請求查閱、更正或刪除本人資訊。請附上玩家 ID。
    • 支援請求經玩家 ID 與紀錄核對;涉及購買紀錄的刪除請求會轉交給購買驗證處理方。
    @@ -86,7 +89,7 @@

    隱私權政策 — 月光烽火

    • 訪客可離線以純本機訪客遊玩,不會顯示為已註冊雲帳號。
    • 後台註冊失敗或版本未設定登入,遊玩都會繼續。未設定登入的版本不嘗試註冊,也不報錯。
    • -
    • 登入、雲存檔、殿堂、商店與購買驗證需要網路連線。
    • +
    • 登入、雲存檔、殿堂、出勤領取、商店與購買驗證需要網路連線。

    分析

    可選的玩法分析須同時滿足出貨版本的匯出配置與預設關閉的設定明示同意才會啟用,否則保持關閉。

    diff --git a/apps/player-care/dist/zh-Hant/privacy/index.html b/apps/player-care/dist/zh-Hant/privacy/index.html index 518dd18..2a471ed 100644 --- a/apps/player-care/dist/zh-Hant/privacy/index.html +++ b/apps/player-care/dist/zh-Hant/privacy/index.html @@ -65,20 +65,23 @@

    隱私權政策 — 月光烽火

  • 先落盤再後台上傳。
  • 存檔不含購買或餘額紀錄。
  • 下載須通過完整檢查才會安裝;不一致的雲端副本等待玩家選擇保留哪一側。
  • +
  • 已登入帳號還持有一條非公開出勤紀錄(上次領取時間),用於限定免費繼續金幣獎勵的間隔;它絕不會顯示在殿堂中。
  • +
  • 在手機上,可在設定中開啟本地提醒,在下次簽到幣可領取時通知您。通知根據上次簽到時間在裝置上安排,不包含帳號資訊、權杖或個人資料,也不會直接發放金幣。在設定中關閉後,所有已安排的提醒都會取消。通知在您離開期間大約每十二小時重複一次,最多安排24天的通知,每次開啟遊戲時都會重新整理。
  • 殿堂榜

      -
    • 殿堂按玩家 ID 保存一條最佳紀錄(玩家 ID、英雄、分數、進度、應用版本、更新時間)。
    • +
    • 殿堂按玩家 ID 保存一條最佳紀錄(玩家 ID、英雄、分數、進度、應用版本、更新時間,以及已認領時的帳號公開遊戲名)。
    • 讀取公開,僅歸屬帳號可寫,分數只增不減,名次由分數推導,同分同名次。
    • -
    • 殿堂紀錄不含姓名、信箱、帳號參照、權杖或存檔。
    • +
    • 每個帳號可認領一個 2–12 字的永久公開名:字母、數字或中日韓文字。認領需要連線;認領後,該名會顯示在所有裝置的殿堂紀錄中。公開名不可改名,也不可轉移到其他帳號。
    • +
    • 該名是遊戲名而非真實姓名,是殿堂紀錄中唯一的名稱。殿堂紀錄不含信箱、帳號參照、權杖或存檔。

    刪除

      -
    • 在遊戲的帳號設定中刪除雲帳號。刪除先以一次已驗證的步驟同時清除殿堂紀錄、雲存檔、預留與資料;確認後再刪除登入本身。
    • +
    • 在遊戲的帳號設定中刪除雲帳號。刪除先以一次已驗證的步驟同時清除殿堂紀錄、已認領的名稱與冒險者紀錄、出勤紀錄、雲存檔、預留與資料;確認後再刪除登入本身。
    • iOS 上刪除 Apple 關聯帳號時,可能要求重開 Apple 登入頁重新確認。
    • 登出會結束登入工作階段並切換到全新訪客 ID;在裝置或雲端都不刪除任何內容。
    • 刪除雲帳號會清除其雲端紀錄與登入,以及其本地旅程存檔檔案和裝置上的帳號綁定;其他帳號的本地存檔,以及購買、Vault、設定等單獨的裝置檔案會繼續保留。
    • -
    • 帳號、檢查點與殿堂紀錄沒有預設的自動到期:保留至帳號刪除為止。
    • +
    • 帳號、名稱、檢查點、出勤與殿堂紀錄沒有預設的自動到期:保留至帳號刪除為止。
    • 也可以寫信到 hyo@hyo.dev,請求查閱、更正或刪除本人資訊。請附上玩家 ID。
    • 支援請求經玩家 ID 與紀錄核對;涉及購買紀錄的刪除請求會轉交給購買驗證處理方。
    @@ -86,7 +89,7 @@

    隱私權政策 — 月光烽火

    • 訪客可離線以純本機訪客遊玩,不會顯示為已註冊雲帳號。
    • 後台註冊失敗或版本未設定登入,遊玩都會繼續。未設定登入的版本不嘗試註冊,也不報錯。
    • -
    • 登入、雲存檔、殿堂、商店與購買驗證需要網路連線。
    • +
    • 登入、雲存檔、殿堂、出勤領取、商店與購買驗證需要網路連線。

    分析

    可選的玩法分析須同時滿足出貨版本的匯出配置與預設關閉的設定明示同意才會啟用,否則保持關閉。

    diff --git a/apps/player-care/dist/zh-Hant/support.html b/apps/player-care/dist/zh-Hant/support.html index e51df0b..1cb8527 100644 --- a/apps/player-care/dist/zh-Hant/support.html +++ b/apps/player-care/dist/zh-Hant/support.html @@ -93,6 +93,8 @@

    支援 — 月光烽火

    • 基礎玩法與本機紀錄,訪客身分可離線使用。
    • 載入商品、購買與購買驗證、登入、雲存檔與殿堂需要網路連線。
    • +
    • 相隔十二小時後回歸可領取兩枚繼續金幣;領取需要連線,離線也可繼續遊玩,只是領不到獎勵。
    • +
    • 在手機上,可在設定中開啟本地提醒,在下次2枚金幣可領取時通知您;關閉後即取消。通知本身不會發放金幣。
    • 遊戲沒有廣告。

    隱私

    diff --git a/apps/player-care/dist/zh-Hant/support/index.html b/apps/player-care/dist/zh-Hant/support/index.html index e51df0b..1cb8527 100644 --- a/apps/player-care/dist/zh-Hant/support/index.html +++ b/apps/player-care/dist/zh-Hant/support/index.html @@ -93,6 +93,8 @@

    支援 — 月光烽火

    • 基礎玩法與本機紀錄,訪客身分可離線使用。
    • 載入商品、購買與購買驗證、登入、雲存檔與殿堂需要網路連線。
    • +
    • 相隔十二小時後回歸可領取兩枚繼續金幣;領取需要連線,離線也可繼續遊玩,只是領不到獎勵。
    • +
    • 在手機上,可在設定中開啟本地提醒,在下次2枚金幣可領取時通知您;關閉後即取消。通知本身不會發放金幣。
    • 遊戲沒有廣告。

    隱私

    diff --git a/firestore.cloud.addition.rules b/firestore.cloud.addition.rules index 6eeadbd..ea98f27 100644 --- a/firestore.cloud.addition.rules +++ b/firestore.cloud.addition.rules @@ -84,6 +84,18 @@ function hallGoneAfter(publicId) { return !existsAfter(/databases/$(database)/documents/mb_hall_v1/$(publicId)); } +function adventurerGoneAfter(publicId) { + return !existsAfter(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)); +} + +function nameGoneAfter(nameKey) { + return !existsAfter(/databases/$(database)/documents/mb_names_v1/$(nameKey)); +} + +function attendanceGoneAfter(publicId) { + return !existsAfter(/databases/$(database)/documents/mb_attendance_v1/$(publicId)); +} + function validProfile(uid, data) { return data.keys().hasOnly( ['uid', 'public_id', 'schema', 'created_at', 'updated_at']) @@ -111,13 +123,15 @@ match /mb_profiles_v1/{uid} { && reservationOwnedAfter(request.resource.data.public_id); allow update: if false; // Missing rows delete as a no-op so an interrupted deletion resumes - // cleanly; a living row goes only with the public row already gone and its - // reservation half gone too, so no crash can strand a profile claiming a - // freed ID. + // cleanly; a living row goes only with the public row already gone, its + // reservation half gone too, and the attendance row gone, so no crash + // can strand a profile claiming a freed ID or a cooldown. allow delete: if isSignedIn() && request.auth.uid == uid && (!exists(/databases/$(database)/documents/mb_profiles_v1/$(uid)) || (hallGoneAfter(resource.data.public_id) - && reservationGoneAfter(resource.data.public_id))); + && reservationGoneAfter(resource.data.public_id) + && adventurerGoneAfter(resource.data.public_id) + && attendanceGoneAfter(resource.data.public_id))); } function validReservation(publicId, data) { @@ -144,13 +158,15 @@ match /mb_reservations_v1/{publicId} { && profileMapsAfter(request.auth.uid, publicId); allow update: if false; // Missing rows delete as a no-op so an interrupted deletion resumes - // cleanly; living rows still need their owner with the public row and the - // profile half both gone. + // cleanly; living rows still need their owner with the public row, the + // profile half, and the attendance row all gone. allow delete: if isSignedIn() && (!exists(/databases/$(database)/documents/mb_reservations_v1/$(publicId)) || (resource.data.uid == request.auth.uid && hallGoneAfter(publicId) - && profileGoneAfter(resource.data.uid))); + && profileGoneAfter(resource.data.uid) + && adventurerGoneAfter(publicId) + && attendanceGoneAfter(publicId))); } function validCheckpointPayload(payload) { @@ -190,7 +206,7 @@ match /mb_checkpoints_v1/{uid} { function validHall(publicId, data) { return data.keys().hasOnly( ['public_id', 'hero', 'score', 'cycles', 'release', 'schema', - 'updated_at']) + 'updated_at', 'display']) && data.public_id == publicId && isAllowedHero(data.hero) && data.score is int && data.score >= 0 && data.score <= 2000000000 @@ -198,7 +214,19 @@ function validHall(publicId, data) { && data.release is string && data.release.size() > 0 && data.release.size() <= 32 && data.schema == 1 - && isNearNow(data.updated_at); + && isNearNow(data.updated_at) + && hallDisplayBound(publicId, data); +} + +// A Hall display is honest when absent (legacy and unnamed rows) or when +// it equals the owner's own claimed adventurer name. Anything else — an +// unclaimed, malformed, or another player's handle — is a forged display +// and the write is denied. +function hallDisplayBound(publicId, data) { + return !('display' in data.keys()) + || (validDisplayName(data.display) + && exists(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)) + && get(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)).data.display == data.display); } // Public best-score row per public ID. Reads are open; writes require the @@ -225,3 +253,185 @@ match /mb_hall_v1/{publicId} { && (!exists(/databases/$(database)/documents/mb_hall_v1/$(publicId)) || ownsPublicId(publicId)); } + +// Adventurer display allow-list, mirrored exactly by the client's +// normalizer: Latin letters, digits, underscore, precomposed Hangul +// syllables (U+AC00-U+D7A3), Hiragana (U+3041-U+3096), Katakana +// (U+30A1-U+30FA plus the prolonged mark U+30FC), CJK ideographs +// (U+4E00-U+9FFF), and single interior ASCII spaces. BMP-only, so +// size() counts the same 2-12 visible characters both sides enforce. +function validDisplayName(display) { + return display is string && display.size() >= 2 && display.size() <= 12 + && display == display.trim() + && display.matches('^[A-Za-z0-9_가-힣ぁ-ゖァ-ヺー一-鿿]+( [A-Za-z0-9_가-힣ぁ-ゖァ-ヺー一-鿿]+)*$'); +} + +function validAdventurer(publicId, data) { + return data.keys().hasOnly(['public_id', 'uid', 'name_key', 'display', + 'intro_complete', 'schema', 'created_at', 'updated_at']) + && data.public_id == publicId + && isSignedIn() && data.uid == request.auth.uid + && validDisplayName(data.display) + && data.name_key == data.display.lower() + && data.intro_complete is bool + && data.schema == 1 + && isNearNow(data.created_at) + && isNearNow(data.updated_at); +} + +// The paired name claim after the commit: same key, same display, same +// owner. Neither half is created without the other. +function nameClaimedAfter(publicId, nameKey, display) { + return existsAfter(/databases/$(database)/documents/mb_names_v1/$(nameKey)) + && getAfter(/databases/$(database)/documents/mb_names_v1/$(nameKey)).data.public_id == publicId + && getAfter(/databases/$(database)/documents/mb_names_v1/$(nameKey)).data.display == display + && getAfter(/databases/$(database)/documents/mb_names_v1/$(nameKey)).data.name_key == nameKey; +} + +// Mirror image: the name rule sees the adventurer after the commit. +function adventurerOwnedAfter(publicId, nameKey, display) { + return existsAfter(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)) + && getAfter(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)).data.uid == request.auth.uid + && getAfter(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)).data.public_id == publicId + && getAfter(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)).data.name_key == nameKey + && getAfter(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)).data.display == display; +} + +// One private adventurer row per public ID: the owner proven by the +// canonical pair reads it — including an authoritative missing-row read, +// since this rule never touches `resource` — nobody lists it, and creation +// binds it to the canonical pair before and after the commit plus its name +// half. Names are immutable: only the tutorial bit moves, false to true. +// A living row goes only with the full account deletion: name half, public +// row, and both canonical halves gone together, so a live name is never +// released or stranded under a living account. +match /mb_adventurers_v1/{publicId} { + allow get: if ownsPublicId(publicId); + allow list: if false; + allow create: if isSignedIn() && isValidPublicId(publicId) + && ownsPublicId(publicId) && ownsPublicIdAfter(publicId) + && validAdventurer(publicId, request.resource.data) + && request.resource.data.intro_complete == false + && nameClaimedAfter(publicId, request.resource.data.name_key, + request.resource.data.display); + allow update: if ownsPublicId(publicId) && ownsPublicIdAfter(publicId) + && request.resource.data.public_id == resource.data.public_id + && request.resource.data.uid == resource.data.uid + && request.resource.data.name_key == resource.data.name_key + && request.resource.data.display == resource.data.display + && resource.data.intro_complete == false + && request.resource.data.intro_complete == true + && validAdventurer(publicId, request.resource.data); + // Missing rows delete as a no-op so an interrupted deletion resumes + // cleanly; a living row goes only with the full account deletion. + allow delete: if isSignedIn() + && (!exists(/databases/$(database)/documents/mb_adventurers_v1/$(publicId)) + || (resource.data.uid == request.auth.uid + && nameGoneAfter(resource.data.name_key) + && hallGoneAfter(publicId) + && reservationGoneAfter(publicId) + && profileGoneAfter(resource.data.uid))); +} + +function validNameClaim(nameKey, data) { + return data.keys().hasOnly(['name_key', 'display', 'public_id', 'schema', + 'created_at']) + && data.name_key == nameKey + && validDisplayName(data.display) + && data.name_key == data.display.lower() + && isValidPublicId(data.public_id) + && data.schema == 1 + && isNearNow(data.created_at); +} + +// Global name index: the already-public handle and public ID only — no +// UID, credential, or private metadata. Any signed-in player may read one +// known key to test a collision; listing is denied. `create` fires only +// when the key is absent, so two owners racing for one name yield exactly +// one winner and mixed-case Latin variants collide on one lowercased key. +// Creation binds the claim to the caller's canonical pair plus its +// adventurer half. A living claim goes only with the full account +// deletion — adventurer half, public row, and both canonical halves gone +// together — so a name is never freed under a living score or a living +// account. +match /mb_names_v1/{nameKey} { + allow get: if isSignedIn(); + allow list: if false; + allow create: if isSignedIn() + && ownsPublicId(request.resource.data.public_id) + && ownsPublicIdAfter(request.resource.data.public_id) + && validNameClaim(nameKey, request.resource.data) + && adventurerOwnedAfter(request.resource.data.public_id, nameKey, + request.resource.data.display); + allow update: if false; + // Missing rows delete as a no-op so an interrupted deletion resumes + // cleanly; a living claim goes only with the full account deletion. + allow delete: if isSignedIn() + && (!exists(/databases/$(database)/documents/mb_names_v1/$(nameKey)) + || (ownsPublicId(resource.data.public_id) + && adventurerGoneAfter(resource.data.public_id) + && hallGoneAfter(resource.data.public_id) + && reservationGoneAfter(resource.data.public_id) + && profileGoneAfter(request.auth.uid))); +} + +// One private attendance row per public ID: the last claim's server +// timestamp plus the receiving install. The owner proven by the canonical +// pair reads it — including an authoritative missing-row read, since this +// rule never touches `resource` — nobody lists it. The claim stamp must +// equal the server write time, so client-forged times die here; updates +// additionally wait out the rolling twelve hours against server time. +// Every advance carries the overwritten row's stamp and install +// (`prev_*`, both or neither): the carried pair must equal the old row +// exactly, so no advance — even from another install — can erase the +// last recoverable receipt, and first claims carry none. Deleting a +// living row would reset the cooldown, so it goes only with both +// canonical halves in the full account deletion. +function validAttendance(publicId, data) { + return data.keys().hasOnly(['public_id', 'uid', 'install_id', + 'last_claim_at', 'schema', 'prev_claim_at', 'prev_install_id']) + && data.public_id == publicId + && isSignedIn() && data.uid == request.auth.uid + && data.install_id is string + && data.install_id.size() >= 1 && data.install_id.size() <= 64 + && data.schema == 1 + && (!data.keys().hasAny(['prev_claim_at', 'prev_install_id']) + || (data.keys().hasAll(['prev_claim_at', 'prev_install_id']) + && data.prev_claim_at is timestamp + && data.prev_install_id is string + && data.prev_install_id.size() >= 1 + && data.prev_install_id.size() <= 64)); +} + +match /mb_attendance_v1/{publicId} { + allow get: if ownsPublicId(publicId); + allow list: if false; + allow create: if isSignedIn() + && ownsPublicId(publicId) + && ownsPublicIdAfter(publicId) + && validAttendance(publicId, request.resource.data) + && request.resource.data.last_claim_at == request.time + && !request.resource.data.keys().hasAny(['prev_claim_at', + 'prev_install_id']); + allow update: if isSignedIn() + && ownsPublicId(publicId) + && ownsPublicIdAfter(publicId) + && request.resource.data.public_id == resource.data.public_id + && request.resource.data.uid == resource.data.uid + && validAttendance(publicId, request.resource.data) + && request.resource.data.last_claim_at == request.time + && request.time - resource.data.last_claim_at + >= duration.value(43200, 's') + && request.resource.data.prev_claim_at + == resource.data.last_claim_at + && request.resource.data.prev_install_id + == resource.data.install_id; + // Missing rows delete as a no-op so an interrupted deletion resumes + // cleanly; a living row goes only with the full account deletion, + // never alone to reset its own cooldown. + allow delete: if isSignedIn() + && (!exists(/databases/$(database)/documents/mb_attendance_v1/$(publicId)) + || (resource.data.uid == request.auth.uid + && reservationGoneAfter(publicId) + && profileGoneAfter(resource.data.uid))); +} diff --git a/notes/plans/3-0-0-build-log.md b/notes/plans/3-0-0-build-log.md index 975cdda..8426683 100644 --- a/notes/plans/3-0-0-build-log.md +++ b/notes/plans/3-0-0-build-log.md @@ -1381,6 +1381,476 @@ geometry for all 24 cells, below-neck stride motion (bounds alone go blind once heads stop bobbing), and mirrored negative controls that fail on the baseline stride heads, split stances, and single-boot fronts. +## End defeated journeys without a free retry: brief 200 (2026-10-07) + +The user asked why death offered a restart from the defeated stage instead of +a coin continue or a return to the beginning. The loss screen now gives three +honest roads — one coin to revive in place, a fresh expedition, or the title — +and a sealed defeat stays sealed across title, relaunch, backup and cloud. + +The representation is an additive `ended: true` on the checkpoint dict, kept +at the last alive seal's id with the final live score/shard echo. Absent +means alive, so every checkpoint sealed before the defeat rules reads +unchanged; a present non-bool refuses the whole save like any mistyped field. +`read_checkpoint` returns a valid ended main as-is and never falls through to +an older alive backup, while `has_valid_checkpoint` and `summary` treat it as +non-resumable, which is what the title, the production host and the arena's +resume entry all gate on. Death settles the final live score once through the +untouched receipt ledger (redelivery reads `DUPLICATE`) and records submit +once; a failed seal blocks every result exit until the marker lands. + +The paid continue is transactional: seal a new alive checkpoint of the live +state first, spend the coin, then revive. A failed seal spends nothing; a +failed spend rewrites the marker. Either way the run cannot become resumable +without payment. Cloud precedence is same-journey scoped: a sealed defeat +outranks a stale alive payload of the same journey without a dialog +(`defeat-kept` locally, `defeat-adopted` from the server), explicit conflict +choices that would resurrect are refused, and a strictly newer alive seal or +a different journey still opens a real dialog with the sealed side marked. + +What bit: the arena's `_restart` reloads the scene, so route tests assert the +wiring through the scene-blocking spy while the real-arena tests drive what +`begin_fresh` arms. The `show_result` signature lost its gate flag, which +touched the result-layout test and the UI shot harness too. The `.translation` +files are gitignored build artifacts, so the isolated single-test runs needed +the editor import first or the new `RESULT_START_OVER` key reads back raw. + +Coverage: `test_defeat_terminal.tscn` (95 cases: terminal-vs-backup, title +hide/show, fresh reset with permanents kept, coin success, zero-coin and +injected journey/Vault/seal failures, alive quit) registered beside the +rewritten journey death tests (519 cases); six coordinator cases for tombstone +round trip, offline retry, restore keep/adopt, both refused choices and the +still-explicit different-journey dialog; a summary-marker case in the +checkpoint suite; and a production-host case proving resume refusal and +confirmation-free fresh planning over a sealed run. + +## Defeat and paid-continue crash boundaries: brief 203 (2026-10-07) + +Correction to brief 200. The director reproduced two crash defects in +isolated storage: the real defeat writer rotated the previous alive main +into the backup, so a corrupt terminal main fell back to a resumable +checkpoint; and `continue_run` wrote the alive seal and notified its +stable hook before the coin debit, so a death at that boundary stranded +a free resume. Both are fixed at the persistence order, superseding the +round-1 "seal first, spend, best-effort rollback" line above. + +An ended write now mirrors the seal into both files, backup first, and +never rotates the older alive main behind it: either crash ordering stays +terminal, and the stable hook fires only once the sealed main lands. +Reads converge the pair toward the seal. A paid continue journals the +debit plus the exact revive seal in the Vault first (`begin_continue_txn`), +materializes the seal from the journal, then clears it (`ack_continue_txn`). +Every entry point settles the journal before reading +(`recover_paid_continue` → `recovered`/`delivered`/`stale`/`none`/`failed`), +so a crash before the debit leaves no resume and a crash after it recovers +exactly one paid continuation without another charge; a new journey clears +a moot journal. A seal that cannot materialize is stuck, not resumed: the +arena redirects to the title, the host reports `revive_stuck` instead of a +save, and the title shows the failure and confirms before anything boots. +Cloud keeps its generation/account guards and retires a stale ended +download only against an acknowledged newer seal (`stale-ended-retired`); +a legitimate newer alive seal still restores over an observed defeat by +explicit remote choice, and the intermediate journal never uploads. + +What bit: fallback reads heal the main back, so a test that deletes "both" +files must delete the main again after its last read. `begin_fresh` arms +the fresh run but the arena boot writes it, so the abandon-half of the +stuck test boots a real arena to assert the cleared journal and the new +journey. Breaking the mirror (main-only seal) fails 7 crash cases; swapping +the debit order fails 7 more, including the stranded free resume. + +Coverage: `test_crash_boundaries.tscn` (112 cases: the real writer's pair +under corrupt/deleted main, title/host gate hiding, debit-before-seal with +a mid-transaction relaunch, a reverse-ordered control documenting the leak +the journal closes, blocked purse alone and both-failures with retry, +stuck host/title/ask/abandon, double tap) registered in +`run_regression_tests.mjs`; one cross-client coordinator case (acknowledged +revive installs over the observed defeat while the journal neither poisons +the comparison nor uploads); defeat 116, Vault 237, production host 814, +coordinator 1218. + +## Paid revive recovery stays in its account: brief 206 (2026-10-07) + +Correction to brief 203. The director reproduced two defects with the real +journal API: a debit journaled under A materialized into B's empty slot on +`use_account(B)` while clearing A's receipt, and the loss-result button +sent a last-coin player to the shop instead of retrying the journaled +revive. Both are fixed at the ownership check, not the call sites. + +Every journal entry now carries its owning scope (`Journey.active_account` +at debit time), and one receipt can pend per scope: the flat entry plus a +persisted `continue_txn_parked` map. All reads go through +`scoped_continue_txn()`; recovery with only a foreign receipt returns +`deferred` — no file touch, no stable-hook notify, no consume — and +`settle_continue_txn` rechecks the owner as defense in depth. Same-attempt +retry requires the exact acknowledged seal bytes, not just equal numbers; +a rival attempt in the same scope refuses without overwriting, while the +same numbers in another scope journal a separate debit. `begin_journey` +clears only its own scope, so B's fresh start never abandons A's receipt; +abandoning still takes the confirmed fresh action in the owning scope. +Slot moves carry the receipt (`rekey_continue_txn_owner`, refusing +occupied targets): legacy migration adopts it once no legacy files remain, +canonical adoption rekeys it with the renamed slot, and account deletion +drops only the deleted scope's entry. Link and token refresh never change +the public ID, so keying on the slot is stable across them. The result +panel routes to `continue_requested` — with a localized `RESULT_CONTINUE_RETRY` +save-retry label — whenever the scope holds a receipt, even at zero +balance; a true zero with no receipt still goes to the shop, and the +retry rebuilds the byte-identical seal, so it never recharges. + +What bit: `account_state()` settles eagerly through the gate summary, so a +receipt journaled before `begin_guest` lands in the guest slot before the +adoption move runs — the canonical test blocks installs to keep the +receipt pending past the status read, proving the move itself carries it. +Pre-fix the new suite failed 23/78, reproducing both probes; post-fix it +passes 92/92. Breaking the settle check fails 3, the scoped read 12+, and +the button route 5. + +Coverage: `test_account_recovery.tscn` (92 cases: the A→empty-B probe with +late hooks and switch-back, B fresh/play/quit/continue beside A's pending +receipt with byte-identical B files, exact-seal validation and no +free-ride, the real button/signal route with a true-zero control, +rekey/clear primitives) registered in `run_regression_tests.mjs`; host +deletion, canonical-adoption and token-rotation extensions (production +host 844); Vault 237, crash 112, defeat 116, journey 519, coordinator +1218, result layout 216. + +## Paid journals survive canonical slot moves: brief 207 (2026-10-07) + +Correction to brief 206. The director ran the real +`CloudCoordinator._move_slot_to_canonical()` with a guest seal 5, an +acknowledged journal for alive seal 6, and `Vault.TEMP_SAVE_PATH` blocked: +it moved both journey files into the canonical slot, ignored +`rekey_continue_txn_owner()` returning false, adopted anyway, and reported +no save error — the receipt stayed owned by the now-empty guest scope and +recovery deferred forever. The same ignored-failure shape sat at all three +`adopt_legacy_continue_txn()` call sites. + +Both adoptions are now rekey-first through one gate, +`Vault.prepare_receipt_move()`, returning `ready` (nothing pending or the +rekey landed), `kept` (the target holds its own receipt or journey — adopt +without moving, round-3 occupied behavior), or `failed` (unsavable rekey — +move nothing). The coordinator holds the move on `failed` or on a failed +file move: it stays on the guest and the reserve reports error with code +`slot-move-failed` on both snapshots instead of adopting over a stranded +receipt. A crash between rekey and move leaves the receipt ahead of its +bytes, and the post-restart retry completes the move with no second debit. +Legacy adoption is one joint `Vault.adopt_legacy_slot()` (rekey, then raw +file moves that faults cannot half-stop): a failed rekey migrates nothing, +an occupied slot keeps files and receipt jointly legacy, and a legacy +receipt with no legacy files left is never adopted blindly, so no settle +can install a foreign seal into an empty slot. Host startup and account +moves honor the hold through `_run_legacy_adoption` and carry +`legacy-adopt-held` in `account_state()["legacy_hold"]` until the next +startup retries. The unsafe `adopt_legacy_continue_txn()` is gone; its +three sites call the joint op. + +What bit: status reads settle eagerly, and `delivered` needs no install, +so an install block cannot hold a seal that already landed — the legacy +fault test journals after parts are built and blocks installs throughout, +keeping the receipt itself (not just its ack) pending so the retry proves +receipt and bytes travel together. A pre-fix leftover journal in one test +also masquerades as another test's `deferred`, which is why each adoption +test now asserts its own no-orphan tail. Breaking prepare fails the vault +contract (2) and every canonical adoption; ignoring the move result +reproduces the probe exactly (10 host failures, orphan receipt, lost seal, +empty save code); skipping the legacy hold fails 7. + +Coverage: host adoption extensions (fault-adopt hold with restart retry, +legacy clean + fault/rotation/retry, occupied canonical, rekey-move crash +window — production host 923); `prepare_receipt_move` contract in Vault +249. Coordinator 1218, journey 519, crash 112, defeat 116, account 92, +result route 76 / layout 216 / depth 55 stay green. + +Known edge: a receipt stranded by the old files-first order (files moved, +rekey never landed — only round-3 builds could write this) reads as an +occupied target and stays retained-but-deferred rather than healing; the +choice is deliberate, since healing by journey-id match could import a +foreign seal on an integer-id collision. + +## Unique adventurer names for scores: brief 201 (2026-10-07) + +The name service the lodge room will use in brief 202. No room yet, no +entry gating: this is the account-owned unique-name and score contract. + +Preservation prerequisite first. The director's probe showed +`prepare_receipt_move()` returning `ready` as soon as the source had no +paid journal, before checking the canonical target: an unrelated living +source journey imported over target C's acknowledged one-coin receipt +for seal 6, and recovery then called C's original receipt `stale` and +deleted it. The gate is now target-first: a target with a pending paid +recovery or an existing journey is occupied even when the source has no +receipt, returning `kept` with the source bytes retained and the target's +exact acknowledged seal recovered, never a second debit. Breaking the +reorder reproduces the probe (import over paid target, `stale` delete); +vault 276 and host 1013 hold it. + +Names are an immutable pair bound to the canonical UID/public-ID before +and after every atomic write: private `mb_adventurers_v1/{public_id}` +(owner-only) plus the public collision index `mb_names_v1/{name_key}` +(handle plus the already-public ID, no UID, no credentials). First-wins +atomic commit; lone or mismatched halves are rejected at both boundaries. +`CloudSchema.normalize_adventurer_name()` is the shared allow-list both +sides mirror: 2-12 BMP characters (Latin, digits, underscore, precomposed +Hangul, hiragana, katakana plus U+30FC, CJK ideographs, single interior +spaces), trimmed, keyed by `to_lower()` so mixed-case Latin collides. +Host/coordinator ops are generation-bound; claim also caches the verified +handle into the bounded (8, per-public-ID) vault cache for offline use +and backfills it onto the existing best row at the row's own saved hero, +cycles, and score — never a zero row, never a downgrade. The intro bit +flips only false to true; deletion removes the pair with the Hall row in +one verified commit. A Firebase-registered guest claims; a local-only +guest cannot certify uniqueness. `Arena._on_result_record()` routes a +verified named player to a read-only named view with no editable field +and no legacy `GlobalLadder` upload. + +What bit: committing raw Unicode document names while URI-encoding only +HTTP URL path segments (a percent-encoded document ID would reserve the +wrong name); cold caches must preserve the stored Hall display rather +than blank it; and the local-guest guard has to run before the +account-not-ready guard or guests get the wrong error. Breaking the +commit body fails the store contract; blanking cold display or swapping +the guard order fails host. + +Coverage: cloud_name 129, hall display/backfill 269, named deletion 52, +vault cache 276, host 1013 (7 name cases plus re-registered adoption), +coordinator 1218, ladder 121, result route 82, journey 519, crash 112, +defeat 116, recovery 92. Rules: 62 tests / 13 suites syntax-ok with the +combined splice verified (677 lines); the real emulator run is the +director's operation. Player-care Hall/deletion/retention disclosure +updated in all five locales with the site rebuilt and its 15 + 21 tests +green. Callable lodge contract: `notes/plans/gate-account-protocol.md`. + +## Name boundaries and token switches: brief 208 (2026-10-07) + +Correction to brief 201, from four director emulator/production probes +plus two inspection items. All preceding defeat, recovery, adoption, and +name coverage stays green. + +Fresh owners could not start naming: the adventurer read rule touched +`resource.data.uid`, so a missing row denied with 403 instead of reading +404. The rule now proves ownership from the canonical pair alone, giving +the proven owner an authoritative missing-row read while other owners, +the unregistered, and lists stay denied. The client keeps returning +`permission-denied` truthfully on every claim path; taken still arrives +only as a commit conflict plus a missing own-row recheck. + +A living name could be released under a living account: both halves' +delete rules now require the Hall row and both canonical halves gone +with them, so only the full atomic deletion (with or without a Hall row +yet) frees a name, missing-row no-ops still resume, and the reverse +(pair halves while the name stays) stays denied. + +A name request could cross a token wait onto the next coordinator. All +four host wrappers now capture an account/coordinator ticket before the +token await and verify it after every async boundary: a parked switch, +deletion-shape retirement, or shutdown cancels with `account-retired` +or `host-closed` instead of dispatching, and a failed token fails the +call without sending any mutation. + +A name restore dropped the tutorial bit and regressed the cache. Every +successful claim now carries the real `intro_complete`, and the vault +bit is monotone per same-name entry: a late incomplete response keeps +its bytes in the result but never clears the durable bit, while a new +account still starts incomplete. + +Also: the Hall mapper and panel now show the verified handle on each +named headline with the MB ID on its own line (unnamed rows unchanged), +and `_submit_best` pushes the refreshed standing to the Arena chip +again — the insertion had orphaned that call below a return. + +What bit: Godot 4.7 forbids un-awaited async calls outright, so parking +a wrapper coroutine is impossible; the mid-flight tests interleave +through timers and a recorder-dispatched signal while awaiting the real +wrapper. Re-injection churns account signals, so the swap is a narrow +white-box seam. Breaking the ticket reproduces the probe exactly (B +receives, old request ok); breaking the mapper, the HUD push, the +monotone guard, or the restore bit fails exactly its new tests. + +Coverage: host 1085 (restore-bit, 4-route switch, token +failure/timeout, shutdown, stale completion, retirement, submit-HUD, +mapper-to-panel), cloud_name 137, vault 282. Rules: 69 tests / 13 +suites, splice verified (690 lines); the real emulator run stays the +director's operation. + +## Two attendance coins per twelve hours: brief 204 (2026-10-07) + +The deletion ticket now pins the deletion generation as well as the +account/coordinator lifetime, so a name/attendance request parked at +token refresh stays cancelled even when the deletion fails on the same +account, while a genuinely new request after the failure proceeds. All +five wrappers (claim/load/intro/backfill/attendance) refuse to start +while a deletion ticket is in flight. + +Attendance is one private row per account, `mb_attendance_v1/{public_id}`, +with a server-enforced rolling 43200-second cooldown: the first visit +grants two coins and days away still grant two, never a stockpile. +Eligibility derives from server read/commit timestamps only, so device +clock changes cannot move it. The wallet grant is idempotent under the +stable key `attendance:{public_id}:{seconds}:{install_id}`, which binds +each reward to its receiving install; replay state stays bounded (newest +8 keys plus a watermark, purchased keys never evicted). Entry and +foreground triggers are nonblocking skips that never gate play, and the +five-language receipt shows only after the durable grant — never over an +IME field or open NPC dialogue. Deletion removes the row with the rest +of the account and clears the local marks; granted coins stay in the +device ledger. + +What bit: the host suite shares one Vault across tests, so every +attendance test after the first saw a duplicate grant key until the +adopt helper cleared `attendance:*` keys; sender-call assertions need an +adopt baseline (the adopt itself makes two calls). The new locale row +needed an editor reimport before `tr()` resolved it. Breaking the +receipt gate (cooldown queuing a receipt) fails exactly the cooldown +test; breaking the deletion-generation pin fails exactly the +pre-deletion attendance cancel. + +Coverage: host 1215 (first claim, cooldown, uncertain ack, wallet +failure, skips, double tap, receipt flush/suppression, foreground, +offline, link, deletion barrier), cloud_attendance 62, vault 324. +Rules: 84 tests / 14 suites, splice verified (741 lines); the real +emulator run stays the director's operation. + +## Earned attendance and deletion boundaries: brief 209 (2026-10-07) + +Correction to brief 204, from three director probes against the real +Vault, coordinator/service/transport, and deletion route. All preceding +defeat, recovery, adoption, name, and attendance coverage stays green. + +Ten owners granting one minute apart evicted the first owner's receipt +key and watermark together, and its identical replay granted again. The +vault now folds every evicted owner watermark into one durable global +floor: an untracked owner at or below the floor fails closed as +`attendance-replay-ambiguous` with the wallet untouched, while a +just-landed conditional commit bypasses the floor (the twelve-hour rule +makes it provably new), so genuine new accounts, new periods, and +same-second cross-owner ties still grant. Purchases never evict. + +A server-acknowledged reward lost to a blocked wallet save never +recovered once the next period went eligible: the advance overwrote the +row and paid only the new two. Eligible advances now backfill first — +the read row's current stamp when this install received it, plus any +carried previous stamp from this install — and commit only after every +owned reward lands, carrying the overwritten stamp and install byte for +byte (the rules require exactly that pair, forbid it on create). A +failed backfill stops before the commit; results name any backfill in +`backfilled` plus `backfilled_receipt`, and the entry receipt counts +fresh plus backfilled coins. The carry preserves one generation. + +The account-deletion commit omitted the attendance row while the rules +require it gone with the canonical halves, so attended deletions died +with `permission-denied`. The client plan, commit body, and +remaining/completed steps now include attendance (seven steps named, +five unnamed, after checkpoint); missing rows stay server no-ops, and +native Auth deletion still runs only after the data commit acks. + +What bit: the rules chain compares exact timestamps, but the client +tracks whole seconds — the carry passes the raw RFC 3339 read through +untouched (sub-second fractions survive; a truncation test pins it). +Breaking the floor re-grants the evicted replay; skipping the backfill +fails exactly the five new recovery tests and nothing else. + +Coverage: vault 354 (multi-owner reload replay), cloud_attendance 86 +(eligible/advance split, carry parse, advance races), host 1288 +(backfill-before-advance with restart, foreign carry, cooldown backfill, +replay idempotence, uncertain advance, ambiguous stop), account 56 +(five/seven steps). Rules: 88 tests / 14 suites authored (carry +required/forged/absent/create-denied/overwrite), splice verified (757 +lines); the real emulator run stays the director's operation. + +## Enter the gate lodge with Lumi: brief 202 (2026-10-07) + +First-login refuge on the reviewed name service. Cloud-linked accounts +whose durable cache holds no verified handle or no acknowledged lesson +bit route through the lodge before the Arena; completed accounts plan +the Arena directly and local-only guests play through unnamed as +before. Two round-10 repairs land with it: a pruned attendance return +now skips the ambiguous old receipt and advances a genuinely new +eligible claim instead of freezing the account, and the RFC 3339 parser +rejects malformed server dates before the engine call, so the full log +stays clean. + +Routing lives in the host, not the buttons: `needs_lodge_lesson()` is a +sync cache read, `plan_entry` refuses unsettled accounts with +`needs_lodge`, `plan_lodge_entry` holds the entry lock without arming +any journey, and `plan_lodge_exit` re-plans exactly like the title +(Resume vs confirmed fresh; sealed runs never resume). The entry +detours into the lodge loader with the same confirm/cancel/retry +semantics. Ten restore tests gained a settled-handle seed: they judge +the restore gate, and the lodge gate now precedes the plans they make. + +The room packs both plates reproducibly (`build_lodge_assets.py`: wide +1881×836, tablet 1448×1086) and Lumi as four 869px-tall facings on one +scale and one bottom-center foot anchor (visible heights 853/850/851/ +852, 0.35% spread) plus a bust crop. Layout is pure math over measured +plate fractions (aspect ≥ 1.7 takes the wide plate); the room covers, +actors scale with it from matching bases, walls clamp, the desk and +Lumi push out, the gate triggers on feet. Thirty-three `gate.lodge.*` +strings ship in all five locales. The lesson (greet, name, walk, dash, +gate seal, departure) completes only from performance plus server acks; +sticky vault gates and the durable onboarding mark resume after +restart; every await re-checks the bound account and retires to the +title on a move. Result recording still routes verified handles to +`ask_named` with no second prompt. + +What bit: the engine clamps `Control.size` to the combined minimum at +assignment time, and wrapped-label minima recompute a frame after the +width pins — sizing in the same breath freezes the stale tall minimum +and the panel never shrinks back, which the headless 808×808 validate +framing caught (panels at 1079px). Seating now pins widths, waits a +frame, then sizes from the fresh minimum. `Rect2.has_point` includes +the top/left edge, so the desk push-out steps one pixel clear. +Breaking the move threshold fails the lesson chain; forcing the lodge +gate false fails 25 host cases. + +Coverage: lodge 542 (layout/Lumi/strings/flow/guards), host 1353 +(routing, exit guards, defeat never resumes, seeded restore), vault +381 (sticky gates, cap, malformed drop), entry state 10315 / layout +85722 / exported locales green, review validate 297 headless; +room/contact/≤120-frame clip render windowed for the director. +Contracts: 7 lodge rows in `custom_asset_contracts.json` (271 files +pass), manifest rows, builder `--check` wired into `check:assets`. +Hero rasters byte-identical (empty diff). Windowed capture, the full +game sweep, and the Android touch pass stay the director's operation. + +## Scale Lumi in the rendered lodge: brief 210 (2026-10-07) + +Two director-confirmed defects in the round-10 scene. First, the guide +drew her 869px packed source at node scale ~1.0 (871px in a 360px +room): the source-to-world conversion never reached the sprite. The +guide node now converts itself (`BASE_SCALE`, `set_actor_scale` for +the room zoom), sized at 42 world px — 1.5x the tallest measured hero +opaque body (28.05, all five heroes agree within 0.5px), not the +assumed 64px hero. Brief 211 plants the boots: the world bob moved +the painted feet 4px peak to peak, so the idle is now a still body +with a breathing shadow (alpha 0.85 +/- 0.04, period 2.6 s); a new +planted test measures the opaque foot line through the real sprite +transform at five idle phases per facing and allows at most 0.5px. +The shadow recomputes from world units, and packed art is untouched. +Second, a cold cache recovering a completed row practiced +anyway (state 1/GREET); the recovery branch now trusts the host's +cached readiness and departs straight to Resume/confirmed fresh, while +a failed cache write still re-seals at the gate. + +The harness room `state=greet` now captures the true unnamed greeting, +the contact sheet uses the self-scaling guide, and the clip drives the +real lesson (taps, walk, dash, neutral stop, gate steer) over 600 sim +frames with ≤120 saved, printing observed phases and exiting nonzero +unless movement, dash, stop, and departure all complete. + +What bit: per-sheet opaque measurement must fold the 4×4 cell grid on +both axes (single-axis folding reported a 174px hero); the headless +viewport runs 808×808, so the new 808×532 iPad-mini-ratio framing +joins the pure layout math instead. Breaking the conversion fails 37 +lodge checks (854px opaque body); forcing practice fails the 4 cold +checks with state 1 vs 7. + +Coverage: lodge 624 (world-bounds bodies at four framings incl. +808×532, cold-completed with living save), host 1353, entry state +10315 / layout 85722 / exported locales, validate 347 (guide +conversion audit added), scripts 172, assets 271, heroes still +byte-identical. Windowed rendering stays the director's operation. + ## Not done - Store screenshots: the fresh five-locale Android originals (phone 40 PNGs, seven-inch 30, ten-inch 30) @@ -1411,3 +1881,384 @@ baseline stride heads, split stances, and single-boot fronts. "no recapture planned" line: the user has now requested changed title imagery as well, so a title recapture is authorized and pending with the director — not done here. + +## Attendance reminders: brief 205 (2026-10-07) + +Local twelve-hour attendance notices with a Settings switch, on the +reviewed attendance service. A host-owned `AttendanceReminders` +controller reads the server-confirmed `next_eligible_utc` as its single +authority and drives a separate reminder API on the MoonlitIdentity +natives (no Firebase gating): status, permission, schedule, cancel, +open-settings, pending diagnostics, and a QA-only short one-shot. +Intent persists in Settings with its own fail-closed disabled mark; +scheduling decisions query the live native permission while an +identical-inputs skip keeps title refreshes from shifting the +deadline. Android holds one inexact `RTC_WAKEUP` alarm re-armed after +boot with a monochrome beacon icon and a five-language channel; iOS +holds a one-shot first fire plus a 43200-second repeat with +app-only `NSUserDefaults` metadata declared by an app-owned +`PrivacyInfo.xcprivacy` (CA92.1). Foreground delivery is suppressed on +both platforms; the notice never grants coins. + +What bit: the reconcile writes the OS display cache, which emits +`Settings.changed` back into refresh — on the never-recorded refusal +path the granted/denied flip-flop recursed until the stack blew. A +reentrancy guard (nested passes are redundant) fixed it, and the new +denial test pins the path. The taller settings card overflowed 808x360 +by 26px; the upper rows moved into the dead air under the title plate +and the lower block compresses to 2px gaps (all rows verified on +screen in five locales at both framings). The lodge name form now +reports keyboard visibility/height plus real field/action bounds for +entry QA and lifts above a covering keyboard, measuring from the +unshifted seat so the correction cannot oscillate. + +## Real reminder bridges: brief 212 (2026-10-07) + +Three director-confirmed boundaries from the round-13 reminder work. +First, the Android bridge failed with unresolved `R.drawable`: the +standalone Gradle renderer copied templates plus Kotlin but never +`src/main/res`, so the owned beacon icon and channel strings never +reached the AAR. The renderer now stages the whole res tree, and two +new contract tests render the project and prove every generated `R` +reference resolves to a staged file. Second, the iOS reminder methods +sat after the worker's `@end` (18 compiler errors); they now live +inside `@implementation MoonlitIdentityWorker`, matching the file's +properties-only interface pattern, with a placement test that slices +the implementation span instead of grepping names. Third, the +controller skipped any window under 60 seconds, permanently stranding +a newly enabled install 30 seconds from eligibility; only the +already-eligible visit (`remaining == 0`) skips now, and the 30-second +probe is a registered case asserting one schedule at the confirmed +deadline with no short repeat. + +What bit: proving the placement test required temporarily restoring +the broken layout, and the restore script cut at the first method's +closing brace, splitting the section across `@end`. Repaired +deterministically (all ten selectors back inside, braces balanced, +each defined exactly once) and re-greened. + +## iOS reminder horizon: brief 213 (2026-10-07) + +The round-13 iOS shape (one-shot at the remaining delay plus a repeat +anchored to now) double-fired on fresh rewards and drifted on +halfway enables (+6/+12/+24 instead of +6/+18/+30). Scheduling now +writes a bounded horizon of 48 non-repeating slot requests at +eligibility plus N * 43200 (24 days, under the 64-request OS budget): +elapsed slots skip instead of bursting, identical refreshes skip +while the horizon is live, a nearly consumed horizon refills from the +same anchor, and a new claim resets it. Cancel removes the horizon +plus legacy first/repeat/debug ids and delivered copies; scheduling +replaces legacy pending copies so the migration cannot double-fire. +Diagnostics report each held trigger plus a horizon block (anchor, +slot count, held slots). Player-facing text now states the bound +honestly (48 notices in EN to respect the day-count freshness +needle; 24 days elsewhere). + +## Keep the player visible during lodge dialogue: brief 214 (2026-10-07) + +Three measured stage defects, one per layer. The wide spawn sat the +hero's opaque feet 5.7px under the greeting card at 880x360 (0.7px at +808x360): WIDE_HERO moved from y 0.66 to 0.634, about 10px up, still +deep in the walk band and clear of desk, Lumi, and gate. A new live +regression sweeps every idle frame against every greeting and +name-recovery card at both wide viewports in all five locales through +the real canvas transform. The name form fed raw device keyboard +pixels straight into viewport layout (432px at 3x produced a 354px +lift and negative displacement); the height now converts through the +inverse screen transform (144 viewport px, 66px lift), the lift parks +at the safe top when cramped, and the QA report names +keyboard_height_device and keyboard_occlusion separately. The contact +board seated its camera current-before-add (engine ERROR) and +captured on the first tick's lagging interpolation, which drew the +last actor huge; the harness now seats, resets, settles, then +audits four rendered silhouettes from the captured pixels and fails +the run when a slot is empty or outsized. What bit, twice: under +project-wide physics interpolation a fresh tree's canvas transform +lags reality until reset — both the new test and the board reset +before judging pixels. + +## Converge reminder state after OS changes and return: brief 216 (2026-10-07) + +The identical-input skip ran before the live permission check, so an +OS revocation on the same deadline never surfaced; the controller now +re-checks permission first (denial keeps intent and record, schedules +nothing), and iOS status answers pending with a fresh async verdict +whose bounded outcome runs the schedule tail once — a tail that never +re-queries status, so it cannot loop. The iOS horizon finally refills: +the first future slot derives from the anchor, 48 future slots plan +under stable ordinal ids whenever fewer than 8 remain, and the held +base/end persist into the receipt and pending diagnostics; the game +records the window and re-asks only when it runs low, never moving +the deadline. The settings row builds lazily on first open (knight +Lv40 node peak 1202 → 1199 against the unchanged 1200 budget) and an +undetermined OS state shows a truthful new string instead of on. +Android status weighs the global switch, runtime grant, and channel +on every version; pending diagnostics separate persisted intent from +token existence, retiring cancels the tokens, and the schedule +receipt reports an unbounded -1 horizon. What bit: muting the suite's +spare host-owned controller exposed the locale test's hardcoded "en" +as a no-op that had passed on interference; it now picks a genuinely +different locale. The divergent JS horizon simulation is gone, +replaced by structural contracts over the production planner plus +real controller tests. + +## Match the actual native plugin APIs: brief 215 (2026-10-07) + +The real toolchains typed what the regex tests could not. Android: +`onMainRequestPermissionsResult` returns Unit in the pinned Godot core +(Boolean was the back-press callback), and `Godot` is not a `Context`, +so the five `activity ?: godot` fallbacks became activity-or-honest +retryable `no_activity` errors; the two null-host paths already there +took the same code. The game controller now treats a native `error` +as a reason to retry, never to cancel: the owned delivery, its +record, and the cached display state survive, and the next refresh +converges. iOS: the 64-bit anchor rides an NSNumber through +`setObject`/`longLongValue` (no longLong selectors exist), +`nextTriggerDate` is read only from concrete interval/calendar +triggers, and the ephemeral grant hides behind `@available(iOS 14)`. +What bit: the reminders scene also runs the host autoload's own +controller, and both reconcile the shared Settings record — a test +that emits between plant and assert watches the other controller +retire it through the real bridge. The new transient-error test sets +the display cache directly and asserts across one direct refresh. + +## Keep the name action above a tall keyboard: brief 217 (2026-10-07) + +The clamp math was honest but the card was not: parking a 228px panel +at the safe top still buries the confirm action under a 180px +keyboard. The production hook now compacts when the lift runs out — +bust, header, and hint hide, the card shrinks to its live minimum +(138 at 808x360 ko), and field, feedback, and confirm lift clear with +the 8px margin. Hide restores chrome, geometry, and the typed value, +then re-seats for the current view; resize never fights a shifted +form. An all-screen keyboard is dismissed once per height instead of +promising impossible UI, and the keyboard's own submit still files +the claim. The lodge scene camera and the contact-board camera ride +the physics callback (verified `CAMERA2D_PROCESS_PHYSICS = 0` from +the engine, not memory). What bit, twice: headless owns no window +(`window_get_size` reads 0,0), so the tests stage the window they +resized to through a second hook parameter; and a coroutine test +helper called without `await` interleaved the lift asserts with the +compact case, printing chrome states exactly inverted from the +returned modes until the missing `await` went in. + +## Close the remaining real reminder refill boundaries: brief 218 (2026-10-07) + +Three native/game disagreements and one build break. Android: +`OPSTR_POST_NOTIFICATION` is not in the compile SDK, so the pre-24 +app-global check now resolves `OP_POST_NOTIFICATION` by reflection +exactly like NotificationManagerCompat, with the same safe-true +fallback; and both schedule paths (direct and boot) move an overdue +first trigger onto the anchor grid's next future slot instead of +firing the backlog immediately. iOS: the planner is integer +milliseconds in a shared header — due-now counts as future, so the +equal-time boundary holds 48 slots and day 25 plans from slot 50 — +and every success receipt carries its held window, duplicates +included. The game side schedules expired known anchors (future-only, +no coins), holds under the exact native duplicate rule from the +recorded base/anchor, derives legacy bases once, and never replaces +known metadata with a bare duplicate's zero. Proof is execution, not +simulation: a node test compiles the production header with the +system C compiler and runs the vectors, while the GDScript suite +mirrors the same agreement fixtures. What bit: the contract suite +pinned the old bugs (OPSTR reference, `maxOf(eligible, now)`, the +`<= now` skip, the metadata-less duplicate), so each fix updated its +contract to pin the corrected shape instead. The numeric proof +compiles the production header itself, never a copy of its math. + +## Respect off after a late native status result: brief 219 (2026-10-07) + +The granted async-status tail called the schedule function directly, +bypassing the reconcile head's enabled/source gates: pending, then +off, then a late grant scheduled one delivery while off. The gates +now live in the shared tail itself, so both paths honor current +intent and ownership; the late verdict still corrects the OS display +cache, and a later genuine re-enable schedules exactly once. What +bit: nothing — the new tests failed first on the unpatched code with +the director's exact signature (schedule_log 1 while off). + +## Reminder release and preservation contracts: brief 220 (2026-10-07) + +Two root integration defects, both producer-side. The reminder off +sentinel `user://attendance_reminders.disabled` is a real preserved +preference that was missing from every fixed inventory, so the +Android and iOS save inventories reported it unclassified. It now +sits in both Node producers and the Python consumer (33 entries), +with every mirror pin updated and a registered regression proving +its presence round-trip, mutation detection, and tampered-proof +rejection. The Android notification vector carried +`android:tint="?attr/colorControlNormal"`, which release AAPT cannot +resolve in the standalone library while debug stays green; the tint +is gone, the white silhouette and resource name stay, and the +drawable contract now rejects any `?attr/` theme reference. What +bit: nothing — both inventory suites failed first with the exact +unclassified sentinel, and the new tests fail on purpose when the +list entry or the tint is reverted. + +## Keep new lodge terms within repo rules: brief 221 (2026-10-07) + +Root hygiene flagged four whole-word hits in the new lodge files: +the Lumi guide's idle comment and breathing-angle local, plus one +practice assertion string in the lodge test. Renamed the local to +`pulse_angle` and reworded both strings; every number, expression, +and behavior is untouched. Lodge regression (2435 checks), the +174-script compile, and hygiene (zero product hits) are green. What +bit: the copy's `.godot` import cache predated the new lodge art, +so the compile check failed on missing `.ctex` preloads until one +editor `--import` refreshed the cache (sandbox-blocked editor +settings save, as usual; no repo files from the import). + +## Fit the attendance label beside its button: brief 224 (2026-10-07) + +English `Attendance reminders` is 164px at font 15 in a 128px +column, so the centered label grew both ways to (214,229,164,34) +and overlapped the button at x368 by 10px; the old probe checked +viewport containment only and missed the sibling overlap. The +settings row now measures the label like the status button and +shrinks 15→11 (119px, the locale-button size) in English while +staying 15 in the other four locales, recomputed on every redraw +so locale changes and repeated opens never stick. The regression +checks text width, combined minimum, rect separation, and a 6px +visible-text-to-button gap at 808x360/532/606 in all five locales +across off/on/denied/unknown/unsupported, plus translation-update +and reopen recompute with no second lazy row. What bit: nothing — +the new checks fail first on the unpatched code with the +director's exact signature (164px label, -10px gap). + +## Free the vault regression's temporary nodes: brief 228 (2026-10-07) + +`_test_continue_txn_atomicity` and `_test_prepare_receipt_move` built ten +plus three temporary normal/failing Vault nodes (including the +malformed-journal loop) and never freed them: 13 leaked Nodes, 16 leaked +ObjectDB instances, 2 scripts still in use, and the strict runner's +correct failure on an otherwise 381-green run. Every fixture now frees +its nodes like the older tests, and each of the two functions asserts +orphan-node baseline equality via +`Performance.OBJECT_ORPHAN_NODE_COUNT`, so an omitted free fails the +registered test instead of only warning at exit. Vault is 383 green +with zero teardown diagnostics; defeat (116), crash boundaries (112), +account recovery (92), IAP store (852), and journey (519) are green +too. The negative control (two frees omitted) fails both guards with +exact orphan deltas and restores clean. What bit: nothing — the probe +confirmed the monitor is synchronous before the guard was written. +Production bytes unchanged; only `tests/test_vault.gd` differs. + +## Project desk collisions onto walkable floor: brief 229 (2026-10-07) + +The lodge's `_push_out_of_desk` picked the nearest desk edge with +float-equality ties and no walk check. At 880x360 the desk center +(167.2, 74.4) exited the top to (167.2, 14.73), off the walk band +(min y 117.42); the player's bounds clamp then settled it back to +(167.2, 117.42), inside the desk again — a frame-order flake the +suite caught once in full verify and never standalone. The push-out +now delegates to pure `GateLodge.desk_exit_for`, which takes the +nearest exit that stays inside the inclusive walk bounds and breaks +near-ties (0.001px) in fixed left/right/top/bottom order; a clamped +fallback covers the no-valid-exit case no real framing reaches. The +registered lodge suite gained per-framing guards over all four judged +sizes plus 880x360 — center, edge midpoints, corners, and overlap +strips through the real method, both constraints after projection +and after real physics settling, sliding sides preserved, live-loop +checks — plus a labeled 880x360 negative control that catches the +old rule stranding off-walk and re-entering on clamp. Lodge is 2850 +green across three clean runs; the old method restored fails the new +guard 84 times with the director's exact 880x360 signature. +Production host (1353), gate entry state (10315) and layout (85722), +terrain movement (536), and gate loading (346) are green too. What +bit: float32/float64 disagree on the center tie (Python picks top at +808x360 where Godot picks bottom), so the tie was never a rule — +and the copy needed one editor import for the gitignored +`gate_entry` translations before any string-dependent check. + +## Default iOS releases to after approval: brief 230 (2026-10-07) + +Future iOS submissions select automatic release immediately after +approval. `scripts/lib/app-store-release.mjs` gains +`APP_STORE_VERSION_RELEASE_TYPE = 'AFTER_APPROVAL'`, generated into +`payload.release.releaseType`, required by manifest verification, and +planned on version create/update with no `earliestReleaseDate`; +`fields[appStoreVersions]` GET selection and `audit.remote` +now carry the actual remote `releaseType`. +`scripts/lib/app-store-connect-apply.mjs` blocks review submission +with `ASC_AUTOMATIC_RELEASE_NOT_VERIFIED` unless the GET readback is +`AFTER_APPROVAL`, before any submission POST/PATCH. Read-only review +and published boundaries are unchanged: non-adoptable versions keep +`ASC_APP_STORE_VERSION_NOT_ADOPTABLE`, and editable-state checks keep +refusing read-only mutation. Three focused tests in +`scripts/lib/app-store-release.test.mjs` cover create, editable +MANUAL/SCHEDULED update, already-matching idempotence, bad-readback +submission block with zero requests, and read-only/published refusal; +83 App Store node tests pass. Negative controls: dropping the create +`releaseType` fails the new default test plus two existing create +asserts; removing the submission guard fails the new readback test. +Author default noted in `notes/release/iap-store-setup.md`. What bit: +nothing — existing create/submit/read-only fixtures needed the new +field added to keep their other guarantees. + +## 4.0.1 store update: brief 231 (2026-10-07) + +iOS 4.0.0 build 14 was already READY_FOR_DISTRIBUTION and Android +4.0.0 code 19 PUBLISHED on internal and production, so the lodge and +attendance work ships as 4.0.1 / Android 20 / iOS 15. Identity only: +`project.godot`, both Android presets plus the iOS application keys, +and the store-page itch rows; every other preset and project value +is byte-identical. `buildAppStoreReviewNotes` now walks a fresh +guest through the gate lodge (Lumi, unique name over a connection, +walk-and-dash practice, moon-gate departure) before the Arena, then +covers cached-name offline re-entry, living resume, sealed defeat, +one-coin revive, rolling twelve-hour attendance, and optional local +reminders — ten IAP IDs, restore guidance, no demo password, and the +4,000 bound intact. Five-language What's New names the new +room/name/attendance/defeat changes within the 500-character Play +limit. The binary-only planner now permits a strictly newer +`major.minor.patch` display version with the current five-language +notes alongside same-version replacement (which keeps retained +notes); downgrades, malformed versions, listing drift, and identity +mismatches refuse before mutation, historical internal release and +ordered gallery hashes still re-verify before any change, and the +proposed identity plus notes bind into tokens and the receipt +(schema 2). Privacy audit §9 scopes the new name/attendance rows +and Hall alias over the untouched 4.0.0 record. App Store suites 85 +green, Play package 94 green; room-guidance removal and downgrade +acceptance both fail their focused tests and pass after byte-exact +restoration. What bit: the `project.godot` bump moves the capture +fingerprint, so `check:store-screenshots` goes red by design while +the gallery is deliberately reused, never recaptured. + +## 4.0.1 review-boundary corrections: brief 232 (2026-10-07) + +Evidence review corrected four round-1 wordings without touching game, +counters, listings, or guards. The lodge name form offers confirmation +and retry with no unnamed opt-out, so privacy §9 now separates required +online naming (unique nickname before practice and departure) from the +error/unconfigured local-only escape, and records the director's saved +Play Name→required correction as saved in Publishing overview, not yet +sent for review; Apple declarations are unchanged. Review guidance is +conditional on a verified name AND a completed guide lesson for both +the generated Apple notes and the Play paste paragraph, with +interrupted practice returning to the lodge; the Play paragraph is 417 +characters against the measured 500-character console cap. The +binary-only display parser now rejects leading-zero versions per +semver specification 2 (`04.0.1` malformed, `4.0.1` and `0.0.0` +valid), with current- and retained-identity regressions. App Store +suites 85 green, Play package 94 green; restoring the old parser +fails the new malformed regression. What bit: the first conditional +wording pushed the generated Apple notes past 4,000 characters and +failed 31 tests at once, so the Returning paragraph was tightened to +land at 3,958 with fixture names. + +## 4.0.1 prose alignment: brief 233 (2026-10-07) + +The ten rewritten What's New blocks still said returning accounts +skip the lodge unconditionally. All ten now require a completed +guide lesson and send interrupted practice back to the lodge, with +the five translations kept paired; the longest Play block (English) +is 472 characters against the 500 limit. The public reference's +version row now reads 4.0.1 / iOS 15 / Android 20 and its run flow +opens with the first-entry lodge visit, while the dated 3.0.0 +history stays historical and nothing claims 4.0.1 is published or +reviewed. The App Store CLI help's retained-gallery sequence is now +version-neutral with no argument or implementation change. Metadata +validation, both release suites, hygiene, and the docs build pass. +What bit: nothing — the English block needed a length check before +committing to the longer conditional sentence. diff --git a/notes/plans/gate-account-protocol.md b/notes/plans/gate-account-protocol.md new file mode 100644 index 0000000..7ebf7de --- /dev/null +++ b/notes/plans/gate-account-protocol.md @@ -0,0 +1,457 @@ +# Gate account protocol (Brief 201 service contract for the lodge) + +Author-only implementation record. Brief 202 (the lodge room UI) reads this +note to call the name service; the lodge builds no new cloud contract. + +## What exists + +Each signed-in account owns at most one adventurer name, stored as an +immutable pair bound to the canonical UID/public-ID pair: + +- Private `mb_adventurers_v1/{public_id}`: `{uid, public_id, display, key, + intro_complete}`. Owner-read, owner-write-once, owner-delete. +- Public collision index `mb_names_v1/{name_key}`: `{public_id, display}`. + Public-readable, owner-write-once, owner-delete. No UID, no credentials, + no private metadata: the handle and the already-public ID are enough to + establish a collision. +- Hall best rows `mb_hall_v1/{public_id}` gained an optional `display` + field. Old rows without it still render; rules require any `display` + written to equal the writer's claimed name. + +Rules live in `firestore.cloud.addition.rules`; enforcement tests in +`tests/cloud-rules/cloud-rules.test.mjs` (the director runs the emulator). + +## Names + +`CloudSchema.normalize_adventurer_name(raw)` returns +`{ok, display, key}` or `{ok: false, error: "invalid-name"}`: + +- Trim boundary whitespace, then 2-12 visible characters. +- Allow-list: Latin letters, digits, underscore, precomposed Hangul + syllables (U+AC00-U+D7A3), hiragana, katakana plus the prolonged-sound + mark U+30FC (as in `ルミー`), CJK ideographs (U+4E00-U+9FFF), and single + interior ASCII spaces. +- Rejected: path separators, controls, invisible/combining characters, + Jamo, astral emoji, doubled or boundary spaces, anything else. +- The key is `display.to_lower()`: mixed-case Latin variants collide, + CJK passes through byte identical. Rules mirror the list exactly. + +Renaming is out of scope: claims are immutable. The handle is a public +game name, not a real name. + +## Lodge call contract (Brief 202 reads this) + +All calls go through the production host +(`apps/game/scripts/net/production_host.gd`). Generation-bound: stale +callbacks after account switch, token refresh, or cancellation report +instead of applying to the wrong account. + +- `verified_display_name() -> String` + Synchronous cache read of the live account's verified handle, or `""` + when it claimed nothing. The lodge shows the naming step when this is + empty after a load attempt; otherwise it skips naming. No request. +- `claim_adventurer_name(raw_display: String) -> Dictionary` + Online-only. A local-only guest gets + `{status: failure, code: local-guest}`: the lodge must not offer naming + without a cloud session. On success the result carries + `{status: ok, display, key, intro_complete: false, cached, backfilled, + backfill_code}`; `cached`/`backfilled` name the legs that did not land so + the lodge can retry exactly that leg (`load_adventurer_name` for the + cache, `backfill_hall_name` for the row). A Firebase-registered anonymous + guest claims like any signed-in account and keeps the name after linking + to Google or Apple. Error legs: `invalid-name`, `name-taken` + (`status: conflict`), transport offline/auth failures. +- `load_adventurer_name() -> Dictionary` + Reload the live account's claimed name and refresh its offline cache. + Restart-after-claim recovery calls this before deciding the account is + unnamed. `{status: ok, display, key, intro_complete, cached}`. +- `mark_intro_complete() -> Dictionary` + Flip the tutorial bit false to true when the lodge tutorial finishes. + Idempotent: double taps and uncertain acknowledgements converge. + `{status: ok, display, key, intro_complete: true, cached}`. +- `backfill_hall_name() -> Dictionary` + Attach the verified handle to the account's existing best row at the + row's own saved score, hero, and cycles (never the currently selected + hero, never a zero-score row, never a downgrade). Writes nothing when no + best row exists yet. + +Result statuses elsewhere: `unregistered`/`account-not-ready`, +`cancelled` (stale generation, `account-retired` when the initiating +account moved on, `host-closed` on shutdown), `failure` with a code, and +`retryable` flags. The host pins every name call to the initiating +account before the token wait: a switch, deletion, or shutdown parked +across the wait cancels instead of dispatching onto the next account, +and a failed token fails the call without sending any mutation. Only +the initial claim needs online confirmation; verified returning accounts +use the cached handle offline. + +Every successful claim carries the real `intro_complete` bit, including +restores of a name claimed earlier and recoveries after an uncertain +acknowledgement. The durable cache bit is monotone per name: a late +incomplete same-name response never clears a stored completion, while a +different account or key still starts incomplete. + +Reads: the owner proven by the canonical pair gets an authoritative +missing-row read (claim naming starts there); other owners, unregistered +callers, and lists stay denied. A `permission-denied` is never a taken +name: taken arrives only as a commit conflict followed by a missing +own-row recheck, so missing backend rules surface as configuration +failures instead. Deletion binds both name halves to the full account +deletion: no partial release lives under a living profile/reservation, +and missing rows still delete as no-ops. + +## Local durability + +`Vault` keeps a bounded (max 8, least-recently-touched eviction) +account-partitioned cache: display, key, intro bit per public ID. No +tokens, emails, or raw native identity profiles. Separate accounts never +share cache entries; cache validation rejects malformed rows on load. +Account deletion removes the verified cache row, then deletes the Hall +row, name, adventurer record, cloud save, reservation, and profile in one +verified commit before the sign-in itself is deleted, so a name is never +stranded or freed under a living score. + +## Score routing + +`Arena._on_result_record()` routes a verified named production player to +`LadderPanel.ask_named()` with the cached handle: read-only, no editable +name field, no legacy `GlobalLadder` upload. Unnamed and legacy fixture +players keep the old editable route unchanged. + +The owned Hall panel renders the verified handle on each named row's +headline (`#rank · score · handle`), with the stable MB ID on its own +line; unnamed rows keep the honest rank-and-score headline. Best +completion pushes the refreshed standing to the Arena rank chip again. + +## Preservation prerequisite (Brief 203/207 shape) + +`Vault.prepare_receipt_move()` treats a canonical target with a pending +paid journal or an existing journey as occupied even when the source has +no receipt: it returns `kept`, keeps the source bytes, and the target +recovers its exact acknowledged seal with no second debit. The lodge must +not bypass this guard when adopting canonical slots at first login. + +## Attendance (Brief 204: two coins per twelve hours) + +One private account-owned row, `mb_attendance_v1/{public_id}`, holding +`{public_id, uid, install_id, last_claim_at, schema}` plus, after the +first advance, the carried `{prev_claim_at, prev_install_id}`. Only +`last_claim_at` is server-set (`request.time`); the client never writes a +timestamp or a count. First claim creates with `exists: false`; later +claims compare-and-swap on `updateTime` while carrying the overwritten +row's stamp and install byte for byte (the rules require exactly that +pair, and forbid it on create), so another install's advance never +erases this install's still-unapplied reward. A first read that finds +nothing returns authoritative not-found for the proved owner; 403 stays +an error and strangers stay denied. Concurrent claims for one account +converge on one receipt through the loser re-reading the winner's row. + +Eligibility is a rolling 43200-second server cooldown from the last +acknowledged claim: the first eligible visit grants two, and days away +still grant two, never a stockpile. Remaining time derives from a server +`batchGet` read time or commit time only; device clock and time zone +cannot move it. Forward/backward clock changes are covered in the client +tests. A notification never grants coins by itself. + +An eligible advance never commits before its owned rewards land: the +coordinator backfills the read row's current stamp when this install +received it, plus any carried previous stamp from this install, then +commits. A failed backfill stops before the commit (the row keeps the +receipt); a failed wallet write reports `local-grant-failed` with +`server_claimed` for retry, while an eviction-floor ambiguity reports +terminal `attendance-replay-ambiguous`. Backfilled calls set `backfilled` +plus `backfilled_receipt`; the entry receipt counts fresh plus +backfilled coins. The carried pair preserves exactly one generation: a +third install's advance drops the oldest carry. Purchased balances stay +local and outside this protocol. + +Wallet application goes through `Vault.grant_attendance_coins(public_id, +claim_seconds, install_id, fresh_commit)` exactly once per claim, +idempotent under the stable receipt key +`attendance:{public_id}:{seconds}:{install_id}`. The key binds the +reward to its receiving install: another device cannot replay the same +grant into a second wallet, but may claim the next eligible period. No +token, email, or raw native profile is persisted. Replay state stays +bounded (newest 8 attendance keys, 8 owner watermarks, one global +eviction floor); purchased-coin keys are never evicted, and an untracked +owner at or below the floor fails closed as ambiguous unless the call +carries fresh-commit proof (a just-landed conditional commit, whose +twelve-hour rule makes same-second cross-owner ties genuine). +`Vault.attendance_receipt_applied(public_id, seconds, install)` reports +whether one receipt already landed. + +Callable surface for the lodge and the reminder brief: + +- `ProductionHost.request_attendance()` — one bounded nonblocking check + for genuine account entry and explicit calls. Skips (never errors) for + local guests, unready accounts, in-flight deletion or restore, and + overlap. Shows the receipt only for coins newly granted by the call. +- `ProductionHost.claim_attendance()` — the direct generation-bound claim; + same deletion/account guards as the name routes, including the pinned + deletion generation across awaits. +- `ProductionHost.notify_foreground_return()` — one bounded re-check on + window-focus return; wired to `focus_entered` on real displays only. +- `ProductionHost.attendance_view()` / `CloudCoordinator.attendance_snapshot()` + — `{state, source, public_id, receipt, last_claim_utc, + next_eligible_utc, remaining_seconds}`. `source` is `live` (server), + `cache` (display/reminder only, never grants), or `none`. Reminder + scheduling reads `next_eligible_utc` plus bounded `remaining_seconds`. +- Signal `ProductionHost.production_attendance(snapshot)` — emitted on + every live publish and every offline/cache serve. +- `CloudAttendance.commit_advance(transport, uid, public_id, install, + update_time, prev_claim_rfc, prev_install)` — the conditional advance + commit after backfill; service `claim_attendance` returns `eligible` + with the read row instead of committing it. +- Result statuses: `granted` (coins: 2, receipt, deadline), + `already-claimed` (mine: top-up once or duplicate; foreign: none), + `cooldown` (deadline, no coins), `offline`/`failure` (honest error, + never coins, play continues). Any advance/hold result may add + `backfilled` plus `backfilled_receipt` for recovered earned coins. + +Entry wiring: the arena scene change flushes a gate-time receipt and +fires a deferred nonblocking claim; living runs are untouched and new +players finish naming first (no overlay while an IME field holds focus +or an NPC dialogue is open). The receipt string is `ATTENDANCE_RECEIPT` +in all five locales, shown only after the durable local grant. + +Deletion removes the attendance row with the Hall row, name, adventurer +record, save, reservation, and profile in the one verified commit (seven +steps named, five unnamed, attendance after checkpoint), and clears the +local watermark and cached deadline; granted coins stay in the device +wallet ledger. Legacy unnamed and never-attended accounts delete +unchanged: missing rows are server no-ops. Firebase Auth deletion still +runs only after the data commit is acknowledged. + +## Reminders (Brief 205: local attendance notices) + +One controller, `scripts/gameplay/attendance_reminders.gd` +(`class_name AttendanceReminders`), owned by the production host so it +outlives scene changes. It reads the attendance view above as its +single authority (`next_eligible_utc` + bounded `remaining_seconds`; +`live` or `cache` may schedule, `none` never does) and drives the +reminder API on the MoonlitIdentity bridge. There is no second reward +timer, and a notification never grants coins. + +- Intent lives in Settings: `reminders_enabled` (default off) with its + own `user://attendance_reminders.disabled` fail-closed mark (same + shape as analytics, separate file, never cross-read), + `reminder_receipt_offered` (the first receipt offers once, never + nags), display-cache `reminder_os_state` + (`unknown/granted/denied/unsupported`), and the scheduled record + (`reminder_sched_account/_eligible_utc/_locale` plus + `reminder_sched_horizon_end`: last held fire, -1 for an unbounded + repeating native, 0 for a legacy schedule). +- Every refresh re-checks the live native permission before skipping + unchanged inputs, so an OS revocation on the same deadline surfaces + as denied (intent and record kept, nothing scheduled) instead of + hiding. Identical inputs then skip only while the recorded window + holds (unbounded, or the refill margin ahead of now still inside + the held end), so a title refresh never shifts the deadline or + stacks requests. Only the already-eligible visit (`remaining == 0`) + arms nothing, since the foreground claim owns the next deadline; + any positive window, however short, schedules with its own first + delay under twelve-hour repeats (brief 212: the old under-60 s + shortcut stranded newly enabled installs). +- `refresh()` reconciles on every attendance snapshot, settings + change, and account transition, with a reentrancy guard (the + reconcile writes the OS cache, which emits back into refresh). + Disable, sign-out, account switch, and deletion cancel the native + delivery and clear the triple. One active account per install. +- `user_enable()` persists intent, then requests OS permission (async, + generation-guarded); denial keeps the intent but schedules nothing + and shows OS-denied. `user_toggle()` from off-plus-denied opens the + OS notification settings instead of re-asking. +- Native API (both platforms, same receipt contract, no Firebase + gating): `moonlitReminderStatus`, `moonlitReminderRequestPermission`, + `moonlitReminderSchedule` (`eligible_utc_millis`, localized + `title`/`body`, `locale`, `account`), `moonlitReminderCancel`, + `moonlitReminderOpenSettings`, `moonlitReminderPending`, + `moonlitReminderDebugSchedule` (QA, one 5–600 s one-shot). +- Android: one inexact `RTC_WAKEUP` twelve-hour alarm re-armed after + boot; delivery re-checks opt-out, permission, channel, and + foreground (fresh resume record or foreground rank; stale records + expire in ten minutes). Effective status weighs the app-global + switch, the 33+ runtime grant, and the channel together on every + version (no blanket grant below 33); the runtime sheet shows only + when the runtime permission is the missing piece. Diagnostics + separate persisted schedule intent from token existence and never + claim an OS-held alarm from a token; retiring cancels the tokens. + iOS: a bounded horizon of 48 non-repeating slots at eligibility + plus N * 43200 (24 days, under the 64-request OS budget), refilled + from the first future slot on the same anchor with stable ordinal + ids 0..47 whenever fewer than 8 future slots remain; elapsed slots + skip, adequate identical refreshes skip, the held base/end persist, + the receipt and pending diagnostics report `base_slot` and + `horizon_end_unix`, and cancel removes the horizon plus legacy + first/repeat/debug ids. iOS status reads the OS asynchronously and + settles as a bounded outcome; a granted outcome runs the schedule + tail once (which never re-queries status, so it cannot loop). + Foreground presentation suppressed through a delegate installed + only when the center has none. iOS scheduling metadata lives in + app-only `NSUserDefaults` (`dev.moonlitbeacon.reminder.*`), + declared by the app-owned + `PrivacyInfo.xcprivacy` (CA92.1) staged into the app resources. +- Cold notification launch opens the ordinary title route (Android + reports `launched_from_reminder` once); tapping never claims or + spends. Desktop answers `unsupported` everywhere and stays playable. +- Director QA: `res://tools/reminder_review.tscn` (`mode=validate` + headless, `mode=ui delay=60` on an isolated native account; + background the app before the fire time). Lodge entry QA prints + `GateLodge.name_form_keyboard_report()` (visibility, height, real + field/action rects, never the typed name); the form lifts above a + covering keyboard via `keyboard_shift_for()` and restores after. +- Late async verdicts (Brief 219): the user-intent and ownership + gates live in the shared schedule tail, not only in the reconcile + head — a pending native status that completes granted after off (or + after the scope drops to none with stale fields retained) still + updates the truthful OS cache but schedules and records nothing. A + later genuine enabled known view schedules once. +- Refill boundaries (Brief 218): the iOS 48-slot planner is integer + milliseconds in `MoonlitReminderPlanner.h`, shared by the native + schedule path and executed by `scripts/lib/reminder-planner.test.mjs` + (48 unique future grid slots at initial/equal/day-25; first-future + exact at every grid multiple). Due-now counts as future (a slot + firing exactly now schedules once; strictly elapsed never bursts), + so the equal-time boundary holds 48 and day 25 plans from slot 50. + The duplicate gate holds while 8 of the recorded window are still + future; every success receipt — duplicates included — carries + `base_slot`, `horizon_end_unix`, and `scheduled_slots`, and the + game side keeps known metadata rather than recording a zero from a + bare duplicate. The game-side hold rule mirrors the native gate + exactly (same base/anchor/integer-ms inputs, same shared vectors); + legacy end-only rows derive their base once or replan once. An + expired known anchor schedules future-only slots on genuine refresh + (no coins move); unknown/none sources never schedule; Android first + triggers land on the anchor grid's next future slot (boot included), + and the pre-24 app-global check resolves its op id by reflection. +- Tall keyboards (Brief 217): a plain lift cannot fit the 228px card, + so `_apply_keyboard_shift()` compacts when the lift runs out — the + optional bust/header/hint hide, the card shrinks to its live minimum + (full 228 vs short 138 at 808x360 ko), and field, error feedback, + and confirm lift above the keyboard with the 8px margin. The mode + decision always measures the full card, so the compact rect cannot + feed back and oscillate; resize never re-seats a shifted form, and + hiding restores chrome, geometry, and typed value, then re-seats for + the current view. An all-screen keyboard that still covers the short + card is dismissed once per height (`virtual_keyboard_hide`, edge on + the occlusion value); the focused field keeps its value and the + keyboard's own submit still files the claim. Tests and the QA + harness drive this same hook with staged device pixels and window + (`_apply_keyboard_shift(height, window)`; headless owns no window, + so the window stages with the viewport); `lodge_review mode=room + state=name kb=432` captures the adapted form (synthetic staging, + never native delivery). + +## The gate lodge (Brief 202 route) + +Scene `scenes/gameplay/gate_lodge.tscn`, controller +`scripts/gameplay/gate_lodge.gd`, guide +`scripts/actors/lumi_guide.gd`. First visit for cloud-linked accounts +whose durable cache holds no verified handle or no acknowledged lesson +bit. Completed accounts plan the Arena directly; local-only guests +never enter (no claim to settle). + +### Routing (production_host) + +- `needs_lodge_lesson() -> bool`: sync, vault cache only. True when + cloud-linked AND (no verified display OR intro bit unset). The only + new refusal in `plan_entry`: `{refused, needs_lodge}`. +- `plan_lodge_entry() -> {ok, lodge, account_id, needs_name}`: same + guards as `plan_entry` (identity/deletion/in-flight/login/restore) + plus `lodge_complete` when settled. Holds the entry lock; arms NO + journey, stamps NO title, touches NO save. Cancel via + `cancel_entry_plan`. +- `plan_lodge_exit(fresh, confirmed)`: needs the live lodge plan on the + same account plus a settled cache (`no_lodge_plan`, + `login_in_flight`, `account_moved`, and the `needs_lodge` double-tap + guard). Releases the hold, then plans exactly like the title (Resume + vs confirmed fresh; defeated journeys refuse resume here too). On + refusal the lodge hold restores. +- `production_entry` detours `needs_lodge` into the lodge loader with + the same confirm/cancel/retry semantics (`_load_is_lodge`); a retry + re-plans the lodge, never the arena. + +### Lesson flow (controller states) + +BOOT → GREET (3 lines + server name recovery) → NAME (form; empty +start; offline/taken/invalid/retry states; no spinner trap) → MOVE → +DASH → GATE (walk to the moon gate) → SEALING (`mark_intro_complete`) +→ DEPART (Resume vs confirmed Fresh vs walk back) → EXITING; LOST on +account move/deletion (releases the plan, walks to title). Skip +advances words only; gates complete only from performance: move needs +60 base units of real walking, dash a real `Player.dash()` in flight, +the gate the hero's feet inside the painted arch trigger plus the +server ack. Sticky per-account gates in the vault (`lodge_progress`, +cap 8, unknown gates refuse, malformed entries drop) resume practice +after restart; `Onboarding.mark_done(key, durable=true)` quiets the +matching arena tip from the actual performance (the lodge arms no +journey, so only the durable path writes). The intro bit flips only on +the ack; a miss re-arms the gate for another explicit touch. The scene +holds `moonlit_dialogue` for the whole visit, so attendance receipts +wait out the keyboard and speeches; the first eligible claim settles +after the lesson. + +### Recovery + +- Restart before claim / before seal: same canonical ID; `load_…` (or + the warm cache) recovers the handle; sticky gates skip taught steps. +- Uncertain claim ack: retry the same name (restore path) or tap again; + never a second name, never a remint. +- Uncertain seal ack: DEPART only on `ok`; anything else re-arms the + gate; re-entry re-seals idempotently. +- Account switch / deletion / token death mid-visit: every await + re-checks the bound account; stale work retires to LOST → title; the + lesson waits for the next entry. +- Cold cache on a fresh install: the lodge loads before asking; a + verified completed account passes straight to departure. The + recovery branch trusts only the host's cached readiness + (`needs_lodge_lesson`), never the reply's intro bit alone: a failed + cache write keeps the lesson open and re-seals idempotently. + +### Layout + +`GateLodge.layout_for(viewport_size)` is pure: aspect ≥ 1.7 → the wide +plate (808×360 and wider), else the 4:3 tablet plate (808×606). The +room covers (never bands); actors scale with the room from their +plate's base (both bases run 1.0, so Lumi keeps one world height on +both plates: 42 world px, 1.5x the tallest hero opaque body at 28.05, +measured across all five heroes — never an assumed 64px hero); +walk/desk/gate/Lumi/hero derive from measured plate +fractions (gate pale mass 0.826/0.196 wide and 0.857/0.163 tablet, +desk warm mass 0.261/0.300 wide and 0.239/0.263 tablet, open floor +below ~0.34 wide / ~0.30 tablet). Walls clamp through the player +bounds, the desk and Lumi push out, the gate triggers on feet. Lumi +packs four 869px-tall facings under one scale and one bottom-center +foot anchor, with a still-body idle (visible heights 853/850/851/852 +source px, 0.35% spread). The guide node converts source to world +itself (`BASE_SCALE`, `set_actor_scale` for the room zoom); only the +floor shadow breathes (alpha 0.85 +/- 0.04, period 2.6 s), so the +planted anchor and the painted foot line never shift, and the shadow +recomputes from world units. + +### Fake-host contract (tests + harness) + +`account_state`, `needs_lodge_lesson`, `verified_display_name`, +`load_adventurer_name`, `claim_adventurer_name`, +`mark_intro_complete`, `saved_gate_summary`, `plan_lodge_exit`, +`confirm_entry_account`, `cancel_entry_plan`, plus the +`production_changed` signal. The lodge also honors `host_override`, +`embedded` (emits `request_title` / `request_arena` instead of +swapping), and `move_override*` (scripted stick for the clip). + +### QA harness + +`apps/game/tools/lodge_review.tscn` +(`-- mode=validate|room|contact|clip framing=… locale=… state=… +out=…`). Validate runs headless (layout math plus safe-area and +clipping and 360-tall height-budget audits, 297 checks, plus the guide +source-to-world audit); +room/contact/clip need `--windowed` with +`--resolution` on a real display. The clip simulates up to 600 frames +with at most 120 saved, prints observed move/dash/stop/depart phases, +and exits nonzero unless all complete; room `state=greet` captures the +true unnamed greeting. Isolated user files throughout. + +## Later extensions + +Reminder tasks extend this same note. diff --git a/notes/release/four-zero-one-review-entry.md b/notes/release/four-zero-one-review-entry.md new file mode 100644 index 0000000..373298a --- /dev/null +++ b/notes/release/four-zero-one-review-entry.md @@ -0,0 +1,112 @@ +# 4.0.1 store update note (brief 231) — author-only note + +A read-only store audit found iOS 4.0.0 build 14 already +READY_FOR_DISTRIBUTION and Android 4.0.0 code 19 PUBLISHED on internal +and production, so the gate-lodge and attendance work ships as a new +patch: display 4.0.1, Android versionCode 20, iOS build 15. This round +changes release identity, reviewer guidance, release prose, and the +retained-gallery Play planner only. No game, art, sound, listing, +image, product, price, signing, or guard change. + +## Identity (4.0.1 / 20 / 15) + +- `apps/game/project.godot`: `config/version` 4.0.0 → 4.0.1. +- `apps/game/export_presets.cfg`: both Android presets + `version/code` 19 → 20 and `version/name` 4.0.0 → 4.0.1; iOS + `application/short_version` 4.0.0 → 4.0.1 and + `application/version` 14 → 15. Every other preset line is + byte-identical (package, signing, team, SDK, render, control). +- `notes/release/store-page.md`: itch.io Version row and APK file row + to 4.0.1. All other listing copy untouched. + +The `project.godot` edit changes the capture `runtime_sha256`, so +`pnpm check:store-screenshots` goes red; the gallery is intentionally +reused (see below), never recaptured here. + +## Reviewer guidance + +- `buildAppStoreReviewNotes` (`scripts/lib/app-store-release.mjs`): + a first guest entry no longer goes straight to the Arena. The note + now walks the gate lodge — Lumi greets, one unique adventurer name + is claimed over a connection, the player walks and dashes on the + open floor, then steps through the moon gate — followed by the + returning-account path (device-cached verified name, offline + identification, living resume, sealed defeat with a one-coin + revive) and rolling twelve-hour attendance with optional local + reminders. Ten IAP IDs, restore guidance, no demo password, and the + 4,000-character bound are unchanged. +- `notes/release/google-play-review-entry.txt`: same lodge path in + one short Play Console paragraph. + +## Release prose + +- The five Google Play release-note blocks and the five App Store + What's New paste blocks in `notes/release/store-page.md` are now + 4.0.1 copy naming the new room (gate lodge), name (adventurer + handle), attendance (two coins per rolling twelve hours), and + defeat (sealed runs, one-coin revive) changes. Titles, + descriptions, promotional copy, prices, product rows, and image + references are untouched. Every note stays within the 500-character + Play limit; `pnpm check:store-metadata` passes. + +## New-version retained-gallery Play path + +- `createPlayBinaryOnlyUpdatePlan` + (`scripts/lib/play-binary-only-update.mjs`) now permits a strictly + newer `major.minor.patch` display version next to a strictly newer + versionCode, in addition to same-version replacement. Downgrades, + malformed versions, and AAB/preset/project identity mismatches + refuse before any mutation. The historical internal release is + still re-verified against its retained name/code immediately before + mutation, and remote-newer, duplicate-code, listing-locale, and + ordered-image-hash gates are unchanged. +- Release notes: a newer display version carries the current + five-language store notes; a same-version replacement keeps the + retained notes and refuses when the store page notes drifted. The + store page left byte-currency for this reason, but current listings + (title, short, full per locale) must equal the retained gallery, + so listing changes still refuse and still need the full path. +- Binding: the proposed code/name plus the served notes are bound + into the check/apply confirmation token (as before) and now also + into the update receipt via `releaseNotesDigest` and + `retainedVersionName` (receipt schema 1 → 2). Promotion and review + tokens keep binding the served notes. No image, listing, product, + or price mutation path was added; the Publisher client allow-list + is unchanged. + +## Privacy audit + +- `notes/release/privacy-four-zero-store-audit.md` §9 scopes the new + `mb_adventurers_v1` / `mb_names_v1` / `mb_attendance_v1` documents + and the public Hall alias, the seven-step deletion commit, and the + local-only unnamed escape next to the normal online first-name + path. §0–§8 stay the untouched 4.0.0 record. Per the + director-read official definitions, nicknames map to Play Name and + screen names/handles to Apple User ID; both are already declared, + and the director verifies the remote form. + +## Tests + +- `scripts/lib/app-store-release.test.mjs`: the fixture-manifest test + asserts the new lodge/name/attendance fragments and the removal of + the old direct path; a focused test pins every room-guidance + fragment plus the 4,000-character/byte bounds; a real-tree test + asserts project/preset/store-page identity agreement. +- `scripts/lib/play-release-package.test.mjs`: display-version + comparator units; newer-version plan acceptance with current-note + binding and token rebind; newer-version apply proving the served + notes ride the track update and the receipt binds identity plus + notes digest; same-version replacement keeping retained notes and + refusing listing drift; the old reject-newer-version case is now a + downgrade/malformed rejection. +- Negative controls: removing the room guidance fails the focused + review test, and disabling the downgrade guard fails the version + test; both pass again after byte-exact restoration. + +## Explicitly not done here + +No device or sandbox purchase test, no twelve-hour delivery or +permission-tap claim, no Apple/Google provisioning, no review +submission, no binary build, and no remote or git operation. Those +remain director-side steps; nothing in this round claims them +complete. diff --git a/notes/release/google-play-review-entry.txt b/notes/release/google-play-review-entry.txt index 278023b..6accf34 100644 --- a/notes/release/google-play-review-entry.txt +++ b/notes/release/google-play-review-entry.txt @@ -1 +1 @@ -Tap the title screen (Tap to start), then choose Continue as guest. The game issues an automatic player ID, so no developer credentials are needed. From the identity card, tap New expedition to play, or Resume the gate when a saved checkpoint is listed. Sign in with Google and Sign in with Apple are optional. Purchases are optional: Store is on the title screen; Restore purchases is in the Store footer. +Tap Tap to start, then Continue as guest; no credentials needed. First entry visits the gate lodge: meet Lumi, claim a unique name (needs connection), practice movement and dash, step through the moon gate. Finished lessons skip the lodge; interrupted practice returns there. New expedition plays; Resume continues a living checkpoint. Google/Apple sign-in optional. Store on title; Restore purchases in Store footer. diff --git a/notes/release/iap-store-setup.md b/notes/release/iap-store-setup.md index b764fbf..344ede2 100644 --- a/notes/release/iap-store-setup.md +++ b/notes/release/iap-store-setup.md @@ -379,6 +379,13 @@ the publishable key. IAP. - [ ] Connected required IAP review information and restore guidance to the app version. +- [ ] Left the App Store version release option on the tool default: + automatic release immediately after approval (App Store Connect + `releaseType` `AFTER_APPROVAL`), verified by GET readback before + review submission. `scripts/lib/app-store-release.mjs` plans this + value on version create/update with no scheduled date; manual or + scheduled release is not used for new submissions. Historical + manual-release receipts stay historical. - [ ] Made a Sandbox Tester as a real test Apple ID. Do not arbitrarily use a developer's personal Apple ID or a customer account as a test account. diff --git a/notes/release/privacy-four-zero-store-audit.md b/notes/release/privacy-four-zero-store-audit.md index dfb1363..c9b3acd 100644 --- a/notes/release/privacy-four-zero-store-audit.md +++ b/notes/release/privacy-four-zero-store-audit.md @@ -377,3 +377,128 @@ Reviewer access (facts): - Deployed Firestore rules against `firestore.cloud.addition.rules`. - iPad Apple delete/re-confirm and a real purchase on the final build before any claim about them. + +## 9. 4.0.1 update: adventurer names, lesson bit, attendance, Hall alias (version-scoped) + +Everything in §0–§8 stays as the 4.0.0 historical record; nothing above +was edited for this update. This section scopes what 4.0.1 adds. It is +audit only, like the rest of the file: the director verifies the remote +form. + +New documents (facts, all under the same `moonlitbeacon-778ee` project +and the same two game-called endpoints from §0 — no new host, no new +SDK, no analytics change): + +- `mb_adventurers_v1/{publicId}`: one row per named account — + `public_id, uid, name_key, display, intro_complete, schema, + created_at, updated_at` (`cloud_schema.gd:18,52-55`). `display` is + the 2–12 character handle the player typed + (`cloud_schema.gd:144-173`); `intro_complete` records the finished + lodge lesson. +- `mb_names_v1/{nameKey}`: one row per claimed handle — + `name_key, display, public_id, schema, created_at` + (`cloud_schema.gd:19,56-58`). Creation is first-writer-wins + (`firestore.cloud.addition.rules:357-370`, `!exists` guard); handles + cannot be renamed or moved (`game.md` account section). +- `mb_attendance_v1/{publicId}`: one row per account — + `public_id, uid, install_id, last_claim_at, schema, prev_claim_at, + prev_install_id` (`cloud_schema.gd:20,59-66`). The only timestamp is + server-set (`last_claim_at == request.time` in the rules); one grant + of two coins per rolling twelve hours, enforced by server time + (`cloud_schema.gd:73-78`, `cloud_attendance.gd:1-30`). + `install_id` is 16 device-minted random bytes kept in the Vault + (`vault.gd:1348-1362`), an app-generated receipt binding, not a + device or advertising identifier. +- Hall alias: Hall rows may now carry `display`, the owner's verified + adventurer name bound server-side to the claimed name pair + (`cloud_schema.gd:48-51`; rules bind at + `firestore.cloud.addition.rules:228-229`). Legacy and unnamed rows + render without one. The Hall stays one row per public ID; no other + Hall field changed. + +Required online naming vs the local-only failure escape (facts): + +- Required path: a cloud-linked account whose Vault cache holds no + verified handle (or no acknowledged lesson bit) must visit the gate + lodge before the Arena (`production_host.gd:812-830`, + `needs_lodge_lesson`, which requires BOTH). There Lumi greets the + hero, the account claims one handle through the atomic service, the + player walks and dashes on the open floor, and the account-owned + lesson seals before departure (`gate_lodge.gd:1-20`). The name form + offers confirmation and retry only — a Confirm button + (`gate_lodge.gd:353-357`) plus the register/taken/offline/retry + strings (`gate_entry.csv:111-116`) — with no unnamed opt-out + control. A successful online first entry therefore requires a + unique nickname BEFORE movement practice and departure. Claiming + needs a connection (`gate_entry.csv:116`, + `gate.lodge.name_offline`); the verified handle is cached per + account only from server-acknowledged states (`vault.gd:1244-1295`), + and the lesson touches no saved run and spends no coins (`game.md` + lodge bullet). A returning named account that stopped midway + through practice still visits the lodge, because the lesson bit is + unset (`production_host.gd:812-830`). +- Failure escape (not ordinary optional collection): + `begin_guest(true)` skips registration only when leaving a held + error screen (`production_host.gd:408-422`, sole `true` caller at + `production_entry.gd:335-341`, same as §3). A local-only guest has + no claim to settle, never enters the lodge (`gate_lodge.gd:11-12`), + and plays unnamed. That escape exists for error/unconfigured + builds; in a configured build every online account passes the + required naming above, so the escape does not make name collection + optional for all users. Per the official Play guidance, optional + requires all users to be able to opt in/out or provide the data + optionally, while primary functionality requiring a type must + declare it required (director-read at + https://support.google.com/googleplay/android-developer/answer/10787469). + +Deletion (facts): the account-deletion commit is now seven steps — +Hall, name claim, adventurer, checkpoint, attendance, reservation, +and profile together, atomically, before the native Auth user +deletes (`cloud_account.gd:1-30,33-75`); unnamed accounts commit the +five legacy steps unchanged. §4's four-step list is the 4.0.0 record +and stays untouched above. + +Reminders (facts): attendance reminders are local-only. One +per-install controller schedules from the server-confirmed deadline; +a notification never grants coins by itself, and the schedule retires +on disable, sign-out, account switch, or deletion +(`attendance_reminders.gd:1-25`). No reminder payload leaves the +device; no new data type. + +Director-saved Play correction (remote fact, not this copy's proposal): + +- The director read the actual Play form: User IDs already required; + Name previously optional; email optional; Name/User IDs purposes + App functionality and Account management, collected, non-ephemeral, + not shared. The adventurer `display` handle is a nickname, and + nicknames are Play Name under the official definitions (same + director-read guidance URL as above). +- Because online naming is required (see the required path above), + the director saved Name as required for the coming review. The + preview shows Name/User IDs without Optional and email with + Optional; the console says the change is saved and ready in + Publishing overview. It has NOT yet been sent for review, and + nothing here claims review is complete or published. +- The public Hall alias is in-app public content under the same + sharing note as §6, not a new sharing recipient; no new Play + data-type row beyond the Name correction is proposed. + +Apple (no declaration edit made): Apple currently declares Name, +Email Address, User ID, Gameplay Content, and Product Interaction, +linked to identity for App Functionality. The nickname/handle fits +the existing identifier declaration (director-read at +https://developer.apple.com/app-store/app-privacy-details/). +Tracking stays No for the new rows for the same §2 reasons (no ad +identifier, no cross-app combination). + +Attendance rows add no new personal-data type beyond the +already-declared IDs: timestamps plus the app-generated install +binding above. Naming is required; attendance is part of the +signed-in account; the local-only failure escape and disabled +reminders collect nothing. + +Open (added by this update, besides §7): the director sends the +saved Name-required Play change for review and confirms the +Required treatment on the 4.0.1 (Android 20 / iOS 15) binaries; no +twelve-hour delivery, no permission tap, and no purchase is claimed +from this copy. diff --git a/notes/release/store-page.md b/notes/release/store-page.md index d802156..823f07f 100644 --- a/notes/release/store-page.md +++ b/notes/release/store-page.md @@ -16,9 +16,9 @@ store-review submit. | Item | Value | | --- | --- | | Title | 달빛 봉화 (Moonlit Beacon) | -| Version | 4.0.0 | +| Version | 4.0.1 | | Platform | Android (arm64-v8a, Android 7.0+) | -| File | `MoonlitBeacon-4.0.0.apk` | +| File | `MoonlitBeacon-4.0.1.apk` | | Category | Action · Arcade | | Tags | `painted-art`, `top-down`, `survivor`, `roguelite`, `mobile`, `godot` | | Price | Free | @@ -582,121 +582,111 @@ Source and course: https://github.com/hyodotdev/MoonlitBeacon ## Google Play release notes — English (`en-US`) ```text -Moonlit Beacon 4.0.0 +Moonlit Beacon 4.0.1 -- Enter as a guest, or with Google/Apple sign-in where configured. -- The gate resumes your saved checkpoint with hero, relics, and growth. -- Your cloud account keeps a copy and asks before either side wins. -- Best hero and score join the Hall board under your player ID. -- A painted night world with redrawn heroes, spirits, and guardians. +- First entry opens the gate lodge: meet Lumi, claim a unique adventurer name, practice movement and dash, then depart. +- A completed guide lesson skips the lodge and resumes the living gate; interrupted practice returns there, and defeated runs stay sealed. +- Attendance grants two continue coins per rolling twelve hours, at most once per twelve hours. +- One coin revives where you fell; optional Settings reminders never grant coins by themselves. ``` ## Google Play release notes — Korean (`ko-KR`) ```text -달빛 봉화 4.0.0 +달빛 봉화 4.0.1 -- 게스트로 들어가거나, 준비된 기기에서 Google/Apple로 로그인하세요. -- 관문이 영웅·유물·성장과 함께 저장된 이야기를 이어줍니다. -- 계정의 클라우드 사본은 어느 쪽을 쓸지 먼저 묻습니다. -- 최고 영웅과 점수가 플레이어 ID로 전당에 오릅니다. -- 손으로 그린 듯한 밤 지형과 다시 그린 영웅·정령·수호자. +- 첫 입장은 관문 등불 방부터: 루미를 만나 고유한 모험가 이름을 정하고 이동과 대시를 연습한 뒤 출발하세요. +- 첫 안내를 마친 계정은 등불 방을 건너뛰고 살아 있는 관문을 이어가고, 중단된 연습은 등불 방으로 돌아가며, 패배한 여정은 봉인된 채 남습니다. +- 출석은 12시간마다 이어하기 코인 2개씩, 최대 12시간에 한 번 받을 수 있습니다. +- 코인 1개로 쓰러진 자리에서 부활합니다. 설정의 출석 알림은 코인을 주지 않습니다. ``` ## Google Play release notes — Japanese (`ja-JP`) ```text -月明かりの烽火 4.0.0 +月明かりの烽火 4.0.1 -- ゲストで入るか、対応端末ではGoogle/Appleでログイン。 -- 関門が英雄・遺物・成長とともに保存場所から再開します。 -- クラウド保存はどちらを使うか先に確認します。 -- 最良の英雄とスコアがプレイヤーIDで殿堂入りします。 -- 手描き風の夜の地形と描き直した英雄・精霊・守護者。 +- 初回は門の灯の部屋から:ルミに会い、唯一の冒険者の名を登録し、移動とダッシュを練習して出発。 +- 案内を終えたアカウントは部屋を飛ばして進行中の関門を再開し、中断した練習は部屋に戻り、敗北した旅は封印のままです。 +- 出席は12時間ごとにコンティニューコイン2枚、最大12時間に一度受け取れます。 +- コイン1枚で倒れた場所から復活。設定の出席リマインダーはコインを配りません。 ``` ## Google Play release notes — Simplified Chinese (`zh-CN`) ```text -月光烽火 4.0.0 +月光烽火 4.0.1 -- 以游客进入,或在已配置设备上使用 Google/Apple 登录。 -- 月之门带着英雄、遗物与成长从存档处继续。 -- 云存档在使用哪一侧之前会先征求确认。 -- 最佳英雄与分数以玩家 ID 登上殿堂榜。 -- 手绘风月夜地形与重绘的英雄、精灵、守护者。 +- 首次进入先到关口灯室:见露米,登记唯一的冒险者名字,练习移动与冲刺后再出发。 +- 完成引导的账号跳过灯室,继续进行中的月之门;中断的练习会回到灯室,战败的旅程保持封印。 +- 签到每十二小时可领2枚继续游戏金币,十二小时内最多领取一次。 +- 1枚金币可在倒下处复活。设置中的签到提醒不会发放金币。 ``` ## Google Play release notes — Traditional Chinese (`zh-TW`) ```text -月光烽火 4.0.0 +月光烽火 4.0.1 -- 以訪客進入,或在已設定裝置上使用 Google/Apple 登入。 -- 月之門帶著英雄、遺物與成長從存檔處繼續。 -- 雲存檔在使用哪一側之前會先徵求確認。 -- 最佳英雄與分數以玩家 ID 登上殿堂榜。 -- 手繪風月夜地形與重繪的英雄、精靈、守護者。 +- 首次進入先到關口燈室:見露米,登記唯一的冒險者名字,練習移動與衝刺後再出發。 +- 完成引導的帳號跳過燈室,繼續進行中的月之門;中斷的練習會回到燈室,戰敗的旅程保持封印。 +- 簽到每十二小時可領2枚繼續遊戲金幣,十二小時內最多領取一次。 +- 1枚金幣可在倒下處復活。設定中的簽到提醒不會發放金幣。 ``` ## App Store What's New — English (`en-US`) ```text -Moonlit Beacon 4.0.0 +Moonlit Beacon 4.0.1 -- Enter as a guest, or with Google/Apple sign-in where configured. -- The gate resumes your saved checkpoint with hero, relics, and growth. -- Your cloud account keeps a copy and asks before either side wins. -- Best hero and score join the Hall board under your player ID. -- A painted night world with redrawn heroes, spirits, and guardians. +- First entry opens the gate lodge: meet Lumi, claim a unique adventurer name, practice movement and dash, then depart. +- A completed guide lesson skips the lodge and resumes the living gate; interrupted practice returns there, and defeated runs stay sealed. +- Attendance grants two continue coins per rolling twelve hours, at most once per twelve hours. +- One coin revives where you fell; optional Settings reminders never grant coins by themselves. ``` ## App Store What's New — Korean (`ko`) ```text -달빛 봉화 4.0.0 +달빛 봉화 4.0.1 -- 게스트로 들어가거나, 준비된 기기에서 Google/Apple로 로그인하세요. -- 관문이 영웅·유물·성장과 함께 저장된 이야기를 이어줍니다. -- 계정의 클라우드 사본은 어느 쪽을 쓸지 먼저 묻습니다. -- 최고 영웅과 점수가 플레이어 ID로 전당에 오릅니다. -- 손으로 그린 듯한 밤 지형과 다시 그린 영웅·정령·수호자. +- 첫 입장은 관문 등불 방부터: 루미를 만나 고유한 모험가 이름을 정하고 이동과 대시를 연습한 뒤 출발하세요. +- 첫 안내를 마친 계정은 등불 방을 건너뛰고 살아 있는 관문을 이어가고, 중단된 연습은 등불 방으로 돌아가며, 패배한 여정은 봉인된 채 남습니다. +- 출석은 12시간마다 이어하기 코인 2개씩, 최대 12시간에 한 번 받을 수 있습니다. +- 코인 1개로 쓰러진 자리에서 부활합니다. 설정의 출석 알림은 코인을 주지 않습니다. ``` ## App Store What's New — Japanese (`ja`) ```text -月明かりの烽火 4.0.0 +月明かりの烽火 4.0.1 -- ゲストで入るか、対応端末ではGoogle/Appleでログイン。 -- 関門が英雄・遺物・成長とともに保存場所から再開します。 -- クラウド保存はどちらを使うか先に確認します。 -- 最良の英雄とスコアがプレイヤーIDで殿堂入りします。 -- 手描き風の夜の地形と描き直した英雄・精霊・守護者。 +- 初回は門の灯の部屋から:ルミに会い、唯一の冒険者の名を登録し、移動とダッシュを練習して出発。 +- 案内を終えたアカウントは部屋を飛ばして進行中の関門を再開し、中断した練習は部屋に戻り、敗北した旅は封印のままです。 +- 出席は12時間ごとにコンティニューコイン2枚、最大12時間に一度受け取れます。 +- コイン1枚で倒れた場所から復活。設定の出席リマインダーはコインを配りません。 ``` ## App Store What's New — Simplified Chinese (`zh-Hans`) ```text -月光烽火 4.0.0 +月光烽火 4.0.1 -- 以游客进入,或在已配置设备上使用 Google/Apple 登录。 -- 月之门带着英雄、遗物与成长从存档处继续。 -- 云存档在使用哪一侧之前会先征求确认。 -- 最佳英雄与分数以玩家 ID 登上殿堂榜。 -- 手绘风月夜地形与重绘的英雄、精灵、守护者。 +- 首次进入先到关口灯室:见露米,登记唯一的冒险者名字,练习移动与冲刺后再出发。 +- 完成引导的账号跳过灯室,继续进行中的月之门;中断的练习会回到灯室,战败的旅程保持封印。 +- 签到每十二小时可领2枚继续游戏金币,十二小时内最多领取一次。 +- 1枚金币可在倒下处复活。设置中的签到提醒不会发放金币。 ``` ## App Store What's New — Traditional Chinese (`zh-Hant`) ```text -月光烽火 4.0.0 +月光烽火 4.0.1 -- 以訪客進入,或在已設定裝置上使用 Google/Apple 登入。 -- 月之門帶著英雄、遺物與成長從存檔處繼續。 -- 雲存檔在使用哪一側之前會先徵求確認。 -- 最佳英雄與分數以玩家 ID 登上殿堂榜。 -- 手繪風月夜地形與重繪的英雄、精靈、守護者。 +- 首次進入先到關口燈室:見露米,登記唯一的冒險者名字,練習移動與衝刺後再出發。 +- 完成引導的帳號跳過燈室,繼續進行中的月之門;中斷的練習會回到燈室,戰敗的旅程保持封印。 +- 簽到每十二小時可領2枚繼續遊戲金幣,十二小時內最多領取一次。 +- 1枚金幣可在倒下處復活。設定中的簽到提醒不會發放金幣。 ``` ## Bug reports diff --git a/notes/workflow/muse/art/gate-chamber/gate-chamber-room-tablet.png b/notes/workflow/muse/art/gate-chamber/gate-chamber-room-tablet.png new file mode 100644 index 0000000..59c4cc8 Binary files /dev/null and b/notes/workflow/muse/art/gate-chamber/gate-chamber-room-tablet.png differ diff --git a/notes/workflow/muse/art/gate-chamber/gate-chamber-room.png b/notes/workflow/muse/art/gate-chamber/gate-chamber-room.png new file mode 100644 index 0000000..529ac9e Binary files /dev/null and b/notes/workflow/muse/art/gate-chamber/gate-chamber-room.png differ diff --git a/notes/workflow/muse/art/gate-chamber/lumi-prompt.md b/notes/workflow/muse/art/gate-chamber/lumi-prompt.md new file mode 100644 index 0000000..996922d --- /dev/null +++ b/notes/workflow/muse/art/gate-chamber/lumi-prompt.md @@ -0,0 +1,7 @@ +# Lumi gate lodge guide source + +Built-in imagegen, transparent background, original production-source turnaround. This is not a playable hero and not Nari, who is missing in the opening story. The runtime packer must preserve the source alpha and ground anchor, keep the skull and face proportions across views, and record the asset in the manifest and custom contracts. Do not change any existing hero sheets. + +## Prompt + +Use case: stylized-concept. Asset type: production source turnaround sprite sheet for the 2D top-down fantasy game Moonlit Beacon, not a UI mockup. Create one original friendly gate lodge guide named Lumi: a young adult lantern keeper, silver ash bob hair with a tiny braid, calm expressive amber eyes, deep teal short travelling cloak with brass crescent clasp, cream shirt, leather waist belt and pouch, fitted charcoal trousers, brown ankle boots, a small brass amber lantern held in the right hand, and a closed slim ledger tucked at the left hip. Polished hand-painted miniature RPG illustration, softly modeled three-dimensional volume, refined readable outlines and fabric shading, charming slightly large head approximately one third of total standing height; not pixel art, not flat vector. Layout: EXACTLY four complete full-body standing turnarounds evenly spaced in ONE horizontal row: front facing camera, rear facing away, exact left profile facing screen left, exact right profile facing screen right. The same person, identical outfit, lantern ownership, body size, skull height and proportions in every view. Both feet firmly planted, relaxed legs close together, neutral welcoming posture. All four boots share the same ground baseline; all figures have the same head top line and enough empty transparent margin around each silhouette for atlas packing. Front and rear truly distinct; side face must not shrink. The entire sheet has a genuinely transparent background. No cast shadow, no floor, no environment, no typography, no labels, no border, no layout guides, no bloom or glaring light; restrained warm lantern illumination confined to the lantern glass. Keep the image tall enough within each cell for crisp full resolution detail and place the four views in equal-width columns. diff --git a/notes/workflow/muse/art/gate-chamber/lumi-turnaround.png b/notes/workflow/muse/art/gate-chamber/lumi-turnaround.png new file mode 100644 index 0000000..2699dd5 Binary files /dev/null and b/notes/workflow/muse/art/gate-chamber/lumi-turnaround.png differ diff --git a/notes/workflow/muse/art/gate-chamber/room-prompt.md b/notes/workflow/muse/art/gate-chamber/room-prompt.md new file mode 100644 index 0000000..14e54d1 --- /dev/null +++ b/notes/workflow/muse/art/gate-chamber/room-prompt.md @@ -0,0 +1,11 @@ +# Gate chamber room source + +Generated with the built-in imagegen tool on 2026-10-07. Original output is +preserved in the imagegen output folder; this copy is an input for the +implementer, not an already packed runtime asset. + +Transparent background: false. + +## Prompt + +Use case: stylized-concept. Asset type: a single original landscape environment plate for the playable onboarding room of Moonlit Beacon, a refined 2D top-down miniature fantasy RPG. Create a cozy moon-gate refuge registration room, viewed from a high three-quarter top-down camera with a broad open walkable floor. Mature hand-painted high-end storybook RPG environment with subtle three-dimensional volume, worn stone and timber, restrained teal shadows and warm amber lanterns; no pixel art. Very wide landscape layout suitable for a 2.25:1 horizontal game viewport. Show the whole small room as a clean interior cutaway, wall across the far top edge with timber beams and a moon-shaped window, a small wooden registry desk with closed ledgers and a warm table lamp on the upper-left side, an unoccupied bench on the upper edge, and a modest stone arch doorway on the upper-right with a quiet pale-blue moon-gate surface and a tiny warm beacon beside it. Keep the central and lower two-thirds of the image a spacious unobstructed lightly textured stone floor, softly lit, with distinct natural slab variation and a small subtle circular rug near the ledger desk. All furniture and architectural details stay around the far top and side perimeter, so moving characters can be composited freely into the open floor and remain readable. No foreground wall or objects obscuring the lower floor. Clear coherent floor perspective and grounded furniture. Welcoming shelter from a dangerous night outside, not a royal throne room. Use soft local lantern illumination without glare, bloom, blown-out light, high-contrast light rays or swirling portal light. Do not include ANY characters, people, human figures, pets, sprites, enemies, UI, writing, letters, numbers, signs, interface panels, frames, buttons, logos or watermarks. This is only the finished room background illustration, not a game screenshot or a mockup. diff --git a/notes/workflow/muse/art/gate-chamber/room-tablet-prompt.md b/notes/workflow/muse/art/gate-chamber/room-tablet-prompt.md new file mode 100644 index 0000000..4c39b2e --- /dev/null +++ b/notes/workflow/muse/art/gate-chamber/room-tablet-prompt.md @@ -0,0 +1,11 @@ +# Gate chamber tablet room source + +Generated with the built-in imagegen tool on 2026-10-07 as an edit of +`gate-chamber-room.png`. Original output remains in the imagegen output +folder. This is a source input for the implementer, not packed game art. + +Canvas: 1448 × 1086 RGB, true 4:3. Transparent background: false. + +## Prompt + +Edit the supplied Moonlit Beacon onboarding lodge environment plate into a faithful 4:3 landscape tablet variant, approximately 1536x1152 or a similar true 4:3 canvas. This is a production background asset, no UI. Preserve the same mature hand-painted miniature RPG room, natural stone and timber volume, restrained teal shadows and soft amber lantern lighting, registry desk and crescent rug on the upper-left, round moon window near the top center, bench across the upper-center/right and quiet pale-blue moon gate on the upper-right. Both the full registry desk and the complete moon-gate doorway must remain visible within the canvas with roomy margins; do not crop either, enlarge their proportions, redesign the architecture or add objects. Preserve architectural shapes and widths without stretching. Make the image taller by extending the spacious open natural stone floor downward and extending the perimeter side-wall/bookcase edges coherently. The upper wall and furniture should occupy roughly the upper quarter to third of the taller canvas, leaving the large lower floor open and readable for moving character sprites. Natural varied slab texture with coherent high three-quarter top-down perspective, not a repeated tiled band. No bottom foreground wall, no obstacles in the open center. Same cozy refuge at night, soft lantern pools, no bloom, glare or bright light rays. Absolutely no characters, people, sprites, pets, enemies, writing, text, numbers, logo, watermark, interface, border or mockup. Do not turn this into pixel art. diff --git a/notes/workflow/muse/briefs/200-end-defeated-journeys-without-free-retry.md b/notes/workflow/muse/briefs/200-end-defeated-journeys-without-free-retry.md new file mode 100644 index 0000000..7aa0de0 --- /dev/null +++ b/notes/workflow/muse/briefs/200-end-defeated-journeys-without-free-retry.md @@ -0,0 +1,44 @@ +# Brief 200: End defeated journeys without a free gate retry + +## The ask +“현재 게임에서 죽으면 코인으로 계속하거나 이어서 처음으로 가야하는데 그 스테이지에서 시작하는게 있네? 이거 수정해주고” + +Remove the free restart of a defeated segment. A coin revives in place; leaving defeat or choosing a new expedition starts from the beginning. Quitting while alive still permits checkpoint continuation. + +## Why, and what good feels like +The loss screen must give honest choices: one continue coin to revive, a fresh expedition, or return to the title. The title, cold launch, backup recovery and owned-cloud restore must not offer a free continuation of a run that already ended in defeat. Wallets, unlocks and earned permanent records survive. + +## Where things stand +- Main was clean and fast-forward checked at `1ed5fb1`; work starts on `codex/gate-chamber-and-defeat-rules`. +- `arena.gd` currently implements `_retry_from_gate()` and `_finish_result_action()` deliberately routes defeat Retry there. `_finish()` preserves a playable checkpoint; `result_panel.gd` changes Retry to `RESULT_GATE_RETRY`. +- `journey.gd` persists main and backup checkpoint files; `has_valid_checkpoint()` powers title Continue. `cloud_coordinator.gd` and `cloud_checkpoint.gd` synchronize valid checkpoint payloads and conflicts. +- Baseline: `pnpm godot:isolated --timeout 180 res://tests/test_journey.tscn` passes 499 cases, including expectations for repeated free death retries. These expectations now describe the wrong product behavior and must be replaced with stronger death-terminal checks, keeping unrelated coverage. +- The title and production host entry are `scripts/ui/production_entry.gd`, `scripts/net/production_host.gd`, and `scripts/ui/title_menu.gd`. + +## Do +- Make defeat a durable terminal journey state, with a compatible representation that the owned-cloud coordinator carries. A valid ended marker must outrank an older alive backup and survive cold launch/account switching/cloud conflict resolution. Do not merely delete a local file and allow remote restore to resurrect it. +- Remove the loss-screen free gate retry and its actual action route. Clearly label fresh restart and coin continue in all five locales. The final failure must settle rewards/records once; repeated exits, terminal restores or retries must not farm rewards. +- Keep paid continuation transactional: consume exactly one coin only on success, revive the current run in place with health/grace as before, and durably make it playable again. Failed persistence or insufficient balance must not erase or double-spend coins or make a terminal run resumable. Keep active living-run continuation after app quit unchanged. +- Update and register meaningful regressions for real arena death/result routes, fresh cycle/zone reset, alive quit, terminal main versus alive backup, cloud round trip and offline retry, coin success/failure, and wallet/record preservation. Update the game reference's affected journey paragraphs and an author-only implementation record. + +## Do not +- No nickname/onboarding-room work in this brief; that is the next task. +- Do not alter hero art, identity/auth providers, purchases, version/build counters, locked engine settings or unrelated gameplay tempo. +- Do not capture or rebuild any store screenshots. Existing marketing images must remain byte-identical. +- No deployment, network, git history changes, secrets or protected files. + +## Acceptance +- The previous defeat/free-gate-retry test behavior is no longer reachable from either result or title/cold launch. Fresh restart begins cycle 1/zone 0 with a new journey, while keeping permanent rewards/unlocks. +- A paid continuation preserves current hero, cycle/zone, score and growth, debits one coin once, and restores a resumable live journey. Zero coins and injected save failures keep balances and terminal status safe. +- Alive checkpoints still resume after process relaunch. Defeat tombstones remain terminal even when an older valid backup or stale remote payload is present; cloud conflict choices do not silently bypass the death. +- Related registered Godot tests, cloud tests, locale checks, game smoke check and documentation build pass. Do not weaken unrelated assertions or declare errors successful merely because Godot exits zero. +- Supply a production-scene desktop result harness for a real defeat with/without coins, so the director can see and exercise the actual buttons, with isolated user files. + +## Deliverables +Affected journey/arena/result/cloud code, five-locale copy, registered regressions, targeted game-reference changes, one author note, and a small desktop review harness. Report exact files/commands, counts and remaining limitations. + +## Settle these yourself +Keep the stable public ID and score ownership contracts. Use an additive terminal marker rather than weakening cloud validation. Preserve the existing once-only settlement ledger. The new-name chamber will use these corrected entry rules later. + +## How the director will judge +Read all changed state transitions and persistence/cloud code; independently run the relevant tests and the full verification after acceptance; render and click the real result screen; inject a guarded defeat-state regression to prove a test fails. Judge terminal backup/cloud behavior, not just button text. diff --git a/notes/workflow/muse/briefs/201-claim-unique-adventurer-names-for-scores.md b/notes/workflow/muse/briefs/201-claim-unique-adventurer-names-for-scores.md new file mode 100644 index 0000000..fa80786 --- /dev/null +++ b/notes/workflow/muse/briefs/201-claim-unique-adventurer-names-for-scores.md @@ -0,0 +1,63 @@ +# Brief 201: Claim unique adventurer names for scores + +## The ask +“로그인했을 때 이세카이 게이트처럼 처음 어떤 방에서 튜토리얼 같이 다른 캐릭터가 플레이어한테 안내하면서 이름부터 정하게 하고 (중복없이 이게 바로 스코어 저장할 때 쓰여야해) 플레이가 진행될 수 있게 해줘.” + +This brief builds the real account-owned unique name and score contracts that the next room UI will use. It does not build the room yet. + +## Why, and what good feels like +A name chosen in the lodge identifies the same adventurer in the Hall, on every device and after linking a guest to Google or Apple. Two players submitting the same name simultaneously cannot both get it. Names do not replace or remint existing public IDs, cloud UIDs, journeys, entitlements or best scores. + +## Where things stand +- The branch is `codex/gate-chamber-and-defeat-rules`, based on fast-forward-checked main `1ed5fb1`. Brief 200 corrects defeat/continuation before this run starts. +- This is a continuation in the same isolated copy because disk space is limited. Preserve the earlier defeat changes and their registered regressions. The director accepts the cumulative diff only after the name service and lodge are also independently judged. +- `player_account.gd` supplies durable `MB-` public IDs. `production_host.gd` owns sessions, generation-bound operations, entry plans, cloud restore and score mapping. `cloud_identity.gd` reserves canonical public IDs atomically. +- `cloud_schema.gd`, `cloud_hall.gd`, `cloud_coordinator.gd`, `cloud_account.gd` and `firestore.cloud.addition.rules` carry the current owned cloud contracts. Profiles and public-ID reservations are immutable pairs. Preserve that protection. +- Current Hall rows carry public ID, hero, score and cycles but no user-chosen name. Baseline `test_cloud_identity.gd` passes 54 cases. Real enforcement lives in `tests/cloud-rules/cloud-rules.test.mjs`; the director supplies its emulator/dependencies. +- `firestore.rules`, `firebase.json`, version counters and credentials are protected. Edit only the additive cloud rules and its tests; the director reviews/deploys a generated combined ruleset if required. + +## Do +- Add private, account-owned adventurer metadata and a globally unique name-reservation pair (e.g. `mb_adventurers_v1/{public_id}` plus `mb_names_v1/{name_key}`). Bind both halves to the existing canonical public-ID/UID pair before and after every relevant atomic write. Never permit lone reservations, another owner's name, reassignment, public enumeration of private UIDs or a stale reply adopting the wrong account. +- Define identical client/server normalization and validation: 2–12 visible characters, precomposed Korean/Japanese/Chinese, Latin letters, digits and underscore; trim boundary whitespace, case-insensitive Latin collision keys, no path separators, controls, invisible/combining characters or ambiguous interior whitespace. Return clear invalid/taken/offline/auth errors. A Firebase-registered anonymous guest can claim a name; a local-only offline guest cannot falsely certify global uniqueness. Existing verified names remain usable offline from a bounded account-partitioned durable cache. +- Expose generation-bound load/claim/mark-intro-complete operations through the production host/coordinator. A successful durable claim returns the chosen display name and immutable key; an intro-complete bit changes only false→true. Restart after claim but before tutorial completion must recover the same name. Token refresh, account switching, cancellation, double tap, local write failure and uncertain network acknowledgement must be recoverable without stealing/reminting IDs or freeing an active name. +- Add the verified display name to new Hall score writes and mapped rows without losing hero, score, rank or stable owner ID. Preserve backward compatibility for unnamed existing rows and existing registration. When an existing player claims a name, attach it to their existing best row at the same score safely, with no invented zero-score record or best-score downgrade. Extend atomic account deletion to remove the owned name and adventurer metadata with existing rows, so deletion cannot strand a name or release it under a living score. Register client regressions and real rules-emulator tests; document the protocol and public nickname disclosure accurately. +- Cover the actual result recording route as well as cloud hooks. `Arena._on_result_record()` currently opens `LadderPanel.ask()` with an independently editable name and uploads via the legacy `GlobalLadder`. A verified named production player must not be asked for another name or allowed to publish a different one here: use the verified account handle for every new score/record, and route the player to a coherent owned Hall or read-only named result view. Preserve existing old records and course/unnamed fixture behavior without falsely labeling legacy names globally reserved. Register a real Arena/result recording regression, including account switch and local save retry. + +## Required account-preservation regression before name integration +The director confirmed one remaining canonical-adoption defect in the preceding correction. `Vault.prepare_receipt_move()` currently returns `ready` as soon as the **source has no paid journal**, before it checks whether the canonical target already owns a paid journal. The director's actual production-coordinator probe at `builds/verify/gate-defeat-probe/target-pending-round4.log` creates target C's acknowledged one-coin receipt for journey `d1`/seal 6 with no surviving target files (a supported paid recovery state), plus an unrelated living source A journey `d1-other` with no source receipt. `_move_slot_to_canonical()` imports A into C, and `recover_paid_continue()` calls C's original paid receipt `stale` and deletes it. Clean exit 1, balance 1, `paid_target_restored:false`, `guest_file_retained:false`, `journal_retained:false`. No human data was used. + +Fix this preservation prerequisite as part of the first-login account integration: a target's own pending paid recovery or existing journey is occupied even when the source has no receipt. Do not import unrelated source bytes over it or mix an unrelated source backup into it. Recover the target's exact acknowledged seal, retain the source bytes safely, and never debit again. Preserve the preceding rekey-first crash-window recovery and canonical/legacy retry behavior. Register source-with-no-receipt → target-with-receipt and occupied-target-with-no-source-receipt cases through the actual coordinator/host route, including reload. This exception permits the small migration guard/integration fix; it does not authorize combat or unrelated gameplay changes. The director will repeat the exact probe independently before accepting names or the cumulative diff. + +## Do not +- Do not gate the current production play UI on these new methods yet; brief 202 integrates the lodge and entry gate. +- Do not build the room, edit existing hero assets, change gameplay, native provider SDKs, IAP, release counters, locked settings, stores or marketing screenshots. +- No network, deployment, git changes, real cloud data, secrets or protected deploy inputs. The director runs the actual emulator and deployment operations. + +## Acceptance +- Two concurrent authenticated owners claiming the same normalized name yield exactly one winner in the real emulator; mixed-case Latin variants collide; valid Korean and Japanese names round-trip through real REST paths/encoding and rules. Invalid/invisible names fail at both boundaries. +- Client and real rules tests catch forged display names on Hall writes, mismatched/lone name pairs, stale account callbacks, arbitrary metadata changes, unverified offline claims, partial deletions and name release while a public score/owner remains. Existing ID ownership and legacy rule tests still pass. +- Linking guest credentials retains the verified name and original best/journey. Separate accounts never share the local nickname cache. Reload and retry after an acknowledged or uncertain claim restore one canonical name. +- Hall rows show the chosen name, actual hero and actual score, including a safely backfilled prior best; older unnamed rows still render honestly. Equal-score retry does not downgrade a newer best. +- Supply one callable API contract for brief 202, include all new registered tests and command results, and pass related cloud/host/locale/hygiene/docs checks. Test reports are claims; the director reruns the enforcement suite. + +## Deliverables +Cloud name/schema/store services, production-host/coordinator operations, Hall name propagation, atomic deletion integration, additive Firestore rules and enforcement tests, registered Godot regressions, targeted reference/privacy-source clarification if needed, and the author-only protocol note `notes/plans/gate-account-protocol.md`. List exact public methods for the next brief. This note belongs in unprotected author plans, not the director-owned briefs/standing-orders tree. Later attendance/lodge/reminder tasks will extend the same note. + +The public player-care privacy sources in `apps/player-care/content/{ko,en,ja,zh-Hans,zh-Hant}.mjs` currently describe a Hall with no name (for example English says “No name, email, account reference, token, or save appears in a Hall entry”). Update the factual disclosure in every supported language for the new user-chosen public game handle; keep the distinctions for private Firebase UID, credentials, email/profile and save data accurate. Inspect the actual locale filenames rather than inventing new ones. Regenerate the site's committed output with its build script and run its source/freshness tests. No deployment in the implementer's copy. + +## Settle these yourself +Use separate adventurer metadata rather than making the canonical UID→public-ID mapping mutable. Keep nickname claims immutable initially: renaming is out of scope. Explain that this is a public game handle, not a real name. Only the initial claim requires online confirmation; previously verified returning accounts can use the cached handle offline. Bounded caches must not expose tokens, emails or raw native identity profiles. + +The Japanese character set includes the ordinary prolonged-sound mark (e.g. `ルミー`). Backfill an existing best row by preserving its saved hero and cycles even if the currently selected hero differs; test that distinction explicitly. + +Prove collision/error mapping with actual REST emulator responses, not just SDK `setDoc` tests. Do not call every permission failure "taken" (missing backend rules is a configuration failure). A name index exposing only the already-public handle/public ID, with no UID, credentials or private metadata, is an acceptable way to establish a collision; the adventurer metadata remains private. Alternatively distinguish the real atomic commit/precondition responses safely. Whichever design you choose, preserve ownership on both sides and do not expose another player's private UID during lookup. + +The director has independently exercised the existing actual GDScript transport/identity registration against the local Firestore emulator (404 profile → 200 atomic commit → 200 owner reload). UTF name tests will use that same actual client path. Keep raw Unicode document resource names in commit JSON; URI-encode path segments only when constructing HTTP request URLs. Do not reserve a literal percent-encoded document ID in place of a Korean/Japanese name. + +## Primary reference +Firebase String rules officially support `lower()`, `matches()`, `size()` and `trim()`: https://firebase.google.com/docs/reference/rules/rules.String . Match the explicit character allow-list on client and server instead of assuming Unicode normalization which either side does not implement. + +## How the director will judge +Read every new private/public data boundary and write batch. Run the client tests and the real rules emulator independently, including a concurrency collision and ownership negative control. Inspect the Hall mapping. Verify raw local files and late callbacks under distinct fake accounts. Rendered room and native gameplay are checked in brief 202, not claimed by this service task. + +Run affected registered suites, not another manual sweep of all existing 80 game suites. If `pnpm test:game` is blocked by the sandbox editor-settings error, report it and stop that check. The director runs the full suite on the cumulative result after the lodge integration. The real rules emulator is also the director's operation. diff --git a/notes/workflow/muse/briefs/202-enter-the-gate-lodge-with-lumi.md b/notes/workflow/muse/briefs/202-enter-the-gate-lodge-with-lumi.md new file mode 100644 index 0000000..5fcdc89 --- /dev/null +++ b/notes/workflow/muse/briefs/202-enter-the-gate-lodge-with-lumi.md @@ -0,0 +1,93 @@ +# Brief 202: Enter the gate lodge with Lumi + +## The ask +“로그인했을 때 이세카이 게이트처럼 처음 어떤 방에서 튜토리얼 같이 다른 캐릭터가 플레이어한테 안내하면서 이름부터 정하게 하고 (중복없이 이게 바로 스코어 저장할 때 쓰여야해) 플레이가 진행될 수 있게 해줘. 안내해주는 애도 스프라이트 잘 만들어주고” + +Build the playable first-login room using the reviewed name service, a new guide sprite and the supplied room art. + +## Why, and what good feels like +The player steps into a warm refuge after login. The lantern keeper greets them as an arriving traveller, asks what the register and Hall should call them, then offers a short hands-on movement and dash lesson before opening the gate. This is a small real game room with characters grounded on its floor. It must look as finished as the existing painted game, with no generic forms covering the whole scene or floating portraits masquerading as sprites. + +## Where things stand +- This continues the same isolated copy after briefs 200 and 201. Preserve their independently reviewed defeat rules, permanent rewards, cloud name protocol and registered tests. Do not reset the existing immutable MB identity, wallet, best scores, living journey or account linkage. +- The user added a two-coin attendance reward every rolling twelve hours. Brief 204 supplies its server-owned service before this scene run. Preserve that service and defer its receipt presentation while the lodge's name keyboard or guide dialogue is active; first-time attendance can settle after the account-owned lesson completes. Native reminder controls belong to the following brief 205, not this scene task. +- Brief 209 closes replay, expired-wallet recovery and actual account + deletion. One director-confirmed integration boundary remains: ten + owners claim on one install in the same window, the first owner's mark + and receipt prune, and that owner returns after twelve hours. The real + Coordinator returns terminal `attendance-replay-ambiguous` and refuses + to commit ANY future period, permanently disabling that account's + attendance. The isolated director probe `attendance-pruned-return` + reports balance 22→22 rather than 22→24. Preserve refusal to repay an + ambiguous forgotten OLD receipt, but allow a genuinely new eligible + server-conditional claim to progress. Never guess or repay old history; + do not freeze future rewards because bounded history was retired. + Register this actual Coordinator case while integrating entry, keep + purchased keys intact, and state the old recovery bound honestly. +- The director's actual `test_cloud_attendance.gd` run has 86 passing + assertions but emits an engine ERROR for `_test_prev_carry_parses`: + malformed `prev_claim_at` reaches `Time.get_unix_time_from_datetime_string` + through `CloudSchema.unix_from_rfc3339`. Keep rejecting malformed + server data without an engine error; the normal full runner rejects + this diagnostic even though the script exits zero. Repair this bounded + parser boundary and verify the full raw log is clean. +- `production_entry.gd` preserves the original title and handles login, cloud restore, entry plans and generation-guarded loading. `production_host.gd` and the new name service provide load/claim/intro-complete operations. Read the reviewed callable contract in `notes/plans/gate-account-protocol.md`; the continuation runner replaces the previous report. Reuse this verified protocol rather than inventing a second nickname store. +- `scenes/actors/player.tscn`, `scripts/actors/player.gd`, existing hero resources and painted animation atlases already handle the selected playable hero. Reuse them, keeping every existing hero raster byte-identical. +- `scripts/gameplay/onboarding.gd` tracks old combat tips. New lodge completion is per canonical account, separately durable. A lodge lesson can mark move/dash as learned only after the actual player performs them. +- Raw original sources are supplied in `notes/workflow/muse/art/gate-chamber/`: `gate-chamber-room.png` (landscape environment only), `lumi-turnaround.png` (transparent front/back/left/right, equal ground baseline), and their prompts. Pack runtime assets reproducibly; do not use the raw turnaround as a portrait billboard. +- Director alpha measurements of the four raw Lumi columns (threshold + 128) give visible heights 853, 850, 851 and 852 source pixels; bottom + anchors are 867, 866, 866 and 867. This source is already consistent. + Preserve its proportions with one common runtime scale and ground + anchor instead of independently filling each facing's bounding box. +- `gate-chamber-room-tablet.png` (1448×1086 RGB, 4:3) and `room-tablet-prompt.md` supply the taller tablet plate with the same desk/window/gate and extended open stone floor. Use this faithful tablet source at taller landscape ratios rather than stretching the wide plate or cropping either interaction. Keep character world scale unchanged; adapt actual room collisions and interaction coordinates to the selected plate. Pack both plates reproducibly and record both originals in the manifest/provenance. +- Nari is missing in the opening story. The new guide is a different person: **등불지기 루미 / Lumi, the Lantern Keeper**. Lumi tends the refuge registry and helps arrivals find the beacon road. + +## Do +- Integrate a real lodge scene into first guest/Google/Apple entry. Load and verify the account's name/intro state before arena entry; completed accounts skip the lodge, incomplete accounts recover their already claimed name after restart. Name confirmation is the first lesson and uses the real atomic service. Show that it is a public adventurer handle, not a real name, and provide localized invalid/taken/offline/retry states without trapping a spinner. Handle Unicode IME, Android/iPad native keyboards and safe areas. A cached verified completed account can still enter offline; a new offline account must not be falsely certified unique. +- Build a warm, restrained diegetic name/dialogue presentation over the supplied room. Lumi stands near the registry desk and faces the approaching hero; the selected actual hero stands on the open floor. After the name is confirmed, let the player move with the existing controls and dash, then walk to the right-hand gate. Use brief readable speech with a consistent speaker label and a skip/return option where appropriate. Do not resume stop-start combat tutorials after this lesson, and do not infer a lesson from a timer or an unrelated tap. +- Keep the refuge free of combat spawns, automatic weapon swings/shots, + damage flashes, wave counters and arena progression. Reuse the selected + hero's actual idle/walk/dash visual and input behavior locally; learning + movement in the lodge must not spend a continue coin or advance an + existing saved expedition. The gate is the explicit transition to play. +- Pack a four-facing grounded Lumi sprite with a reproducible generator, bounded idle breath/lantern gesture, neutral planted feet, stable head/body dimensions and a fixed common foot anchor. Lumi may remain by the desk; if she walks, use a genuine alternating planted-leg cycle, never the earlier foot wiggle. Keep floor shadows and Y ordering coherent with the scene. Map walls, desk, floor and gate collision to the actual art, responsive at 808×360 and expanded landscape aspect ratios. Reuse existing world-skinned controls and font/localization conventions, avoiding default Godot buttons, rectangles, unreadable small text, portal glare and blinking debug overlays. +- Complete the durable account-owned lesson only after actual name/move/dash gates. A new account then starts a fresh expedition. An existing living journey must still offer Resume versus confirmed fresh departure without silently discarding it; a defeated journey must never offer free same-stage resumption. Guard scene transitions, repeated taps, pending claims, token refresh and account changes. Register meaningful scene/entry/service regressions, asset contracts and manifest rows, update the player guide and provide a bounded production-style QA harness to render/name/move/dash/door/Hall states with an injected host and isolated saves. + +## Do not +- Do not change the original title, official Google/Apple buttons, login consent links, menus, existing heroes, native auth SDKs, IAP, release counters, locked renderer/resolution/filter settings or stores. +- Do not name Nari as the guide, preload the arena behind every title, create a new player ID on naming, delete a returning save on lodge entry or treat fixture replies as verified production claims. +- Never prefill or publish a Google/Apple real name, email or native profile label as the adventurer nickname. The player chooses this public game handle explicitly; an existing acknowledged game handle is the only account value to restore into the form. +- Do not deploy, access the network, touch credentials, perform git actions or recapture marketing screenshots. The director supplies emulator/live operations and captures only isolated development QA. + +## Acceptance +- Automated production-entry tests cover guest and provider first entry, returning complete accounts, incomplete claimed accounts, timeout/offline errors, cancellation, stale account callbacks and repeated scene loads. Runtime host plans cannot enter the arena before the required verified name/lesson state, including direct calls rather than only disabled UI. +- A restart during name registration or midway through the lesson keeps the same canonical ID/name and safely resumes setup. An existing saved run survives migration and lodge completion byte-for-byte until an explicit fresh confirmation or legitimate resume. Claiming an existing account's name preserves its best score and Hall hero, and new score writes show the same chosen name. +- The result screen's record action never asks a verified named player to choose a second name. Inspect that actual scene route, not only the cloud payload, and make its chosen-name/hero/score presentation coherent with the Hall. +- The QA harness renders the actual room and Lumi at both 808×360 and a wider native landscape viewport, with no overlapping clipped text at each of five locales. Provide front/back/left/right idle contact sheets and a short scene clip showing the selected hero moving, dashing, stopping with neutral feet and entering the gate. Foot anchors remain aligned and all Lumi head heights remain within 5% across facings; idle frames do not resize the face. Old hero raster hashes are unchanged. +- Also render iPad's taller 4:3 landscape (an expanded 808×606 logical viewport). Keep both registry and gate accessible and character scale consistent; do not crop either interaction offscreen or leave plain black/gray unused bands. Keyboard-visible naming layout must remain readable and tappable in this aspect as well. +- Supply actionable automated checks for the new asset dimensions/alpha/frame layout and generator freshness. Test the real scene collision, movement, dash and door conditions, including a failed/uncertain intro-complete acknowledgement. Normal production has no test nickname, fake ranking rows or tutorial debug state. +- Related cloud/name/host/production-entry/onboarding/locale/asset/hygiene/docs checks pass. Reports are claims: the director reruns tests and sees the rendered game independently before accepting. + +## Deliverables +Lodge scene/controller and production-entry/host integration, Lumi packed runtime art and generator/contracts/manifest, localized dialogue and nickname form, account-specific completion integration, registered regressions, player guide and a compact isolated development harness. Update the author-only protocol note with the exact route and recovery behavior. + +## Constraints specific to this task +Disk space is limited: keep QA recordings short and small. No full marketing capture, redundant movie copies or new dependency install. Keep existing hero PNG files byte-identical. Five player languages: Korean, English, Japanese, Simplified Chinese and Traditional Chinese. Preserve original Tap to start → official login selection → account restore sequence. + +Use a room-owned fixed camera and disable the instantiated player's arena camera locally, so walking does not pan off the environment plate. Keep player animation/root tint conventions intact. Limit the raw development movie to 120 frames at internal resolution; wider/native states can be PNGs. Raw art source sizes are 1774×887 RGBA (Lumi) and 1881×836 RGB (room). + +## Settle these yourself +Lumi stays by the registry desk and turns naturally toward the player; an escort walk is optional and should be omitted if its gait is not consistent. Keep her taller adult proportions readable but scale her world size to the playable hero's grounded presence. A short calm refuge ambience may reuse the existing title music at an appropriate level. Returning old accounts without a chosen name enter setup once, keeping their saved journey and choosing resume afterward. The initial verified nickname is immutable; nickname renaming is outside this task. + +Tie the greeting to the existing “road remembers itself” and Nari's warm-kettle story in `STORY_OPEN_A/B`, rather than explaining databases. The registry gives arrivals a name by which the refuge and Hall remember them. Lumi can briefly point toward the missing beacon keeper's road when the gate opens. Keep Nari missing at this point and avoid inventing a new amnesia plot. The player's chosen public handle should be used naturally in Lumi's subsequent line. + +## How the director will judge +Read the production routing and every account generation guard, independently run registered regressions and rule enforcement, inspect the exact saved files around interrupted lessons, compare existing hero raster SHA-256 values, and watch screenshots and motion in the real room. The director will then exercise the Android emulator with an actual landscape touch flow before accepting the cumulative change. + +Run affected registered suites and asset/static/locale/docs checks. If `pnpm test:game` is blocked by the sandbox editor-settings failure, report it and stop that check instead of manually sweeping all old suites. The director runs the normal full suite on the cumulative copy and `/verify` on the accepted tree. + +Keep the true exit status and complete diagnostics of each check. Use a +45-second timeout for small Godot checks; do not hide a parser/settings +error with a successful `tail` or `grep`, and do not repeat a sandbox-blocked +command. Report it for the director's normal environment. diff --git a/notes/workflow/muse/briefs/203-protect-defeat-and-paid-continue-crash-boundaries.md b/notes/workflow/muse/briefs/203-protect-defeat-and-paid-continue-crash-boundaries.md new file mode 100644 index 0000000..1acee99 --- /dev/null +++ b/notes/workflow/muse/briefs/203-protect-defeat-and-paid-continue-crash-boundaries.md @@ -0,0 +1,37 @@ +# Brief 203: Protect defeat and paid-continue crash boundaries + +## The ask +“죽으면 코인으로 계속하거나 이어서 처음으로 가야하는데 그 스테이지에서 시작하는게 있네? 이거 수정해주고” + +Correction to brief 200: defeat must remain terminal through recovery, and an unpaid continuation must never become durable/playable. + +## Evidence to verify before changing +- `Journey.write_checkpoint(ended)` rotates the previous **alive** main into the backup. `read_checkpoint()` falls back to that alive backup when the terminal main is corrupt/missing. The new terminal test instead manually overwrites the backup with an ended payload before corruption, so it does not test the file pair produced by the real writer. +- `Arena.continue_run()` writes a newer **alive** checkpoint and notifies its stable/cloud hook before calling `Vault.spend_continue_coin()`. A process death at that boundary leaves a resumable checkpoint with no durable coin debit. A failed wallet write followed by a failed tombstone rewrite has the same result. The happy path and isolated single-write-failure cases do not prove crash safety. + +The director reproduced both in isolated storage on 2026-10-07: +- `builds/verify/gate-defeat-probe/result.log`: actual writer, then corrupt primary; exit 1 with `free_resume: true`, `recovered_ended: false`, checkpoint 2. +- `builds/verify/gate-defeat-probe/paid-result.log`: at the alive stable hook, balance still 2 and checkpoint resumable; inject blocked wallet temp and `InstallFault.FAIL_ALL` for rollback. `continue_run()` returns false, balance stays 2, but `has_valid_checkpoint()` returns true. Exit 1. This observes the production code rather than a simulated ordering. + +Preserve existing results, score settlement and living-quit behavior. + +The director independently reran the registered suites on this initial implementation: defeat 95, Journey 519, coordinator 1167 and production host 804 cases all passed, with exit 0. The two additional probes still fail. Add the missing boundary coverage rather than relying on the existing green suites. + +## Do +- Add registered regressions using the **actual** file pair left by normal defeat. Then corrupt/delete the main, reload independently and attempt title/host/resume entry. No surviving older backup or local cloud state may revive this ended journey. Mirror or journal terminal authority durably without destroying unrelated account/run data; preserve healthy alive backup recovery. +- Make paid continuation durable as one recoverable protocol: before a checkpoint is considered alive/resumable or notified/uploaded, its coin debit must be durably acknowledged. Tie any interrupted transaction to the same journey/attempt and ensure retry cannot double-charge, lose a coin or give a free revive. Do not rely on best-effort rollback across two independent saves. Retire any stale ended cloud candidate according to the same transaction's acknowledged newer seal, keeping existing generation/account guards. +- Exercise interrupted execution at each durable boundary, both write failures together, reload and double tap with meaningful fault injection. Include stable-hook observations proving no unacknowledged alive payload reaches the cloud. Report exact outcomes, register the tests and rerun the touched journey/Vault/arena/cloud/host suites. + +## Do not +No new names/lodge work in this correction. No native auth, IAP grants, account IDs, version counters, network, stores, git, marketing capture or weakening tests to accept the defects. + +## Acceptance +The normal defeat writer's redundant state cannot resurrect after one failed primary file; a crash before durable debit leaves no free resume, and a crash after durable debit recovers a single paid continuation without another charge. Coin balance and receipts agree after reload. Existing living checkpoint recovery, result rewards and cloud ownership continue passing. Supply one negative control for each new guarded boundary which the director can independently run. + +A legitimate acknowledged paid continuation must also restore on another client that previously observed the defeat, using the newer alive seal. Intermediate local transaction state must not poison that comparison or publish a resumable checkpoint before payment. Test this explicitly with the real coordinator fixture. + +## How the director will judge +Read the actual persistence order, run the registered fault/reload tests in isolated storage, break the two guards temporarily to observe failures, and compare wallet/checkpoint/hook bytes rather than trusting the report. + +## Verification scope for this continuation +Run the affected registered suites and static checks. If `pnpm test:game` is blocked by the known sandbox editor-settings failure, report that exact block and stop that check; do not repeat the manual sweep of all 80 existing suites. The director runs the full game suite on the cumulative result after briefs 201 and 202. This correction should focus on the two confirmed persistence defects. diff --git a/notes/workflow/muse/briefs/204-grant-two-attendance-coins-every-twelve-hours.md b/notes/workflow/muse/briefs/204-grant-two-attendance-coins-every-twelve-hours.md new file mode 100644 index 0000000..2de802c --- /dev/null +++ b/notes/workflow/muse/briefs/204-grant-two-attendance-coins-every-twelve-hours.md @@ -0,0 +1,70 @@ +# Brief 204: Grant two attendance coins every twelve hours + +## The ask +“코인은 그리고 반나절마다 출석하면 2코인씩 주고 반나절마다 알림을 보내면좋아 지금 출석하면 코인 받는다고. 그리고 알림은 설정에서 안받게 할 수도 있게 해야하고” + +This task implements the real attendance reward. The next brief adds native reminders and settings, using this task's next-eligible time. + +## Why, and what good feels like +Returning to the game gives two continue coins, at most once per rolling twelve hours after the last successful claim. The first eligible visit also receives two. Returning after several days still grants two, not a stockpile for missed periods. A notification never grants coins by itself. The player can keep playing while attendance is unavailable; no extra blocking login or tutorial. + +## Where things stand +- Continue the same isolated cumulative copy after defeat recovery and unique adventurer-name work. Preserve their guards, tests and original title, existing saves, IDs, names, bests and purchases. +- `Vault.grant_continue_coins(count, transaction_key)` already makes a durable idempotent coin grant alongside purchased-coin receipts. Preserve all existing IAP receipts, balances and paid-continuation recovery. +- The production host/coordinator owns generation-bound Firebase operations. Anonymous Firebase guests count as accounts; local-only offline guests cannot establish server eligibility. The new name/intro service supplies the account's setup state. +- `firestore.rules`, `firebase.json`, version counters and credentials are protected. Only the additive cloud rules and their real emulator tests may be authored here. The director deploys a generated reviewed combination if needed. + +## Do + +### Confirmed shared-operation prerequisite + +The director independently executed the real production +`delete_current_account()` wrapper while a name claim waited at token +refresh. The UID/coordinator stayed A, the actual deletion ticket became +nonempty, and the remote deletion body was held behind a latency barrier +(no real deletion). Releasing the name token dispatched `Alpha` to A +during deletion and returned `ok`. Proof +`builds/verify/gate-defeat-probe/name-token-deletion-round6.log`, clean +exit 1 in 2.5 seconds. The round-6 ticket checks UID/coordinator but not +the real in-progress deletion or its lifetime. + +Before adding attendance calls, close this shared boundary: claim/load/ +intro/backfill and attendance must not start or resume while deletion is +in flight. A request begun before deletion must remain cancelled even if +the deletion later fails while keeping the same account/coordinator; pin +the deletion generation as well as the account lifetime across awaits. +Register the actual begin-deletion barrier, not a test that only calls +`_retire_coordinator()` after deletion. Preserve retry after failed +deletion for a genuinely new request, and all preceding wrapper guards. +This is the only extra name correction in this attendance brief; do not +expand into unrelated old account behavior. The director reruns the exact +probe and all reviewed name/rules suites. + +### Attendance implementation + +- Add a small private account-owned attendance record and bounded REST service. Server time and ownership enforce a rolling 43200-second cooldown; use Firestore server timestamps and conditional atomic writes. Concurrent claims for one account yield one new receipt. Client wall-clock changes, time zones, repeated app launches and account/provider switches cannot reset eligibility. Return granted/already-claimed/cooldown/offline/error honestly, with the server-confirmed next eligible time for reminders. +- A first attendance read must yield authoritative not-found for the owner proved by the canonical ownership pair. Do not repeat the name-service defect where dereferencing a missing `resource.data.uid` produces 403 before the writer can run. Real 403/configuration errors must remain errors; strangers and private enumeration stay denied. Include the actual REST read-before-first-write route in registered tests. +- Derive remaining cooldown from a real server read/commit timestamp (for example the single-document `batchGet` read time), not from an assumed-correct device UTC clock. Supply both deadline and bounded remaining delay to the reminder API; an offline cached reminder must never become an authority to grant coins. Cover forward/backward device-clock changes in the client tests. +- Make acknowledged or uncertain claims recoverable across process death and failed local wallet writes. Apply exactly two through the real Vault with a stable receipt key once. Bind a claimed reward to its receiving install so another device cannot replay that same grant into a second wallet; a different device may claim the next eligible period. Do not invent cloud synchronization of existing purchased/device coin balances. Persist no auth token, email or raw native profile in the attendance data/cache. Report the exact receipt/receiving-install contract. +- Keep attendance replay state bounded for an endless game (for example an owner-bound monotone receipt watermark), rather than accumulating a new large wallet entry every twelve hours forever. Never evict purchased-coin transaction keys or make a pruned attendance receipt grant again. Bound failures must preserve playable wallets and report honestly. +- Wire a nonblocking claim on genuine account entry or foreground attendance after setup permits play, and on a later eligible foreground return. Never a background timer/notification/capture harness. Respect pending restore/account deletion/generation changes and retry only in bounded fashion. Existing players keep their living run. A new player can finish naming first; do not overlay attendance while an IME or NPC dialogue is active. +- Show a small world-skinned five-language receipt such as “출석 보상 · 계속하기 코인 +2”, only after durable local grant. Expose a safe view/signal for the upcoming reminder controller and a readable next-reward time where it belongs without redesigning the title/shop. +- Include attendance in atomic account deletion, preserving legacy unnamed and never-attended accounts. Update the player guide and private/public data disclosure accurately. Register focused service/host/Vault tests and extend the real rules-emulator tests. + +## Do not +No native reminder/permission code yet, no new dependencies, new shop products, purchased-coin migration, release counters, original hero changes, combat rebalance, stores, marketing capture, network, deployment or git operations. + +## Acceptance +- Real rules execution permits owner claims and denies strangers, forged timestamps/counts, premature retry, receipt reassignment and partial deletion. Seed an old server record to test twelve-hour boundaries without waiting twelve hours. Real REST concurrent conditional claims give one grant; existing cloud/name/legacy contracts still pass. +- Client tests cover first claim, just-before/at/after twelve hours, no catch-up stacking, restart, double tap, uncertain acknowledgement, wallet temp-file failure, stale callbacks, guest-to-provider link, another account and another receiving install. Two coins and its receipt survive reload together, with no replay and no loss after the remote claim was acknowledged. +- Offline or permission failures neither grant unverified coins nor block normal play. The notification layer can get the confirmed next eligible UTC time without being able to mint a reward. +- Supply a callable API note for the next brief; tests are registered and affected static/locale/docs/rules checks pass. Do not manually repeat all old game suites if sandbox editor-settings blocks the full runner; the director runs the cumulative full suite. +- Extend `notes/plans/gate-account-protocol.md` with the exact attendance methods, snapshot/signal fields and native-ready eligibility values, so the later lodge and reminder briefs can reuse one verified protocol without depending on a report file that the continuation runner replaces. +- Preserve each check's true exit status and full diagnostic log. For small Godot checks use a bounded 45-second timeout; a parse/settings error is not a successful check even if a later `tail` or `grep` exits 0. Do not repeat a sandbox-blocked command; report it for the director's normal environment. + +## Primary references +- Firestore `request.time` equals a server timestamp in a write: https://firebase.google.com/docs/reference/rules/rules.firestore.Request . +- REST conditional writes and server-time field transforms: https://firebase.google.com/docs/firestore/reference/rest/v1/Write . + +## How the director will judge +Read the actual remote-claim/local-grant ordering and receiving-install partition; independently run the client tests and real rules emulator with competing claimers and fault injection. Check wallet bytes and receipts after reload, and confirm entry UI remains playable without cloud availability. Preserve the original defeat and lodge work throughout. diff --git a/notes/workflow/muse/briefs/205-remind-players-when-attendance-coins-are-ready.md b/notes/workflow/muse/briefs/205-remind-players-when-attendance-coins-are-ready.md new file mode 100644 index 0000000..e57b928 --- /dev/null +++ b/notes/workflow/muse/briefs/205-remind-players-when-attendance-coins-are-ready.md @@ -0,0 +1,70 @@ +# Brief 205: Remind players when attendance coins are ready + +## The ask +“코인은 그리고 반나절마다 출석하면 2코인씩 주고 반나절마다 알림을 보내면좋아 지금 출석하면 코인 받는다고. 그리고 알림은 설정에서 안받게 할 수도 있게 해야하고” + +Add real Android and iOS local attendance reminders and a persistent settings control, based on the reviewed attendance service. + +## Why, and what good feels like +After an acknowledged attendance reward, a player who enables reminders is told when another two coins are ready: “지금 출석하면 2코인을 받을 수 있어요.” The reminder repeats roughly every twelve hours while away and resets after the next successful attendance. Turning it off in Settings stops all pending/delivered attendance reminders. Notifications never grant coins, interrupt first-login naming or require the game to remain running. + +## Where things stand +- Continue the same isolated copy after the defeat/name/attendance/lodge integration. Preserve all preceding invariants and registered regressions. +- The attendance host view exposes server-confirmed next eligibility and the same canonical account. Use that single authority; do not create another reward timer. +- Native plugin sources already exist in `addons/moonlit-identity/android/` and `addons/moonlit-identity/ios/`, with GDScript adapter, export hooks and Node build contracts. They currently implement identity only; adding a clearly separate reminder API is acceptable, but do not change provider-auth or IAP behavior. +- `scripts/gameplay/settings.gd` and `scripts/ui/settings_panel.gd` persist and display locale/audio/reduced-motion preferences using the game's skin. Add a useful readable reminder control without generic default Godot visuals. +- `Settings` already has a durable analytics-revocation sentinel for failed preference saves; reuse the proven pattern with a separate reminder sentinel, without tying the two preferences together. `SettingsPanel._redraw()` currently returns early when Analytics is unconfigured. Reminder rendering/actions must work independently of analytics availability and consent; register that production panel case too. +- The director compiled both existing native identity targets successfully + with the cached toolchains before reminder work. Inspection of the real + pinned Godot Android AAR confirms these plugin hooks: + `onMainRequestPermissionsResult(int, String[], int[])`, `onMainPause()`, + `onMainStop()`, `onMainStart()`, `onMainResume()` and `onMainDestroy()`. + Use their actual signatures for permission/lifecycle handling. The + director also pinned hashes for all 18 existing debug/release SDK + privacy resources for the final preservation check; they are inputs, + not the new app-owned privacy declaration. + +## Do +- Implement Android local notification permission/status, a dedicated localized attendance channel, a safe immutable launch PendingIntent, and a battery-conscious inexact twelve-hour alarm/receiver that survives normal app exit and reboot. Check the persisted opt-out and OS permission/channel state again at delivery. Do not request special exact-alarm privileges or keep a foreground service alive. +- Give the Android notification a proper monochrome small beacon icon derived from this game's existing emblem; do not use a generic system warning/info icon or the full-color launcher bitmap as a small icon. Preserve native notification accessibility and the existing launcher branding. +- Implement iOS UserNotifications permission/status, localized local requests starting at the confirmed next-eligible time and recurring at twelve-hour intervals. Link the needed system framework through the existing plugin export pipeline. Only cancel this app's attendance identifiers, never unrelated requests. Avoid accumulating pending requests or shifting eligibility on every title refresh. +- If the iOS implementation adds `NSUserDefaults` for app-only reminder preferences or scheduling metadata, include the app's own required-reason privacy declaration (`NSPrivacyAccessedAPICategoryUserDefaults`, `CA92.1`) in an actual exported `PrivacyInfo.xcprivacy` resource. Do not rely on or rewrite the existing Firebase/Google SDK manifests. Preserve those SDK bundles byte-identical and register staging/export tests proving this new app declaration reaches the final iOS app resources. The director will inspect the built app's privacy resources, not only the source template. If another persistence approach is chosen, document the real accessed API and its declaration requirements. +- Handle enabling reminders halfway through a cooldown correctly: the first remaining delay and the twelve-hour repeat period are different values. Do not put a short remaining delay in a repeating `UNTimeIntervalNotificationTrigger` and thereby repeat every few minutes/hours forever. Prove the actual native pending triggers maintain the requested twelve-hour cadence; document any bounded scheduling horizon honestly. A locale/status refresh must not postpone the first deadline. +- Expose bounded asynchronous bridge results and platform status honestly. Unsupported desktop remains playable with no fake “scheduled” success. Native callbacks must respect lifecycle/account changes, not dereference a dead controller or interfere with authentication callbacks. +- Add a persistent Settings reminder switch and localized status for enabled/off/OS-denied/unsupported/error. An explicit “출석 알림 받기” affordance on the first unobtrusive reward receipt may request OS permission; skip leaves play available. Do not automatically stack an OS prompt over login, IME, Lumi dialogue or combat. Denial is not enabled; repeated launches do not nag. If OS access was denied, an explicit settings action may open the app's notification settings. Disabling cancels immediately and stays off across save failure/restart; do not let an old setting re-enable delivery. +- Keep one active account's reminder per install, with safe retirement on sign-out/switch/deletion and rescheduling after a genuine new reward, enabled preference or locale change. A cold notification launch opens the ordinary title/account route; an existing background game may resume its living session safely. Preserve a living save and never auto-start a fresh expedition. The notification action does not claim or spend coins itself. +- Reminders are for a player who is away. Suppress attendance sound/banner delivery while the game is genuinely foregrounded, including naming, guide dialogue and combat; keep the future cadence without granting coins. Use actual native lifecycle/presentation state with a registered delivery case, without interfering with auth lifecycle callbacks. +- Add focused registered GDScript tests, native/export contract tests and a bounded isolated native QA harness that can schedule a short upcoming reminder without changing the production twelve-hour rule or touching a real player's wallet. Update the player guide, plugin API note and privacy/support source in five languages to explain local reminders and opt-out accurately. + +## Do not +No FCM/APNs backend, push tokens, new service accounts, dependencies, analytics messaging, exact-alarm permission, provider SDK changes, purchase changes, store submission, screenshots/gallery updates, version bumps, network, deployment or git operations. The director builds both native targets and verifies real device behavior. + +## Acceptance +- Source/build contracts prove notification permissions, receiver/component configuration, safe launch intents, native method registration, iOS framework/request handling and preservation of auth/IAP exports. Director native compile must succeed; source text alone is not a native build pass. +- Tests cover initially unknown permission, granted/denied/dismissed permission, off→on→off, offline cached deadline, locale change, reschedule, restart, OS revocation, failed settings save, stale callbacks and account deletion. No alert before the known eligibility time; a late OS-delivered alert never grants an early or duplicate reward. +- Settings fits all five languages at 808×360 and iPad 4:3 with readable status and tappable controls. Turning reminders off actually cancels rather than merely changing a label. Existing controls and original title remain usable. +- Supply a director-run native QA path to verify a short notification, its real launch action, and subsequent cancellation on an isolated Android test account. iOS real pending-request diagnostics and compile must be observable; report any physical-device interaction still needed honestly rather than claiming simulator success. +- Include the existing lodge's native naming flow in the director-run + entry QA path. The director's actual 808×360 desktop name-form image + currently places the field at roughly y136–184 and registration action + at y229–275; desktop has no native keyboard, so that image is not an IME + pass. Make keyboard visibility/height and real field/action bounds + observable without logging the chosen name or any provider personal + data. The name form must remain readable, finishable and retryable on + Android and the taller iPad when the OS keyboard appears, including + invalid/taken/offline responses. Preserve native composition and do not + rely on a human needing clipboard or debug calls. If this integration + needs a bounded keyboard-aware layout adjustment, reuse the game's skin + and actual viewport transform; do not alter the original login chooser. +- Run affected registered and build/static/locale/docs checks. If sandbox editor-settings blocks the full old game suite, report that block and stop it; the director runs the cumulative full suite once. +- Preserve true exit statuses and complete diagnostics. Use a 45-second timeout for small Godot checks, do not hide parse/settings errors behind `tail` or `grep`, and do not repeat a sandbox-blocked command; report it for the director's normal environment. + +## Primary references +- Android permission and denial behavior: https://developer.android.com/develop/ui/compose/notifications/notification-permission . +- Android inexact scheduling and Doze restrictions: https://developer.android.com/develop/background-work/services/alarms . +- iOS permission: https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications . +- iOS local interval triggers: https://developer.apple.com/documentation/usernotifications/untimeintervalnotificationtrigger . +- Apple required-reason manifest guidance and app-only UserDefaults example: https://developer.apple.com/documentation/technotes/tn3183-adding-required-reason-api-entries-to-your-privacy-manifest and https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api . + +## How the director will judge +Read delivery-time guards and cancellation behavior independently. Run the service/settings/bridge/export regressions, compile Android and iOS, inspect the final manifests/frameworks, and exercise a short reminder and opt-out on an isolated Android account. Inspect iOS pending-request evidence without claiming unperformed physical touches. Check the actual UI and unchanged saved journey on notification entry. diff --git a/notes/workflow/muse/briefs/206-keep-paid-revive-recovery-in-its-own-account.md b/notes/workflow/muse/briefs/206-keep-paid-revive-recovery-in-its-own-account.md new file mode 100644 index 0000000..80b6f41 --- /dev/null +++ b/notes/workflow/muse/briefs/206-keep-paid-revive-recovery-in-its-own-account.md @@ -0,0 +1,28 @@ +# Brief 206: Keep paid revive recovery in its own account + +## The ask +Preserve the user's login-owned last-played journey while fixing coin continuation. A paid recovery must never transfer gameplay into another account. + +## Confirmed evidence +While independently reviewing brief 203, the director ran the actual journal API in isolated storage: +1. `Journey.use_account(MB-a...)`, write terminal seal 5. +2. `Vault.begin_continue_txn(journey, 6, alive_text)` acknowledges the debit; reload Vault before materializing the checkpoint. +3. `Journey.use_account(MB-b...)`, whose checkpoint slot is empty. +4. `Vault.recover_paid_continue()` returns `recovered`, writes account A's alive checkpoint into B's slot and clears A's journal. + +Proof: `builds/verify/gate-defeat-probe/account-result.log`, director operational probe exit 1, `wrong_account_import: true`, `journal_retained: false`, balance 1. No real account data was used. + +The actual result-button routing also strands the last paid coin: start with one coin, seal a real defeat, inject `Journey.InstallFault.FAIL_ALL` for the alive install, then call `continue_run()`. Balance is 0 with a journaled paid revive. `ResultPanel.reopen_after_failed_continue()` followed by the real `_request_continue()` emits `continue_purchase_requested` instead of retrying that already paid transaction. Proof: `builds/verify/gate-defeat-probe/last-coin-result.log`, `routes.purchase: true`, `routes.continue: false`. This routing probe disconnects scene-swap subscribers only so its exit does not launch an asynchronous shop; the production button method itself is unchanged. + +## Do +- Bind every pending paid revive to its exact owning public-account/save slot as well as its typed journey ID and attempt. Recovery on another account is deferred without touching its files, notifying its cloud hook or consuming/discarding the original pending receipt. Account switch, token refresh, deletion and guest-to-provider linking must respect ownership. +- Keep A's pending paid recovery recoverable while B starts, plays, quits or continues its own run. Do not overwrite A's pending receipt with another attempt/account, and do not clear it merely because B's file is empty or contains a different journey. Avoid blocking ordinary B play; explicitly abandoning A's paid revive requires the existing user-confirmed fresh action in A's scope. +- Keep the debit/journal atomic, preserve all purchased-coin and shard receipts, and make same-attempt retry validate the exact journaled seal/owner rather than treating any equal numbers as an acknowledged transaction. +- Register isolated account-A/B reload/switch tests (including B empty and B with a living run), late cloud hooks, B fresh and B paid-continue, then switch back to A and prove exactly one debit and its correct original revive. Preserve the prior corrupt-main and stable-hook fault tests. +- Let the real loss-result button retry its own acknowledged pending revive even when its debit spent the last coin. Show a localized save-retry state rather than sending the player to buy another coin. A true zero balance with no owned pending receipt still goes to the shop as before. Register this real button/signal route and prove recovery never charges again. + +## Do not +No names, attendance, room, native auth, IAP behavior, version counters, network, stores, git or marketing screenshots. Do not relax tests to permit transfer or silently lose the first paid recovery. + +## Acceptance and judgment +The director's actual A→empty-B probe no longer imports any checkpoint into B; A's pending receipt survives until A is restored. Registered multi-account tests prove both attempts can settle independently, balances and receipt bytes survive reload, and unrelated account files remain byte-identical. Run affected Journey/Vault/arena/coordinator/host tests only. If the full runner hits the sandbox editor-settings block, report it and stop; the director runs the cumulative full suite after the remaining features. diff --git a/notes/workflow/muse/briefs/207-recover-paid-journals-through-canonical-slot-moves.md b/notes/workflow/muse/briefs/207-recover-paid-journals-through-canonical-slot-moves.md new file mode 100644 index 0000000..90bfc4c --- /dev/null +++ b/notes/workflow/muse/briefs/207-recover-paid-journals-through-canonical-slot-moves.md @@ -0,0 +1,26 @@ +# Brief 207: Recover paid journals through canonical slot moves + +## The ask +Keep the user's already-paid continue recoverable when login adopts an existing canonical public ID. Preserve all preceding defeat and account ownership guards. + +## Confirmed evidence +The director ran the actual `CloudCoordinator._move_slot_to_canonical()` against isolated Journey/Vault storage after brief 206: +- Guest slot A contains ended seal 5 and an acknowledged one-coin journal for alive seal 6, owned by A. +- Occupy `Vault.TEMP_SAVE_PATH` with a directory, producing a real wallet save failure. +- Set the real coordinator's requested partition to A and canonical ID to C, with the real Vault; call its slot-move operation. +- It moves both journey files out of A into C, ignores `rekey_continue_txn_owner()` returning false, adopts C and reports no save error. +- Remove the fault and reload the wallet. Recovery on C returns `deferred`; the receipt still owns A, whose files no longer exist. The paid revive is stranded. + +Director operational probe: `builds/verify/gate-defeat-probe/rekey-round3.log`, clean exit 1, `guest_file_exists:false`, `canonical_file_exists:true`, `journal_owner:A`, `paid_revive_recovered:false`, balance 1, empty save code. This exercises actual production code with fake public IDs and no human data. + +## Do +- Make the joint journal/file adoption recoverable across failed journal writes, failed/partial slot moves and process death between these operations. A failure must keep an explicitly recoverable receipt and slot association, not merely its old bytes in a now-unreachable owner scope. Retry after restart must converge without a second debit or importing into an unrelated account. +- Do not ignore migration/rekey failures in coordinator/host startup or canonical adoption. Carry a truthful safe error/retry state where necessary, and retain ordinary play/save behavior for accounts with no pending paid continue. +- Register a real coordinator/host adoption regression with actual Vault temp-path failure, reload and retry. Verify exactly one debit, correct alive seal 6 under the final canonical account, no stale cloud hook for another account and no orphan receipt. Add a process-boundary fixture for the chosen write order. Occupied canonical slots and account deletion still preserve the unrelated account and the prior scoped-journal tests. +- Review the comparable `adopt_legacy_continue_txn()` integration sites for the same ignored-failure problem and cover a confirmed legacy-to-owned paid receipt adoption boundary. + +## Do not +No names, attendance, room, native identity SDK changes, IAP change, counters, network, git or store operations. Do not loosen the owner guard, adopt any arbitrary foreign receipt, drop an acknowledged paid revive or handwave that a user can buy another coin. + +## Acceptance +After the fault is removed and the account resumes/restarts, the paid transaction reaches the correct canonical slot with no extra charge; the director's probe and registered production-route fault cases pass. Earlier corrupt-main, debit-before-alive, A/B isolation and last-coin result routing stay green. Run affected suites only; no manual sweep of all old game suites if sandbox editor settings blocks the full runner. The director runs the cumulative full suite later. diff --git a/notes/workflow/muse/briefs/208-close-real-name-boundaries-and-token-switches.md b/notes/workflow/muse/briefs/208-close-real-name-boundaries-and-token-switches.md new file mode 100644 index 0000000..cfa20ed --- /dev/null +++ b/notes/workflow/muse/briefs/208-close-real-name-boundaries-and-token-switches.md @@ -0,0 +1,156 @@ +# Brief 208: Close real name boundaries and token switches + +## The ask +Continue the user's first-login unique-name and same-name score request. +Correct the confirmed boundaries in brief 201 before attendance or the +playable lodge builds on this protocol. Preserve every preceding defeat, +paid recovery, account migration and name/Hall regression. + +## Confirmed director evidence + +1. **A fresh registered owner cannot start naming.** The director ran the + actual GDScript CloudIdentity + CloudName + CloudTransport through the + local Firestore emulator with six synthetic owners. Canonical ID pairs + register successfully. `claim_name()` starts with a missing adventurer + GET; the new rule uses `resource.data.uid` even when no row exists, so + the server returns HTTP 403 before the atomic name writer runs. Korean + `달빛기사`, Japanese `ルミー`, and Latin `Luna` all fail. Independent + SDK inspection reproduces an owner's missing-row read as + `permission-denied`, not authoritative not-found. Proofs: + `builds/verify/gate-chamber-names-preliminary.log` and + `builds/verify/gate-name-rule-boundaries-preliminary.log`, both clean + exit 1. Permit an authoritative missing-row read only for the owner + proven by the canonical ownership pair; keep other owners/private + enumeration denied. Then confirm actual Unicode commit/reload, + intro-complete reload, case collision and concurrent claim results. + Do not translate every 403 to taken. A known public name-index lookup + containing only display/public ID is acceptable for distinguishing a + real collision from missing rules/configuration errors. + + A separate low-level actual GDScript writer probe already passes with + the current atomic builder: raw Korean document names commit, reload + and index GET correctly; intro completion and full six-row deletion + also succeed. A competing real `exists:false` writer receives HTTP + 409 `ALREADY_EXISTS`, mapped to `conflict/precondition-failed`. + Proof `builds/verify/gate-name-writer-preliminary.log`, clean exit 0. + Preserve this working writer behavior; the first missing-row GET is + the concrete blocker, not a need to rewrite the entire protocol. + +2. **The immutable name can be released while its account stays live.** + The independent emulator probe registers the canonical pair and the + name/adventurer pair using ordinary authorized atomic SDK writes. + Deleting only the name and adventurer, with no Hall row, succeeds while + the profile/reservation remain. The rules require only the other name + half and Hall to be absent, not the living canonical pair to be gone. + Proof `gate-name-rule-boundaries-preliminary.log`: `partial_release` + is `allowed`, `profile_retained:true`. Make an owned live name release + part of the full atomic account deletion: no partial release under a + living profile/reservation, no orphan or reassignment, including a + name-only pair with no Hall/checkpoint yet. Preserve no-op missing-row + deletes, legacy unnamed accounts and actual six-row full deletion. + Check both ownership halves before/after relevant writes, not just + client deletion order. + +3. **A name request can cross a token wait onto a different coordinator.** + The director executes the unchanged production + `claim_adventurer_name()` method with a held token-refresh barrier and + recorder coordinators. It begins under A, the configured account and + coordinator retire to B during the await, then token completion + resumes it. B receives `Alpha` and the old request returns `ok`. + Proof `builds/verify/gate-defeat-probe/name-token-switch-preliminary.log`, + clean exit 1, `account_b_calls:["Alpha"]`. The test overrides only the + token latency and state observation, not the name wrapper. Capture an + account/coordinator lifetime ticket BEFORE the token await and verify + it after every async boundary. Claim, load, intro completion and Hall + backfill must cancel when the initiating account retires, even before + entering the coordinator service; a token failure must not dispatch a + new mutation. Register production-wrapper regressions for all four + routes, token refresh/switch/deletion/shutdown and stale completions. + +4. **Restoring a claimed name erases a completed tutorial cache.** The + director's real REST probe registers A, writes the Korean name pair, + marks the server intro complete, configures the actual coordinator, + calls its actual `complete_intro()` (cache true), then calls actual + `claim_adventurer_name("Different")`, which correctly restores A's + existing immutable name. However the restore result omits + `intro_complete`; `_cache_verified_name()` defaults it to false and + the Vault overwrites true. After `Vault.load_vault()`, the same owned + name still has false. Proof `builds/verify/gate-name-restore-preliminary.log`, + clean exit 1: `before.intro_complete:true`, + `after_reload.intro_complete:false`, restored claim acknowledged/cached. + Return the real completion bit on every restored/uncertain claim path + and prevent an older incomplete same-name response from regressing + this monotone durable bit. A new unrelated account must still start + incomplete. Register the actual coordinator/Vault reload path plus + delayed same-account claim-vs-complete ordering; the director repeats + this exact emulator probe independently. + +The director's probes are outside the isolated copy and are operational +measurements, not product fixtures. Reproduce the specified states in +registered tests; the director will rerun the exact independent probes. + +## Also inspect + +The current production `_map_hall_rows()` drops the server row's `display` +entirely, and `GateHallPanel` renders only rank/score, hero and MB ID. A +verified nickname must actually reach and appear in the owned Hall, not +only exist in stored payloads. Preserve the stable MB ID as a separate +field/line, add readable chosen-name presentation with the existing skin, +and register the production mapper-to-real-panel test. Legacy unnamed rows +keep their honest existing fallback. Cover Korean/Japanese maximum-length +handles and the actual row layout at 808×360 and iPad 4:3. + +The new host method insertion currently leaves `_push_rank_to_hud()` below +`return filled` in `backfill_hall_name()`, rather than after +`_submit_best()`'s completion. Restore the actual score/HUD flow and pin it +with a named production result/standing regression. Do not weaken existing +rank propagation tests or treat a source-only assertion as evidence. + +## Acceptance + +- A fresh canonical owner receives a true missing-row response and can + atomically claim/reload a Korean or Japanese name through actual REST. +- Exactly one concurrent owner wins a normalized name. The loser sees + taken; unrelated permission/configuration errors stay truthful. +- Partial name/adventurer release is denied with either canonical half + still alive; full owned deletion succeeds for named and unnamed users. +- The production token-barrier probe returns cancelled with no call to B. + All four wrappers and their registered callback/refresh tests agree. +- A completed name restore retains the true tutorial bit after local + reload, and a late incomplete same-name response cannot regress it. +- The actual named result uses the verified handle and same saved hero, + score and cycles; best completion reaches HUD standing again. +- Earlier defeat, paid journal, canonical/legacy adoption and existing + name/Hall/deletion tests stay green. Update the exact shared callable + contract in `notes/plans/gate-account-protocol.md` for the next brief. + +## Scope and checks + +During round 5 the director observed a concrete parser failure in the new +host tests: `test_production_host.gd:1209` split +`await (host as Node)` and `.claim_adventurer_name(...)` onto separate +lines without a continuation. The analogous load/intro calls had the +same shape. The host command stayed alive until the wrapper timeout because +the scene script never loaded. Round 5 has since repaired that syntax and +restored the registered calls. Preserve those repairs and run the +registered host scene with its real exit status. A pipeline that only +greps `FAIL|cases` omits `SCRIPT ERROR: Parse Error` and can report shell +success after timeout; it is not evidence of a passing suite. Preserve +complete output and the wrapper exit code, and report any sandbox-only +platform errors separately. Small Godot checks need a 45-second bound, +not another five-minute timeout. Do not repeat a sandbox-blocked check; +the director runs it in the normal environment. + +The independent reverse partial-deletion probe already passes: deleting +only profile/reservation while the name pair stays is denied, and the +public name remains. Proof `gate-name-orphan-preliminary.log`, exit 0. +Preserve that existing protection while fixing the opposite name-only +release described above. + +Only these confirmed name integration boundaries and their meaningful +tests/documentation. No room, attendance, native SDK changes, release +counters, original hero edits, live network, deployment, git or marketing +recapture. Keep all six director-provided raw art source files untouched. +Run affected suites/static/locale/care/docs checks; do not manually sweep +all old suites if the sandbox editor-settings error blocks the full runner. +The director supplies the real rules emulator and cumulative verification. diff --git a/notes/workflow/muse/briefs/209-preserve-earned-attendance-through-pruning-and-expiry.md b/notes/workflow/muse/briefs/209-preserve-earned-attendance-through-pruning-and-expiry.md new file mode 100644 index 0000000..872259e --- /dev/null +++ b/notes/workflow/muse/briefs/209-preserve-earned-attendance-through-pruning-and-expiry.md @@ -0,0 +1,113 @@ +# Brief 209: Close earned attendance and deletion boundaries + +## The ask + +Keep the requested rolling twelve-hour two-coin attendance reward reliable. +Correct only the three independently reproduced boundaries below, +then supply the verified protocol for the lodge and reminder briefs. + +## Confirmed evidence + +The director executed the actual Vault in an isolated HOME, using a +durable generated install ID and a synthetic purchased-coin receipt. Ten +different canonical public IDs each received one acknowledged attendance +grant one minute apart, all within one twelve-hour cooldown, then the +Vault reloaded. Replaying the first public ID's identical +old receipt increased balance from 27 to 29. Both its receipt and owner +watermark had been evicted at eight entries. The purchased receipt stayed. +Proof: `builds/verify/gate-defeat-probe/attendance-pruned-owner-round7.log`, +clean exit 1 after 2.4 seconds. Same-owner long-history tests miss this +cross-owner pruning boundary. + +The director also executed the real coordinator, attendance service, +transport and Vault with a scripted sender and real filesystem failure. +The server commit acknowledged a reward at 2026-10-06T21:00:00Z while a +directory at the actual wallet temp path prevented its local save. The +coordinator returned `local-grant-failed` with `server_claimed: true` and +the old receipt. After removing the fault and reloading the Vault, the +next visit read that same owned server row at 2026-10-07T10:00:00Z. The +service overwrote it with the next eligible claim and granted only the +new two coins. Balance was 2 then 4; the previously acknowledged two +coins never recovered. Correct total is 6: two initial coins plus two +earned visits. This is no missed-period stockpile. +Proof: `builds/verify/gate-defeat-probe/attendance-expired-recovery-round7.log`, +clean exit 1 after 1.4 seconds. No human account, token or wallet was used. + +The director ran the real `CloudAccount.delete_account_data()` REST route +after the actual attendance service's first conditional server claim. All +attendance reads/claim/hold/race checks passed, but full account-data +deletion returned `permission-denied` with four remaining steps: +`hall`, `checkpoint`, `reservation`, `profile`. The source commit builder +still omits the attendance row, while the new rules correctly require it +gone with the canonical halves. The registered rules suite's updated SDK +helper covers a different, correct payload; it does not verify this actual +client route. Proof: `builds/verify/gate-attendance-rest-delete-round7.log`, +clean exit 1 after 2.4 seconds. Only emulator identities/rows were used; +Firebase Auth deletion was never called. + +## Do + +- Preserve a bounded durable replay guard when attendance receipt keys + and owner entries prune. An evicted owner must never regrant an already + applied receipt. Preserve every purchased receipt and balance. Handle + a genuine new account/period and timestamp ties honestly; a bound + failure must preserve the playable wallet rather than silently grant + or lose replay protection. Register the actual multi-owner reload case. +- Recover a previously acknowledged, locally unapplied owned reward + before advancing to the next eligible period. Preserve the exact + durable earned amount across restart, failed wallet writes and an + uncertain acknowledgement, including when the visit is after twelve + hours. Do not erase the last recoverable earned receipt merely because + a new period or another receiving install can now claim. Choose a small + bounded protocol rather than a full cloud-wallet synchronization or + indefinite list of twelve-hour receipt documents. Purchased balances + remain local and outside this protocol. Explain the exact recovery and + bounds in the durable API note. +- Include attendance in the actual account deletion plan, commit body and + remaining/completed-step contract for both named and unnamed accounts. + Preserve idempotent deletion when there is no attendance row, and never + delete native Firebase Auth until the single remote data commit really + succeeded. Register the actual client builder/transport route and run it + through the emulator; a hand-authored SDK helper is not that evidence. +- Register the real service/coordinator/Vault boundaries above, including + retry after the first acknowledged save failure, restart, next-period + receipt, another receiving install, double replay, purchased-receipt + preservation, attended-account deletion and any new remote + conditional/rule transitions. +- Keep the working claim APIs/deadlines usable by briefs 202 and 205. + Update `notes/plans/gate-account-protocol.md` with their exact verified + methods and fields. Do not rely on an overwritten implementer report. + +## Preserve + +The director independently passed the existing cumulative real rules +suite: 84 tests in 14 suites, zero failures; the attendance service suite: +62 cases, exit 0; actual client REST owner first read, first claim, +same-install reload, cross-install hold, stranger denial, standalone +attendance deletion denial and competing claims: exit 0. Those passes do +not waive the three confirmed failures. Preserve all preceding name/deletion, +defeat/paid-revival, Hall and original-hero contracts. + +## Do not + +No lodge or native reminder implementation in this correction, no new +dependencies, purchase migration, title/provider/button redesign, +release versions, stores, network, deployment or git operations. Do not +broaden into speculative old-account or anti-abuse work. + +## Acceptance and director judgment + +All three exact operational probes pass with true clean exits. Registered +affected tests cover the previously missed boundaries. The director reads +and executes the actual remote conditional protocol with the emulator, +independently verifies coins and receipts after reload, then continues +the playable lodge and native reminders. + +Use 45-second bounds for small Godot checks and preserve their true exit +codes and complete logs. A parser/settings error is not a pass. Do not +repeat the sandbox-blocked full runner or manually sweep all old suites; +the director runs the cumulative full suite after the final integration. +Round 7 used commands such as a 280-second host run piped through +`grep` and `head`, whose tool exit was 0 while its output said the suite +failed. Stop using this pattern. Redirect the full log to a file, retain +the original runner exit, and read diagnostics in a separate operation. diff --git a/notes/workflow/muse/briefs/210-scale-lumi-in-the-rendered-lodge.md b/notes/workflow/muse/briefs/210-scale-lumi-in-the-rendered-lodge.md new file mode 100644 index 0000000..ceaab99 --- /dev/null +++ b/notes/workflow/muse/briefs/210-scale-lumi-in-the-rendered-lodge.md @@ -0,0 +1,91 @@ +# Brief 210: Scale Lumi in the rendered lodge + +## The ask +The user asked for a real first-login tutorial room with a grounded guide +sprite, preserving character consistency and the existing playable heroes. +Finish that scene's actual rendering before native reminder work. + +## Confirmed evidence +The director instantiated the actual `gate_lodge.tscn` with its loaded +controller, an isolated vault and an injected unnamed host at 808×360. +The post-seat-fix run has no script error. The speech card now sits at +`(124,231)` with size `(560,117)` and its Korean text is visible; do not +reintroduce that repaired wrapped-label seating defect. + +Lumi's actual rendered frame height remains **871.151 pixels** in that +360-pixel-tall viewport. Her node scale is `(1.002475,1.002475)` and +the sprite scale is `(1,1)`: the 869-pixel packed source is being drawn +without the source-to-world conversion. The screenshot shows an enormous +boot dropping from outside the top of the room. The selected playable +hero is correctly small and grounded; do not enlarge or repack the old +hero to conceal the guide bug. `WORLD_HEIGHT` currently never reaches +the sprite scale. + +The windowed contact harness also creates raw unscaled `LumiGuide` +instances. Its headless audit checks only the node origin, so it passes +while almost all of the actual guide is outside the screen. + +One actual scene recovery boundary also fails: an initially empty local +name cache enters the greeting, then `load_adventurer_name` returns a +verified cloud handle with `intro_complete:true` and acknowledges caching +it. The real controller calls `_begin_practice` anyway. The director's +isolated `cold-completed` scene probe exits 1, with host `needs_lodge_lesson` +false but actual scene state 1/GREET and the named-practice speech, instead +of state 7/DEPART. Existing boot-completed tests start with the name already +in memory and miss this genuine fresh-install/cache-eviction recovery. + +## Do +- Apply one common packed-source-to-world scale for every Lumi facing, + then the scene's layout transform. Pick a grounded, readable guide size + relative to the existing playable hero's actual opaque rendered body, + rather than a source-pixel size or an assumed 64-pixel hero. Keep adult + proportions but avoid an enormous guide beside the arrival. Preserve + the four source dimensions, original proportions and common foot line. +- Ensure the source-local idle motion converts to a restrained world + motion and does not shift the planted ground anchor. Keep the floor + shadow coherent with the resulting body size and adjust guide personal + space if needed. Preserve the already fixed speech seating. +- Register a real instantiated-guide transform/body-bounds assertion, + not only `foot_local()==ZERO` or a layout-origin check. Audit the actual + sprite rectangle and its opaque bounds under the real room camera at + 808×360, wider landscape, 808×606 and iPad mini's ~1.52 landscape ratio. +- Make the windowed QA harness render correct initial unnamed greeting + as well as name/move/gate/departure states, with the same actual guide + scale as the production room. The current room mode supplies a known + name for `state=greet`, unlike its validate mode. Fix that fixture + mismatch so the captured greeting proves the requested state. +- For the compact motion harness, print actual observed movement, dash, + neutral stop and gate/departure state, and exit nonzero if the intended + phases were not reached. Do not call a 120-frame recording complete + merely because PNGs were written. A bounded selection of frames from + a longer real-time lesson may stay within the 120-output-frame budget. +- After loading the verified account row from a cold cache, respect its + acknowledged intro-complete bit and the host's actual cached readiness. + A completed account proceeds to the ordinary Resume/confirmed fresh + departure choice, without repeating name, movement or dash registration. + Preserve incomplete claimed-name recovery and uncertain/offline cases. + Register this actual scene boundary, including an existing living save; + do not weaken the verified host plan guard or replace a failed cache + write with an unverified completion. Completion must remain account-bound. + +## Acceptance +The director's actual windowed room no longer shows the giant boot. Both +guide and selected hero remain visibly grounded and proportionate, with +the guide completely inside the room at rest. All four guide facings have +the same world scale and stable foot anchor. Existing 100 hero rasters +remain byte-identical. Five-language dialogue/name controls remain inside +the safe area at each real viewport; the director will render and inspect +them. Scene/entry/host and asset checks stay green. + +## Do not +Do not write native reminders in this correction, touch credentials or +the network, change release counters, alter the original title/login +buttons, repack existing heroes, change locked settings, deploy, perform +git operations or recapture marketing screenshots. + +## Verification +Run only the affected registered lodge/entry/host/asset checks with true +exit statuses and complete diagnostics. Small Godot checks have a +45-second bound. Do not repeat a sandbox-blocked old full suite. Update +the durable protocol/report with what the checks actually measure; the +director supplies real windowed rendering. diff --git a/notes/workflow/muse/briefs/211-keep-lumis-idle-feet-planted.md b/notes/workflow/muse/briefs/211-keep-lumis-idle-feet-planted.md new file mode 100644 index 0000000..f95a079 --- /dev/null +++ b/notes/workflow/muse/briefs/211-keep-lumis-idle-feet-planted.md @@ -0,0 +1,50 @@ +# Brief 211: Keep Lumi's idle feet planted + +## The ask +The user's repeated character requirement is consistency when moving and +stopping. Finish the new lodge guide without a floating idle pose. + +## Confirmed evidence +Round 11 fixes the source-to-world size: the actual loaded room now draws +the guide frame at **42.104 world pixels** in 808×360, with common node +scale `(0.048451,0.048451)`. Preserve that correction and the repaired +cloud-completed recovery. + +The director's actual instantiated-guide probe then advances its real +idle `_process` to quarter- and three-quarter-period phases and measures +the drawn sprite's bottom through its actual transform. The body foot +line moves **4.0 world pixels peak to peak**, while the shadow/root remain +planted. This is not a source-size hypothesis or a node-origin check: +`IDLE_BOB_WORLD_PX=2` translates the entire visible body, including boots. +The source-local motion was converted to a larger floating world bob. +`foot_local()==ZERO` and an unchanged guide node position cannot prove +that the painted feet stay on the floor. + +## Do +- Keep the painted boots planted during idle. Preserve the common facing + scale, foot line and all existing rasters. Use a restrained lantern, + tint or shadow pulse if that is simpler than moving only the upper + body; a simple idle is acceptable. Do not resize or distort the face, + wiggle the feet, translate the whole body, or generate another art set. +- Register an actual rendered-body foot stability measurement across + idle phases. It must fail on the previous 4-pixel motion, rather than + asserting only the guide root is stationary. Inspect the actual sprite + transform/opaque bounds for each facing under the production scale. +- Keep the current correct guide height, world shadow size, collision, + speech seating, account guards and cold-cache completion recovery. + +## Acceptance +The actual guide's painted foot line remains fixed (at most 0.5 internal +pixel peak-to-peak, including rounding) across idle phases and facings. +No face/height resize occurs. The lodge suite and asset freshness stay +green. The director will inspect the actual windowed contact/room/motion. + +## Do not +No native reminders, network, credentials, deployment, git operations, +version/store changes, marketing capture, old hero repack or broad cleanup. + +## Verification +Only the affected lodge regression and small source checks are needed; +do not rerun every asset generator or the unrelated full suite for this +bounded correction. Use a 45-second Godot bound, true exit status and full +diagnostics. Update the exact idle behavior in the report/protocol. diff --git a/notes/workflow/muse/briefs/212-build-and-arm-the-real-reminder-bridges.md b/notes/workflow/muse/briefs/212-build-and-arm-the-real-reminder-bridges.md new file mode 100644 index 0000000..062fd0e --- /dev/null +++ b/notes/workflow/muse/briefs/212-build-and-arm-the-real-reminder-bridges.md @@ -0,0 +1,75 @@ +# Brief 212: Build and arm the real reminder bridges + +## The ask +The user wants real Android/iOS attendance reminders every twelve hours +and a working opt-out. Close director-confirmed runtime/build boundaries +from brief205; preserve the existing reward, name, lodge and auth contracts. + +## Confirmed evidence +- The director compiled the actual Android bridge with the cached Gradle + toolchain. `:MoonlitIdentity:compileDebugKotlin` fails with unresolved + `R.drawable` at `MoonlitReminderAlarm.kt:165`, followed by the chained + `setContentTitle` diagnostic. Full raw log is the director artifact + `gate-native-reminder-android-round13.log`. Verify the owned monochrome + beacon resource actually reaches the generated Gradle project/AAR; + source presence alone and regex contracts did not prove this build. +- The director compiled the actual iOS bridge with the cached SCons, + Godot C++ and existing Firebase Pods. It fails with **18 compiler errors**: + reminder Objective-C methods report `missing context for method + declaration`, and the bindings report no visible worker interface for + `reminderStatus:`, `reminderRequestPermission:`, `reminderSchedule:args:`, + `reminderCancel:`, `reminderOpenSettings:`, `reminderPending:` and + `reminderDebugSchedule:args:`. They are currently outside the worker's + Objective-C implementation/interface context. Full raw log is + `gate-native-reminder-ios-round13.log`. Preserve auth delegate behavior + while placing/declaring the methods correctly. +- Actual `AttendanceReminders` + real Settings, injected registered fake + host/bridge, isolated user files: a server-confirmed eligibility thirty + seconds away, OS permission granted, explicit user enable. Clean script + run exits1, **native schedule calls0**, wantedtrue/permissiongranted. + The `remaining < MIN_FIRST_DELAY_SECONDS(60)` shortcut permanently + leaves this newly enabled install without a first or subsequent alarm + if the player leaves. Log `gate-reminder-near-deadline-mid-round13.log`. + The player may enable reminders halfway through a cooldown or right + before eligibility; being foregrounded now is not permission to omit + all future reminders. Native foreground suppression is the delivery guard. + +## Do +- Fix actual Android resource staging and native compilation. Keep the + existing owned monochrome icon, immutable intents and receivers; no + generic icon fallback, new dependency or exact-alarm privilege. +- Fix Objective-C worker context/interface declarations and the actual + iOS compile. Keep notification callback isolation, twelve-hour cadence, + foreground suppression, app-owned privacy export and all SDK bundles. +- Schedule positive short remaining windows correctly, with a distinct + first delay and twelve-hour repeats. Preserve skip/no-award behavior for + an already eligible foreground visit where the real attendance claim + owns the next deadline; do not mint coins from this correction. Register + the actual controller case for 30 seconds, and native first-vs-repeat + diagnostics, rather than extending a short first delay into a short + repeating period. +- Strengthen meaningful build/export regressions so they exercise the + generated Android resource tree and Objective-C worker placement; + regexes asserting a method's name somewhere in a file are insufficient. + Keep the director-run native QA harness usable and observable. + +## Acceptance +The director's real Android and iOS bridge builds compile. The actual +thirty-second controller probe sends one schedule with the confirmed +future deadline and native repeat43200seconds. Permission denial/off, +revocation, account retirement and stale callbacks remain safe. SDK +privacy bundles and old hero rasters remain unchanged. Director then +tests actual device notification delivery/tap/cancel and final UI. + +## Do not +No network/credential access, deployments, store/version changes, native +auth SDK or IAP changes, git actions, marketing capture, broad asset +regeneration or unrelated suite rewrites. The director supplies native +toolchains and builds; report any unavailable compile honestly. + +## Verification +Run affected registered controller/bridge/export checks with true exit +statuses and full diagnostics; small Godot checks have a 45-second bound. +No repeated sandbox-blocked full sweeps. Update the protocol/report with +the exact repaired boundaries; do not claim native build success from +static contracts. diff --git a/notes/workflow/muse/briefs/213-anchor-ios-reminders-to-attendance-eligibility.md b/notes/workflow/muse/briefs/213-anchor-ios-reminders-to-attendance-eligibility.md new file mode 100644 index 0000000..987f84c --- /dev/null +++ b/notes/workflow/muse/briefs/213-anchor-ios-reminders-to-attendance-eligibility.md @@ -0,0 +1,49 @@ +# Brief 213: Anchor iOS reminder delivery to attendance eligibility + +## The ask and confirmed defect +The user wants an attendance reminder every twelve hours while away, with +one switch to disable it. The current iOS schedule creates a one-shot at +`eligible - now` and, simultaneously, a repeating request at 43200 seconds +from NOW. Both use the same notification content. Actual source inspection +of `reminderSchedule:args:` confirms this; it is not just a report risk. + +At a fresh reward, firstDelay is approximately 43200, so first and repeat +fire together. Enabling six hours into the cooldown produces deliveries at ++6h, +12h, +24h rather than +6h, +18h, +30h. Locale rescheduling shifts that +second schedule again. The current regex tests affirm this incorrect shape. + +## Do +Use a bounded horizon of owned, non-repeating iOS requests at the server +eligibility deadline plus N * 43200 seconds. Reserve room under the OS +pending-request budget for other app requests and the debug request; a +reasonable explicit cap is 48 deliveries (24 days). Refresh that horizon +on a genuine foreground visit without changing its original deadline; +reset after a successful new attendance claim. Existing identical live +schedules can skip needless work but do not permanently prevent horizon +refill. Never schedule already elapsed deadlines as immediate bursts. + +Keep request identifiers scoped to this feature. Cancellation, sign-out, +account switch and Settings off must remove the entire owned horizon plus +legacy first/repeat/debug requests and delivered copies, without deleting +other app notifications. Preserve permission/foreground safeguards, no +coins from notifications, no APNs/push service and app privacy disclosure. + +Expose actual pending trigger times/owned count/horizon end through the +existing diagnostics. Truthfully document the bounded away period rather +than promising indefinite background scheduling. Correct the source, +contracts and five-language care text as needed, including generated dist. + +## Acceptance +Native pending deliveries are unique and twelve hours apart, anchored to +eligibility both for a fresh reward and for a halfway enable. A short first +delay never becomes a short repeat. Refresh/locale change cannot duplicate +or shift deliveries. Off/account retirement cancel all owned requests, +including legacy ids. The director compiles the actual native bridge and +inspects device pending diagnostics. Meaningful regressions exercise the +planned timestamps and identifier/cancel rules, not just matching strings. + +## Constraints +No network, credential access, store/version changes, SDK bundle changes, +IAP changes, old hero rasters, git operations or broad suite rewrites. +Run only affected checks with bounded Godot timeouts and full diagnostics; +report native build limits honestly. Preserve the fixes from brief212. diff --git a/notes/workflow/muse/briefs/214-show-the-player-above-lodge-dialogue.md b/notes/workflow/muse/briefs/214-show-the-player-above-lodge-dialogue.md new file mode 100644 index 0000000..c561ed9 --- /dev/null +++ b/notes/workflow/muse/briefs/214-show-the-player-above-lodge-dialogue.md @@ -0,0 +1,73 @@ +# Brief 214: Keep the player visible during lodge dialogue + +## Confirmed evidence and task +The director rendered the actual lodge at 880x360 (the attached Android's +ratio) in Korean greeting state. Opaque bounds from the current real +Player/Sprite frame and actual canvas transform are x447.4 y209.1428, +20.145x27.795. The actual bottom dialogue card starts y231, height117: +5.938px of the player's legs/body are covered. Source WIDE_HERO is (.52,.66). +The full raw director log is gate-hero-occlusion-fixed-probe-round13.log. +The player should remain clearly visible while Lumi introduces the room. + +Adjust only the room's initial wide spawn/placement to keep the opaque +player silhouette above all localized greeting/name-recovery dialogue, +with a small visual gap. Keep the spawn on the painted traversable floor, +away from Lumi's desk/personal space. Do not scale, regenerate or change +any old hero raster; do not move every dialogue to cover another actor. +Tablet compositions and walk/dash/gate lesson must still be usable. + +## Acceptance +Actual player opaque rect and visible greeting dialogue do not intersect +for all five languages at808x360 and880x360; the complete player and guide +are visible. Existing tablet views, actual movement/dash completion and +living-save departure stay correct. Add a meaningful bounds regression; +do not weaken existing safe-area or sprite-consistency tests. + +## Constraints +No network, stores, versions, auth/reward changes, SDK bundles, original +hero image changes, git actions or marketing capture. Use the existing +lodge art and Lumi. Bounded affected checks only, full diagnostics. + +## Also close the measured keyboard coordinate boundary +Godot's DisplayServer keyboard height is in DEVICE pixels (official +DisplayServer docs and pinned Android get_vk_height delegation); lodge +controls and get_visible_rect() are in stretched VIEWPORT coordinates. +The current `_apply_keyboard_shift` passes the raw pixel height directly +into `keyboard_shift_for` without the inverse screen transform. Existing +Screen.viewport_safe_rect already uses that inverse for display-safe-area. + +Director actual GateLodge static method, autoload-equipped isolated scene: +physical keyboard432px at3x, viewport360px, confirm bottom274 returns +shift354px, field top -216 and confirm top -124. Normalized144 viewport +pixels should produce66px. Full raw log gate-keyboard-units-scene-round14.log +clean exit1; an earlier bare-script invocation lacked Vault and is not +accepted evidence. + +Convert the keyboard's occupied screen rectangle through the actual +viewport screen transform (handling letterbox/translation rather than +assuming a fixed3x). Bound/adapt the form when little space remains so the +name field and confirm stay visible and usable above the keyboard; avoid +negative top displacement. Keep measured-from-original-seat behavior and +restore exactly when hidden. Diagnostics must clearly distinguish device +height from viewport occlusion; preserve no-typed-name/no-provider-data. + +Register scale1/3/noninteger and tall/short keyboard regressions against +the actual production conversion/layout. Director will inspect actual +Android/iPad keyboard screenshots/bounds; desktop height0 never claims +native keyboard verification. No old hero raster changes. + +## Keep the director visual harness clean +Actual `lodge_review mode=contact` currently calls camera.make_current() +BEFORE adding Camera2D to the tree, generating an engine ERROR even though +it captures and exits0. Seat/enable the camera first, then make it current; +keep the four-facing board and source consistency audit usable. Actual +lesson clip completed moved266.9/dashedtrue/stoppedtrue/departtrue in126 +simframes/26PNGs, so preserve that working actual-input path. +Director viewed the actual contact PNG: first three actors are expected +~42px, but the last/right actor is huge and partly outside the image. +Do not conclude its source art is inconsistent: all frame-height contracts +already match. The harness captures immediately after adding the final +actor and Camera2D under physics interpolation. Ensure all initial actor +scales/poses and camera interpolation settle/reset before capture; check +what was actually rendered, not only global transforms before draw. +Audit four visible silhouettes in the captured board. No raster resize. diff --git a/notes/workflow/muse/briefs/215-match-the-pinned-android-plugin-api.md b/notes/workflow/muse/briefs/215-match-the-pinned-android-plugin-api.md new file mode 100644 index 0000000..61346af --- /dev/null +++ b/notes/workflow/muse/briefs/215-match-the-pinned-android-plugin-api.md @@ -0,0 +1,58 @@ +# Brief 215: Match the actual Android Godot plugin API + +## Confirmed actual native build failures +After brief212, generated resources now reach Gradle and the drawable +error is gone. The director's REAL debug build exits1 on newly exposed +Kotlin API typing defects, full log gate-native-reminder-android-round14.log: + +- `onMainRequestPermissionsResult` currently returns Boolean, but the + overridden `GodotPlugin` member returns Unit. The actual pinned Godot + core AAR extracted jar was inspected with javap: public void + onMainRequestPermissionsResult(int, String[], int[]). This is distinct + from onMainBackPressed, which returns boolean. Current regex regression + incorrectly demands Boolean and must be corrected, not preserved. +- Five `val context: Context = activity ?: godot` assignments fail: + Godot is not a Context, common inferred type Any. Occurrences currently + at1260/1288/1342/1387/1435/1459. Obtain a real Activity/Context through + the existing actual plugin/Godot API, or return honest unavailable when + lifecycle has no context. Never crash, dereference null, or fabricate a + type cast; avoid leaking Activity references across lifecycle. + +## Do +Fix those compiler boundaries against the pinned engine artifact. Preserve +permission-owner isolation, cancellation/stale completion guards and all +existing Firebase auth/IAP behavior. Add meaningful regression proof of +the required callback return and context source. The director will rebuild +the actual AAR and inspect the resource entries, then perform native QA. +No changes to cadence/server rewards/room layout in this task. + +## Acceptance +Real Gradle Kotlin compilation succeeds using existing pinned Godot AAR +and dependencies. Reminder permission callback settles once when matching +and ignores unrelated callbacks without violating Unit signature. Status, +schedule, cancel, settings, diagnostics work or honestly fail without a +context. Existing identity callbacks and scoped mutation behavior hold. + +## Constraints and verification +No network, credentials, new dependencies, SDK bump, store/version changes, +IAP changes, git or image changes. Bounded affected contracts/controller +checks, true exit status/full diagnostics. Do not claim native compilation +from static tests; director supplies the actual toolchain/build. + +## Actual iOS build exposes typed API failures too +Director round14 SCons build now clears the previous Objective-C context +errors, but exits1 with4errors and2availabilitywarnings, raw log +`gate-native-reminder-ios-round14.log`: +- NSUserDefaults has no longLongForKey: or setLongLong:forKey: selectors. + Use a supported NSNumber object or appropriate supported typed accessor, + preserving full 64-bit UTC milliseconds without truncation. Occurs in + schedule persistence and pending diagnostics. +- nextTriggerDate is not a property on base UNNotificationTrigger. Query + supported concrete interval/calendar trigger types safely and preserve + correct future timestamp diagnostics for the eligibility horizon. +- UNAuthorizationStatusEphemeral is iOS14+, deployment target13. Keep the + app's existing minimum; availability-guard supported handling. + +Close these real typed API boundaries too, preserving brief213's corrected +eligibility-anchored horizon. Director compiles actual iOS again. Do not +weaken the API diagnostics or privacy checks to pass regex-only tests. diff --git a/notes/workflow/muse/briefs/216-refresh-real-reminder-permission-and-horizon.md b/notes/workflow/muse/briefs/216-refresh-real-reminder-permission-and-horizon.md new file mode 100644 index 0000000..b540ab9 --- /dev/null +++ b/notes/workflow/muse/briefs/216-refresh-real-reminder-permission-and-horizon.md @@ -0,0 +1,100 @@ +# Brief 216: Converge reminder state after OS changes and return + +## Confirmed evidence +Director actual AttendanceReminders + real Settings (autoload scene, +isolated user files), registered fake native bridge. Explicit enable under +granted permission schedules once. Native permission then changes to +denied and controller.refresh() runs on the same account/deadline/locale. +It exits1: Settings.reminder_os_state still granted. Raw log +`gate-reminder-revocation-round16.log`. The identical-input return happens +before live status checking. Native delivery protects the device, but the +controller and existing open Settings row can continue to claim on. + +SettingsPanel.open() queries refresh_permission(), but on iOS reminderStatus +returns its cached permission BEFORE requesting an asynchronous OS refresh; +no updated status outcome is delivered afterwards. Returning from system +notification settings must converge in the current open panel, without +requiring close/reopen twice. Explicit intent remains distinct from OS +permission; unknown/pending/failed permission must not display as enabled. + +Round15's iOS horizon refill is also not a refill: loop always enumerates +absolute slot0..47 from eligibility. For now >eligible+47*43200 it adds0 +requests and returns ok. The identical deadline controller guard also +prevents reaching that native path. Its independent JS test mirrors the +loop and tests only3elapsed days, so cannot catch exhausted/refill failure. + +## Do +Re-check effective OS permission before unchanged-schedule skipping, and +on genuine foreground return / opening Settings. Handle iOS async status +refresh via an explicit bounded bridge result or another genuinely observed +convergence mechanism, with account/generation guards. No automatic OS +permission prompt: only user's explicit enable/offer/Settings actions. +Keep denial/off cancellation safe and show truthful unknown/permission +needed when appropriate. Avoid settings-changed recursion or polling loops. + +When a genuine foreground refresh refills a bounded iOS horizon, compute +the first future N on the SAME eligibility anchor and plan up to48future +slots from there. Stable bounded identifiers can use ordinal positions +0..47, while timestamps remain eligibility+N*43200. Persist enough metadata +to determine actual held horizon end; skip needless reschedule while it is +still adequately held. No elapsed burst and no forever-first48 cutoff. +A fresh successful attendance claim resets its anchor as before. Ensure +controller doesn't prevent this native refresh forever. It must not move +the deadline or create repeat calls on every frame/settings redraw. + +## Acceptance +- Director same-deadline revocation probe updates to denied and never + leaves effective-on UI; already-scheduled no-duplication still holds. +- Current open Settings converges after returning from OS deny/grant + changes on Android/iOS, without a second reopen and without prompting. +- Actual planned timestamps at day25 retain full48future unique12h slots + on the original anchor; diagnostic horizon_end matches the held times. + A mid-window refresh does not duplicate/shift existing schedules. +- Off, account switch/deletion and stale callback cancellation still pass; + notification/permission transitions cannot credit a coin. +- Meaningful production-side planner/controller tests, not a duplicated + JS implementation that can diverge independently. Keep the API build + repairs from brief215 and privacy/SDK assets unchanged. + +## Constraints +No network, credentials, stores/versions, new SDK/backend/FGservice, IAP, +hero images, git operations or broad suite rewrite. Five locale strings +only if a truthful status requires them. Affected bounded checks only, +fullraw logs and real exit statuses; director compiles/delivers QA. + +## Related confirmed reminder node-budget regression +Director cumulative `pnpm test:game` stops at late-game performance: +KnightLv40 node_peak1202 exceeds unchanged1200 budget; failed1/73, true +exit1, rawgate-cumulative-game-round15.log. New reminder controller is1 +persistent node and SettingsPanel scene adds2ReminderLabel/Reminder nodes +although its panel is closed during combat. HUD offer button is already +lazy, so do not misdiagnose it. Baseline director comparison is running. +Keep the budget1200 unchanged; make the reminder Settings controls lazy +when the panel is opened (or another evidenced actual lifetime reduction), +fully rendered/interactive on open, rather than adding dormant combat +nodes. Do not hide work only in headless tests, remove real gameplay or +relax test caps. Registered Settings interaction and five-language fit +must exercise opened actual controls; native offer/controller remain safe. +Director unchanged-main comparison now passed73/73, knightLv40peak1198, +clean exit0/29.6s (gate-late-game-main-baseline.log). Newcopy1202 is a real +regression, not a reason to raise1200. Preserve projectile density/output. + +If updating relevant protocol/docs, also correct the cloud-rules README's +obsolete Expected62/13 paragraph to the actual88/14 suite reported at top; +no lesson or broader docs rewrite. + +Native diagnostics also currently label PendingIntent.FLAG_NO_CREATE token +existence as `alarm_scheduled` / "OS actually holds the repeating alarm". +That is not an AlarmManager query, and cancel() creates/retains those +PendingIntent tokens, so it can remain true after actual cancellation. +During this status correction, make the diagnostic honest (e.g. distinguish +owned persisted schedule intent from token existence, clear/cancel tokens +when retiring), do not claim an OS-held alarm from token existence alone. +The actual Android director will exercise schedule/cancel diagnostics. +Android effective status must include the app-global notification switch +and attendance-channel importance, rather than permission alone. Current +notificationsAllowed returns true unconditionally below33, and status +returns granted even when the delivery path channelOpen would refuse. +Use the actual NotificationManager effective switches for honest status +(and preserve the runtime permission request when permission is missing). +No new permission prompt on foreground/settings refresh. diff --git a/notes/workflow/muse/briefs/217-keep-the-name-action-above-a-tall-keyboard.md b/notes/workflow/muse/briefs/217-keep-the-name-action-above-a-tall-keyboard.md new file mode 100644 index 0000000..f2aaf14 --- /dev/null +++ b/notes/workflow/muse/briefs/217-keep-the-name-action-above-a-tall-keyboard.md @@ -0,0 +1,57 @@ +# Brief 217: Keep the actual name action above a tall keyboard + +## Confirmed remaining defect in brief214 +The device-to-viewport conversion is repaired. But clamping the form's +lift to its safe top cannot fit its existing228px panel into a shorter +visible area. Current real Japanese name controls at808x360: panel +(124,58,560,228), field(140,138,528,44), confirm(140,230,528,44). +With432devicepx at3x (144occlusion), keyboard top216, current capped +lift58 puts confirm bottom216 with no8px margin. With540devicepx at3x, +keyboard top180, the same cap leaves36px of confirm below the keyboard. +Moving a whole228px panel to the top does not shrink it or make its +lower action visible. Current math tests only pin the clamp, not actual +form/action clearance. Director actual-autoload window probe reports +this; initially missing fake.display empty was a fixture bug, corrected. + +## Do +Adapt the actual visible name UI while the native keyboard is present: +compact/reflow optional portrait/header/explanatory copy or scroll only +nonessential content, keeping the actual focused field, error feedback +and confirm action visible and tappable above the keyboard. Preserve the +original skin, composition/IME, typed value, retry state, localized copy +and full layout restoration when the keyboard disappears. Do not merely +allow a negative panel top or assert clamp math. Support both standard +phone landscape and the tall iPad, native scale1/3/noninteger/letterbox. +Bound unreasonable all-screen keyboard cases honestly (hide keyboard or +an explicit usable escape), without promising impossible zero-height UI. +Actual keyboard conversion must keep device and viewport units separate. + +## Acceptance +For realistic occlusions144/180 at360-high and tablet occlusions, actual +rendered field/confirm/error feedback bounds stay inside the available +safe rect with the requested margin, at all five locales, including +taken/offline/invalid feedback. The same production layout function/hook +must run in tests and live `_apply_keyboard_shift`; do not test only a +separately mirrored planner. Restore original panel/controls after hide, +no per-frame oscillation. Add registered regression based on actual +controls, plus an isolated render harness for the director to inspect +keyboard-adapted UI without claiming that is native keyboard delivery. +Preserve corrected hero placement and the settled contact/lesson clip. + +The actual lodge Camera2D defaults to idle while project physics +interpolation forces physics, producing a warning when the packed scene +is instantiated. Set its intended callback before tree entry (scene +property) if this is the only cause; do not alter project interpolation. + +## Boundaries +No native reminders/auth/wallet/rules/network, oldhero raster, SDK/privacy, +versions/store screenshots, git actions or tests-budget weakening. Affected +bounded Godot/locales/compile checks, true exits and raw diagnostics. +Director final round17 actual probe after implementer completion confirms +540devicepx ->180viewport occlusion; confirm stays216>keyboard180,36px +covered (clean assertion exit1). Rawgate-keyboard-tall-clearance-round17-final.log. +An earlier probe ran during a negative-control mutation and returned540 +occlusion: discarded, not product evidence. Use the final restored run. +Stock round17 contact now captures four silhouettes42px with exit0 and +no ERROR, but its new Camera2D also defaults to idle and warns. Seat its +physics callback before tree entry while closing that warning too. diff --git a/notes/workflow/muse/briefs/218-close-the-real-reminder-refill-boundaries.md b/notes/workflow/muse/briefs/218-close-the-real-reminder-refill-boundaries.md new file mode 100644 index 0000000..d3af424 --- /dev/null +++ b/notes/workflow/muse/briefs/218-close-the-real-reminder-refill-boundaries.md @@ -0,0 +1,68 @@ +# Brief218: Close the remaining real reminder refill boundaries + +## Actual native build defect +Director round18 actual Gradle build fails at MoonlitReminderAlarm.kt106: +Unresolved reference OPSTR_POST_NOTIFICATION. That is not in the public +SDK used here. Keep the effective API23 app-global check using the verified +AndroidX fallback pattern, not a blanket grant or unguarded24+API. Primary +reference (standard reflective int-op check, with safe fallback): +https://android.googlesource.com/platform/frameworks/support/+/f82200123a365205ce2af67df269fb1dc56b55bb/core/core/src/main/java/androidx/core/app/NotificationManagerCompat.java +No new dependency needed. Director rebuilds. iOS18actualcompile+stage PASS. + +## Confirmed game/native integration starvation +Director actual AttendanceReminders + registered fake host/bridge, isolated +scene, known cache source owned account, anchor25days ago, expired native +horizon, explicit enabled+granted: native_schedule_calls0, cleanassertion +exit1. Rawgate-reminder-expired-horizon-round18.log. `_schedule_for_view` +still returns whenever remaining_seconds==0. This prevents the production +native refill on any expired cached deadline (especially offline returns), +although the native48-slot math now supports it. Successful online claims +reset the anchor, but a reminder refresh must also work without awarding +a coin or requiring a successful claim. + +Allow a known server-owned expired anchor to plan future-only notifications +on genuine refresh. Preserve source/account/intent/status guards and no +wallet changes. Newly successful attendance still resets its anchor. +For Android too, an overdue first trigger should stay on its original12h +anchor's next future slot without an immediate/burst overdue delivery. +Do not replan on every frame or every settings redraw. + +## Native/GDS horizon disagreement and incomplete duplicate receipt +The current native duplicate gate holds when at least8 future slots remain: +firstFuture-recordedBase+8<=48. GDS uses now+8*43200