Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agents/skills/ship-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<UUID>
Expand Down
10 changes: 10 additions & 0 deletions .claude/skills/ship-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<UUID>
Expand Down
24 changes: 23 additions & 1 deletion .github/scripts/check-locale.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions apps/docs/docs/assets/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
80 changes: 65 additions & 15 deletions apps/docs/docs/game.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.<id>.json` | one saved gate per player ID: cycle, hero, relics, growth and score counters, plus backup, revision and rejected-copy files |
| `journey.<id>.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.

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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).
Expand All @@ -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`.
Expand Down
67 changes: 67 additions & 0 deletions apps/game/addons/moonlit-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading