feat: 게임 시계 reconciliation 권위 기반 추가
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user