feat: 게임 시계 reconciliation 권위 기반 추가

This commit is contained in:
2026-09-03 08:44:43 +00:00
parent b91dcbcaaa
commit ae7d55ef47
29 changed files with 1265 additions and 81 deletions
@@ -0,0 +1,205 @@
{
"schemaVersion": 1,
"authority": "game-tick",
"tickPerTurn": 36000000,
"policies": ["SHIFT", "KEEP", "REBUILD", "FORBID"],
"coveredFields": [
"input_event.accepted_game_tick",
"input_event.accepted_clock_revision",
"world_state.clock_tick",
"world_state.last_turn_tick",
"world_state.clock_revision",
"world_state.deadline_generation",
"general.turn_tick",
"general.recent_war_tick",
"select_pool.reserved_until_tick",
"select_npc_token.valid_until_tick",
"select_npc_token.pick_more_from_tick",
"message.time_tick",
"message.valid_until_tick",
"auction.open_tick",
"auction.close_tick",
"vote_poll.start_tick",
"vote_poll.end_tick",
"clock_suspension.source_revision",
"clock_suspension.target_revision",
"clock_suspension.cut_tick",
"clock_suspension.catch_up_ticks",
"clock_suspension.gap_ticks",
"clock_suspension.shift_ticks",
"clock_suspension.aligned_tick",
"clock_projection_outbox.target_revision"
],
"participants": [
{
"key": "world-clock",
"policy": "REBUILD",
"authorityFields": ["world_state.clock_tick", "world_state.clock_revision"],
"projectionFields": ["world_state.clock_base_time", "world_state.clock_wall_anchor"],
"owner": "game-engine/clock-operation"
},
{
"key": "turn-cursor",
"policy": "SHIFT",
"authorityFields": ["world_state.last_turn_tick"],
"projectionFields": ["world_state.meta.lastTurnTime", "in-memory.checkpoint.turnTime"],
"owner": "game-engine/turn-daemon"
},
{
"key": "general-next-turn",
"policy": "SHIFT",
"authorityFields": ["general.turn_tick"],
"projectionFields": ["general.turn_time"],
"owner": "game-engine/turn-daemon"
},
{
"key": "general-recent-war-occurrence",
"policy": "KEEP",
"authorityFields": ["general.recent_war_tick"],
"projectionFields": ["general.recent_war_time"],
"owner": "game-engine/battle"
},
{
"key": "auction-open-occurrence",
"policy": "KEEP",
"authorityFields": ["auction.open_tick"],
"projectionFields": ["auction.created_at"],
"owner": "game-api/auction"
},
{
"key": "auction-deadline",
"policy": "SHIFT",
"authorityFields": ["auction.close_tick"],
"projectionFields": ["auction.close_at"],
"owner": "game-api/auction-worker"
},
{
"key": "auction-finalizing-recovery",
"policy": "REBUILD",
"authorityFields": ["auction.status", "world_state.deadline_generation"],
"projectionFields": ["redis.auction.timer"],
"owner": "game-api/auction-worker"
},
{
"key": "message-occurrence",
"policy": "KEEP",
"authorityFields": ["message.time_tick"],
"projectionFields": ["message.time"],
"owner": "game-engine/message"
},
{
"key": "message-expiry",
"policy": "SHIFT",
"authorityFields": ["message.valid_until_tick"],
"projectionFields": ["message.valid_until"],
"owner": "game-engine/message"
},
{
"key": "vote-start-occurrence",
"policy": "KEEP",
"authorityFields": ["vote_poll.start_tick"],
"projectionFields": ["vote_poll.start_at"],
"owner": "game-api/vote"
},
{
"key": "vote-end-deadline",
"policy": "SHIFT",
"authorityFields": ["vote_poll.end_tick"],
"projectionFields": ["vote_poll.end_at"],
"owner": "game-api/vote"
},
{
"key": "select-pool-reservation",
"policy": "SHIFT",
"authorityFields": ["select_pool.reserved_until_tick"],
"projectionFields": ["select_pool.reserved_until"],
"owner": "game-engine/select-pool"
},
{
"key": "npc-selection-window",
"policy": "SHIFT",
"authorityFields": ["select_npc_token.valid_until_tick", "select_npc_token.pick_more_from_tick"],
"projectionFields": ["select_npc_token.valid_until", "select_npc_token.pick_more_from"],
"owner": "game-engine/npc-selection"
},
{
"key": "accepted-command-coordinate",
"policy": "KEEP",
"authorityFields": ["input_event.accepted_game_tick", "input_event.accepted_clock_revision"],
"projectionFields": [],
"owner": "game-api/input-event"
},
{
"key": "tournament-deadlines",
"policy": "REBUILD",
"authorityFields": ["redis.tournament.state.nextTick", "redis.tournament.state.bettingCloseTick"],
"projectionFields": ["redis.tournament.state.nextAt", "redis.tournament.state.bettingCloseAt"],
"owner": "game-api/tournament-worker",
"migration": "Redis-only legacy dates must dual-write ticks before exact reconciliation is enabled."
},
{
"key": "movable-json-rule-anchors",
"policy": "FORBID",
"authorityFields": ["world_state.meta.turntime", "world_state.meta.starttime", "world_state.meta.tnmt_time"],
"projectionFields": [],
"owner": "game-engine/world-meta",
"migration": "Register typed columns or explicit participant adapters before exact reconciliation can complete."
},
{
"key": "unification-wait",
"policy": "FORBID",
"authorityFields": ["world_state.meta.isunited", "world_state.meta.lastTurnTime"],
"projectionFields": [],
"owner": "game-engine/unification",
"migration": "Replace the lastTurnTime workaround with a durable UNIFICATION_WAIT suspension."
},
{
"key": "clock-operation-ledger",
"policy": "KEEP",
"authorityFields": [
"clock_suspension.source_revision",
"clock_suspension.target_revision",
"clock_suspension.cut_tick",
"clock_suspension.catch_up_ticks",
"clock_suspension.gap_ticks",
"clock_suspension.shift_ticks",
"clock_suspension.aligned_tick",
"clock_projection_outbox.target_revision"
],
"projectionFields": [],
"owner": "game-engine/clock-operation"
}
],
"redis": [
{
"keyPattern": "sammo:{profile}:clock:active-revision",
"policy": "REBUILD",
"status": "planned"
},
{
"keyPattern": "sammo:{profile}:auction:timer",
"policy": "REBUILD",
"status": "implemented-without-clock-revision-fence"
},
{
"keyPattern": "sammo:{profile}:tournament:state",
"policy": "REBUILD",
"status": "legacy-date-dual-write-required"
}
],
"wallOnly": [
"input_event.created_at",
"input_event.processing_at",
"input_event.completed_at",
"input_event.lease_until",
"turn_daemon_lease.lease_until",
"turn_daemon_lease.heartbeat_at",
"clock_suspension.cut_wall_at",
"clock_suspension.resume_wall_at",
"clock_projection_outbox.available_at",
"clock_projection_outbox.locked_at",
"clock_projection_outbox.applied_at",
"*.created_at",
"*.updated_at"
]
}
@@ -0,0 +1,107 @@
# Game clock reconciliation
## Product contract
Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000`
ticks. Wall time is an observation and operational-control input, never the
authority for gameplay ordering. A long suspension advances the observed game
coordinate to the resume wall instant without replaying skipped turns, monthly
events, RNG, auctions, or tournaments. Every movable future schedule is shifted
by the same exact tick delta, including the sub-turn remainder.
The clock state is stored in `world_state`:
- `clock_phase` gates gameplay commits.
- `clock_revision` identifies the coordinate conversion generation.
- `deadline_generation` fences worker deadlines rebuilt from that generation.
- `clock_tick` and `clock_wall_anchor` form the durable observed-time snapshot.
- `last_turn_tick` is the execution cursor and is independent from occurrence
history.
The phases are `PREOPEN`, `RUNNING`, `SUSPENDED`, `RECONCILING`, `MANUAL`, and
`COMPLETED`. `PREOPEN` alone permits signed negative observed ticks and floors
executable schedules at zero. `RUNNING` never projects below its durable tick
when wall time moves backward. `SUSPENDED`, `RECONCILING`, and `COMPLETED` do not
permit turn or monthly commits. `MANUAL` moves only through explicit engine
progression.
## Durable operation
A suspension begins under the turn-daemon fence and schema-scoped clock lock.
It records the cut tick, database wall instant, rate, source revision, and
participant checksum in `clock_suspension`. Resume reads the database wall
instant and builds an exact plan:
```text
gapTicks = max(0, ticksBetween(cutWall, resumeWall, rateAtCut))
shiftTicks = gapTicks - catchUpTicks
alignedTick = cutTick + gapTicks
deadlineAfter = deadlineBefore + shiftTicks
```
Planned maintenance, delayed opening, and unification wait use zero catch-up.
The compatibility-only complete-turn behavior is named
`LEGACY_COMPLETE_TURNS`; it is not the exact policy.
Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision,
row count, and before/after checksum to `clock_reconciliation_participant`.
The authoritative registry is
[`game-clock-participants.json`](./game-clock-participants.json). The
architecture gate rejects a new tick/revision field that is absent from that
inventory.
## DB to Redis boundary
The database transaction leaves the phase `RECONCILING` and creates exactly one
`clock_projection_outbox` row for the target revision. An outbox worker rebuilds
auction and tournament projections and writes
`sammo:{profile}:clock:active-revision` last. Only after checksum verification
may the database transition to `RUNNING` for the same target revision and
deadline generation.
Workers must compare DB revision, Redis active revision, phase, and deadline
generation before dequeue and again in their final database transaction. Due
pop is one Redis operation: verify revision/phase, read `-inf..nowTick`, and
remove the claimed members. A failed Redis rebuild therefore leaves the game in
`RECONCILING`; process liveness alone is not readiness.
## Lock order
All mutation paths use this order:
```text
turn-daemon fencing row
-> game-clock:operation advisory transaction lock
-> general-access:persistence advisory transaction lock (only if needed)
-> world_state FOR UPDATE
-> participant rows/tables in registry order
-> DB commit
-> Redis outbox projection
```
The ordinary turn flush already validates phase, revision, and deadline
generation after taking this lock prefix. Clock operation participants will be
added without changing that prefix.
## Opening invariant
Both production and direct seeding use the same scenario seeder. It stores
`clock_tick = 0`, `last_turn_tick = 0`, and the scheduled opening as
`clock_wall_anchor`. The metadata names `seededAtWall`, `scheduledOpenAtWall`,
`projectedGameDateAtOpening`, and `calendarStart` separately. Precreated general
turn ticks are calculated from zero and therefore cannot be negative. At the
wall anchor the in-memory phase promotion refuses any PREOPEN clock whose stored
tick is not exactly zero.
## Compatibility and migration
This branch begins with dual-read defaults for callers and fixtures built before
the new columns. Database migration backfills manual profiles as `MANUAL`,
future anchored realtime profiles as `PREOPEN`, and other profiles as
`RUNNING`. Existing DateTime columns remain projections while tick columns are
authoritative.
Exact reconciliation stays disabled while any registry participant is
`FORBID`. In particular, Redis-only tournament dates and unification wait must
be moved to durable tick/revision contracts before the operation can reach
`RUNNING`. Removing these guards to make a partial operation pass is prohibited.
@@ -0,0 +1,81 @@
# Game clock reconciliation implementation plan
Baseline: `main@b91dcbcaaac5acd4c7349cd3ed0996c547f58756`
Branch: `test/game-clock-reconciliation-20260903`
This plan is the status source for the long-running user test branch. A checked
item means code and focused automated evidence exist on this branch; it does not
mean deployment or production validation.
## Milestone 1 - authority and inventory
- [x] Branded `GameTick`, `ObservedGameInstant`, `ScheduleInstant`,
`WallInstant`, and `ClockRevision` boundaries.
- [x] Explicit clock phase and monotonic RUNNING projection.
- [x] Exact alignment arithmetic preserving millisecond/sub-turn remainder.
- [x] Opening tick zero and PREOPEN executable floor in the shared seeder.
- [x] Schema columns for phase, revision, and deadline generation.
- [x] Suspension, participant-checksum, and Redis projection outbox tables.
- [x] Machine-readable DB/Redis/JSON participant inventory and architecture gate.
- [x] Turn flush lock prefix and phase/revision/generation fence.
- [ ] Empty and upgraded database migration execution evidence.
## Milestone 2 - exact DB reconciliation
- [ ] Suspension start command with DB wall time and idempotent source revision.
- [ ] Exact resume plan transaction with deterministic participant lock order.
- [ ] SHIFT adapters for cursor, generals, active auctions, message expiry, vote
end, select pool, and NPC selection windows.
- [ ] KEEP checksum adapters for occurrences and history.
- [ ] Explicit `LEGACY_COMPLETE_TURNS` and bounded `CATCH_UP` policies.
- [ ] Property tests for remaining distance, ordering, and history invariants.
- [ ] 24-hour and 65m17.250s PostgreSQL integration evidence.
## Milestone 3 - revisioned Redis and workers
- [ ] Projection outbox claimer/retry/recovery state machine.
- [ ] Redis active revision and atomic due-pop script.
- [ ] Auction OPEN/FINALIZING revision and generation fence.
- [ ] Tournament durable tick dual-write and projection rebuild.
- [ ] DB-commit/Redis-failure restart tests and readiness integration.
## Milestone 4 - command and lifecycle workflows
- [ ] All durable input events record accepted tick and accepted revision.
- [ ] Processing converts accepted coordinates across revisions or fails closed.
- [ ] Gateway pause/resume/open orchestration writes the DB clock phase.
- [ ] Unification wait becomes a durable `UNIFICATION_WAIT` suspension.
- [ ] Alignment, optional rate change, invader IDs/RNG, creation, first schedule,
outbox, verification, and RUNNING transition form one retry-safe workflow.
- [ ] Multi-host drift and general-access/clock-operation deadlock tests.
## Milestone 5 - test-branch release gate
- [ ] Full typecheck, architecture, lint, unit, build, and non-conditional
integration suites.
- [ ] Dedicated PostgreSQL/Redis conditional integration suite with skip count
recorded.
- [ ] Recovery runbook exercised from each incomplete status.
- [ ] Admin status/readiness exposes revision, phase, participant checksums, and
incomplete outbox state.
- [ ] User-test deployment evidence is recorded separately from Git push.
- [ ] All `FORBID` inventory entries are removed by typed migrations or proven
inactive preconditions.
## Evidence log
### 2026-09-03 - authority foundation
- `pnpm test:bootstrap`: dependency installation, Prisma generation, and package
preparation passed in the dedicated worktree.
- `CI=1 TURBO_CONCURRENCY=1 pnpm typecheck`: 21/21 tasks passed.
- `CI=1 TURBO_CONCURRENCY=1 pnpm test`: 12/12 package tasks passed. Conditional
suites remain classified separately and are not integration evidence.
- `CI=1 TURBO_CONCURRENCY=1 pnpm build`: 26/26 tasks passed.
- `TURBO_CONCURRENCY=1 pnpm lint`: passed with 36 pre-existing frontend
warnings and no errors.
- `pnpm check:architecture`: package boundaries passed; 21 authoritative clock
fields and 18 participants were registered.
- Migration SQL was generated, formatted, validated, and registered as the
release manifest head. Empty/upgraded PostgreSQL execution is still pending.
+40
View File
@@ -0,0 +1,40 @@
# Game clock reconciliation recovery
This runbook is intentionally fail-closed. Do not force a profile to `RUNNING`
or delete an outbox row merely because its process is alive.
## Observe
Read only the target game schema. Record `world_state.clock_phase`,
`clock_revision`, `deadline_generation`, the latest `clock_suspension`, all its
participant checksums, and the matching `clock_projection_outbox`. Compare that
target revision with `sammo:{profile}:clock:active-revision`. Never print DB or
Redis credentials.
## Status meaning
- `SUSPENDED`: the cut is durable; no alignment DB transaction has committed.
- `RECONCILING` with `PENDING`/`FAILED` outbox: DB schedules moved, Redis is not
authoritative yet, and gameplay must remain stopped.
- `RECONCILING` with `APPLIED` outbox: verify Redis active revision and all
participant checksums before finalizing.
- `RUNNING`: DB revision, deadline generation, and Redis active revision must
agree. A mismatch is an incident and workers must not dequeue.
## Retry
Retry the same suspension ID and target revision through the clock-operation
service. The service must re-read participant checksums and either return the
already-applied result or resume the pending outbox. Never create a replacement
revision to hide a failed target revision.
## Rollback
There is no blind inverse update. Before enabling exact reconciliation in an
environment, keep the normal database backup required for schema migrations.
If participant verification shows an unexpected mutation, stop the profile,
retain the ledger/outbox evidence, and restore the whole game schema from that
backup. Redis projections are then rebuilt from the restored DB revision.
The implementation-plan release gate remains open until these steps have an
automated fixture and an operator-facing status endpoint.