merge: 최신 main을 실행 중 게임 옵션 변경에 통합

# Conflicts:
#	packages/infra/prisma/gateway.prisma
This commit is contained in:
2026-08-17 16:09:03 +00:00
79 changed files with 5542 additions and 1055 deletions
+30
View File
@@ -17,6 +17,20 @@
Gateway API와 game API는 기본적으로 `0.0.0.0`에 bind합니다. 실제 port와
prefix는 환경 변수와 배포 profile이 결정합니다.
현재 PM2 조립에서 game profile 하나는 frontend, API, turn daemon, auction,
battle-sim, tournament worker의 여섯 process를 만듭니다. 각 정의에는
`instances`나 cluster `exec_mode`가 없으므로 모두 단일 fork입니다. frontend도
Caddy 정적 파일이 아니라 profile별 Vite preview Node process이고, API도 하나의
Fastify process입니다. worker 역할 분리는 API event loop의 작업을 줄이지만
frontend/API replica나 장애 대체 backend를 제공하지는 않습니다.
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
PostgreSQL instance, runtime cgroup을 공유합니다. 현재 `PrismaPg` adapter에는
role별 pool 상한을 명시하지 않아 각 DB 사용 process가 `pg` 기본 pool 상한을
독립적으로 가질 수 있습니다. 따라서 profile 수를 늘릴 때는 process RSS뿐 아니라
API, daemon, 세 worker와 Gateway 계열의 합산 connection budget을 PostgreSQL
`max_connections` 안에서 먼저 정해야 합니다.
## Gateway 실행
`resolveGatewayApiConfigFromEnv()`가 PostgreSQL schema, Redis prefix, session
@@ -178,6 +192,22 @@ Checkpoint의 단일 소유자는 `InMemoryTurnWorld`이며 state store는 이
예약 턴은 revision/CAS와 lease를 사용합니다. API의 편집과 daemon의 실행이
경합해도 오래된 revision이 새 queue를 덮어쓰지 않게 합니다.
정상 gameplay 경로는 table 전체를 배타 잠그지 않습니다. 서로 다른 profile
schema의 row lock은 직접 충돌하지 않지만 다음 직렬화 지점은 남습니다.
- daemon flush마다 profile별 `turn_daemon_lease`와 단일 `world_state` 행을 갱신합니다.
- `read_model_revision`의 전역 entity와 input-event revision/CAS는 같은 profile에서 hot row가 될 수 있습니다.
- outbox dispatcher는 `FOR UPDATE SKIP LOCKED`로 claim 경쟁을 분산합니다.
- 경매, 베팅, 메시지, 장수 선택·생성은 대상 row lock 또는 advisory lock을 사용합니다.
- PostgreSQL advisory lock은 schema가 아니라 database 범위입니다. key에 profile/schema를 포함하지 않은 일부
기능별 lock은 서로 다른 profile 사이에서도 같은 key일 때 잠깐 직렬화될 수 있습니다.
월 경계 flush는 dirty world, 장수·국가·도시, 로그와 outbox를 한 transaction에
저장하므로 일반 장수 1턴보다 lock 보유 시간이 깁니다. profile별 월 경계 시각이
겹치면 row 자체는 달라도 PostgreSQL CPU/I/O, connection과 runtime memory에서
경합합니다. Migration과 `RESET`의 강한 lock은 일반 운영 중 실행하지 않고
orchestrator의 process 정지·배포 경계에서 다룹니다.
## 월간 경계
Calendar handler는 turn time이 월 경계를 지날 때 scenario event table의
+80 -37
View File
@@ -9,6 +9,13 @@ PostgreSQL advisory locks serialize an apply per profile, and every target row
uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
repeatable.
Gateway apply is one PostgreSQL transaction. A game apply records a
`legacy_archive.import_run`: archive and current-user projection writes commit
together with `COMPLETED`, while a rollback leaves a `FAILED` run record. A
repeat import updates archive-owned rows but does not replace a live Gateway
account's password, reset status, login/display identity, OAuth connection,
roles, sanctions, consent, icon or login timestamps.
The source of truth for eligibility is the checked ref schema, not every table
that happens to exist in a dump. Tables outside that schema remain only in the
recovery dump.
@@ -28,18 +35,26 @@ Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump.
Kakao members retain `oauth_id`, email and metadata. A parseable legacy
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
required before an imported row is marked Kakao-verified. A parseable legacy
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
remaining KakaoTalk ownership-proof interval instead of forcing an immediate
message at cutover. Cutover also sets `kakao_verified_at` and
message at cutover. Valid provider rows also receive `kakao_verified_at`; all
rows receive
`kakao_grace_started_at` to the migration time and starts the local-account
verification grace period there. Source rows without an OAuth ID retain their
metadata, but the importer does not invent a provider identifier.
metadata but are not treated as verified, and the importer never invents a
provider identifier.
Legacy password hashes remain usable when gateway-api has
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored
value to Argon2id. A test-only account can instead be reset with the CLI and a
mode-0600 password file:
Every imported 128-hex legacy password is marked `password_reset_required`.
The dump contains the per-user salt but not Ref's installation-wide salt, so
the dump alone cannot validate the old plaintext password. If the original
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is recovered through the runtime secret,
a successful password login upgrades the value to Argon2id and clears the
flag. Otherwise, a Kakao login (including a confirmed retained-email relink)
issues a one-time password-setup challenge before any normal session; the new
password is sent in the existing RSA envelope and clears the flag. Accounts
without usable Kakao recovery require the CLI and a mode-0600 password file:
```sh
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
@@ -50,24 +65,43 @@ The password is never accepted as an argument or printed.
### Game profiles
| Legacy table | Target | Policy |
| ------------------------------- | ------------------------ | --------------------------------------------------------------------- |
| `ng_games` | `ng_games` | Preserve completed season metadata |
| `hall` | `hall` | Preserve hall-of-fame rows |
| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner |
| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs |
| `emperior` | `emperior` | Preserve dynasty detail and legacy key |
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
| `ng_history` | `yearbook_history` | Preserve map, nation, global history and global action snapshots |
| Legacy table | Dedicated target | Policy |
| ------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
| `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment |
| `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records |
| `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner |
| `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key |
| `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID |
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
| `ng_history` | `legacy_archive.yearbook` | Preserve map, nation, global history and global action snapshots |
The archive schema is shared by all game-profile schemas in the PostgreSQL
database. Every natural key contains `source_profile`; the accepted profiles
are `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, and `hwe`. This prevents equal
legacy IDs from different servers from colliding while allowing any profile API
to read one central archive.
`ng_games.date` is retained as `legacy_date`. The displayed opening date uses
`env.opentime`, then `env.starttime`, then `ng_games.date`. The dumps do not
carry a trustworthy completion timestamp, so `completed_at` remains null
instead of treating the opening date as completion.
`ng_old_generals.data` is adapted at import time to
`ArchivedGeneralSnapshotV1`. Both old `leader/power` with
`dex0/10/20/30/40` and newer `leadership/strength` with `dex1..5` map to one
shape. Missing battle aggregates and logs are `null` plus explicit
`availability`, never fabricated zeroes. The source JSON remains in
`legacy_archive.general.raw_data` for recovery, but no API returns it.
The source contains legitimate duplicate `(server_id, nation)` old-nation rows
and `(server_id, year, month)` history rows. `source_id` is consequently part of
the archive unique keys. Runtime-generated rows use `source_id = 0`; migrated
rows use the legacy primary key. This avoids a lossy last-row-wins upsert.
their current-schema archive keys, while the dedicated legacy archive uses
`(source_profile, legacy_id)` from the original primary key. This avoids a lossy
last-row-wins upsert and keeps runtime current archives separate.
Current-season actor/world/queue/lock/message/market/vote state is explicitly
excluded. In particular, `general`, `city`, `nation`, their turn queues,
@@ -108,35 +142,44 @@ season or as a substitute for the long-lived archive cutover procedure.
archive owner from the game session and never accepts an owner ID from the
browser.
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional
`emperior` row. It returns summary fields and a link target for the existing
public dynasty/nation detail.
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in
the database predicate before returning `data.history`. A foreign or missing
record uses the same not-found response.
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API
normalizes both to a newest-first string array, and the frontend renders plain
text rather than archived markup.
- `archive.myPastPlays` reads the central legacy archive across profiles and
current runtime archives, tags each source, and suppresses a current-schema
duplicate when the central legacy copy exists.
- `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes
the session owner in the database predicate. A foreign or missing record uses
the same not-found response.
- The detail DTO feeds the same `GeneralBasicCard`, battle summary,
`LegacyGeneralProgress`, and record panels used by My Page/Battle Center.
Missing battle/mastery/log channels show an explicit not-preserved state.
- Legacy `data.history` may be either an array or a `<br>`-joined string. It is
normalized to plain-text archive entries; archived markup is never rendered
as trusted HTML.
- Runtime death and unification archival writes the current general
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
seasons remain compatible with imported rows.
Hall-of-fame and dynasty APIs and pages take an explicit `current` or `legacy`
source. Legacy results use the central archive and show the source profile;
they are never merged into the current rankings or current dynasty list.
## Cutover procedure
1. Keep the original compressed dumps immutable and restore each source to a
private MariaDB instance.
2. Deploy the gateway and game Prisma migrations to empty staging databases.
3. Run gateway and each non-empty profile without `--apply`; archive the JSON
3. Run gateway and each non-empty official profile without `--apply`; archive the JSON
counts and excluded-table reasons.
4. Compare source counts, malformed JSON checks and duplicate natural-key
counts. Stop on unexplained drift.
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
run the same commands with `--apply`.
6. Repeat each apply. Counts must remain unchanged.
7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership,
old-nation/history duplicate preservation, `/past-plays` list/detail access,
foreign-owner denial and the dynasty link.
6. Repeat each apply. Counts must remain unchanged; verify the newest
`legacy_archive.import_run` is `COMPLETED` and current Gateway credentials
are unchanged.
7. Verify valid/invalid Kakao-ID classification, password-reset-required rows,
Kakao password setup, CLI fallback, archive ownership, canonical source
format counts, opening dates, `/past-plays`, foreign-owner denial, legacy
Hall and legacy Dynasty source switches.
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
pre-cutover PostgreSQL backup; it does not reverse individual importer
upserts.