Author SHA1 Message Date
Hide_D 957a599608 외교 제의 만료와 처리 완료를 삭제 표시에서 분리 2026-09-10 06:03:47 +00:00
Hide_D 73e1743a3d 복구 2배속의 게임·실제 시간 표시 설정과 시계 빠른 전환 추가 2026-09-10 04:22:18 +00:00
Hide_D 3a5d4e7833 메인 턴 당기기와 미루기를 1턴 기본 분할 버튼으로 변경 2026-09-10 02:50:19 +00:00
Hide_D 3f4e5095b5 외교 조회 권한자의 문서와 시스템 메시지 도착 알림 복원 2026-09-10 02:38:23 +00:00
Hide_D a00f46dfc8 관리자 서버 진단에 lease 상태와 영속 장애 이력 추가 2026-09-09 23:36:16 +00:00
Hide_D 9c73085353 현재 복구 migration 기준으로 manifest 검증 기대값 갱신 2026-09-09 23:05:39 +00:00
Hide_D 604b951eff 대기 후 복구 migration과 릴리스 manifest 기준 일치 2026-09-09 23:05:21 +00:00
Hide_D 09f4e60df4 lease 상실 데몬을 종료하고 관리자에게 즉시 오류 기록 2026-09-09 23:04:02 +00:00
Hide_D d191960ea1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-09 22:54:38 +00:00
Hide_D c1fd79da19 메인 지도 연월 툴팁에 유니크 보유 한도 표시 2026-09-08 00:49:39 +00:00
Hide_D d8c9a63915 NPC와 알림 메시지의 장수 아이콘 경로를 공통 처리 2026-09-07 15:28:24 +00:00
Hide_D c39527bdd7 중단 후 대기와 2배속 복구 경계 및 전체 공지 적용 2026-09-07 14:37:55 +00:00
Hide_D d57fd3e553 설문 제목과 알림을 닫을 수 있는 새 창으로 열기 2026-09-07 12:42:27 +00:00
Hide_D bdb4e22028 장비 구매 목록을 시나리오 JSON 순서로 표시 2026-09-07 12:30:47 +00:00
Hide_D b2be86a9a2 fix: 신규 오픈 시각과 게임 시계 원점을 정확히 보존 2026-09-07 01:48:23 +00:00
Hide_D 69f461612d fix: 등용장을 발송 국가 멸망 시 함께 만료 2026-09-06 23:55:20 +00:00
Hide_D 9d5447dd3f fix: 토너먼트 관리자 요청의 Redis와 DB 잠금 순서 통일 2026-09-06 23:32:24 +00:00
Hide_D 27049dae1f fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정 2026-09-06 15:39:30 +00:00
Hide_D 09dfe96ff1 fix: 설문조사 창 닫기 버튼이 팝업을 닫도록 수정 2026-09-06 15:11:05 +00:00
Hide_D b14d8d52f6 feat: 정지·종료 기수의 장수 참여를 허용하고 확정 기록 보호 2026-09-06 13:39:30 +00:00
Hide_D 4d64978cb6 fix: 후계자 없는 군주 사망 시 국가 멸망을 함께 처리 2026-09-06 12:09:09 +00:00
Hide_D d3443ec7f5 refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현 2026-09-06 10:04:33 +00:00
Hide_D 63da0921cb fix: 재개 시 미처리 턴을 실행하고 장기 지연은 12턴씩 보정 2026-09-06 08:06:00 +00:00
Hide_D 6dcc914f94 fix: 운영 중단 복구 시 장수의 지정 턴 시각을 보존 2026-09-06 07:37:51 +00:00
Hide_D d3b2bcd155 fix: 보유 장비 판매 선택지와 판매가 표시를 복원
구매 pool 밖 보유 유니크도 장비 이름으로 판매를 선택할 수 있게 한다. 기존 슬롯명 판매 brief와 None 예약 인자는 유지하고 API 및 Chromium 회귀를 검증한다.
2026-09-06 07:20:31 +00:00
Hide_D ce7d15eba9 feat: 환경 설정으로 메뉴를 확장하고 전용 아이콘 변경을 이동 2026-09-05 14:10:19 +00:00
Hide_D 8488bf1aae fix: 사령부 발령 후보의 부대와 능력치 정보를 보강 2026-09-05 13:48:45 +00:00
Hide_D 71d7abcda4 docs: Qwen 보조 분석 지침 제거 2026-09-05 13:29:21 +00:00
Hide_D 332bd9a3dd 메인 장수 정보에서 상세 통계와 숙련도 표시를 제외 2026-09-05 13:21:08 +00:00
Hide_D 3437cd99f4 fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정 2026-09-05 00:55:33 +00:00
Hide_D 1d53e6f226 docs: 공통 작업 지침 통합 및 충돌 정리 2026-09-04 23:54:53 +00:00
Hide_D 0157ff6a6b 인게임 예약 명령과 장수 상태 표시를 바로잡음 2026-09-04 19:16:56 +00:00
Hide_D 5d220de739 장시간 Ref 행렬과 NPC 회귀 비용을 줄인다 2026-09-04 08:59:10 +00:00
Hide_D 07897d9765 통합 테스트를 실행 권위별로 분리한다 2026-09-04 06:13:31 +00:00
Hide_D 01fdb51b6e 전투 차등 통합 테스트를 일괄 실행한다 2026-09-04 05:53:08 +00:00
Hide_D 746bbe7e7d 토너먼트 참가 트랜잭션 교착을 해소한다 2026-09-04 05:43:08 +00:00
Hide_D f009c50df5 외교 메시지 응답과 알림 권한 정리 2026-09-04 04:39:00 +00:00
Hide_D 38e516de6d 피장파장 연속 실행 판정을 복구한다 2026-09-04 02:02:36 +00:00
Hide_D ffbd51a2ef docs(clock): record lifecycle recovery boundaries 2026-09-03 21:08:48 +00:00
Hide_D b77276f202 fix(test): make load runner dependency explicit 2026-09-03 21:08:48 +00:00
Hide_D 424f0b66ec test(clock): prevent terminal lifecycle starvation 2026-09-03 21:08:48 +00:00
Hide_D 51f8be492f fix(game): complete clock after invader ending 2026-09-03 20:16:28 +00:00
Hide_D 156f4e68b8 chore(release): advance game schema head 2026-09-03 19:41:42 +00:00
Hide_D a2b0b997a2 fix(clock): preserve UTC leases and unification recovery 2026-09-03 19:40:14 +00:00
Hide_D 62006fb2d4 fix(game): sync diplomatic responses into turn world 2026-09-03 18:18:22 +00:00
Hide_D 03e47feef1 fix(game-api): separate engine message responses 2026-09-03 17:57:17 +00:00
Hide_D 1cb66f76fb fix(game-engine): sync clock authority during pause 2026-09-03 17:50:26 +00:00
Hide_D b250e908f4 test(clock): add lifecycle deploy checkpoint 2026-09-03 17:30:24 +00:00
Hide_D 0aa549a6c5 test(clock): exercise suspended lifecycle actions 2026-09-03 17:29:18 +00:00
Hide_D 000e7e8fa5 일반 메시지 WALL_TIME 저장 회귀 수정 2026-09-03 16:29:48 +00:00
Hide_D abceee8315 시간 도메인과 정지 중 메시지·베팅 경계 정리 2026-09-03 16:01:19 +00:00
Hide_D 10cddbb565 10사용자 장기 수명주기 검증 도구 추가 2026-09-03 13:30:00 +00:00
Hide_D 24a63d338c fix: 게임 시계 완료 감사 경계 보강 2026-09-03 11:14:01 +00:00
Hide_D c10a71fbcc test: 시계 릴리스 게이트와 격리 fixture 완성 2026-09-03 10:31:11 +00:00
Hide_D 69ac6028df feat: 통일 대기 시계를 원자적 reconciliation으로 전환 2026-09-03 10:18:44 +00:00
Hide_D a9f23703d9 feat: 게이트웨이 시계 생명주기 권위 연결 2026-09-03 09:49:53 +00:00
Hide_D a3e2bf90ae feat: 시계 reconciliation 워커와 명령 경계 완성 2026-09-03 09:40:48 +00:00
Hide_D ae7d55ef47 feat: 게임 시계 reconciliation 권위 기반 추가 2026-09-03 08:44:43 +00:00
Hide_D b91dcbcaaa fix: 유산 강화 범위 오류를 한국어로 안내 2026-09-03 07:29:35 +00:00
322 changed files with 21879 additions and 14143 deletions
+67 -241
View File
@@ -1,40 +1,29 @@
# core2026 작업 지침 # core2026 작업 지침
## 적용 범위와 목표 ## 적용 범위와 문서 역할
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운 이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간 AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
지침 순으로 적용합니다. 보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP 상위 지침의 상대 경로는 `sam_rebuild/`, 이 파일의 경로는 `core2026/` 기준입니다.
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로, 독립 checkout에서 상위 작업공간 문서를 참조할 수 없으면 실제 작업공간 위치를
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되 확인하고, 확인하지 못한 규칙이나 Ref 근거를 읽었다고 보고하지 않습니다.
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
## 현재 기준과 저장소 경계 | 작업 | 상세 기준 |
| -------------------------- | ------------------------------------------------------------------------------------------ |
| 구조·파일 책임 | [README](README.md#저장소-구성), [개발자 핸드북](docs/developer/index.md) |
| Ref 비교·계측·분류 | [상위 호환성 계약](../AGENTS.md#호환성-계약), [매핑](../docs/ref-core2026-mapping.md) |
| 테스트 준비·실행·오류 복구 | [테스트 정책](docs/testing-policy.md) |
| CSS·실제 Chromium 비교 | [CSS 구조](docs/frontend-css-architecture.md), [호환 검증](docs/frontend-legacy-parity.md) |
| DB 이관 | [이관 절차](tools/legacy-db-migration/README.md) |
| 배포 대상 선택 | [환경 라우팅](../docs/docker-environment-routing.md) |
| prefix·빌드 변수 | [Caddy 계약](docs/e2e-caddy-routing.md) |
| Gateway·profile 릴리스 | [릴리스 운영](docs/release-operations.md) |
- `core2026/`은 제품 구현 저장소입니다. gateway/game frontend·API, game engine, PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
공통 package, Prisma와 검증 도구가 실제로 존재합니다. 매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`니다. 각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
`devel`은 변경하지 않는 기준선이고 비교 fixture·계측은 `ng_compare`에만
두어 주세요.
- 아키텍처 매핑과 보고서는 별도 상위 저장소의
`../docs/ref-core2026-mapping.md`, `../report/`에 있습니다. 두 Git 경계의
commit과 상태를 혼동하지 말아 주세요.
- 이미지의 운영 소유자는 외부 Caddy의 `/image/*`입니다. 제품 저장소에 무단
복제하거나 앱 경로로 rewrite하지 말아 주세요.
작업 시작과 종료에 최소한 다음을 확인해 주세요.
```sh
git status --short --branch
git remote -v
git rev-parse HEAD
```
상위 매핑/보고서를 바꾸면 상위 저장소에서도 같은 검사를 별도로 수행해 주세요.
dirty 파일은 사용자의 변경일 수 있으므로 관련 없는 수정, 정리, stage,
commit 또는 삭제를 하지 말아 주세요.
## 완료 상태를 해석하는 법 ## 완료 상태를 해석하는 법
@@ -55,87 +44,19 @@ commit 또는 삭제를 하지 말아 주세요.
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재 기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
코드와 commit으로 다시 확인해 주세요. 코드와 commit으로 다시 확인해 주세요.
## 실제 구조와 책임 ## 도메인 조립과 비교
- `app/gateway-frontend`: 가입·로그인, 로비, 계정, 관리자 운영 UI
- `app/gateway-api`: 계정·세션·profile 정책, operation queue와 PM2
orchestration
- `app/game-frontend`: profile별 게임 SPA와 ref 호환 UI
- `app/game-api`: tRPC/SSE, 조회·입력 API와 battle/auction/tournament worker
- `app/game-engine`: turn daemon, scheduler, 월간 lifecycle와 DB flush
- `packages/common`: 타입, 직렬화, RNG와 공통 유틸리티
- `packages/logic`: 전투·명령·월간 action과 typed action module 도메인 로직
- `packages/infra`: gateway/game Prisma schema, migration과 client
- `tools/integration-tests`: PostgreSQL/Redis 및 ref↔core 차등
- `tools/frontend-legacy-parity`: 실제 Chromium 비교
- `tools/legacy-db-migration`: 장기보존 데이터 CLI 이관
`tools/build-scripts/build-server.mjs`는 현재 profile resource 복사
placeholder입니다. 이를 완성된 배포 bundle이나 검증된 profile build로 설명하지
않습니다. 운영 build/reset/open은 gateway operation, commit별 worktree와
orchestrator 실행 경로를 조사해 주세요.
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold, 장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지 priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()` 말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는 `RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언 `GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md` 상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
있습니다.
## 레거시 매핑과 비교 차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
기능을 변경하기 전에 다음을 end-to-end로 연결해 주세요. 되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
따릅니다.
1. ref entry point와 호출 순서
2. PHP domain class, SQL read/write, template/CSS/JS와 사용자 출력
3. core service/router/action/loader/flush와 frontend 호출부
4. session/auth, validation, transaction, 비동기 경계와 오류 복구
5. RNG 생성·소비와 persistence 순서
`../docs/ref-core2026-mapping.md`에는 1:1 대응을 억지로 만들지 말고 1:N/N:1
소유권과 변환 지점을 기록해 주세요. `확인`, `부분 확인`, `가설`, `미구현`,
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
Ref 비교 전에 대상 계약도 구분해 주세요.
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
- 기존 `ng_compare` 이력과 분기 기준을 조사하고 reset/overwrite하지 말아 주세요.
- 계측은 최소·deterministic·가능하면 read-only로 두고 test 환경 guard를
사용해 주세요.
- 계측 유무가 결과, RNG 소비, DB mutation과 출력 순서를 바꾸지 않는지
확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 계승 계약의 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
3. API 요청·응답·오류, session과 인증
4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
이름만으로 완화하지 않습니다.
## 인증과 권한 ## 인증과 권한
@@ -182,18 +103,18 @@ API request
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`, 게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에 `RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가 추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
소비하는 RNG call을 생략하지 말아 주세요. 소비하는 RNG call을 생략하지 말아 주세요.
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state 전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기 side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요. 전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
## 프론트엔드와 룩앤필 ## 프론트엔드와 룩앤필
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는 UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity, 따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
- gateway `/gateway/`, game `/che/``/hwe/` prefix에서 direct navigation, - gateway `/gateway/`, game `/che/``/hwe/` prefix에서 direct navigation,
refresh, tRPC, SSE와 asset URL을 확인해 주세요. refresh, tRPC, SSE와 asset URL을 확인해 주세요.
@@ -210,20 +131,10 @@ layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을 - `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
일괄 disable하지 말아 주세요. 일괄 disable하지 말아 주세요.
UI를 변경하면 실제 Chromium을 사용해 주세요. Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
- ref와 core에 같은 Chromium, viewport, device scale, zoom, locale, font,
image와 로그인/test data를 사용해 주세요.
- 전체·영역 screenshot과 `getBoundingClientRect()`,
`getComputedStyle()`을 수집해 주세요.
- `hover()`, focus, pointer down/up, checked/selected/disabled, dropdown,
modal과 transition 상태를 실제 interaction으로 만들어 주세요.
- pixel diff mask는 시간·난수 등 불가피한 영역만 최소화하고 이유를 기록해 주세요.
- auth redirect만 확인하고 visual parity라고 주장하지 말아 주세요.
- 민감정보가 보이는 artifact는 저장하거나 report에 넣지 말아 주세요.
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은 CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
`docs/frontend-legacy-parity.md`를 따라 주세요. `docs/frontend-legacy-parity.md`를 따릅니다.
## DB와 레거시 데이터 이관 ## DB와 레거시 데이터 이관
@@ -263,94 +174,29 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가 worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요. 명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
현재 외부 호스트 계약은 `0.0.0.0` bind`/gateway/`, `/che/`, `/hwe/` 컨테이너 서비스는 필요한 listener를 `0.0.0.0` bind합니다. 로컬 E2E의
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지 `/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요. 대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다. HTTPS 검증과 구분합니다.
```sh
VITE_APP_BASE_PATH=/gateway \
VITE_GATEWAY_API_URL=/gateway/api/trpc \
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
pnpm --filter @sammo-ts/gateway-frontend build
```
profile별 정확한 포트와 game frontend/API 변수는
`docs/e2e-caddy-routing.md`에서 확인하고 현재 인프라와 대조해 주세요.
## 개발과 검증 절차 ## 개발과 검증 절차
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요. 상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요. 검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다. 명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요. [테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
기본 정적 검사는 실제 루트 script를 사용해 주세요. - 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
```sh `packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
CI=1 pnpm typecheck - Vitest filter에 불필요한 `--`를 넣지 않습니다.
pnpm check:architecture
pnpm lint
pnpm test
pnpm build
```
루트 `pnpm test``pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
adapter로 분리합니다.
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts` 예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요. - frontend package의 `test` placeholder UI 검증이 아닙니다. 해당 Playwright
해당 Playwright script 또는 legacy parity suite를 사용해 주세요. script 또는 legacy parity suite를 실행합니다.
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use` - ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
(nvm 환경은 `nvm use`) 후 오래된 report만 보고 명령을 가정하지 않습니다.
`pnpm test:bootstrap`을 실행해 install, Prisma client와 - 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
common → logic → infra → game-engine → API 산출물을 준비해 주세요. 이미
install된 checkout은 `pnpm test:prepare`를 사용합니다. Playwright
`webServer` 실패를 이 준비가 끝나기 전 제품 회귀로 분류하지 말아 주세요.
- E2E port가 다른 worktree와 겹치면 설정 파일을 임시 수정하지 말고
`PLAYWRIGHT_FRONTEND_PORT`, `PLAYWRIGHT_GATEWAY_FRONTEND_PORT` 또는
`FRONTEND_PARITY_*_PORT`를 지정합니다. 같은 game frontend `dist`를 쓰는
profile production run은 직렬로 실행합니다.
- 전체 lint/test의 기존 실패가 있으면 targeted 결과와 baseline 재현 결과를
구분하고, 관련 없는 기대값을 완화해 숨기지 말아 주세요.
외부 서비스 없는 기본 integration:
```sh
pnpm test:integration
```
전용 PostgreSQL/Redis를 준비한 조건부 전체 경계:
```sh
pnpm test:integration:conditional
```
ref 명령과 UI entry point:
```sh
pnpm check:legacy:general
pnpm check:legacy:nation
pnpm test:e2e:frontend-legacy
```
각 command가 요구하는 ref checkout, Docker, DB URL, secret과 fixture는 관련
docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report나 제안 문서만 보고
실행하지 말아 주세요.
## 코드 스타일 ## 코드 스타일
@@ -374,42 +220,22 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
## 문서와 보고서 ## 문서와 보고서
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요. 변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계 보고서의 필수 내용과 저장소별 commit 기록은
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약 [상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
- `docs/*`: core2026 내부 구현·운영 상세
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
## Git, worktree와 commit ## Git, worktree와 commit
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다. [상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에 [기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요. 충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요. 않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
- ref 비교 변경과 core 제품 변경, 상위 mapping/report 변경은 각 Git
저장소에서 별도 commit해 주세요.
- 생성물, `.env`, DB volume, log, coverage, screenshot, test-results와
secret을 commit하지 말아 주세요.
- worktree 정리는 clean status와
`git merge-base --is-ancestor HEAD <baseline>`을 모두 확인한 뒤 non-force
제거해 주세요. 이름, 나이 또는 uncommitted 여부만으로 삭제하지 말아 주세요.
- 사용자가 commit을 요청하지 않은 일반 작업은 diff와 권장 commit 경계를
준비하되 임의로 commit하지 말아 주세요.
## Qwen 보조 분석 Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만 worktree 정리가 요청 범위라면 clean status와
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을 `git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
보내지 말아 주세요. 이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
- 응답 budget은 1284096 token 범위로 제한해 주세요.
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
로컬 분석을 계속해 주세요.
+9 -6
View File
@@ -1,8 +1,9 @@
# 삼국지 모의전투 HiDCHe core2026 # 삼국지 모의전투 HiDCHe core2026
`core2026``../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm `core2026` PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작ref 구현과 실제 실행 발전하는 TypeScript pnpm 모노레포입니다. 계승 영역Ref의 동작·상태·권한과
결과를 기준으로 검증합니다. 비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
## 저장소 구성 ## 저장소 구성
@@ -252,9 +253,11 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
`game → gateway`입니다. 양쪽 migration directory 이름은 공유 `game → gateway`입니다. 양쪽 migration directory 이름은 공유
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다. `_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한 활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE, `/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다. [환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다. `build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의 완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
+3 -2
View File
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
generalId: number, generalId: number,
input: OpenAuctionInput, input: OpenAuctionInput,
requestId?: string requestId?: string
): Promise<{ auctionId: number; closeAt: string }> => { ): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen', type: 'auctionOpen',
...(requestId ? { requestId } : {}), ...(requestId ? { requestId } : {}),
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
const closeAt = new Date(result.closeAt); const closeAt = new Date(result.closeAt);
const gameTime = await loadCurrentGameTime(ctx.db); const gameTime = await loadCurrentGameTime(ctx.db);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) }, { score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
]); ]);
return { return {
auctionId: result.auctionId, auctionId: result.auctionId,
closeAt: result.closeAt, closeAt: result.closeAt,
closeTick: result.closeTick,
}; };
}; };
+5 -4
View File
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
} }
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => { export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
if (closeTick !== null && closeTick !== undefined) { void time;
void closeAt;
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
const value = Number(closeTick); const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`); if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value; return value;
}
return time.dateToTick(closeAt) ?? closeAt.getTime();
}; };
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => { export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
if (row.status === 'FINALIZING') { if (row.status === 'FINALIZING') {
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다. // 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
return time.tick ?? time.now.getTime(); if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
return time.tick;
} }
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick); return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
}; };
+111 -54
View File
@@ -1,4 +1,7 @@
import { import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector, createGamePostgresConnector,
createRedisConnector, createRedisConnector,
type GamePrismaClient, type GamePrismaClient,
@@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import { buildAuctionTimerKeys } from './keys.js'; import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js'; import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
@@ -24,13 +28,27 @@ interface RedisTimerClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>; zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
zRem(key: string, values: string | string[]): Promise<number>; zRem(key: string, values: string | string[]): Promise<number>;
zRemRangeByScore(key: string, min: number, max: number): Promise<number>; zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
} }
const POP_DUE_AUCTIONS_SCRIPT = `
if redis.call('GET', KEYS[2]) ~= ARGV[1]
or redis.call('GET', KEYS[3]) ~= ARGV[2]
or redis.call('GET', KEYS[4]) ~= 'RUNNING' then
return { '__CLOCK_FENCE__' }
end
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4])
if #ids > 0 then
redis.call('ZREM', KEYS[1], unpack(ids))
end
return ids
`;
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1; const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
interface AuctionFinalizeDeadline { interface AuctionFinalizeDeadline {
closeAt: Date; closeAt: Date;
closeTick: bigint | null; closeTick: bigint;
} }
interface AuctionFinalizeCommand { interface AuctionFinalizeCommand {
@@ -38,19 +56,10 @@ interface AuctionFinalizeCommand {
requestId: string; requestId: string;
auctionId: number; auctionId: number;
expectedCloseAt: string; expectedCloseAt: string;
expectedCloseTick?: number; expectedCloseTick: number;
} }
interface AuctionFinalizeEventRecord { const readSafeCloseTick = (closeTick: bigint): number => {
target: string;
eventType: string;
payload: unknown;
status: string;
result: unknown;
}
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
if (closeTick === null) return undefined;
const value = Number(closeTick); const value = Number(closeTick);
if (!Number.isSafeInteger(value)) { if (!Number.isSafeInteger(value)) {
throw new Error(`Auction close tick is unsafe: ${closeTick}`); throw new Error(`Auction close tick is unsafe: ${closeTick}`);
@@ -63,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
deadline: AuctionFinalizeDeadline, deadline: AuctionFinalizeDeadline,
retry = 0 retry = 0
): string => { ): string => {
const generation = const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
const base = `auction:finalize:${auctionId}:${generation}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
return retry > 0 ? `${base}:retry:${retry}` : base; return retry > 0 ? `${base}:retry:${retry}` : base;
}; };
@@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = (
requestId, requestId,
auctionId, auctionId,
expectedCloseAt: deadline.closeAt.toISOString(), expectedCloseAt: deadline.closeAt.toISOString(),
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }), expectedCloseTick: readSafeCloseTick(deadline.closeTick),
}); });
const isMatchingAuctionFinalizeEvent = ( const isMatchingAuctionFinalizeEvent = (
@@ -95,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
payload !== null && typeof payload === 'object' && !Array.isArray(payload) payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>) ? (payload as Record<string, unknown>)
: null; : null;
const expectedGenerationMatches = const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
payloadRecord?.expectedCloseTick !== undefined
? payloadRecord.expectedCloseTick === command.expectedCloseTick
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
return ( return (
event.target === 'ENGINE' && event.target === 'ENGINE' &&
event.eventType === command.type && event.eventType === command.type &&
@@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId; return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
}; };
const popDueAuctionIds = async ( export const popDueAuctionIds = async (
redis: RedisTimerClient, redis: RedisTimerClient,
timerKey: string, timerKey: string,
nowMs: number, nowMs: number,
batchSize: number batchSize: number,
clockFence?: {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
}
): Promise<string[]> => { ): Promise<string[]> => {
if (clockFence) {
if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.');
const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, {
keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey],
arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)],
});
if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.');
if (result[0] === '__CLOCK_FENCE__') return [];
return result.map(String);
}
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } }); const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
if (ids.length > 0) { if (ids.length > 0) {
await redis.zRem(timerKey, ids); await redis.zRem(timerKey, ids);
@@ -157,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') { if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
continue; continue;
} }
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick }; const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline); const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
const bases = [...new Set([canonicalBase, legacyBase])];
const events = await options.db.inputEvent.findMany({ const events = await options.db.inputEvent.findMany({
where: { where: {
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]), OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
}, },
select: { requestId: true, target: true, eventType: true, payload: true, status: true }, select: { requestId: true, target: true, eventType: true, payload: true, status: true },
orderBy: { sequence: 'desc' }, orderBy: { sequence: 'desc' },
@@ -182,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
timers.push({ timers.push({
score: score:
row.status === 'FINALIZING' row.status === 'FINALIZING'
? (options.gameTime.tick ?? options.gameTime.now.getTime()) ? (() => {
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
return options.gameTime.tick;
})()
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick), : resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
value: String(row.id), value: String(row.id),
}); });
@@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: {
nowMs: number; nowMs: number;
nowTick?: number | null; nowTick?: number | null;
historyNowMs?: number; historyNowMs?: number;
expectedClockRevision?: number;
expectedDeadlineGeneration?: number;
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => { }): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options; const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
const auctionId = Number(id); const auctionId = Number(id);
@@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: {
} }
const now = new Date(nowMs); const now = new Date(nowMs);
const outcome = await db.$transaction(async (transaction) => { const outcome = await db.$transaction(async (transaction) => {
if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const [world] = await transaction.$queryRaw<
Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }>
>(GamePrisma.sql`
SELECT
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (
!world ||
world.clockPhase !== 'RUNNING' ||
world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) ||
world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1)
) {
return { status: 'RESCHEDULED' as const, clockFenceFailed: true };
}
}
const current = await transaction.auction.findUnique({ const current = await transaction.auction.findUnique({
where: { id: auctionId }, where: { id: auctionId },
select: { status: true, closeAt: true, closeTick: true }, select: { status: true, closeAt: true, closeTick: true },
@@ -221,10 +264,10 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const }; return { status: 'IGNORED' as const };
} }
if (current.status === 'OPEN') { if (current.status === 'OPEN') {
const isDue = if (current.closeTick === null || nowTick === null) {
current.closeTick !== null && nowTick !== null throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
? current.closeTick <= BigInt(nowTick) }
: current.closeTick === null && current.closeAt.getTime() <= now.getTime(); const isDue = current.closeTick <= BigInt(nowTick);
if (!isDue) { if (!isDue) {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick }; return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
} }
@@ -233,24 +276,15 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const }; return { status: 'IGNORED' as const };
} }
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick }; const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) { for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry); const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry); const existing = await transaction.inputEvent.findUnique({
const candidateRequestIds = [...new Set([requestId, legacyRequestId])]; where: { requestId },
let existing: AuctionFinalizeEventRecord | null = null;
let existingRequestId = requestId;
for (const candidateRequestId of candidateRequestIds) {
existing = await transaction.inputEvent.findUnique({
where: { requestId: candidateRequestId },
select: { target: true, eventType: true, payload: true, status: true, result: true }, select: { target: true, eventType: true, payload: true, status: true, result: true },
}); });
if (existing) { const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
existingRequestId = candidateRequestId;
break;
}
}
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
if (!existing) { if (!existing) {
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId); const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
await transaction.inputEvent.create({ await transaction.inputEvent.create({
@@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: {
return { status: 'PENDING' as const }; return { status: 'PENDING' as const };
} }
if (!isMatchingAuctionFinalizeEvent(existing, command)) { if (!isMatchingAuctionFinalizeEvent(existing, command)) {
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`); throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
} }
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') { if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
return { status: 'PENDING' as const }; return { status: 'PENDING' as const };
} }
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) { if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
throw new Error( throw new Error(
`Auction remained ${current.status} after successful durable event: ${existingRequestId}` `Auction remained ${current.status} after successful durable event: ${requestId}`
); );
} }
} }
@@ -284,6 +318,10 @@ export const processDueAuctionId = async (options: {
return 'PENDING'; return 'PENDING';
} }
if (outcome.status === 'RESCHEDULED') { if (outcome.status === 'RESCHEDULED') {
if ('clockFenceFailed' in outcome) {
await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]);
return 'RESCHEDULED';
}
const gameTime = await loadCurrentGameTime(db, now); const gameTime = await loadCurrentGameTime(db, now);
await redis.zAdd(timerKey, [ await redis.zAdd(timerKey, [
{ {
@@ -315,18 +353,30 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() }, { name: 'auction-worker-postgres', run: () => postgres.disconnect() },
]); ]);
let nextResyncAt = Date.now(); let nextResyncAt = performance.now();
const pendingFinalizationIds = new Set<number>(); const pendingFinalizationIds = new Set<number>();
try { try {
while (!control.signal.aborted) { while (!control.signal.aborted) {
const operationalNowMs = Date.now(); const operationalNowMs = Date.now();
const operationalElapsedMs = performance.now();
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs)); const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime(); const gameNowMs = gameTime.now.getTime();
const dueScore = gameTime.tick ?? gameNowMs; const dueScore = gameTime.tick ?? gameNowMs;
if (operationalNowMs >= nextResyncAt) { if (gameTime.phase && gameTime.phase !== 'RUNNING') {
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue;
}
const clockFence = gameTime.phase
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
: null;
if (gameTime.phase && !clockFence) {
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue;
}
if (operationalElapsedMs >= nextResyncAt) {
await seedAuctionTimers(postgres.prisma, redis.client, keys); await seedAuctionTimers(postgres.prisma, redis.client, keys);
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs; nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
} }
if (pendingFinalizationIds.size > 0) { if (pendingFinalizationIds.size > 0) {
const reconciliation = await reconcilePendingAuctionTimers({ const reconciliation = await reconcilePendingAuctionTimers({
@@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
if (historyTrimBefore > 0) { if (historyTrimBefore > 0) {
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore); await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
} }
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100); const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined);
if (dueIds.length > 0) { if (dueIds.length > 0) {
for (const id of dueIds) { for (const id of dueIds) {
try { try {
@@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
nowMs: gameNowMs, nowMs: gameNowMs,
nowTick: gameTime.tick, nowTick: gameTime.tick,
historyNowMs: operationalNowMs, historyNowMs: operationalNowMs,
...(clockFence
? {
expectedClockRevision: clockFence.revision,
expectedDeadlineGeneration: clockFence.generation,
}
: {}),
}); });
if (outcome === 'PENDING') { if (outcome === 'PENDING') {
pendingFinalizationIds.add(Number(id)); pendingFinalizationIds.add(Number(id));
@@ -395,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
await closeResources(); await closeResources();
} }
}; };
import { performance } from 'node:perf_hooks';
+12 -1
View File
@@ -65,7 +65,18 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>; type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>; type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'; type WorldClockFields =
| 'clockBaseTime'
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'clockRecoveryStartTick'
| 'clockRecoveryEndTick'
| 'clockRecoveryStartWallAt'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
| 'deadlineGeneration';
type GeneralClockFields = 'turnTick' | 'recentWarTick'; type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma // Transitional API fixtures may still model the pre-clock row. Runtime Prisma
+28 -19
View File
@@ -1,10 +1,15 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra'; import {
acquireGameSchemaAdvisoryXactLock,
readInputEventClockCoordinate,
type DatabaseClient,
type GamePrisma,
} from '@sammo-ts/infra';
import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
@@ -82,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
async sendCommand(command: TurnDaemonCommand): Promise<string> { async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
if (durableCommand.type === 'npcPossessGeneral') { // Rolling-upgrade compatibility: older API versions supplied game
delete durableCommand.acceptedGameAt; // coordinates. They are deliberately not persisted as command facts;
} // the daemon assigns the authoritative processing coordinate while
// claiming the input event under the clock fence.
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
if (command.type === 'npcPossessGeneral') { if (command.type === 'npcPossessGeneral') {
const existing = await this.db.inputEvent.findUnique({ const existing = await this.db.inputEvent.findUnique({
where: { requestId }, where: { requestId },
@@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
try { try {
if (command.type === 'npcPossessGeneral' && this.db.$transaction) { if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
const rejectionReason = await this.db.$transaction(async (transaction) => { const rejectionReason = await this.db.$transaction(async (transaction) => {
const coordinate = await readInputEventClockCoordinate(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`); await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
const token = await transaction.npcSelectionToken.findFirst({ const token = await transaction.npcSelectionToken.findFirst({
where: { where: {
ownerUserId: command.userId, ownerUserId: command.userId,
nonce: command.tokenNonce, nonce: command.tokenNonce,
validUntil: { gte: acceptedGameAt }, validUntilTick: { gte: coordinate.gameTick },
}, },
select: { pickResult: true }, select: { pickResult: true },
}); });
@@ -126,19 +133,21 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
) { ) {
return '선택한 장수가 목록에 없습니다.'; return '선택한 장수가 목록에 없습니다.';
} }
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = { await this.createInputEvent(transaction, durableCommand, requestId);
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
acceptedGameAt: acceptedGameAt.toISOString(),
};
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
return null; return null;
}); });
if (rejectionReason) { if (rejectionReason) {
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason); throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
} }
} else {
if (this.db.$transaction) {
await this.db.$transaction(async (transaction) => {
await this.createInputEvent(transaction, durableCommand, requestId);
});
} else { } else {
await this.createInputEvent(this.db, durableCommand, requestId); await this.createInputEvent(this.db, durableCommand, requestId);
} }
}
} catch (error) { } catch (error) {
if (error instanceof RejectedNpcPossessionCommandError) { if (error instanceof RejectedNpcPossessionCommandError) {
throw error; throw error;
@@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
private async createInputEvent( private async createInputEvent(
db: DatabaseClient, db: DatabaseClient,
command: TurnDaemonCommand, command: TurnDaemonCommand,
requestId: string, requestId: string
createdAt?: Date
): Promise<void> { ): Promise<void> {
await db.inputEvent.create({ await db.inputEvent.create({
data: { data: {
@@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
eventType: command.type, eventType: command.type,
payload: asJson(command), payload: asJson(command),
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
...(createdAt ? { createdAt } : {}), // PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
// authoritative game coordinate when the daemon claims it.
}, },
}); });
} }
@@ -192,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
} }
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> { private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
while (Date.now() < deadline) { while (performance.now() < deadline) {
const event = await this.db.inputEvent.findUnique({ const event = await this.db.inputEvent.findUnique({
where: { requestId }, where: { requestId },
select: { status: true, result: true, error: true }, select: { status: true, result: true, error: true },
@@ -204,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
if (event?.status === 'FAILED') { if (event?.status === 'FAILED') {
throw new FailedTurnDaemonCommandError(requestId, event.error); throw new FailedTurnDaemonCommandError(requestId, event.error);
} }
await delay(Math.min(50, Math.max(1, deadline - Date.now()))); await delay(Math.min(50, Math.max(1, deadline - performance.now())));
} }
return null; return null;
} }
+74 -50
View File
@@ -1,6 +1,11 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra'; import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type DatabaseClient as InfraDatabaseClient,
} from '@sammo-ts/infra';
import type { DatabaseClient } from './context.js'; import type { DatabaseClient } from './context.js';
@@ -29,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>; $executeRawUnsafe(query: string): Promise<number>;
}; };
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string => const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => { JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') { if (typeof entry === 'bigint') {
@@ -85,6 +88,9 @@ const insertPendingIfAbsent = async (
actor_user_id, actor_user_id,
status, status,
attempts, attempts,
accepted_game_tick,
accepted_clock_revision,
accepted_deadline_generation,
created_at created_at
) )
VALUES ( VALUES (
@@ -95,6 +101,9 @@ const insertPendingIfAbsent = async (
${options.actorUserId}, ${options.actorUserId},
'PENDING'::"InputEventStatus", 'PENDING'::"InputEventStatus",
0, 0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
ON CONFLICT (request_id) DO NOTHING ON CONFLICT (request_id) DO NOTHING
@@ -153,20 +162,22 @@ const claimInputEvent = async (
requestId: string, requestId: string,
payloadIdentity: ApiInputPayloadIdentity payloadIdentity: ApiInputPayloadIdentity
): Promise<void> => { ): Promise<void> => {
await db.inputEvent.update({ await db.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
payload: asJson(payloadIdentity), status = 'PROCESSING'::"InputEventStatus",
status: 'PROCESSING', result = NULL,
result: GamePrisma.DbNull, error = NULL,
error: null, attempts = attempts + 1,
attempts: { increment: 1 }, locked_by = NULL,
lockedBy: null, lease_until = NULL,
leaseUntil: null, processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), processing_game_tick = NULL,
completedAt: null, processing_clock_revision = NULL,
}, processing_deadline_generation = NULL,
}); completed_at = NULL
WHERE request_id = ${requestId}
`);
}; };
const markUnexpectedFailure = async ( const markUnexpectedFailure = async (
@@ -177,6 +188,7 @@ const markUnexpectedFailure = async (
actorUserId: string | null; actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity; payloadIdentity: ApiInputPayloadIdentity;
error: unknown; error: unknown;
acquireClockFence: boolean;
} }
): Promise<void> => { ): Promise<void> => {
if (!db.$transaction) return; if (!db.$transaction) return;
@@ -184,6 +196,9 @@ const markUnexpectedFailure = async (
try { try {
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
if (options.acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, options); await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId); const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options); const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
@@ -192,20 +207,19 @@ const markUnexpectedFailure = async (
// late failure recorder must never replace its durable success. // late failure recorder must never replace its durable success.
return; return;
} }
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId: options.requestId }, UPDATE input_event
data: { SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
payload: asJson(options.payloadIdentity), status = 'FAILED'::"InputEventStatus",
status: 'FAILED', result = NULL,
result: GamePrisma.DbNull, error = ${message},
error: message, attempts = attempts + 1,
attempts: { increment: 1 }, locked_by = NULL,
lockedBy: null, lease_until = NULL,
leaseUntil: null, processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${options.requestId}
}, `);
});
}); });
} catch { } catch {
// Preserve the transaction failure that the caller actually observed. If // Preserve the transaction failure that the caller actually observed. If
@@ -220,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
eventType: string; eventType: string;
payload: unknown; payload: unknown;
actorUserId?: string | null; actorUserId?: string | null;
acquireClockFence?: boolean;
execute(db: DatabaseClient): Promise<T>; execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => { }): Promise<T> => {
const { db, requestId, eventType, payload, execute } = options; const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null; const actorUserId = options.actorUserId ?? null;
const acquireClockFence = options.acquireClockFence !== false;
const payloadIdentity = createApiInputPayloadIdentity(payload); const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) { if (!db.$transaction) {
return execute(db); return execute(db);
@@ -233,6 +249,9 @@ export const executeInputEvent = async <T>(options: {
let outcome: InputEventOutcome<T>; let outcome: InputEventOutcome<T>;
try { try {
outcome = await db.$transaction(async (transaction) => { outcome = await db.$transaction(async (transaction) => {
if (acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity }); await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
const row = await lockInputEvent(transaction, requestId); const row = await lockInputEvent(transaction, requestId);
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity }); const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
@@ -258,36 +277,41 @@ export const executeInputEvent = async <T>(options: {
try { try {
const value = await execute(transaction); const value = await execute(transaction);
const durableResult = canonicalJsonValue(value); const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET status = 'SUCCEEDED'::"InputEventStatus",
status: 'SUCCEEDED', result = CAST(${JSON.stringify(durableResult)} AS jsonb),
result: asJson(durableResult), error = NULL,
error: null, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${requestId}
}, `);
});
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value }; return { kind: 'executed', value };
} catch (error) { } catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.'; const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET status = 'FAILED'::"InputEventStatus",
status: 'FAILED', result = NULL,
result: GamePrisma.DbNull, error = ${message},
error: message, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${requestId}
}, `);
});
return { kind: 'failed', error }; return { kind: 'failed', error };
} }
}); });
} catch (error) { } catch (error) {
if (businessStarted && !(error instanceof DuplicateInputEventError)) { if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error }); await markUnexpectedFailure(db, {
requestId,
eventType,
actorUserId,
payloadIdentity,
error,
acquireClockFence,
});
} }
throw error; throw error;
} }
+30 -2
View File
@@ -1,5 +1,6 @@
import type { GameApiContext, WorldStateRow } from '../context.js'; import type { GameApiContext, WorldStateRow } from '../context.js';
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
export type MapCityCompact = [number, number, number, number, number, number]; export type MapCityCompact = [number, number, number, number, number, number];
@@ -16,6 +17,7 @@ export type BaseMapResult = {
initialLevel: number; initialLevel: number;
increaseYears: number; increaseYears: number;
}; };
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
cityList: MapCityCompact[]; cityList: MapCityCompact[];
nationList: MapNationCompact[]; nationList: MapNationCompact[];
}; };
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
const BASE_MAP_TTL_SECONDS = 30; const BASE_MAP_TTL_SECONDS = 30;
const PUBLIC_MAP_TTL_SECONDS = 600; const PUBLIC_MAP_TTL_SECONDS = 600;
const resolveStartYear = (worldState: WorldStateRow): number => { const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta); const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = scenarioMeta.startYear; const startYear = scenarioMeta.startYear;
@@ -87,6 +89,28 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe
}; };
}; };
// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다.
export const resolveMapUniqueItemLimit = (
worldState: Pick<WorldStateRow, 'config' | 'meta' | 'currentYear'>
): BaseMapResult['uniqueItemLimit'] => {
const config = resolveUniqueConfig(asRecord(asRecord(worldState.config).const));
const startYear = resolveStartYear(worldState);
const relativeYear = worldState.currentYear - startYear;
const slotCount = Object.keys(config.allItems).length;
let count = Math.min(1, slotCount);
for (const [targetYear, targetCount] of config.maxUniqueItemLimit) {
const nextCount = Math.min(targetCount, slotCount);
if (relativeYear < targetYear) {
if (nextCount !== count) {
return { count, until: { year: startYear + targetYear - 1, month: 12 } };
}
} else {
count = nextCount;
}
}
return { count, until: null };
};
const normalizeNumberRecord = (value: unknown): Record<number, number> => { const normalizeNumberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) { if (!isRecord(value)) {
return {}; return {};
@@ -154,7 +178,10 @@ const loadBaseMap = async (
} }
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) as BaseMapResult; const parsed = JSON.parse(cached) as BaseMapResult;
if (parsed.uniqueItemLimit) {
return parsed;
}
} catch { } catch {
// Ignore cache parse errors. // Ignore cache parse errors.
} }
@@ -207,6 +234,7 @@ const loadBaseMap = async (
year: worldState.currentYear, year: worldState.currentYear,
month: worldState.currentMonth, month: worldState.currentMonth,
techLevelLimit: resolveTechLevelLimit(worldState), techLevelLimit: resolveTechLevelLimit(worldState),
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
cityList, cityList,
nationList, nationList,
}; };
+146 -85
View File
@@ -1,9 +1,13 @@
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import {
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; enqueuePrivateMessageWebPush,
GamePrisma,
persistMessageEnvelope,
type MessageGameContext,
} from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime } from '../services/gameClock.js';
export interface MessageView { export interface MessageView {
id: number; id: number;
@@ -22,7 +26,9 @@ interface MessageRow {
src: number; src: number;
dest: number; dest: number;
time: Date; time: Date;
valid_until: Date; created_at_wall: Date;
action_status: string | null;
expires_game_tick: bigint | null;
message: unknown; message: unknown;
} }
@@ -48,70 +54,73 @@ const formatMessageTime = (value: Date): string => {
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; )} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
}; };
const messageValidityPredicate = (gameTime: CurrentGameTime) => { const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
if (gameTime.tick === null) {
// A legacy or partially migrated profile has no authoritative logical
// tick. Rows that already carry a tick still need the wall-time
// fallback used by the clock migration.
return GamePrisma.sql`valid_until > ${gameTime.now}`;
}
return GamePrisma.sql`(
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)`;
};
const toMessageView = (row: MessageRow): MessageView => {
const payload = parsePayload(row.message); const payload = parsePayload(row.message);
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
const actionState =
actionStatus === null
? null
: actionStatus === 'PENDING'
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
? 'expired'
: 'pending'
: actionStatus === 'RESOLVED'
? 'resolved'
: 'unavailable';
return { return {
id: row.id, id: row.id,
msgType: row.type, msgType: row.type,
src: payload.src, src: payload.src,
dest: row.type === 'public' ? null : payload.dest, dest: row.type === 'public' ? null : payload.dest,
text: payload.text, text: payload.text,
option: payload.option ?? null, option:
time: formatMessageTime(new Date(row.time)), actionState !== null && payload.option && typeof payload.option === 'object'
? { ...payload.option, actionState, used: actionState !== 'pending' }
: (payload.option ?? null),
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
}; };
}; };
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => { export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
let gameContext: MessageGameContext | null = null;
if (typeof action === 'string' && action !== '') {
const gameTime = await loadCurrentGameTime(db); const gameTime = await loadCurrentGameTime(db);
const toTickOrNull = (date: Date): bigint | null => { if (
// Ref represents its unlimited 9999-12-31 message lifetime with the gameTime.tick === null ||
// largest safe game tick instead of falling back to a wall-clock-only row. gameTime.revision === null ||
if (date.getUTCFullYear() >= 9000) { gameTime.revision === undefined ||
return BigInt(MAX_SAFE_GAME_TICK); gameTime.deadlineGeneration === null ||
gameTime.deadlineGeneration === undefined
) {
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
} }
try { let expiresGameTick: bigint | null = null;
const tick = gameTime.dateToTick(date); if (draft.validUntil.getUTCFullYear() < 9000) {
return tick === null ? null : BigInt(tick); const expires = gameTime.dateToTick(draft.validUntil);
} catch { if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
return null; expiresGameTick = BigInt(expires);
} }
gameContext = {
occurredGameTick: BigInt(gameTime.tick),
clockRevision: BigInt(gameTime.revision),
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
expiresGameTick,
}; };
const rows = await db.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to insert message row.');
} }
const id = await persistMessageEnvelope(db, draft, gameContext);
await enqueuePrivateMessageWebPush(db, draft, id); await enqueuePrivateMessageWebPush(db, draft, id);
return id; return id;
}; };
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
const gameTime = await loadCurrentGameTime(db);
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
return rows.map((row) => toMessageView(row, currentGameTick));
};
export const fetchMessagesFromMailbox = async (params: { export const fetchMessagesFromMailbox = async (params: {
db: DatabaseClient; db: DatabaseClient;
mailbox: number; mailbox: number;
@@ -120,19 +129,20 @@ export const fetchMessagesFromMailbox = async (params: {
fromSeq: number; fromSeq: number;
}): Promise<MessageView[]> => { }): Promise<MessageView[]> => {
const fromSeq = Math.max(params.fromSeq, 0); const fromSeq = Math.max(params.fromSeq, 0);
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>` const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE mailbox = ${params.mailbox} ma.status AS action_status, ma.expires_game_tick
AND type = ${params.msgType} FROM message m
AND ${messageValidityPredicate(gameTime)} LEFT JOIN message_action ma ON ma.message_id = m.id
AND id >= ${fromSeq} WHERE m.mailbox = ${params.mailbox}
ORDER BY id DESC AND m.type = ${params.msgType}
AND m.id >= ${fromSeq}
ORDER BY m.id DESC
LIMIT ${params.limit} LIMIT ${params.limit}
`; `;
return rows.map(toMessageView); return loadMessageViews(params.db, rows);
}; };
export const fetchOldMessagesFromMailbox = async (params: { export const fetchOldMessagesFromMailbox = async (params: {
@@ -142,28 +152,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
toSeq: number; toSeq: number;
limit: number; limit: number;
}): Promise<MessageView[]> => { }): Promise<MessageView[]> => {
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>` const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE mailbox = ${params.mailbox} ma.status AS action_status, ma.expires_game_tick
AND type = ${params.msgType} FROM message m
AND ${messageValidityPredicate(gameTime)} LEFT JOIN message_action ma ON ma.message_id = m.id
AND id < ${params.toSeq} WHERE m.mailbox = ${params.mailbox}
ORDER BY id DESC AND m.type = ${params.msgType}
AND m.id < ${params.toSeq}
ORDER BY m.id DESC
LIMIT ${params.limit} LIMIT ${params.limit}
`; `;
return rows.map(toMessageView); return loadMessageViews(params.db, rows);
}; };
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => { export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>` const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE id = ${id} ma.status AS action_status, ma.expires_game_tick
AND ${messageValidityPredicate(gameTime)} FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.id = ${id}
LIMIT 1 LIMIT 1
`; `;
const row = rows[0]; const row = rows[0];
@@ -172,20 +184,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
id: row.id, id: row.id,
mailbox: row.mailbox, mailbox: row.mailbox,
msgType: row.type, msgType: row.type,
time: new Date(row.time), time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message), payload: parsePayload(row.message),
}; };
}; };
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => { export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db); const gameTime = await loadCurrentGameTime(db);
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
const rows = await db.$queryRaw<MessageRow[]>` const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE id = ${id} ma.status AS action_status, ma.expires_game_tick
AND ${messageValidityPredicate(gameTime)} FROM message m
JOIN message_action ma ON ma.message_id = m.id
JOIN world_state world ON TRUE
WHERE m.id = ${id}
AND ma.status = 'PENDING'
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
AND world.clock_phase IN ('RUNNING', 'MANUAL')
AND ma.clock_revision = world.clock_revision
AND ma.deadline_generation = world.deadline_generation
LIMIT 1 LIMIT 1
FOR UPDATE FOR UPDATE OF m, ma, world
`; `;
const row = rows[0]; const row = rows[0];
if (!row) return null; if (!row) return null;
@@ -193,7 +214,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
id: row.id, id: row.id,
mailbox: row.mailbox, mailbox: row.mailbox,
msgType: row.type, msgType: row.type,
time: new Date(row.time), time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message), payload: parsePayload(row.message),
}; };
}; };
@@ -202,16 +223,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return; if (uniqueIds.length === 0) return;
const gameTime = await loadCurrentGameTime(db); const gameTime = await loadCurrentGameTime(db);
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
});
await db.message.updateMany({ await db.message.updateMany({
where: { id: { in: uniqueIds } }, where: { id: { in: uniqueIds } },
data: { data: {
validUntil: gameTime.now, validUntil: gameTime.now,
// A partially migrated profile can still carry a legacy logical validUntilTick: BigInt(gameTime.tick),
// sentinel even while no authoritative clock exists. Replace it
// with an already-expired logical tick when expiring by wall time;
// NULL would fall back to the wall timestamp after clock recovery
// and could make the handled message visible again.
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
}, },
}); });
}; };
@@ -233,8 +254,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
END END
) || jsonb_build_object('invalid', true), ) || jsonb_build_object('invalid', true),
true true
) ),
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id IN (${GamePrisma.join(uniqueIds)}) WHERE id IN (${GamePrisma.join(uniqueIds)})
` `
); );
}; };
export const tombstoneMessagesWithinDeleteWindow = async (
db: DatabaseClient,
authorityMessageId: number,
ids: number[]
): Promise<number[]> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return [];
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
WITH wall AS (
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
), authority AS (
SELECT m.id
FROM message m, wall
WHERE m.id = ${authorityMessageId}
AND m.tombstoned_at_wall IS NULL
AND m.delete_until_wall >= wall.now_wall
FOR UPDATE
)
UPDATE message m
SET message = jsonb_set(
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
'{option}',
(
CASE
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
ELSE '{}'::jsonb
END
) || jsonb_build_object('invalid', true),
true
),
tombstoned_at_wall = wall.now_wall
FROM wall
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
AND EXISTS (SELECT 1 FROM authority)
RETURNING m.id
`);
return rows.map(({ id }) => id).sort((left, right) => left - right);
};
+1 -4
View File
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
color: '#000000', color: '#000000',
}; };
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
export const resolveNationInfo = async ( export const resolveNationInfo = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number nationId: number
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => { export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
const nation = await resolveNationInfo(db, general.nationId); const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return { return {
generalId: general.id, generalId: general.id,
generalName: general.name, generalName: general.name,
nationId: general.nationId, nationId: general.nationId,
nationName: nation.name, nationName: nation.name,
color: nation.color, color: nation.color,
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`, icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
}; };
}; };
+5 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { import {
readModelOutboxPayloadToDiplomacyMailboxes,
readModelOutboxPayloadToChanges, readModelOutboxPayloadToChanges,
readModelOutboxPayloadToMessageMailboxes, readModelOutboxPayloadToMessageMailboxes,
type ReadModelDomain, type ReadModelDomain,
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
'access.general', 'access.general',
'dashboard.global', 'dashboard.global',
'messages.mailbox', 'messages.mailbox',
'messages.diplomacyMailbox',
'tournament', 'tournament',
'betting', 'betting',
]); ]);
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
this.db, this.db,
async (payload) => { async (payload) => {
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload); const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
if (mailboxes.length > 0) { const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes); if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
} }
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) { if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
const changes = readModelOutboxPayloadToChanges(payload); const changes = readModelOutboxPayloadToChanges(payload);
+16 -3
View File
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => { const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const seen = new Set<string>(); const seen = new Set<string>();
return identities.filter((identity) => { return identities.filter((identity) => {
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`; const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
const changes = eventChanges(event); const changes = eventChanges(event);
if (!changes) return false; if (!changes) return false;
const generalId = identity.generalId; const generalId = identity.generalId;
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
return [ return [
changes.generalIds, changes.generalIds,
changes.mapGeneralIds ?? changes.generalIds, changes.mapGeneralIds ?? changes.generalIds,
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
); );
if (event.type === 'messageCreated' || event.type === 'messagesChanged') { if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes; const mailboxes =
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
const diplomacyMailboxes =
event.type === 'messageCreated'
? event.msgType === 'diplomacy'
? [event.mailbox]
: []
: (event.diplomacyMailboxes ?? []);
return viewers.some(
(identity) =>
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
(identity.canReadDiplomacy &&
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
)
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() } ? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null; : null;
} }
+6 -2
View File
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
export const publishRealtimeMessageChanges = async ( export const publishRealtimeMessageChanges = async (
redis: RedisConnector['client'], redis: RedisConnector['client'],
profileName: string, profileName: string,
mailboxes: readonly number[] mailboxes: readonly number[],
diplomacyMailboxes: readonly number[] = []
): Promise<void> => { ): Promise<void> => {
if (mailboxes.length === 0) return; if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
await publishRealtimeEvent(redis, profileName, { await publishRealtimeEvent(redis, profileName, {
type: 'messagesChanged', type: 'messagesChanged',
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right), mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
...(diplomacyMailboxes.length > 0
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
: {}),
}); });
}; };
+4 -10
View File
@@ -60,10 +60,7 @@ export interface AuctionDetail {
export const hasAuctionClosePassed = ( export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null } time: { now: Date; tick: number | null }
): boolean => ): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
interface AuctionBidRow { interface AuctionBidRow {
id: number; id: number;
@@ -433,7 +430,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -448,7 +444,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]); ]);
return { ok: true }; return { ok: true };
@@ -511,7 +507,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -526,7 +521,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]); ]);
return { ok: true }; return { ok: true };
@@ -650,7 +645,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false, tryExtendCloseDate: input.tryExtendCloseDate ?? false,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -665,7 +659,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]); ]);
return { ok: true }; return { ok: true };
+40 -5
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra'; import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { z } from 'zod'; import { z } from 'zod';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
return world; return world;
}; };
interface BettingClockFenceRow {
currentYear: number;
currentMonth: number;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
}
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
SELECT current_year AS "currentYear",
current_month AS "currentMonth",
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
const world = rows[0];
if (!world) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
}
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
});
}
return world;
};
export const bettingRouter = router({ export const bettingRouter = router({
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()) getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
.query(async ({ ctx, input }) => { async ({ ctx, input }) => {
requireUserId(ctx.auth); requireUserId(ctx.auth);
await getMyGeneral(ctx); await getMyGeneral(ctx);
const [world, rows] = await Promise.all([ const [world, rows] = await Promise.all([
@@ -66,7 +100,8 @@ export const bettingRouter = router({
year: world.currentYear, year: world.currentYear,
month: world.currentMonth, month: world.currentMonth,
}; };
}), }
),
getDetail: authedProcedure getDetail: authedProcedure
.input(z.object({ bettingId: z.number().int().positive() })) .input(z.object({ bettingId: z.number().int().positive() }))
@@ -141,7 +176,7 @@ export const bettingRouter = router({
if (betting.finished) { if (betting.finished) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
} }
const world = await loadWorldDate(ctx.db); const world = await lockBettingClockFence(ctx.db);
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth); const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
if (betting.closeYearMonth <= yearMonth) { if (betting.closeYearMonth <= yearMonth) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
+8 -10
View File
@@ -15,7 +15,7 @@ import {
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js'; import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
import { insertMessage } from '../../messages/store.js'; import { insertMessage } from '../../messages/store.js';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { readDatabaseWallTime } from '../../services/wallClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
await sendMessage(store, { ...draft, msgType: 'national' }); await sendMessage(store, { ...draft, msgType: 'national' });
} }
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId); const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId); options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
}; };
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => { const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
@@ -306,8 +307,6 @@ export const diplomacyRouter = router({
nationColor: destNation.color, nationColor: destNation.color,
}, },
}; };
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({ const created = await ctx.db.diplomacyLetter.create({
data: { data: {
srcNationId: srcNation.id, srcNationId: srcNation.id,
@@ -316,7 +315,6 @@ export const diplomacyRouter = router({
state: 'PROPOSED', state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief), textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail), textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id, srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue, aux: aux as GamePrisma.InputJsonValue,
}, },
@@ -332,7 +330,7 @@ export const diplomacyRouter = router({
src: srcTarget, src: srcTarget,
dest: destTarget, dest: destTarget,
text, text,
time: letterDate, time: created.date,
}); });
return { id: created.id }; return { id: created.id };
@@ -371,7 +369,7 @@ export const diplomacyRouter = router({
); );
const messageSrc = buildActorTarget(me, destNation); const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation); const messageDest = buildNationTarget(srcNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux); const aux = asRecord(letter.aux);
let messageText: string; let messageText: string;
if (input.agree) { if (input.agree) {
@@ -458,7 +456,7 @@ export const diplomacyRouter = router({
); );
const messageSrc = buildActorTarget(me, srcNation); const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation); const messageDest = buildNationTarget(destNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux); const aux = asRecord(letter.aux);
aux.reason = { aux.reason = {
who: me.id, who: me.id,
@@ -519,7 +517,7 @@ export const diplomacyRouter = router({
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation; const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation); const messageSrc = buildActorTarget(me, actorNation);
const messageDest = buildNationTarget(otherNation); const messageDest = buildNationTarget(otherNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
let resultState: 'ACTIVATED' | 'CANCELLED'; let resultState: 'ACTIVATED' | 'CANCELLED';
let messageText: string; let messageText: string;
+9 -1
View File
@@ -38,6 +38,8 @@ const BUFF_KEYS: InheritBuffType[] = [
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
const POSTGRES_INTEGER_MAX = 2_147_483_647; const POSTGRES_INTEGER_MAX = 2_147_483_647;
const MIN_INHERIT_BUFF_LEVEL = 1;
const MAX_INHERIT_BUFF_LEVEL = 5;
const parseBuffRecord = (raw: unknown): Record<string, number> => { const parseBuffRecord = (raw: unknown): Record<string, number> => {
if (typeof raw === 'string') { if (typeof raw === 'string') {
@@ -302,7 +304,7 @@ export const inheritRouter = router({
.input( .input(
z.object({ z.object({
type: z.enum(BUFF_KEYS), type: z.enum(BUFF_KEYS),
level: z.number().int().min(1).max(5), level: z.number().int(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -310,6 +312,12 @@ export const inheritRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
if (input.level < MIN_INHERIT_BUFF_LEVEL || input.level > MAX_INHERIT_BUFF_LEVEL) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
});
}
const result = await requestInheritanceAction(ctx, userId, { const result = await requestInheritanceAction(ctx, userId, {
action: 'buyHiddenBuff', action: 'buyHiddenBuff',
+31 -17
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js'; import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { import {
isWarTraitKey, isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS, JOIN_PERSONALITY_TRAIT_KEYS,
@@ -334,6 +335,11 @@ export const joinRouter = router({
}; };
}); });
const serverId = asRecord(worldState.meta).serverId;
const history =
typeof serverId === 'string'
? await ctx.db.gameHistory.findUnique({ where: { serverId }, select: { status: true } })
: null;
const inheritConst = resolveInheritConstants(worldState); const inheritConst = resolveInheritConstants(worldState);
const inheritTotalPoint = ctx.auth?.user.id const inheritTotalPoint = ctx.auth?.user.id
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous') ? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
@@ -371,6 +377,7 @@ export const joinRouter = router({
npcGeneralCount, npcGeneralCount,
}, },
inherit: { inherit: {
enabled: history?.status !== 'COMPLETED',
totalPoint: inheritTotalPoint, totalPoint: inheritTotalPoint,
costs: { costs: {
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint, inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
@@ -393,15 +400,12 @@ export const joinRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const gameTime = await loadCurrentGameTime(ctx.db);
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId); const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve', type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
userId, userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId, seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
return resolveSelectionReservationCommandResult(result); return resolveSelectionReservationCommandResult(result);
}), }),
@@ -431,7 +435,6 @@ export const joinRouter = router({
}); });
} }
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create'); const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate', type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -440,8 +443,6 @@ export const joinRouter = router({
uniqueName: input.uniqueName, uniqueName: input.uniqueName,
personality: input.personality, personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon ...(selectedIcon
? { ? {
ownerPicture: selectedIcon.picture, ownerPicture: selectedIcon.picture,
@@ -471,15 +472,12 @@ export const joinRouter = router({
input.clientRequestId, input.clientRequestId,
'reselect' 'reselect'
); );
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect', type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
userId, userId,
ownerDisplayName: auth.user.displayName, ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName, uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
return resolveSelectionCommandResult(result, 'selectPoolReselect'); return resolveSelectionCommandResult(result, 'selectPoolReselect');
}), }),
@@ -583,30 +581,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
}); });
} }
const worldState = await ctx.db.worldState.findFirst(); try {
if (!worldState) { return await ctx.db.$transaction!(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
SELECT clock_phase AS "clockPhase"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (!clockRows[0]) {
throw new TRPCError({ throw new TRPCError({
code: 'PRECONDITION_FAILED', code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.', message: 'World state is not initialized.',
}); });
} }
try { if (!['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'COMPLETED'].includes(clockRows[0].clockPhase)) {
const gameTime = await loadCurrentGameTime(ctx.db); throw new TRPCError({
if (gameTime.tick === null) { code: 'PRECONDITION_FAILED',
message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
const gameTime = await loadCurrentGameTime(transaction);
if (!worldState || gameTime.tick === null) {
throw new TRPCError({ throw new TRPCError({
code: 'PRECONDITION_FAILED', code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.', message: 'Game clock is not initialized.',
}); });
} }
return await reserveNpcPossessionCandidates({ return reserveNpcPossessionCandidates({
db: ctx.db, db: transaction,
worldState, worldState,
userId: auth.user.id, userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id, ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh, refresh: input.refresh,
keepIds: input.keepIds, keepIds: input.keepIds,
now: gameTime.now, now: gameTime.now,
acceptedGameTick: gameTime.tick, createdGameTick: gameTime.tick,
});
}); });
} catch (error) { } catch (error) {
if (error instanceof NpcPossessionError) { if (error instanceof NpcPossessionError) {
+1
View File
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
clockMode: gameTime.mode ?? 'realtime', clockMode: gameTime.mode ?? 'realtime',
clockRunning: gameTime.running, clockRunning: gameTime.running,
clockStartsAt: gameTime.startsAt?.toISOString() ?? null, clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
clockRecovery: gameTime.recovery ?? null,
turnEngineRunning, turnEngineRunning,
otherTextInfo: worldState.meta.otherTextInfo ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '',
npcMode: worldState.config.npcMode ?? 0, npcMode: worldState.config.npcMode ?? 0,
+101 -28
View File
@@ -1,11 +1,19 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord, ChangeJournal } from '@sammo-ts/common';
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import {
accessLimitAuthedInputProcedure,
accessWallAuthedInputProcedure,
authedProcedure,
engineAuthedProcedure,
router,
wallAuthedProcedure,
} from '../../trpc.js';
import { import {
MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC, MESSAGE_MAILBOX_PUBLIC,
@@ -20,13 +28,13 @@ import {
fetchOldMessagesFromMailbox, fetchOldMessagesFromMailbox,
fetchMessageById, fetchMessageById,
insertMessage, insertMessage,
tombstoneMessages, tombstoneMessagesWithinDeleteWindow,
type MessageView, type MessageView,
} from '../../messages/store.js'; } from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js'; import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { executeInputEvent } from '../../inputEventBoundary.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -41,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
return { return {
...message, ...message,
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { ...(message.option ?? {}), permissionRedacted: true },
}; };
}); });
}; };
@@ -68,9 +77,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
return value === true || value === 1 || value === '1'; return value === true || value === 1 || value === '1';
}; };
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => { const markMessageMailboxes = (
ctx: Pick<GameApiContext, 'changeJournal'>,
mailboxes: Iterable<number>,
msgType?: MessageType
): void => {
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
for (const mailbox of mailboxes) { for (const mailbox of mailboxes) {
ctx.changeJournal?.mark('messages.mailbox', mailbox); ctx.changeJournal?.mark(domain, mailbox);
} }
}; };
@@ -231,7 +245,7 @@ export const messagesRouter = router({
})), })),
}; };
}), }),
readLatest: authedProcedure readLatest: wallAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -264,7 +278,7 @@ export const messagesRouter = router({
`; `;
return { ok: true }; return { ok: true };
}), }),
delete: authedProcedure delete: wallAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -289,17 +303,16 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) { if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
} }
const { now } = await loadCurrentGameTime(ctx.db);
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID; const receiverMessageId = message.payload.option?.receiverMessageID;
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national'; const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [ const ids = [
message.id, message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
]; ];
await tombstoneMessages(ctx.db, ids); const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
if (!deletedIds.includes(message.id)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMailbox = const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId ? message.payload.dest.generalId
@@ -308,10 +321,14 @@ export const messagesRouter = router({
message.msgType === 'national' message.msgType === 'national'
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId ? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null; : null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); markMessageMailboxes(
return { ok: true, deletedIds: ids }; ctx,
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
message.msgType
);
return { ok: true, deletedIds };
}), }),
respond: authedProcedure respond: engineAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -326,6 +343,9 @@ export const messagesRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
} }
const action = message.payload.option?.action; const action = message.payload.option?.action;
if (message.payload.option?.invalid === true) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
}
if (action === 'scout' || action === 'raiseInvader') { if (action === 'scout' || action === 'raiseInvader') {
if (!ctx.auth) { if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -342,29 +362,80 @@ export const messagesRouter = router({
} }
return { result: commandResult.ok, reason: commandResult.reason }; return { result: commandResult.ok, reason: commandResult.reason };
} }
const result = await respondToDiplomaticMessage({ const ownsChangeJournal = !ctx.changeJournal;
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
let journalPersisted = false;
const response = await executeInputEvent({
db: ctx.db, db: ctx.db,
actor: general, requestId: `messages.respond.diplomatic:${input.messageId}`,
eventType: 'messages.respond.diplomatic',
payload: input,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const transactionContext = { ...ctx, db: transaction, changeJournal };
const transactionGeneral = ownsChangeJournal
? await getOwnedGeneral(transactionContext, input.generalId)
: general;
const result = await respondToDiplomaticMessage({
db: transaction,
actor: transactionGeneral,
messageId: input.messageId, messageId: input.messageId,
response: input.response, response: input.response,
}); });
markMessageMailboxes(ctx, result.affectedMailboxes); markMessageMailboxes(transactionContext, result.affectedMailboxes);
for (const generalId of result.affectedGeneralRecordIds) { for (const generalId of result.affectedGeneralRecordIds) {
ctx.changeJournal?.mark('records.general', generalId); changeJournal.mark('records.general', generalId);
} }
for (const nationId of result.affectedNationIds) { for (const nationId of result.affectedNationIds) {
ctx.changeJournal?.mark('nation.content', nationId); changeJournal.mark('nation.content', nationId);
} }
for (const cityId of result.affectedCityIds) { for (const cityId of result.affectedCityIds) {
ctx.changeJournal?.mark('city.content', cityId); changeJournal.mark('city.content', cityId);
} }
if (result.affectedCityIds.length > 0) { if (result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('map.world'); changeJournal.mark('map.world');
} }
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) { if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('dashboard.global'); changeJournal.mark('dashboard.global');
} }
return { result: result.result, reason: result.reason }; if (ownsChangeJournal) {
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
}
return {
result: result.result,
reason: result.reason,
affectedNationIds: result.affectedNationIds,
affectedCityIds: result.affectedCityIds,
};
},
});
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const synchronized = await ctx.turnDaemon.requestCommand({
type: 'syncDiplomaticResponse',
userId: ctx.auth.user.id,
generalId: general.id,
messageId: input.messageId,
nationIds: response.affectedNationIds,
cityIds: response.affectedCityIds,
});
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
const synchronizationReason =
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
});
}
}
return { result: response.result, reason: response.reason };
}), }),
getOld: authedProcedure getOld: authedProcedure
.input( .input(
@@ -420,7 +491,7 @@ export const messagesRouter = router({
...messageBuckets, ...messageBuckets,
}; };
}), }),
send: accessAuthedInputProcedure( send: accessWallAuthedInputProcedure(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
mailbox: z.number().int(), mailbox: z.number().int(),
@@ -436,7 +507,9 @@ export const messagesRouter = router({
} }
const src = await buildTargetFromGeneral(ctx.db, general); const src = await buildTargetFromGeneral(ctx.db, general);
const { now } = await loadCurrentGameTime(ctx.db); // Compatibility-only projection. persistMessageEnvelope records and
// displays the authoritative PostgreSQL wall instant.
const now = new Date();
const validUntil = new Date('9999-12-31T00:00:00Z'); const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType; let msgType: MessageType;
@@ -560,7 +633,7 @@ export const messagesRouter = router({
: msgType === 'private' : msgType === 'private'
? general.id ? general.id
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId; : MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]); markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
return { msgType, msgId: result.receiverId }; return { msgType, msgId: result.receiverId };
}), }),
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
name: general.name, name: general.name,
npcState: general.npcState, npcState: general.npcState,
injury: general.injury, injury: general.injury,
baseStats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
stats: { stats: {
leadership: woundedStat(general.leadership, general.injury), leadership: woundedStat(general.leadership, general.injury),
strength: woundedStat(general.strength, general.injury), strength: woundedStat(general.strength, general.injury),
+9 -3
View File
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
}; };
const resolveCommandEnv = ( const resolveCommandEnv = (
config: Record<string, unknown> config: Record<string, unknown>,
worldMeta: Record<string, unknown>
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => { ): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
const constValues = asRecord(config.const ?? config.consts); const constValues = asRecord(config.const ?? config.consts);
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
return { return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0), develCost: resolveNumberFromKeys(
worldMeta,
['develcost', 'develCost', 'develrate'],
configuredDevelCost
),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0), defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12), maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
}; };
@@ -409,7 +415,7 @@ export const npcRouter = router({
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const stat = resolveScenarioStat(config); const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config); const env = resolveCommandEnv(config, worldMeta);
const unitSetName = resolveUnitSetName(config, 'che'); const unitSetName = resolveUnitSetName(config, 'che');
const nationTech = readNumber(nation.tech, 0); const nationTech = readNumber(nation.tech, 0);
+105 -16
View File
@@ -5,12 +5,14 @@ import { asRecord } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic'; import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js'; import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js'; import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js'; import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime } from '../../services/gameClock.js';
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => { const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -36,13 +38,85 @@ const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured); return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
}; };
const adminProcedure = authedProcedure.use(({ ctx, next }) => { const adminProcedure = engineAuthedProcedure
.use(({ ctx, next }) => {
const roles = ctx.auth?.user.roles ?? []; const roles = ctx.auth?.user.roles ?? [];
if (!hasAdminRole(roles, ctx.profile.name)) { if (!hasAdminRole(roles, ctx.profile.name)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' }); throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
} }
return next(); return next();
}); })
.use(async ({ ctx, type, next }) => {
if (type !== 'mutation') return next({ ctx: { tournamentMutationLockHeld: false } });
// 참가·베팅은 Redis lock 안에서 ENGINE의 DB commit을 기다린다.
// 관리자도 Redis를 먼저 잡아 DB clock fence → Redis 역순 대기를 막는다.
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(() => next({ ctx: { tournamentMutationLockHeld: true } }));
})
.concat(procedure);
const withTournamentClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
profile: { name: string };
tournamentMutationLockHeld?: boolean;
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureActiveRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament mutation is disabled.',
});
}
const clockContext: TournamentClockContext = {
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
};
return store.withClockContext(clockContext, () =>
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
);
};
const withTournamentBetClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
profile: { name: string };
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
});
}
return store.withClockContext(
{
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
},
() => store.withMutationLock(operation)
);
};
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
const tournamentJoinCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentJoin:${step}` : undefined;
const zTournamentState = z.object({ const zTournamentState = z.object({
stage: z.number().int().min(0), stage: z.number().int().min(0),
@@ -143,7 +217,10 @@ export const tournamentRouter = router({
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.getState(); return store.getState();
}), }),
getAdminStatus: adminProcedure.query(async () => ({ ok: true })), getAdminStatus: adminProcedure.query(async ({ ctx }) => ({
ok: true,
clock: await loadClockAdminStatus(ctx.db),
})),
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => { getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
@@ -271,7 +348,7 @@ export const tournamentRouter = router({
}), }),
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => { setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setState({ await store.setState({
...input, ...input,
type: input.type as TournamentType, type: input.type as TournamentType,
@@ -281,7 +358,7 @@ export const tournamentRouter = router({
}), }),
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => { patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
const current = await store.getState(); const current = await store.getState();
if (!current) { if (!current) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -297,21 +374,21 @@ export const tournamentRouter = router({
}), }),
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => { setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(input); await store.setParticipants(input);
return { ok: true, count: input.length }; return { ok: true, count: input.length };
}); });
}), }),
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => { setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setMatches(input); await store.setMatches(input);
return { ok: true, count: input.length }; return { ok: true, count: input.length };
}); });
}), }),
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => { setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setBettingEntries(input); await store.setBettingEntries(input);
return { ok: true, count: input.length }; return { ok: true, count: input.length };
}); });
@@ -347,7 +424,7 @@ export const tournamentRouter = router({
}; };
}); });
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(participants); await store.setParticipants(participants);
return { ok: true, count: participants.length }; return { ok: true, count: participants.length };
}); });
@@ -391,10 +468,12 @@ export const tournamentRouter = router({
return { state, totals, myTotals, totalAmount, myAmount }; return { state, totals, myTotals, totalAmount, myAmount };
}), }),
join: authedProcedure.mutation(async ({ ctx }) => { // 참가비는 ENGINE transaction이 차감한다. API input-event transaction으로
// 감싸면 clock advisory lock을 쥔 채 child ENGINE event를 기다리게 된다.
join: engineAuthedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state || state.stage !== 1 || state.participantsLockedAt) { if (!state || state.stage !== 1 || state.participantsLockedAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
@@ -414,6 +493,7 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState); const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({ const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin', reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }], adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
}); });
@@ -449,6 +529,7 @@ export const tournamentRouter = router({
} catch (error) { } catch (error) {
await ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback', reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }], adjustments: [{ generalId: general.id, goldDelta: develCost }],
}); });
@@ -459,7 +540,7 @@ export const tournamentRouter = router({
}), }),
cancel: adminProcedure.mutation(async ({ ctx }) => { cancel: adminProcedure.mutation(async ({ ctx }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state) { if (!state) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -513,7 +594,10 @@ export const tournamentRouter = router({
return { ok: true }; return { ok: true };
}); });
}), }),
placeBet: authedProcedure // This route delegates its game mutations to durable ENGINE input events.
// Wrapping it in the API input-event transaction would hold the clock
// advisory lock while waiting for the daemon to claim the child event.
placeBet: engineAuthedProcedure
.input( .input(
z.object({ z.object({
targetId: z.number().int().positive(), targetId: z.number().int().positive(),
@@ -523,7 +607,7 @@ export const tournamentRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentBetClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state || state.stage !== 6) { if (!state || state.stage !== 6) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
@@ -558,6 +642,7 @@ export const tournamentRouter = router({
const adjustResult = await ctx.turnDaemon.requestCommand({ const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
}); });
@@ -573,6 +658,7 @@ export const tournamentRouter = router({
const rankResult = await ctx.turnDaemon.requestCommand({ const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [ adjustments: [
{ {
@@ -584,6 +670,7 @@ export const tournamentRouter = router({
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) { if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}); });
@@ -600,11 +687,13 @@ export const tournamentRouter = router({
await Promise.all([ await Promise.all([
ctx.turnDaemon.requestCommand({ ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}), }),
ctx.turnDaemon.requestCommand({ ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [ adjustments: [
{ {
+4 -1
View File
@@ -224,7 +224,10 @@ export const troopRouter = router({
name: troop.name, name: troop.name,
nationId: troop.nationId, nationId: troop.nationId,
turnTime: leader?.turnTime.toISOString() ?? null, turnTime: leader?.turnTime.toISOString() ?? null,
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [], reservedCommands:
leader?.npcState === 5
? ['집합', '집합', '집합', '집합', '집합']
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
leader: leader leader: leader
? { ? {
id: leader.id, id: leader.id,
+13
View File
@@ -17,6 +17,7 @@ import {
assertReservedTurnActionAvailable, assertReservedTurnActionAvailable,
assertReservedTurnArgsPassLegacyBasicValidation, assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions, buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
parseReservedTurnArgs, parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS, TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions, type TurnCommandInputOptions,
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
traits, traits,
moduleBundle, moduleBundle,
map, map,
itemOrder,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -310,6 +312,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
officerLevel: true, officerLevel: true,
gold: true, gold: true,
rice: true, rice: true,
leadership: true,
strength: true,
intel: true,
crew: true, crew: true,
train: true, train: true,
atmos: true, atmos: true,
@@ -327,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
loadBattleSimTraitOptions(), loadBattleSimTraitOptions(),
moduleBundlePromise, moduleBundlePromise,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)), loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
loadEquipmentTradeItemOrder(worldState.scenarioCode),
]); ]);
const nationById = new Map(nations.map((entry) => [entry.id, entry])); const nationById = new Map(nations.map((entry) => [entry.id, entry]));
@@ -380,10 +386,17 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])), troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
}); });
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
itemOrder,
configConst: asRecord(asRecord(worldState.config).const), configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules, itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0, currentSecurity: city?.security ?? 0,
generalGold: general.gold, generalGold: general.gold,
ownedItems: {
horse: general.horseCode,
weapon: general.weaponCode,
book: general.bookCode,
item: general.itemCode,
},
}); });
const inputOptions: TurnCommandInputOptions = { const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({ cities: cities.map((entry) => ({
+15 -23
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -102,18 +102,13 @@ export const hasPollEnded = (
time: CurrentGameTime time: CurrentGameTime
): boolean => ): boolean =>
Boolean(poll.closed_at) || Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => { const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null; if (!date) return null;
try {
const tick = time.dateToTick(date); const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick); if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
} catch { return BigInt(tick);
return null;
}
}; };
type VoteListRow = { type VoteListRow = {
@@ -292,7 +287,9 @@ export const voteRouter = router({
userCnt, userCnt,
}; };
}), }),
submitVote: authedProcedure // 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
submitVote: engineAuthedProcedure
.input( .input(
z.object({ z.object({
voteId: z.number().int().positive(), voteId: z.number().int().positive(),
@@ -358,7 +355,6 @@ export const voteRouter = router({
voteId: input.voteId, voteId: input.voteId,
generalId: general.id, generalId: general.id,
selection: sortedSelection, selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
throwIfCommandRejected(rewardResult); throwIfCommandRejected(rewardResult);
@@ -369,7 +365,6 @@ export const voteRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
} }
ctx.changeJournal?.mark('front.general', general.id);
return { ok: true, wonLottery: rewardResult.awardedUnique }; return { ok: true, wonLottery: rewardResult.awardedUnique };
}), }),
addComment: authedProcedure addComment: authedProcedure
@@ -399,8 +394,6 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null; : null;
const nationName = nation?.name ?? '재야'; const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment ( INSERT INTO vote_comment (
vote_id, vote_id,
@@ -418,7 +411,7 @@ export const voteRouter = router({
${general.name}, ${general.name},
${nationName}, ${nationName},
${input.text}, ${input.text},
${createdAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -451,7 +444,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const operationalAt = new Date();
let multipleOptions = input.multipleOptions; let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) { if (multipleOptions < 0) {
@@ -464,7 +456,8 @@ export const voteRouter = router({
if (input.closePrevious) { if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt} SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE closed_at IS NULL WHERE closed_at IS NULL
`); `);
} }
@@ -497,8 +490,8 @@ export const voteRouter = router({
${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt}, ${endAt},
${toGameTickOrNull(gameTime, endAt)}, ${toGameTickOrNull(gameTime, endAt)},
${operationalAt}, CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
${operationalAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -573,7 +566,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const updatedAt = new Date();
if ( if (
input.title === undefined && input.title === undefined &&
@@ -596,7 +588,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode), reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at}, end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = ${updatedAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId} WHERE id = ${input.voteId}
`); `);
@@ -609,10 +601,10 @@ export const voteRouter = router({
.input(z.object({ voteId: z.number().int().positive() })) .input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db); const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt} SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId} WHERE id = ${input.voteId}
RETURNING id RETURNING id
`); `);
+27 -7
View File
@@ -38,6 +38,7 @@ import {
shouldReloadRealtimeViewerIdentity, shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent, toPublicRealtimeEvent,
} from './realtime/publicEvent.js'; } from './realtime/publicEvent.js';
import { resolveNationPermission } from './router/nation/shared.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js'; import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
@@ -50,6 +51,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.js'; import { scopeHttpIdempotencyKey } from './requestId.js';
import { loadClockReadiness } from './services/clockReadiness.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => { const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) { if (!value) {
@@ -312,11 +314,24 @@ export const createGameApiServer = async () => {
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => { const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({ const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 }, where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true }, select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
}); });
return general if (!general) {
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId } return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
: { generalId: null, cityId: null, nationId: null }; }
const nation =
general.nationId > 0
? await postgres.prisma.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
return {
generalId: general.id,
cityId: general.cityId,
nationId: general.nationId,
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
};
}; };
let viewerIdentity = await loadViewerIdentity(); let viewerIdentity = await loadViewerIdentity();
@@ -420,12 +435,17 @@ export const createGameApiServer = async () => {
request.raw.on('aborted', close); request.raw.on('aborted', close);
}); });
app.get('/healthz', async () => ({ app.get('/healthz', async (_request, reply) => {
ok: true, const clock = await loadClockReadiness(postgres.prisma);
if (!clock.reconciliationComplete) reply.code(503);
return {
ok: clock.reconciliationComplete,
profile: config.profileName, profile: config.profileName,
postgresPool: postgres.getPoolStats(), postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(), accountIconReconciliation: accountIconResetReconciler.getHealth(),
})); clock,
};
});
try { try {
await realtimeHub.start(); await realtimeHub.start();
@@ -0,0 +1,92 @@
import { parseGameClockPhase } from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
const safeInteger = (value: bigint, label: string): number => {
const result = Number(value);
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`);
return result;
};
export const loadClockReadiness = async (db: DatabaseClient) => {
if (!db.clockProjectionOutbox) {
return {
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount: null,
};
}
const [world, incompleteOutboxCount] = await Promise.all([
db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
}),
db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }),
]);
if (!world) {
return {
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount,
};
}
const phase = parseGameClockPhase(world.clockPhase);
return {
reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
phase,
revision: safeInteger(world.clockRevision, 'clock revision'),
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
incompleteOutboxCount,
};
};
export const loadClockAdminStatus = async (db: DatabaseClient) => {
const readiness = await loadClockReadiness(db);
if (!db.clockSuspension) {
return { ...readiness, latestReconciliation: null };
}
const latest = await db.clockSuspension.findFirst({
orderBy: { createdAt: 'desc' },
include: {
participants: { orderBy: { participantKey: 'asc' } },
projectionOutbox: { orderBy: { id: 'asc' } },
},
});
if (!latest) return { ...readiness, latestReconciliation: null };
return {
...readiness,
latestReconciliation: {
id: latest.id,
source: latest.source,
policy: latest.policy,
status: latest.status,
sourceRevision: safeInteger(latest.sourceRevision, 'source revision'),
targetRevision: safeInteger(latest.targetRevision, 'target revision'),
cutTick: safeInteger(latest.cutTick, 'cut tick'),
alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'),
participantChecksumBefore: latest.participantChecksumBefore,
participantChecksumAfter: latest.participantChecksumAfter,
participants: latest.participants.map((participant) => ({
key: participant.participantKey,
policy: participant.policy,
beforeChecksum: participant.beforeChecksum,
afterChecksum: participant.afterChecksum,
affectedCount: participant.affectedCount,
})),
outbox: latest.projectionOutbox.map((entry) => ({
id: entry.id.toString(),
targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'),
status: entry.status,
attempts: entry.attempts,
lastError: entry.lastError,
})),
},
};
};
+62 -4
View File
@@ -1,13 +1,26 @@
import { GameClock, type GameClockMode } from '@sammo-ts/common'; import {
GameClock,
readTurnRecovery,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
type GameClockPhase,
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { readTurnRuntimeReady } from '@sammo-ts/infra';
export interface CurrentGameTime { export interface CurrentGameTime {
now: Date; now: Date;
wallNow: Date; wallNow: Date;
tick: number | null; tick: number | null;
mode: GameClockMode | null; mode: GameClockMode | null;
phase?: GameClockPhase | null;
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean; running: boolean;
runtimeReady?: boolean;
recovery?: { startsAt: string; endsAt: string } | null;
startsAt: Date | null; startsAt: Date | null;
dateToTick(date: Date): number | null; dateToTick(date: Date): number | null;
} }
@@ -19,6 +32,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow, wallNow,
tick: null, tick: null,
mode: null, mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true, running: true,
startsAt: null, startsAt: null,
dateToTick: () => null, dateToTick: () => null,
@@ -31,7 +47,13 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockTick: true, clockTick: true,
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
tickSeconds: true, tickSeconds: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
}, },
}); });
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) { if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
@@ -40,32 +62,68 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow, wallNow,
tick: null, tick: null,
mode: null, mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true, running: true,
startsAt: null, startsAt: null,
dateToTick: () => null, dateToTick: () => null,
}; };
} }
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime'; const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
// During the dual-read migration an older profile (or a rolling-deploy
// fixture) can lack clock_phase. Preserve the existing future-anchor
// PREOPEN contract until every profile has the authoritative column.
const phase = state.clockPhase
? parseGameClockPhase(state.clockPhase)
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
? 'PREOPEN'
: inferClockPhase(mode);
const storedTick = Number(state.clockTick); const storedTick = Number(state.clockTick);
if (!Number.isSafeInteger(storedTick)) { if (!Number.isSafeInteger(storedTick)) {
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`); throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
} }
const revision = Number(state.clockRevision ?? 1n);
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
}
const clock = new GameClock({ const clock = new GameClock({
baseTime: state.clockBaseTime, baseTime: state.clockBaseTime,
tick: storedTick, tick: storedTick,
mode, mode,
wallAnchor: state.clockWallAnchor, wallAnchor: state.clockWallAnchor,
recovery: readTurnRecovery(state),
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase,
revision,
}); });
const tick = clock.nowTick(wallNow); const runtimeReady =
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime(); phase !== 'RUNNING' ||
mode !== 'realtime' ||
state.clockRecoveryStartTick === undefined ||
(await readTurnRuntimeReady(db, state.clockRevision));
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
return { return {
now: clock.tickToDate(tick), now: clock.tickToDate(tick),
wallNow, wallNow,
tick, tick,
mode, mode,
phase,
revision,
deadlineGeneration,
running, running,
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null, runtimeReady,
recovery:
clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow
? {
startsAt: clock.recovery.startWallAt.toISOString(),
endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(),
}
: null,
startsAt:
phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date), dateToTick: (date) => clock.dateToTick(date),
}; };
}; };
@@ -0,0 +1,86 @@
import type { CurrentGameTime } from './gameClock.js';
import type { GameClockPhase } from '@sammo-ts/common';
interface ClockFenceRedis {
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
const BOOTSTRAP_CLOCK_FENCE_SCRIPT = `
local revision = redis.call('GET', KEYS[1])
local generation = redis.call('GET', KEYS[2])
local phase = redis.call('GET', KEYS[3])
if not revision and not generation and not phase then
redis.call('SET', KEYS[1], ARGV[1])
redis.call('SET', KEYS[2], ARGV[2])
redis.call('SET', KEYS[3], ARGV[3])
return 1
end
if revision == ARGV[1] and generation == ARGV[2] and phase == ARGV[3] then
return 2
end
return 0
`;
export interface ActiveRedisClockFence {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
}
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
const ensureRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime,
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => {
if (
gameTime.runtimeReady === false ||
!gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
!Number.isSafeInteger(gameTime.revision) ||
!Number.isSafeInteger(gameTime.deadlineGeneration)
) {
return null;
}
const phase: MutableProjectionPhase = gameTime.phase;
const fence: ActiveRedisClockFence = {
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
phaseKey: `sammo:${profileName}:clock:phase`,
revision: gameTime.revision!,
generation: gameTime.deadlineGeneration!,
phase,
};
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
arguments: [String(fence.revision), String(fence.generation), phase],
});
return Number(result) === 1 || Number(result) === 2 ? fence : null;
};
export const ensureActiveRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
if (!gameTime.running) return null;
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
/**
* User betting is allowed against a frozen tournament deadline while the game
* clock is suspended. Stage progression and settlement continue to use the
* RUNNING-only helper above.
*/
export const ensureBettingRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> =>
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
+18 -13
View File
@@ -1,32 +1,37 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities } from '@sammo-ts/common'; import { gatewayProfileCapabilities } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js'; import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
interface TurnDaemonLeaseSource { interface TurnDaemonLeaseSource {
turnDaemonLease: { $queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
findUnique(input: {
where: { profile: string };
select: { leaseUntil: true };
}): Promise<{ leaseUntil: Date } | null>;
};
} }
export const loadTurnEngineRunning = async ( export const loadTurnEngineRunning = async (
source: ProfileStatusSource | undefined, source: ProfileStatusSource | undefined,
db: TurnDaemonLeaseSource, db: TurnDaemonLeaseSource,
profileName: string, profileName: string,
now = new Date() now?: Date
): Promise<boolean | null> => { ): Promise<boolean | null> => {
if (!source) return null; if (!source) return null;
try { try {
const status = await source.get(profileName); const status = await source.get(profileName);
if (status === null) return null; if (status === null) return null;
if (!gatewayProfileCapabilities(status).turnsRunning) return false; if (!gatewayProfileCapabilities(status).turnsRunning) return false;
const lease = await db.turnDaemonLease.findUnique({ const wallNow = now
where: { profile: profileName }, ? GamePrisma.sql`${now}`
select: { leaseUntil: true }, : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
}); const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
return lease !== null && lease.leaseUntil.getTime() > now.getTime(); SELECT EXISTS (
SELECT 1
FROM turn_daemon_lease
WHERE profile = ${profileName}
AND lease_until > ${wallNow}
) AS running
`);
return rows[0]?.running ?? false;
} catch { } catch {
return null; return null;
} }
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
private readonly db: TurnDaemonLeaseSource, private readonly db: TurnDaemonLeaseSource,
private readonly profileName: string, private readonly profileName: string,
private readonly cacheMs = 2_000, private readonly cacheMs = 2_000,
private readonly now = () => Date.now() private readonly now = () => performance.now()
) {} ) {}
get(): Promise<boolean | null> { get(): Promise<boolean | null> {
+13
View File
@@ -0,0 +1,13 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
`);
const wallNow = rows[0]?.wallNow;
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
return new Date(wallNow);
};
@@ -1,4 +1,5 @@
import { createHmac, randomUUID } from 'node:crypto'; import { createHmac, randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common'; import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
`); `);
if (rows.length === 0) return []; if (rows.length === 0) return [];
const ids = rows.map((row) => row.id); const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({ await tx.$executeRaw(GamePrisma.sql`
where: { id: { in: ids } }, UPDATE "web_push_outbox"
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } }, SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
}); "lock_owner" = ${this.owner},
"attempts" = "attempts" + 1
WHERE "id" IN (${GamePrisma.join(ids)})
`);
return tx.webPushOutbox.findMany({ return tx.webPushOutbox.findMany({
where: { id: { in: ids }, lockOwner: this.owner }, where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
}); });
for (const event of claimed) { for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) { const expired = await this.db.$executeRaw(GamePrisma.sql`
await this.db.webPushOutbox.updateMany({ UPDATE "web_push_outbox"
where: { id: event.id, lockOwner: this.owner }, SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, "locked_at" = NULL,
}); "lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id}
AND "lock_owner" = ${this.owner}
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
`);
if (expired > 0) {
continue; continue;
} }
try { try {
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}); });
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`); if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({ await this.db.$executeRaw(GamePrisma.sql`
where: { id: event.id, lockOwner: this.owner }, UPDATE "web_push_outbox"
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
}); "locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
} catch (error) { } catch (error) {
const attempts = event.attempts; const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8)); const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({ const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
where: { id: event.id, lockOwner: this.owner }, await this.db.$executeRaw(GamePrisma.sql`
data: { UPDATE "web_push_outbox"
availableAt: new Date(Date.now() + delaySeconds * 1_000), SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
lockedAt: null, + ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
lockOwner: null, "locked_at" = NULL,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500), "lock_owner" = NULL,
}, "last_error" = ${errorText}
}); WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
this.onError(error); this.onError(error);
} }
} }
if (Date.now() >= this.nextPruneAt) { if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000; this.nextPruneAt = performance.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql` await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS ( WITH expired AS (
SELECT "id" SELECT "id"
+6
View File
@@ -8,6 +8,9 @@ export interface TournamentKeys {
sourceRevisionKey: string; sourceRevisionKey: string;
sourceRevisionChannel: string; sourceRevisionChannel: string;
realtimeEventChannel: string; realtimeEventChannel: string;
activeClockRevisionKey: string;
deadlineGenerationKey: string;
clockPhaseKey: string;
} }
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName), realtimeEventChannel: buildGameEventChannel(profileName),
activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
clockPhaseKey: `sammo:${profileName}:clock:phase`,
}); });
+64 -5
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common'; import { performance } from 'node:perf_hooks';
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import type { TournamentKeys } from './keys.js'; import type { TournamentKeys } from './keys.js';
@@ -37,8 +38,12 @@ const zTournamentState = z
openMonth: z.number().int(), openMonth: z.number().int(),
termSeconds: z.number(), termSeconds: z.number(),
nextAt: z.string(), nextAt: z.string(),
nextTick: z.number().int().safe().optional(),
clockRevision: z.number().int().positive().safe().optional(),
deadlineGeneration: z.number().int().positive().safe().optional(),
bettingId: z.number().int().optional(), bettingId: z.number().int().optional(),
bettingCloseAt: z.string().optional(), bettingCloseAt: z.string().optional(),
bettingCloseTick: z.number().int().safe().optional(),
winnerId: z.number().int().optional(), winnerId: z.number().int().optional(),
bettingSettled: z.boolean().optional(), bettingSettled: z.boolean().optional(),
rewardSettled: z.boolean().optional(), rewardSettled: z.boolean().optional(),
@@ -131,12 +136,62 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
return parsed.data; return parsed.data;
}; };
export interface TournamentClockContext {
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
revision: number;
deadlineGeneration: number;
dateToTick(date: Date): number | null;
}
const parseDeadlineTick = (value: string, context: TournamentClockContext, field: string): number => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(`Tournament ${field} is not a valid instant.`);
}
const tick = context.dateToTick(date);
if (tick === null || !Number.isSafeInteger(tick)) {
throw new Error(`Tournament ${field} cannot be represented in the active game clock.`);
}
return tick;
};
export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({
...state,
nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'),
clockRevision: context.revision,
deadlineGeneration: context.deadlineGeneration,
...(state.bettingCloseAt
? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') }
: { bettingCloseTick: undefined }),
});
const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({
activeRevisionKey: keys.activeClockRevisionKey,
deadlineGenerationKey: keys.deadlineGenerationKey,
phaseKey: keys.clockPhaseKey,
revision: context.revision,
deadlineGeneration: context.deadlineGeneration,
phase: context.phase,
});
export class TournamentStore { export class TournamentStore {
private clockContext: TournamentClockContext | null = null;
constructor( constructor(
private readonly redis: RedisClientLike, private readonly redis: RedisClientLike,
private readonly keys: TournamentKeys private readonly keys: TournamentKeys
) {} ) {}
async withClockContext<T>(context: TournamentClockContext, operation: () => Promise<T>): Promise<T> {
const previous = this.clockContext;
this.clockContext = context;
try {
return await operation();
} finally {
this.clockContext = previous;
}
}
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> { async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
if (!this.redis.del) { if (!this.redis.del) {
return operation(); return operation();
@@ -144,8 +199,8 @@ export class TournamentStore {
const lockKey = `${this.keys.stateKey}:mutation-lock`; const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID(); const token = randomUUID();
const deadline = Date.now() + timeoutMs; const deadline = performance.now() + timeoutMs;
while (Date.now() < deadline) { while (performance.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 }); const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) { if (acquired) {
try { try {
@@ -174,11 +229,15 @@ export class TournamentStore {
} }
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> { private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]); const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined;
return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence);
} }
async setState(state: TournamentState): Promise<string> { async setState(state: TournamentState): Promise<string> {
return this.writeWithSourceRevision(this.keys.stateKey, state); return this.writeWithSourceRevision(
this.keys.stateKey,
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
);
} }
async getParticipants(): Promise<TournamentParticipantEntry[]> { async getParticipants(): Promise<TournamentParticipantEntry[]> {
+4
View File
@@ -9,8 +9,12 @@ export interface TournamentState {
openMonth: number; openMonth: number;
termSeconds: number; termSeconds: number;
nextAt: string; nextAt: string;
nextTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
bettingId?: number; bettingId?: number;
bettingCloseAt?: string; bettingCloseAt?: string;
bettingCloseTick?: number;
winnerId?: number; winnerId?: number;
bettingSettled?: boolean; bettingSettled?: boolean;
rewardSettled?: boolean; rewardSettled?: boolean;
+79 -7
View File
@@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import type { TurnDaemonTransport } from '../daemon/transport.js'; import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js'; import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js'; import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js';
import type { TournamentMatchEntry, TournamentState } from './types.js'; import type { TournamentMatchEntry, TournamentState } from './types.js';
import { import {
applyGroupMatch, applyGroupMatch,
@@ -595,18 +596,34 @@ export const processTournamentTick = async (options: {
prisma: GamePrismaClient; prisma: GamePrismaClient;
daemonTransport: TurnDaemonTransport; daemonTransport: TurnDaemonTransport;
now?: () => number; now?: () => number;
clockContext?: TournamentClockContext;
}): Promise<TournamentState | null> => { }): Promise<TournamentState | null> => {
const { store, prisma, daemonTransport } = options; const { store, prisma, daemonTransport } = options;
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
let processedState: TournamentState | null = null; let processedState: TournamentState | null = null;
await store.withMutationLock(async () => { const processWithLock = async (): Promise<void> =>
store.withMutationLock(async () => {
const state = await store.getState(); const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) { if (!state || (!state.auto && !needsSettlement(state))) {
return; return;
} }
if (
options.clockContext &&
(state.clockRevision !== options.clockContext.revision ||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
) {
throw new Error('Tournament state does not match the active clock revision.');
}
const nextAt = new Date(state.nextAt).getTime(); const nextAt = new Date(state.nextAt).getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { const notDue = options.clockContext
? !Number.isSafeInteger(state.nextTick) ||
state.nextTick! > options.clockContext.dateToTick(new Date(now()))!
: Number.isFinite(nextAt) && nextAt > now();
if (state.auto && notDue) {
if (options.clockContext && !Number.isSafeInteger(state.nextTick)) {
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
}
return; return;
} }
@@ -631,6 +648,12 @@ export const processTournamentTick = async (options: {
})) ?? nextState; })) ?? nextState;
}); });
if (options.clockContext) {
await store.withClockContext(options.clockContext, processWithLock);
} else {
await processWithLock();
}
return processedState; return processedState;
}; };
@@ -656,16 +679,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
try { try {
while (!control.signal.aborted) { while (!control.signal.aborted) {
const state = await store.getState(); let state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) { if (!state || (!state.auto && !needsSettlement(state))) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs); await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue; continue;
} }
const gameTime = await loadCurrentGameTime(postgres.prisma);
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const clockFence = gameTime.phase
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
: null;
if (gameTime.phase && !clockFence) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const clockContext: TournamentClockContext | undefined = clockFence
? {
phase: 'RUNNING',
revision: clockFence.revision,
deadlineGeneration: clockFence.generation,
dateToTick: gameTime.dateToTick,
}
: undefined;
if (
clockContext &&
(!Number.isSafeInteger(state.nextTick) ||
state.clockRevision === undefined ||
state.deadlineGeneration === undefined)
) {
state = stampTournamentClock(state, clockContext);
await store.withClockContext(clockContext, () => store.setState(state!));
}
if (
clockContext &&
(state.clockRevision !== clockContext.revision ||
state.deadlineGeneration !== clockContext.deadlineGeneration)
) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const nextAt = new Date(state.nextAt).getTime(); const nextAt = new Date(state.nextAt).getTime();
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime(); const gameNow = gameTime.now.getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) { const notDue = clockContext
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow)); ? state.nextTick! > gameTime.tick!
: Number.isFinite(nextAt) && nextAt > gameNow;
if (state.auto && notDue) {
await waitForWorkerPoll(
control.signal,
Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow))
);
continue; continue;
} }
@@ -675,6 +742,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
prisma: postgres.prisma, prisma: postgres.prisma,
daemonTransport, daemonTransport,
now: () => gameNow, now: () => gameNow,
clockContext,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
@@ -700,8 +768,12 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
lastError: message, lastError: message,
lastErrorAt: failedAt, lastErrorAt: failedAt,
}; };
if (clockContext) {
await store.withClockContext(clockContext, () => store.setState(nextState));
} else {
await store.setState(nextState); await store.setState(nextState);
} }
}
await waitForWorkerPoll(control.signal, config.tournamentPollMs); await waitForWorkerPoll(control.signal, config.tournamentPollMs);
} }
+4 -1
View File
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
const resolveScheduledBaseMs = (state: TournamentState): number => { const resolveScheduledBaseMs = (state: TournamentState): number => {
const scheduled = new Date(state.nextAt).getTime(); const scheduled = new Date(state.nextAt).getTime();
return Number.isFinite(scheduled) ? scheduled : Date.now(); if (!Number.isFinite(scheduled)) {
throw new Error('Tournament GAME_TIME schedule is invalid.');
}
return scheduled;
}; };
export const resolveNextAt = (state: TournamentState): string => export const resolveNextAt = (state: TournamentState): string =>
+22 -3
View File
@@ -62,7 +62,8 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string => export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`; `${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => { const createInputEventMiddleware = (acquireClockFence: boolean) =>
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) { if (type !== 'mutation' || !ctx.db.$transaction) {
return next(); return next();
} }
@@ -79,6 +80,7 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
eventType: path, eventType: path,
payload, payload,
actorUserId: ctx.auth?.user.id, actorUserId: ctx.auth?.user.id,
acquireClockFence,
execute: async (transaction) => { execute: async (transaction) => {
const result = await next({ const result = await next({
ctx: { ctx: {
@@ -91,7 +93,9 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
if (!result.ok) { if (!result.ok) {
throw result.error; throw result.error;
} }
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
executedResult = result; executedResult = result;
return result.data; return result.data;
}, },
@@ -116,7 +120,10 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex,
} }
throw error; throw error;
} }
}); });
const inputEventMiddleware = createInputEventMiddleware(true);
const wallInputEventMiddleware = createInputEventMiddleware(false);
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => { const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다. // 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router; export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware); export const procedure = t.procedure.use(inputEventMiddleware);
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
export const authedProcedure: typeof procedure = t.procedure export const authedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalActivityMiddleware) .use(generalActivityMiddleware)
.use(inputEventMiddleware); .use(inputEventMiddleware);
export const wallAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
.use(generalAccessEndpointMiddleware) .use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware) .use(generalActivityMiddleware)
.use(inputEventMiddleware); .use(inputEventMiddleware);
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) => export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
+50 -9
View File
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod'; import { z } from 'zod';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -79,6 +80,7 @@ export interface TurnCommandInputField {
required: boolean; required: boolean;
min?: number; min?: number;
max?: number; max?: number;
legacyWidthMax?: number;
step?: number; step?: number;
defaultValue?: TurnCommandOptionValue | boolean; defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue; constValue?: TurnCommandOptionValue;
@@ -119,22 +121,60 @@ const plainLegacyInfo = (value: string): string =>
.replace(/\s+/gu, ' ') .replace(/\s+/gu, ' ')
.trim(); .trim();
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
return undefined;
}
try {
const scenario = await loadScenarioDefinitionById(Number(normalized));
const configConst = scenario.config.const;
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
} catch (error) {
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
if (isRecord(error) && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
};
export const buildEquipmentTradeItemOptions = (options: { export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>; configConst: Record<string, unknown>;
itemOrder?: readonly string[];
itemModules: readonly EquipmentTradeItemModule[]; itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number; currentSecurity: number;
generalGold: number; generalGold: number;
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
}): TurnCommandInputOptions['items'] => { }): TurnCommandInputOptions['items'] => {
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst); const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
const items: TurnCommandInputOptions['items'] = { const items: TurnCommandInputOptions['items'] = {};
horse: [{ value: 'None', label: '판매/해제' }], const catalog = new Map(options.itemModules.map((item) => [item.key, item]));
weapon: [{ value: 'None', label: '판매/해제' }], // Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다.
book: [{ value: 'None', label: '판매/해제' }], for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
item: [{ value: 'None', label: '판매/해제' }], const ownedCode = options.ownedItems[slot];
}; const ownedItem = ownedCode && ownedCode !== 'None' ? catalog.get(ownedCode) : undefined;
const slotName = STATIC_LABELS.itemType?.[slot] ?? slot;
items[slot] = [
{
value: 'None',
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
description: ownedItem
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}`
: ownedCode && ownedCode !== 'None'
? '보유 장비 정보를 확인할 수 없습니다.'
: '현재 보유한 장비가 없습니다.',
},
];
}
for (const item of options.itemModules) { // 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
if (!item.buyable || !purchasableItemKeys.has(item.key)) { const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
for (const key of orderedKeys) {
const item = catalog.get(key);
if (!item?.buyable || !purchasableItemKeys.has(key)) {
continue; continue;
} }
const cost = item.cost ?? 0; const cost = item.cost ?? 0;
@@ -328,6 +368,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
required, required,
min: typeof schema.minLength === 'number' ? schema.minLength : undefined, min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined, max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
legacyWidthMax: key === 'nationName' ? 18 : undefined,
}; };
} }
@@ -565,7 +606,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
getLegacyStringWidth(nationName) < 1 || getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18) getLegacyStringWidth(nationName) > 18)
) { ) {
throwLegacyBasicTurnArgError(); throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
} }
} }
}; };
+35 -2
View File
@@ -11,6 +11,9 @@ export interface GeneralTargetSource {
officerLevel: number; officerLevel: number;
gold?: number; gold?: number;
rice?: number; rice?: number;
leadership?: number;
strength?: number;
intel?: number;
crew?: number; crew?: number;
train?: number; train?: number;
atmos?: number; atmos?: number;
@@ -64,7 +67,7 @@ export const buildRefGeneralTargetOptions = (options: {
const isTroopExit = action === 'che_부대탈퇴지시'; const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => { const label = (() => {
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`; if (action === 'che_발령') return `${entry.name} (${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`; if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`; return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})(); })();
@@ -80,13 +83,43 @@ export const buildRefGeneralTargetOptions = (options: {
? `탑승 부대 ${troopLabel}` ? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음', : '탑승 부대 없음',
].filter((value): value is string => Boolean(value)); ].filter((value): value is string => Boolean(value));
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
const assignmentDetails =
action === 'che_발령'
? [
entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음',
[
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
]
.filter(Boolean)
.join('\n')
: undefined;
if (isTroopExit) { if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가'); details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
} }
return { return {
value: entry.id, value: entry.id,
label, label,
description: details.join(' · '), description: assignmentDetails ?? details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }), ...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }), ...(entry.gold === undefined ? {} : { gold: entry.gold }),
...(entry.rice === undefined ? {} : { rice: entry.rice }), ...(entry.rice === undefined ? {} : { rice: entry.rice }),
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'), lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T10:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'MANUAL',
clockRevision: 1,
deadlineGeneration: 1,
meta: { killturn: 24, scenarioMeta }, meta: { killturn: 24, scenarioMeta },
}; };
const lifecycleGeneral: TurnGeneral = { const lifecycleGeneral: TurnGeneral = {
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
currentYear: lifecycleState.currentYear, currentYear: lifecycleState.currentYear,
currentMonth: lifecycleState.currentMonth, currentMonth: lifecycleState.currentMonth,
tickSeconds: lifecycleState.tickSeconds, tickSeconds: lifecycleState.tickSeconds,
clockBaseTime: lifecycleState.clockBaseTime,
clockTick: BigInt(lifecycleState.clockTick ?? 0),
clockMode: lifecycleState.clockMode ?? 'manual',
clockWallAnchor: lifecycleState.clockWallAnchor,
lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0),
clockPhase: lifecycleState.clockPhase ?? 'MANUAL',
clockRevision: BigInt(lifecycleState.clockRevision ?? 1),
deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: lifecycleState.meta as GamePrisma.InputJsonValue, meta: lifecycleState.meta as GamePrisma.InputJsonValue,
}, },
+5 -8
View File
@@ -96,6 +96,7 @@ const buildContext = (options: {
ok: true as const, ok: true as const,
auctionId: 91, auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z', closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
}; };
} }
return { return {
@@ -103,6 +104,7 @@ const buildContext = (options: {
ok: true as const, ok: true as const,
auctionId: 91, auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z', closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
}; };
}); });
const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
@@ -112,14 +114,10 @@ const buildContext = (options: {
currentYear: 200, currentYear: 200,
currentMonth: 1, currentMonth: 1,
tickSeconds: 3600, tickSeconds: 3600,
...(options.clockTick === undefined
? {}
: {
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'), clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick), clockTick: BigInt(options.clockTick ?? 100),
clockMode: 'manual', clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'), clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
config: { config: {
const: { const: {
auctionName: ['청룡', '백호', '주작', '현무'], auctionName: ['청룡', '백호', '주작', '현무'],
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
tick: 72_000_001, tick: 72_000_001,
}) })
).toBe(true); ).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false); expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
}); });
it('rejects unauthenticated auction reads', async () => { it('rejects unauthenticated auction reads', async () => {
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31, auctionId: 31,
generalId: 7, generalId: 7,
amount: 110, amount: 110,
acceptedGameTick: 100,
tryExtendCloseDate: false, tryExtendCloseDate: false,
}); });
}); });
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false }, detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN', status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000), closeAt: new Date(Date.now() + 60 * 60_000),
closeTick: 200n,
}, },
]; ];
} }
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31, auctionId: 31,
generalId: 7, generalId: 7,
amount: 500, amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
}); });
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { amount: 100 }, detail: { amount: 100 },
status, status,
closeAt, closeAt,
closeTick: 0n,
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}), ...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
}, },
}); });
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
return auction; return auction;
}; };
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
buildAuctionFinalizeRequestId(auction.id, { if (auction.closeTick === null || auction.closeTick === undefined) {
throw new Error(`auction ${auction.id} fixture requires closeTick`);
}
return buildAuctionFinalizeRequestId(auction.id, {
closeAt: auction.closeAt, closeAt: auction.closeAt,
closeTick: auction.closeTick ?? null, closeTick: auction.closeTick,
}); });
};
const memoryRedis = () => ({ const memoryRedis = () => ({
zRangeByScore: vi.fn(async () => []), zRangeByScore: vi.fn(async () => []),
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`); ).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id }, payload: {
type: 'auctionFinalize',
requestId,
auctionId: auction.id,
expectedCloseAt: auction.closeAt.toISOString(),
expectedCloseTick: Number(auction.closeTick),
},
status: 'FAILED', status: 'FAILED',
attempts: 3, attempts: 3,
error: 'simulated terminal failure', error: 'simulated terminal failure',
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`); ).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
}); });
it('creates a new generation after an earlier close was extended', async () => { it('creates a new generation after an earlier close was extended', async () => {
const auction = await createAuction('OPEN'); const auction = await createAuction('OPEN');
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`; const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
await connector.prisma.inputEvent.create({ await connector.prisma.inputEvent.create({
data: { data: {
requestId: priorRequestId, requestId: priorRequestId,
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'), lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T11:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'RUNNING',
clockRevision: 1,
deadlineGeneration: 1,
meta: { killturn: 24, scenarioMeta }, meta: { killturn: 24, scenarioMeta },
}; };
const buildGeneral = (options: { const buildGeneral = (options: {
@@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => {
currentYear: state.currentYear, currentYear: state.currentYear,
currentMonth: state.currentMonth, currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds, tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'realtime',
clockWallAnchor: state.clockWallAnchor,
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockPhase: state.clockPhase ?? 'RUNNING',
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
@@ -415,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 200, amount: 200,
eventId: `auction-durable-bid:${auction.id}`, eventId: `auction-durable-bid:${auction.id}`,
eventAt: new Date(), eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
}, },
}); });
const requestId = requestIdFor(auction); const requestId = requestIdFor(auction);
@@ -425,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}); });
const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const world = new InMemoryTurnWorld(state, snapshot, { schedule });
@@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { remainCloseDateExtensionCnt: 1 }, detail: { remainCloseDateExtensionCnt: 1 },
status: 'OPEN', status: 'OPEN',
closeAt: logicalPastCloseAt, closeAt: logicalPastCloseAt,
closeTick: 0n,
}, },
}); });
extensionAuctionId = extensionAuction.id; extensionAuctionId = extensionAuction.id;
@@ -505,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 50, amount: 50,
eventId: `auction-extension-bid:${extensionAuction.id}`, eventId: `auction-extension-bid:${extensionAuction.id}`,
eventAt: new Date(), eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
meta: { tryExtendCloseDate: true }, meta: { tryExtendCloseDate: true },
}, },
}); });
@@ -516,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(extensionAuction.id), id: String(extensionAuction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}); });
let reopened: { status: string; closeAt: Date } | null = null; let reopened: { status: string; closeAt: Date } | null = null;
+63 -14
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra'; import type { GamePrismaClient } from '@sammo-ts/infra';
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js'; import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js'; import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
const buildRedis = () => ({ const buildRedis = () => ({
@@ -33,7 +33,7 @@ const buildDb = (options: {
$executeRaw: vi.fn(async () => options.updated), $executeRaw: vi.fn(async () => options.updated),
auction: { auction: {
findUnique: vi.fn(async () => findUnique: vi.fn(async () =>
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
), ),
}, },
inputEvent: { inputEvent: {
@@ -55,6 +55,38 @@ const buildDb = (options: {
}; };
describe('auction worker clock-shift race', () => { describe('auction worker clock-shift race', () => {
it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => {
const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) };
await expect(
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
activeRevisionKey: 'clock-revision',
deadlineGenerationKey: 'deadline-generation',
phaseKey: 'clock-phase',
revision: 4,
generation: 8,
})
).resolves.toEqual(['7', '9']);
expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), {
keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'],
arguments: ['4', '8', '123456', '100'],
});
expect(redis.zRangeByScore).not.toHaveBeenCalled();
expect(redis.zRem).not.toHaveBeenCalled();
});
it('returns no due member when the atomic Redis clock fence rejects the pop', async () => {
const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) };
await expect(
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
activeRevisionKey: 'clock-revision',
deadlineGenerationKey: 'deadline-generation',
phaseKey: 'clock-phase',
revision: 4,
generation: 8,
})
).resolves.toEqual([]);
});
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => { it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
const now = new Date('2026-07-30T12:00:00.000Z'); const now = new Date('2026-07-30T12:00:00.000Z');
const time = { const time = {
@@ -201,11 +233,12 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 36_000_000,
}) })
).resolves.toBe('RESCHEDULED'); ).resolves.toBe('RESCHEDULED');
expect(redis.zAdd).toHaveBeenCalledTimes(1); expect(redis.zAdd).toHaveBeenCalledTimes(1);
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]); expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
expect(transaction.inputEvent.create).not.toHaveBeenCalled(); expect(transaction.inputEvent.create).not.toHaveBeenCalled();
}); });
@@ -235,7 +268,7 @@ describe('auction worker clock-shift race', () => {
it('leaves OPEN untouched and creates one durable command before recording history', async () => { it('leaves OPEN untouched and creates one durable command before recording history', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } }); const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime(); const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
@@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs, nowMs,
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -318,6 +353,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -330,7 +366,7 @@ describe('auction worker clock-shift race', () => {
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => { it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
auction: { status: 'OPEN', closeAt }, auction: { status: 'OPEN', closeAt },
@@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
status: 'PENDING', status: 'PENDING',
result: null, result: null,
@@ -359,6 +396,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled(); expect(transaction.inputEvent.create).not.toHaveBeenCalled();
@@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: logicalNowMs, nowMs: logicalNowMs,
nowTick: 72_000_000,
historyNowMs: operationalNowMs, historyNowMs: operationalNowMs,
}); });
@@ -388,12 +427,12 @@ describe('auction worker clock-shift race', () => {
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => { it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const existingEvent = { const existingEvent = {
requestId, requestId,
target: 'ENGINE' as const, target: 'ENGINE' as const,
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'PENDING' as const, status: 'PENDING' as const,
result: null, result: null,
}; };
@@ -411,6 +450,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -423,7 +463,7 @@ describe('auction worker clock-shift race', () => {
it('creates one bounded successor after a terminal event failure', async () => { it('creates one bounded successor after a terminal event failure', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const retryRequestId = `${requestId}:retry:1`; const retryRequestId = `${requestId}:retry:1`;
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
@@ -433,7 +473,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'FAILED', status: 'FAILED',
result: null, result: null,
}, },
@@ -448,6 +488,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => {
requestId: retryRequestId, requestId: retryRequestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -468,19 +510,23 @@ describe('auction worker clock-shift race', () => {
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => { it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
const redis = buildRedis(); const redis = buildRedis();
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
const closeAt = new Date('2026-07-30T11:30:00.000Z'); const closeAt = new Date('2026-07-30T11:30:00.000Z');
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`; const previousRequestId = 'auction:finalize:7:tick:36000000';
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
auction: { status: 'OPEN', closeAt }, auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
existingEvents: [ existingEvents: [
{ {
requestId: previousRequestId, requestId: previousRequestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 }, payload: {
type: 'auctionFinalize',
requestId: previousRequestId,
auctionId: 7,
expectedCloseTick: 36_000_000,
},
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { type: 'auctionFinalize', ok: false, auctionId: 7 }, result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
}, },
@@ -495,6 +541,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -527,6 +575,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).rejects.toThrow('event insert failed'); ).rejects.toThrow('event insert failed');
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
describe('clock reconciliation readiness', () => {
it('fails closed when the reconciliation schema is not available', async () => {
const db = {} as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toEqual({
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount: null,
});
});
it('blocks readiness for RECONCILING or incomplete outbox state', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockPhase: 'RECONCILING',
clockRevision: 9n,
deadlineGeneration: 4n,
})),
},
clockProjectionOutbox: { count: vi.fn(async () => 1) },
} as unknown as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toMatchObject({
reconciliationComplete: false,
gameplayEnabled: false,
phase: 'RECONCILING',
revision: 9,
deadlineGeneration: 4,
incompleteOutboxCount: 1,
});
});
it('exposes participant checksums and incomplete outbox detail to admins', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockPhase: 'RUNNING',
clockRevision: 3n,
deadlineGeneration: 2n,
})),
},
clockProjectionOutbox: { count: vi.fn(async () => 0) },
clockSuspension: {
findFirst: vi.fn(async () => ({
id: 'maintenance-1',
source: 'MAINTENANCE',
policy: 'EXACT',
status: 'APPLIED',
sourceRevision: 2n,
targetRevision: 3n,
cutTick: 100n,
alignedTick: 130n,
participantChecksumBefore: 'before-all',
participantChecksumAfter: 'after-all',
participants: [
{
participantKey: 'general-turn',
policy: 'SHIFT',
beforeChecksum: 'before',
afterChecksum: 'after',
affectedCount: 2,
},
],
projectionOutbox: [{ id: 8n, targetRevision: 3n, status: 'APPLIED', attempts: 1, lastError: null }],
})),
},
} as unknown as DatabaseClient;
await expect(loadClockAdminStatus(db)).resolves.toMatchObject({
reconciliationComplete: true,
latestReconciliation: {
id: 'maintenance-1',
participantChecksumBefore: 'before-all',
participantChecksumAfter: 'after-all',
participants: [{ key: 'general-turn', policy: 'SHIFT', affectedCount: 2 }],
outbox: [{ id: '8', status: 'APPLIED' }],
},
});
});
});
+103 -3
View File
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
import { import {
assertReservedTurnArgsPassLegacyBasicValidation, assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions, buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
buildTurnCommandInputFields, buildTurnCommandInputFields,
parseReservedTurnArgs, parseReservedTurnArgs,
sanitizeReservedTurnArgs, sanitizeReservedTurnArgs,
@@ -112,12 +113,22 @@ describe('turn command argument input', () => {
).toThrow('턴이 입력되지 않았습니다.'); ).toThrow('턴이 입력되지 않았습니다.');
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow(); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow(); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
);
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow( expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
'턴이 입력되지 않았습니다.' '턴이 입력되지 않았습니다.'
); );
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined(); await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
}); });
it('exposes the Ref fullwidth nation-name limit to command input clients', async () => {
const specs = await loadGeneralTurnCommandSpecs(['che_건국']);
expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual(
expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 })
);
});
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => { it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
expect( expect(
sanitizeReservedTurnArgs({ sanitizeReservedTurnArgs({
@@ -212,20 +223,109 @@ describe('turn command argument input', () => {
); );
}); });
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => { it('preserves scenario order while filtering unavailable catalog entries in every slot', () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: { [slot]: { z_last_code: 0, missing: 0, unique: 1, blocked: 0, a_first_code: -1 } },
},
itemModules: [
{ ...buildShopItem('a_first_code', '첫 코드'), slot },
{ ...buildShopItem('z_last_code', '마지막 코드'), slot },
{ ...buildShopItem('unique', '유니크'), slot },
{ ...buildShopItem('blocked', '비매품'), slot, buyable: false },
],
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items[slot].map((item) => item.value)).toEqual(['None', 'z_last_code', 'a_first_code']);
}
});
it('restores original scenario order after DB key reordering without restoring removed purchase permissions', async () => {
const scenario = await loadScenarioDefinitionById(2701);
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
const originalKeys = Object.keys(pool.item!);
const buyableKeys = originalKeys.filter((key) => pool.item![key]! <= 0);
const removedKey = buyableKeys[1]!;
const reorderedPool = Object.fromEntries(Object.entries(pool.item!).reverse());
reorderedPool[removedKey] = 1;
reorderedPool.runtime_added = 0;
const items = buildEquipmentTradeItemOptions({
configConst: { allItems: { item: reorderedPool } },
itemOrder: await loadEquipmentTradeItemOrder('scenario_2701.json'),
itemModules: [...buyableKeys]
.reverse()
.concat('runtime_added')
.map((key) => buildShopItem(key, key)),
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items.item.map((item) => item.value)).toEqual([
'None',
...buyableKeys.filter((key) => key !== removedKey),
'runtime_added',
]);
expect((await loadEquipmentTradeItemOrder('1'))?.filter((key) => key.startsWith('che_치료'))).toEqual([
'che_치료_환약',
]);
expect(await loadEquipmentTradeItemOrder('unknown')).toBeUndefined();
expect(await loadEquipmentTradeItemOrder('999999')).toBeUndefined();
});
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
configConst: {}, configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')], itemModules: [owned],
currentSecurity: 0,
generalGold: 0,
ownedItems: { horse: null, weapon: null, book: null, item: null, [slot]: owned.key },
});
expect(items[slot]).toEqual([
{ value: 'None', label: '보유 유니크 판매', description: '소유 물품 판매 · 판매가 5,000금' },
]);
await expect(
parseReservedTurnArgs('general', 'che_장비매매', { itemType: slot, itemCode: 'None' })
).resolves.toEqual({ itemType: slot, itemCode: 'None' });
}
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: {},
itemModules: [
buildShopItem('event_전투특기_격노', '격노의 비급'),
buildShopItem('che_계략_향낭', '향낭'),
buildShopItem('che_계략_이추', '이추'),
buildShopItem('che_훈련_청주', '청주'),
buildShopItem('che_사기_탁주', '탁주'),
buildShopItem('che_저격_수극', '수극'),
buildShopItem('che_치료_환약', '환약'),
],
currentSecurity: 5000, currentSecurity: 5000,
generalGold: 1000, generalGold: 1000,
}); });
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']); expect(items.item.map((item) => item.value)).toEqual([
'None',
'che_치료_환약',
'che_저격_수극',
'che_사기_탁주',
'che_훈련_청주',
'che_계략_이추',
'che_계략_향낭',
]);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명'); expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
}); });
it('shows only zero-count buyable items selected by an explicit scenario pool', () => { it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: { configConst: {
allItems: { allItems: {
item: { item: {
+14 -4
View File
@@ -67,6 +67,9 @@ describe('Ref command general targets', () => {
gold: 100, gold: 100,
rice: 200, rice: 200,
crew: 900, crew: 900,
leadership: 100,
strength: 95,
intel: 0,
train: 80, train: 80,
atmos: 70, atmos: 70,
troopId: 3, troopId: 3,
@@ -88,11 +91,18 @@ describe('Ref command general targets', () => {
description: expect.stringContaining('탑승 부대 청룡대'), description: expect.stringContaining('탑승 부대 청룡대'),
}); });
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([ expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (부대 없음 · 업)', '본인 (업)',
'부대원 (청룡대 · 업)', '부대원 (업)',
'부대장 (청룡대 (부대장) · 업)', '부대장 (업)',
]); ]);
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대'); expect(detailed.generalTargets.che_발령?.[1]?.description).toBe(
'탑승 부대 청룡대\n통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200'
);
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe(
'탑승 부대 없음\n병력 1,000\n금 5,000 · 쌀 4,000'
);
expect(detailed.generalTargets.che_발령?.[2]?.description).toContain('청룡대 (부대장)');
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([ expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
'본인 (업)', '본인 (업)',
'부대원 (업)', '부대원 (업)',
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
any: { any: {
ban: { expire: 4_102_444_800, value: 1 }, ban: { expire: 4_102_444_800, value: 1 },
expired: { expire: 1, value: 9 }, expired: { expire: 1, value: 9 },
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
}, },
hwe: { hwe: {
ban: { expire: 4_102_444_800, value: 2 }, ban: { expire: 4_102_444_800, value: 2 },
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 2, scenarioId: 2,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'), now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: { installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5, turnTermMinutes: 5,
npcMode: 0, npcMode: 0,
showImgLevel: 3, showImgLevel: 3,
@@ -227,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => {
create: { userId, key, value }, create: { userId, key, value },
}); });
} }
// 이 fixture는 특기 없는 일반 생성/reload를 검증한다. 1% 천재 추첨은 별도 계약이다.
const seededWorld = await db.worldState.findFirstOrThrow();
await db.worldState.update({
where: { id: seededWorld.id },
data: {
meta: { ...(seededWorld.meta as Record<string, GamePrisma.InputJsonValue>), genius: 0 },
},
});
await startRuntime('create-general-integration-daemon'); await startRuntime('create-general-integration-daemon');
}, 60_000); }, 60_000);
@@ -289,6 +299,7 @@ integration('generic general creation through the durable turn daemon', () => {
}, },
}); });
expect(created.affinity).toBeGreaterThanOrEqual(1); expect(created.affinity).toBeGreaterThanOrEqual(1);
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
expect(created.affinity).toBeLessThanOrEqual(150); expect(created.affinity).toBeLessThanOrEqual(150);
expect(created.meta).toMatchObject({ expect(created.meta).toMatchObject({
createdBy: 'join', createdBy: 'join',
@@ -305,6 +316,9 @@ integration('generic general creation through the durable turn daemon', () => {
throw new Error('created general must have an initial access timestamp'); throw new Error('created general must have an initial access timestamp');
} }
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
expect( expect(
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() - new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
acceptedEvent.createdAt.getTime() acceptedEvent.createdAt.getTime()
@@ -553,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => {
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })]) expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
); );
}, 10_000); }, 10_000);
it.each([
{ phase: 'SUSPENDED', united: 0, finalized: false },
{ phase: 'SUSPENDED', united: 2, finalized: true },
{ phase: 'COMPLETED', united: 3, finalized: true },
])(
'creates a visitor in $phase/$united without reopening season records',
async ({ phase, united, finalized }) => {
await stopRuntime('prepare frozen participation');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `visitor-${phase}-${united}`;
const auth = buildAuth(visitorId, '방문자', 7000 + united);
await db.inheritancePoint.create({ data: { userId: visitorId, key: 'previous', value: 5000 } });
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: {
...(original.meta as Record<string, GamePrisma.InputJsonValue>),
isUnited: united,
isunited: united,
},
},
});
await db.gameHistory.update({
where: { serverId: profile },
data: { status: finalized ? 'COMPLETED' : 'OPEN' },
});
const readRecords = async () => ({
points: await db.inheritancePoint.findMany({ where: { userId: visitorId }, orderBy: { id: 'asc' } }),
logs: await db.inheritanceLog.count({ where: { userId: visitorId } }),
baseline: await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }),
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
oldGenerals: await db.oldGeneral.count({ where: { serverId: profile } }),
});
const before = await readRecords();
try {
await startRuntime(`visitor-${phase}-${united}`);
const caller = appRouter.createCaller(buildContext(`visitor-${phase}-${united}`, auth));
expect((await caller.join.getConfig()).inherit.enabled).toBe(!finalized);
const input = {
name: `방문${united}`,
pic: false,
leadership: 55,
strength: 55,
intel: 55,
character: 'che_안전' as const,
};
if (finalized) {
await expect(
appRouter
.createCaller(buildContext(`visitor-paid-${united}`, auth))
.join.createGeneral({ ...input, inheritTurntimeZone: 7 })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(await readRecords()).toEqual(before);
}
const result = await caller.join.createGeneral(input);
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
npcState: 0,
});
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
if (finalized) expect(await readRecords()).toEqual(before);
else
expect(
await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } })
).toBe(1);
await stopRuntime('verify visitor persisted');
await startRuntime(`visitor-reload-${phase}-${united}`);
expect(runtime?.world.getGeneralById(result.generalId)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore participation fixture');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('participation-fixture-restored');
}
},
30_000
);
}); });
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some( changes.some(
(change) => (change) =>
Array.isArray(change) && Array.isArray(change) &&
change[0] === 'messages.mailbox' && (change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
) )
); );
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await deleteFixtureOutboxes(); await deleteFixtureOutboxes();
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
}); });
}; };
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0); await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
await expect( await expect(
db.readModelRevision.count({ db.readModelRevision.count({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
}) })
).resolves.toBe(0); ).resolves.toBe(0);
await expect( await expect(
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some( changes.some(
(change) => (change) =>
Array.isArray(change) && Array.isArray(change) &&
change[0] === 'messages.mailbox' && (change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
) )
); );
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
}); });
await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
}); });
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id); const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
+1 -10
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra'; import type { RedisConnector } from '@sammo-ts/infra';
@@ -88,7 +87,7 @@ const storedLetter = {
}; };
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => { const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
const create = vi.fn(async () => ({ id: 9 })); const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
let messageId = 100; let messageId = 100;
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]); const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
const db = { const db = {
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>', textBrief: '<p><strong>공개</strong></p>',
textDetail: textDetail:
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>', '<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
date: new Date('0185-01-01T00:00:00.000Z'),
}), }),
}); });
expect(fixture.queryRaw).toHaveBeenCalledTimes(2); expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
);
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
}); });
it('purifies legacy stored rows on every read while preserving secret redaction', async () => { it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
+88 -2
View File
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { loadCurrentGameTime } from '../src/services/gameClock.js'; import { loadCurrentGameTime } from '../src/services/gameClock.js';
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient => const buildDatabase = (
mode: 'realtime' | 'manual' = 'realtime',
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
): DatabaseClient =>
({ ({
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
@@ -12,11 +15,90 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
clockMode: mode, clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600, tickSeconds: 600,
clockPhase: phase,
clockRevision: 1n,
deadlineGeneration: 1n,
})), })),
}, },
}) as unknown as DatabaseClient; }) as unknown as DatabaseClient;
describe('current game time projection', () => { describe('current game time projection', () => {
it('keeps independent workers frozen until this clock revision has a ready daemon', async () => {
const row = {
clockBaseTime: new Date('2026-09-06T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-06T00:00:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: 288_000_000n,
clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'),
};
let ready = false;
const db = {
worldState: { findFirst: vi.fn(async () => row) },
$queryRaw: vi.fn(async () => [{ ready }]),
} as unknown as DatabaseClient;
const now = new Date('2026-09-06T05:00:00Z');
expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false });
ready = true;
expect(await loadCurrentGameTime(db, now)).toMatchObject({
tick: 72_000_000,
running: true,
runtimeReady: true,
recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' },
});
});
it('exposes waiting, 2x and normal speed boundaries from a reloaded recovery', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-09-07T00:00:00Z'),
clockTick: 6000000n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-07T00:35:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 6000000n,
clockRecoveryEndTick: 36000000n,
clockRecoveryStartWallAt: new Date('2026-09-07T00:35:00Z'),
})),
},
$queryRaw: vi.fn(async () => [{ ready: true }]),
} as unknown as DatabaseClient;
for (const now of ['00:24:00', '00:34:59.999']) {
expect(await loadCurrentGameTime(db, new Date(`2026-09-07T${now}Z`))).toMatchObject({
tick: 6000000,
running: false,
startsAt: new Date('2026-09-07T00:35:00Z'),
recovery: { startsAt: '2026-09-07T00:35:00.000Z', endsAt: '2026-09-07T01:00:00.000Z' },
});
}
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:35:00.001Z'))).toMatchObject({
tick: 6000020,
running: true,
startsAt: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:59:59.999Z'))).toMatchObject({ tick: 35999980 });
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00Z'))).toMatchObject({
tick: 36000000,
running: true,
recovery: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00.001Z'))).toMatchObject({ tick: 36000010 });
});
it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') });
});
it('projects negative realtime ticks until the future opening anchor', async () => { it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase(); const db = buildDatabase();
@@ -26,11 +108,15 @@ describe('current game time projection', () => {
wallNow: new Date('2026-08-21T10:30:00.000Z'), wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: -108_000_000, tick: -108_000_000,
mode: 'realtime', mode: 'realtime',
phase: 'PREOPEN',
running: false, running: false,
startsAt: new Date('2026-08-21T11:00:00.000Z'), startsAt: new Date('2026-08-21T11:00:00.000Z'),
}); });
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z')); const opened = await loadCurrentGameTime(
buildDatabase('realtime', 'RUNNING'),
new Date('2026-08-21T11:00:05.000Z')
);
expect(opened).toMatchObject({ expect(opened).toMatchObject({
now: new Date('2026-08-21T11:00:05.000Z'), now: new Date('2026-08-21T11:00:05.000Z'),
tick: 300_000, tick: 300_000,
@@ -326,18 +326,14 @@ describe('general access tracking', () => {
: [{ id: 41 }]; : [{ id: 41 }];
}), }),
$executeRaw: vi.fn(async (query: unknown) => { $executeRaw: vi.fn(async (query: unknown) => {
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) { const sql = (query as { sql?: string }).sql ?? '';
if (sql.includes('INSERT INTO input_event')) {
events.push('input-event-create'); events.push('input-event-create');
} }
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
return 1; return 1;
}), }),
$executeRawUnsafe: vi.fn(async () => 0), $executeRawUnsafe: vi.fn(async () => 0),
inputEvent: {
update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'FAILED') events.push('input-event-failed');
return {};
}),
},
}; };
const db = { const db = {
general: { general: {
+11 -7
View File
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
} }
}); });
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => { it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
const persistedPayload = { const persistedPayload = {
type: 'voteReward' as const, type: 'voteReward' as const,
requestId: 'vote-reward', requestId: 'vote-reward',
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
selection: [0], selection: [0],
acceptedGameTick: 100, acceptedGameTick: 100,
}; };
const currentCommand = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
};
const create = async () => { const create = async () => {
throw Object.assign(new Error('duplicate'), { code: 'P2002' }); throw Object.assign(new Error('duplicate'), { code: 'P2002' });
}; };
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
); );
await expect( await expect(
transport.sendCommand({ transport.sendCommand(currentCommand)
...persistedPayload,
acceptedGameTick: 101,
})
).resolves.toBe('vote-reward'); ).resolves.toBe('vote-reward');
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) { for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
await expect( await expect(
transport.sendCommand({ transport.sendCommand({
...persistedPayload, ...currentCommand,
...changedIdentity, ...changedIdentity,
acceptedGameTick: 101,
}) })
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError); ).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
} }
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
currentMonth: 4, currentMonth: 4,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'), lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
clockBaseTime: new Date('2026-08-19T00:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'MANUAL',
clockRevision: 1,
deadlineGeneration: 1,
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta }, meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
}; };
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
currentYear: 200, currentYear: 200,
currentMonth: 4, currentMonth: 4,
tickSeconds: 600, tickSeconds: 600,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor,
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockPhase: state.clockPhase ?? 'MANUAL',
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
+15
View File
@@ -285,6 +285,21 @@ describe('inherit router actor and permission boundaries', () => {
}); });
}); });
it('rejects an out-of-range inheritance buff level with a user-facing message', async () => {
const fixture = buildContext({});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'warAvoidRatio',
level: 6,
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => { it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
const fixture = buildContext({ const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' }, configMap: { targetGeneralPool: 'SPoolUnderU100' },
@@ -18,6 +18,7 @@ import {
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const journalGeneralIds = [9_980_081, 9_980_082] as const; const journalGeneralIds = [9_980_081, 9_980_082] as const;
const clockScenarioCode = 'input-event-boundary';
const journalBoundaryRouter = router({ const journalBoundaryRouter = router({
mutate: procedure mutate: procedure
@@ -56,6 +57,21 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } }, where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
}); });
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
const base = new Date('2099-09-03T00:00:00.000Z');
await db.worldState.create({
data: {
scenarioCode: clockScenarioCode,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: base,
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: base,
clockPhase: 'RUNNING',
},
});
}); });
afterAll(async () => { afterAll(async () => {
@@ -68,6 +84,7 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } }, where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
}); });
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
await close?.(); await close?.();
}); });
@@ -518,6 +535,10 @@ integration('API input event boundary', () => {
expect(event.actorUserId).toBe('user-7'); expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
expect(event.acceptedGameTick).toBeNull();
expect(event.acceptedClockRevision).toBeNull();
expect(event.acceptedDeadlineGeneration).toBeNull();
expect(event.processingGameTick).toBeNull();
await expect( await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }) transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId); ).resolves.toBe(requestId);
+47 -3
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js'; import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js'; import { procedure, router, wallProcedure } from '../src/trpc.js';
const testRouter = router({ const testRouter = router({
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => { mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
@@ -14,6 +14,14 @@ const testRouter = router({
}), }),
}); });
const wallTestRouter = router({
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
return { ok: true };
}),
});
const createContext = (payload: unknown = {}) => { const createContext = (payload: unknown = {}) => {
const order: string[] = []; const order: string[] = [];
const queryRaw = vi.fn(async (query: { sql?: string }) => { const queryRaw = vi.fn(async (query: { sql?: string }) => {
@@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => {
status: 'PENDING', status: 'PENDING',
result: null, result: null,
attempts: 0, attempts: 0,
acceptedGameTick: 100n,
acceptedClockRevision: 3n,
acceptedDeadlineGeneration: 2n,
}, },
]; ];
} }
@@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => {
}); });
const transaction = { const transaction = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async () => { $executeRaw: vi.fn(async (query: { sql?: string }) => {
order.push('accepted'); const sql = query.sql ?? '';
order.push(
sql.includes('pg_advisory_xact_lock')
? 'clock-fence'
: sql.includes("status = 'PROCESSING'")
? 'processing'
: sql.includes("status = 'SUCCEEDED'")
? 'succeeded'
: sql.includes("status = 'FAILED'")
? 'failed'
: 'accepted'
);
return 1; return 1;
}), }),
$executeRawUnsafe: vi.fn(async (statement: string) => { $executeRawUnsafe: vi.fn(async (statement: string) => {
@@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([ expect(fixture.order).toEqual([
'transaction-begin', 'transaction-begin',
'clock-fence',
'accepted', 'accepted',
'locked', 'locked',
'processing', 'processing',
@@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([ expect(fixture.order).toEqual([
'transaction-begin', 'transaction-begin',
'clock-fence',
'accepted', 'accepted',
'locked', 'locked',
'processing', 'processing',
@@ -126,4 +150,24 @@ describe('API input-event change journal boundary', () => {
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled(); expect(fixture.wake).not.toHaveBeenCalled();
}); });
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
const fixture = createContext();
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'journal',
'succeeded',
'savepoint-release',
'commit',
'wake',
]);
});
}); });
+1
View File
@@ -42,6 +42,7 @@ const buildContext = (
turnDaemonLease: { turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })), findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
}, },
$queryRaw: vi.fn(async () => [{ running: true }]),
} as unknown as DatabaseClient, } as unknown as DatabaseClient,
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) }, profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
}) as unknown as GameApiContext; }) as unknown as GameApiContext;
@@ -0,0 +1,82 @@
import { afterAll, describe, expect, it, vi } from 'vitest';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox } from '../src/messages/store.js';
vi.mock('../src/services/gameClock.js', () => ({
loadCurrentGameTime: vi.fn(async () => ({ tick: 100 })),
}));
// SQL is mocked; this client is never connected. Keep the real delegate types.
const connector = createGamePostgresConnector({ url: 'postgresql://localhost:1/message_lifecycle_unit' });
const db = connector.prisma;
const queryRaw = vi.spyOn(db, '$queryRaw');
afterAll(() => connector.disconnect());
const target = { generalId: 1, generalName: '장수', nationId: 1, nationName: '국가', color: '#000', icon: '' };
for (const older of [false, true]) {
describe(older ? 'older message lifecycle' : 'recent message lifecycle', () => {
it.each([
['PENDING', 101n, 'pending', false],
['PENDING', 100n, 'expired', true],
['PENDING', 99n, 'expired', true],
['PENDING', null, 'pending', false],
['RESOLVED', 101n, 'resolved', true],
['RESOLVED', 99n, 'resolved', true],
['CANCELLED', 101n, 'unavailable', true],
['UNKNOWN', 101n, 'unavailable', true],
[null, null, undefined, undefined],
])('preserves body for status %s and deadline %s', async (status, deadline, state, used) => {
const row = {
id: 10,
mailbox: 1,
type: 'diplomacy',
src: 2,
dest: 1,
time: new Date('0200-01-01T00:00:00Z'),
created_at_wall: new Date('2026-09-10T00:00:00Z'),
action_status: status,
expires_game_tick: deadline,
message: {
src: target,
dest: target,
text: '210년 1월까지 불가침 제의',
option: { action: 'noAggression' },
},
};
queryRaw.mockResolvedValue([row]);
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
const result = older
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
expect(result[0]?.text).toBe(row.message.text);
expect(result[0]?.option?.invalid).toBeUndefined();
expect(result[0]?.option?.actionState).toBe(state);
expect(result[0]?.option?.used).toBe(used);
});
it('keeps actual deletion authoritative even when an action remains', async () => {
queryRaw.mockResolvedValue([
{
id: 10,
type: 'diplomacy',
time: new Date(),
created_at_wall: new Date(),
action_status: 'RESOLVED',
expires_game_tick: 99n,
message: {
src: target,
dest: target,
text: '삭제된 메시지입니다.',
option: { action: 'noAggression', invalid: true },
},
},
]);
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
const result = older
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
expect(result[0]).toMatchObject({ text: '삭제된 메시지입니다.', option: { invalid: true, used: true } });
});
});
}
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra';
import { tombstoneMessages } from '../src/messages/store.js'; import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
@@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => {
afterAll(async () => close?.()); afterAll(async () => close?.());
it('persists an ordinary wall-time envelope without creating a game action', async () => {
const rollback = new Error('rollback ordinary message envelope fixture');
await expect(
db.$transaction(async (transaction) => {
const target = {
generalId: 7,
generalName: '보낸이',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '',
};
const id = await persistMessageEnvelope(
transaction,
{
mailbox: 9999,
msgType: 'public',
srcId: target.generalId,
destId: 9999,
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
payload: {
src: target,
dest: target,
text: '일반 메시지는 WALL_TIME envelope만 저장한다.',
option: {},
},
},
null
);
const message = await transaction.message.findUniqueOrThrow({
where: { id },
include: { action: true },
});
expect(message.createdAtWall).toBeInstanceOf(Date);
expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000);
expect(message.occurredGameTick).toBeNull();
expect(message.action).toBeNull();
throw rollback;
})
).rejects.toBe(rollback);
});
it('keeps sender and receiver rows readable while replacing their bodies', async () => { it('keeps sender and receiver rows readable while replacing their bodies', async () => {
const rollback = new Error('rollback message tombstone fixture'); const rollback = new Error('rollback message tombstone fixture');
await expect( await expect(
@@ -70,6 +115,7 @@ integration('message deletion tombstone persistence', () => {
expect(rows).toHaveLength(2); expect(rows).toHaveLength(2);
for (const row of rows) { for (const row of rows) {
expect(row.validUntil).toEqual(validUntil); expect(row.validUntil).toEqual(validUntil);
expect(row.tombstonedAtWall).not.toBeNull();
expect(row.message).toMatchObject({ expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.', text: '삭제된 메시지입니다.',
option: { invalid: true }, option: { invalid: true },
@@ -81,4 +127,43 @@ integration('message deletion tombstone persistence', () => {
}) })
).rejects.toBe(rollback); ).rejects.toBe(rollback);
}); });
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
const rollback = new Error('rollback wall deletion fixture');
await expect(
db.$transaction(async (transaction) => {
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
`;
const draft = (text: string) => ({
mailbox: 7,
type: 'private' as const,
src: 7,
dest: 8,
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
createdAtWall: nowWall,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text,
option: {},
},
});
const deletable = await transaction.message.create({
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
});
const expired = await transaction.message.create({
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
});
expect(
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
).toEqual([deletable.id]);
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
throw rollback;
})
).rejects.toBe(rollback);
});
}); });
+161 -19
View File
@@ -128,9 +128,13 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(false); expect(result.canRespondDiplomacy).toBe(false);
}); });
it('redacts recent and old diplomacy content below secret permission 3', async () => { it.each([null, 'RESOLVED'])(
'redacts recent and old diplomacy content below secret permission 3 (%s)',
async (actionStatus) => {
const diplomacyRow = { const diplomacyRow = {
id: 19, id: 19,
action_status: actionStatus,
expires_game_tick: null,
mailbox: 9001, mailbox: 9001,
type: 'diplomacy', type: 'diplomacy',
src: 9002, src: 9002,
@@ -177,15 +181,16 @@ describe('messages router missing-flow compatibility', () => {
expect(recent.permission).toBe(2); expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({ expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' }, option: { action: 'noAggression', permissionRedacted: true },
}); });
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({ expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' }, option: { action: 'noAggression', permissionRedacted: true },
}); });
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
}); }
);
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]); const queryRaw = vi.fn(async () => [{ id: 51 }]);
@@ -210,7 +215,7 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('national'); expect(result.msgType).toBe('national');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); expect(queryRaw).toHaveBeenCalledOnce();
}); });
it('journals committed message mailbox copies instead of publishing before commit', async () => { it('journals committed message mailbox copies instead of publishing before commit', async () => {
@@ -228,6 +233,84 @@ describe('messages router missing-flow compatibility', () => {
expect(redis.publish).not.toHaveBeenCalled(); expect(redis.publish).not.toHaveBeenCalled();
}); });
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
'keeps ordinary public messages available while the game clock is %s',
async (clockPhase) => {
const queryRaw = vi.fn(async () => [{ id: 52 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ clockPhase })) },
});
await expect(
caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` })
).resolves.toMatchObject({ msgType: 'public' });
expect(queryRaw).toHaveBeenCalledOnce();
}
);
it.each(['SUSPENDED', 'RECONCILING'] as const)(
'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s',
async (clockPhase) => {
const scoutRow = {
id: 54,
mailbox: general.id,
type: 'private',
src: 8,
dest: general.id,
time: new Date('0200-01-01T00:00:00.000Z'),
created_at_wall: new Date('2026-09-03T15:00:00.000Z'),
action_status: 'PENDING',
expires_game_tick: 200n,
message: {
src: {
generalId: 8,
generalName: '등용권유자',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
dest: {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: '위',
color: '#fff',
icon: '',
},
text: '등용 권유 서신',
option: { action: 'scout' },
},
};
const { caller } = buildContext({
$queryRaw: vi.fn(async () => [scoutRow]),
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
clockTick: 100n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
tickSeconds: 600,
clockPhase,
clockRevision: 9n,
deadlineGeneration: 4n,
})),
},
});
const result = await caller.messages.getRecent({ generalId: general.id });
expect(result.private[0]).toMatchObject({
id: scoutRow.id,
text: '등용 권유 서신',
option: { action: 'scout' },
time: '2026-09-03 15:00:00',
});
expect(result.private[0]?.option).not.toMatchObject({ invalid: true });
}
);
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = { const ambassador = {
...general, ...general,
@@ -259,7 +342,7 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('diplomacy'); expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); expect(queryRaw).toHaveBeenCalledTimes(2);
}); });
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => { it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
@@ -288,11 +371,10 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('diplomacy'); expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 }, { domain: 'messages.diplomacyMailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 }, { domain: 'messages.diplomacyMailbox', entityId: 9001 },
]); ]);
}); });
@@ -314,7 +396,6 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('national'); expect(result.msgType).toBe('national');
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
}); });
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => { it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
@@ -555,7 +636,11 @@ describe('messages router missing-flow compatibility', () => {
}); });
it('invalidates a recent owned message and its receiver copy', async () => { it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [ let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
return [
{ {
id: 21, id: 21,
mailbox: general.id, mailbox: general.id,
@@ -585,14 +670,16 @@ describe('messages router missing-flow compatibility', () => {
option: { receiverMessageID: 22 }, option: { receiverMessageID: 22 },
}, },
}, },
]); ];
});
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]); expect(result.deletedIds).toEqual([21, 22]);
expect(executeRaw).toHaveBeenCalledOnce(); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 }, { domain: 'messages.mailbox', entityId: 7 },
@@ -601,7 +688,11 @@ describe('messages router missing-flow compatibility', () => {
}); });
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [ let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 25 }];
return [
{ {
id: 25, id: 25,
mailbox: 9001, mailbox: 9001,
@@ -631,13 +722,15 @@ describe('messages router missing-flow compatibility', () => {
option: { receiverMessageID: 26 }, option: { receiverMessageID: 26 },
}, },
}, },
]); ];
});
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]); expect(result.deletedIds).toEqual([25]);
expect(executeRaw).toHaveBeenCalledOnce(); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled();
}); });
@@ -713,8 +806,11 @@ describe('messages router missing-flow compatibility', () => {
action, action,
reason: 'success', reason: 'success',
})); }));
const transaction = vi.fn(async () => {
throw new Error('ENGINE message response must not enter the API input-event transaction.');
});
const { caller } = buildContext( const { caller } = buildContext(
{ $queryRaw: vi.fn(async () => [messageRow]) }, { $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
{ turnDaemon: { requestCommand } } { turnDaemon: { requestCommand } }
); );
@@ -734,6 +830,7 @@ describe('messages router missing-flow compatibility', () => {
response: true, response: true,
}) })
); );
expect(transaction).not.toHaveBeenCalled();
} }
); );
@@ -745,6 +842,7 @@ describe('messages router missing-flow compatibility', () => {
proposerCurrentNationId?: number; proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>; proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number; diplomacyState?: number;
invalid?: boolean;
response?: boolean; response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>; cities?: Array<{ id: number; nationId: number; frontState: number }>;
}) => { }) => {
@@ -799,6 +897,7 @@ describe('messages router missing-flow compatibility', () => {
text: '외교 제안', text: '외교 제안',
option: { option: {
action, action,
...(options?.invalid ? { invalid: true } : {}),
...(action === 'noAggression' ? { year: 201, month: 2 } : {}), ...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
}, },
}, },
@@ -864,8 +963,18 @@ describe('messages router missing-flow compatibility', () => {
const nationUpdate = vi.fn(async () => ({})); const nationUpdate = vi.fn(async () => ({}));
const logCreateMany = vi.fn(async () => ({ count: 1 })); const logCreateMany = vi.fn(async () => ({ count: 1 }));
const messageUpdateMany = vi.fn(async () => ({ count: 1 })); const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
const cityUpdate = vi.fn(async () => ({})); const cityUpdate = vi.fn(async () => ({}));
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({
type: 'syncDiplomaticResponse' as const,
ok: true,
generalId: command.generalId,
messageId: command.messageId,
nations: 2,
diplomacy: 2,
cities: 0,
}));
const { caller } = buildContext( const { caller } = buildContext(
{ {
general: { general: {
@@ -941,13 +1050,22 @@ describe('messages router missing-flow compatibility', () => {
currentYear: 200, currentYear: 200,
currentMonth: 3, currentMonth: 3,
config: { environment: { mapName: 'che' } }, config: { environment: { mapName: 'che' } },
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
clockTick: 1_000n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'),
tickSeconds: 600,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
})), })),
}, },
logEntry: { createMany: logCreateMany }, logEntry: { createMany: logCreateMany },
message: { updateMany: messageUpdateMany }, message: { updateMany: messageUpdateMany },
messageAction: { updateMany: messageActionUpdateMany },
$queryRaw: queryRaw, $queryRaw: queryRaw,
}, },
{ changeJournal } { changeJournal, turnDaemon: { requestCommand } }
); );
return { return {
caller, caller,
@@ -960,9 +1078,25 @@ describe('messages router missing-flow compatibility', () => {
messageUpdateMany, messageUpdateMany,
cityUpdate, cityUpdate,
changeJournal, changeJournal,
requestCommand,
}; };
}; };
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
const setup = buildDiplomaticContext({ invalid: true });
await expect(
setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: true,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
expect(setup.requestCommand).not.toHaveBeenCalled();
});
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => { it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
const setup = buildDiplomaticContext(); const setup = buildDiplomaticContext();
@@ -973,6 +1107,14 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result).toEqual({ result: true, reason: 'success' }); expect(result).toEqual({ result: true, reason: 'success' });
expect(setup.requestCommand).toHaveBeenCalledWith({
type: 'syncDiplomaticResponse',
userId: auth.user.id,
generalId: setup.actor.id,
messageId: 31,
nationIds: [1, 2],
cityIds: [],
});
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
expect(setup.nationUpdate).toHaveBeenCalledWith( expect(setup.nationUpdate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -987,7 +1129,7 @@ describe('messages router missing-flow compatibility', () => {
); );
expect(setup.messageUpdateMany).toHaveBeenCalledWith({ expect(setup.messageUpdateMany).toHaveBeenCalledWith({
where: { id: { in: [31] } }, where: { id: { in: [31] } },
data: { validUntil: expect.any(Date), validUntilTick: 0n }, data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
}); });
expect(setup.queryRaw).toHaveBeenCalledTimes(9); expect(setup.queryRaw).toHaveBeenCalledTimes(9);
}); });
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071; const bettingId = 990_071;
const concurrentBettingId = 990_072; const concurrentBettingId = 990_072;
const phaseBettingId = 990_073;
const generalId = 9_971; const generalId = 9_971;
const otherGeneralId = 9_972; const otherGeneralId = 9_972;
const phaseGeneralId = 9_973;
const nationId = 990_071; const nationId = 990_071;
const otherNationId = 990_072; const otherNationId = 990_072;
const userId = 'nation-betting-router-user'; const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user'; const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user'; const noGeneralUserId = 'nation-betting-router-no-general-user';
const phaseUserId = 'nation-betting-router-phase-user';
const auth: GameSessionTokenPayload = { const auth: GameSessionTokenPayload = {
version: 1, version: 1,
@@ -58,6 +61,17 @@ const noGeneralAuth: GameSessionTokenPayload = {
}, },
}; };
const phaseAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-phase-session',
user: {
...auth.user,
id: phaseUserId,
username: 'phase-bettor',
displayName: 'Phase Bettor',
},
};
integration('nation betting router', () => { integration('nation betting router', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined; let closeDb: (() => Promise<void>) | undefined;
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.inputEvent.deleteMany({
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.createMany({ await db.nation.createMany({
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
turnTime: new Date('0200-01-01T00:00:00.000Z'), turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {}, meta: {},
}, },
{
id: phaseGeneralId,
userId: phaseUserId,
name: '정지중베팅장수',
nationId,
cityId: 1,
npcState: 0,
officerLevel: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
], ],
}); });
const world = await db.worldState.create({ const world = await db.worldState.create({
@@ -169,6 +196,17 @@ integration('nation betting router', () => {
], ],
}, },
}); });
await db.nationBetting.create({
data: {
id: phaseBettingId,
name: '정지 중 베팅',
selectCount: 1,
requiresInheritancePoint: true,
openYearMonth: 2_400,
closeYearMonth: 2_424,
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
},
});
await db.nationBetting.create({ await db.nationBetting.create({
data: { data: {
id: concurrentBettingId, id: concurrentBettingId,
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
data: [ data: [
{ userId, key: 'previous', value: 1_000 }, { userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 }, { userId: otherUserId, key: 'previous', value: 500 },
{ userId: phaseUserId, key: 'previous', value: 500 },
], ],
}); });
}); });
afterAll(async () => { afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.inputEvent.deleteMany({
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } }); await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.(); await closeDb?.();
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
).toMatchObject({ value: 250 }); ).toMatchObject({ value: 250 });
}); });
it('accepts nation betting during suspension but rejects it during reconciliation', async () => {
const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const frozenTick = before.clockTick;
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } });
await expect(
appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({
bettingId: phaseBettingId,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 400 });
await expect(
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
).resolves.toMatchObject({ amount: 100 });
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({
clockPhase: 'SUSPENDED',
clockTick: frozenTick,
});
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } });
await expect(
appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({
bettingId: phaseBettingId,
bettingType: [0],
amount: 50,
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 400 });
await expect(
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
).resolves.toMatchObject({ amount: 100 });
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } });
});
it('requires authentication and an owned player general for every betting operation', async () => { it('requires authentication and an owned player general for every betting operation', async () => {
await expect( await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({ appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
@@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => {
const ally = general({ const ally = general({
id: 3, id: 3,
userId: 'u3', userId: 'u3',
injury: 25,
gold: 3000, gold: 3000,
crew: 200, crew: 200,
train: 80, train: 80,
@@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => {
expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]); expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200); expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 52, strength: 45, intelligence: 37 },
});
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 }); expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(result.generals[0]?.reservedCommands).toEqual([ expect(result.generals[0]?.reservedCommands).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } }, { action: 'che_징병', args: { crewType: 1, amount: 300 } },
+14
View File
@@ -161,6 +161,20 @@ describe('NPC policy router', () => {
}); });
}); });
it('uses the live world development cost instead of the scenario snapshot', async () => {
const fixture = createContext({
world: {
...baseWorld,
config: { ...baseWorld.config, const: { develCost: 100 } },
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
},
});
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
});
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => { it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 }; const reader = { ...baseGeneral, officerLevel: 2 };
const requestCommand = vi.fn(async () => ({ const requestCommand = vi.fn(async () => ({
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
attempts: 1, attempts: 1,
actorUserId: userId, actorUserId: userId,
}); });
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const logs = await db.logEntry.findMany({ const logs = await db.logEntry.findMany({
where: { where: {
OR: [ OR: [
@@ -420,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
}); });
}, 45_000); }, 45_000);
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => { it('revalidates a queued token at the authoritative daemon processing tick', async () => {
const reservation = await appRouter const reservation = await appRouter
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth)) .createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
.join.listPossessCandidates({}); .join.listPossessCandidates({});
@@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
).rejects.toMatchObject({ code: 'TIMEOUT' }); ).rejects.toMatchObject({ code: 'TIMEOUT' });
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
const acceptedGameAt = new Date( expect(event.acceptedGameTick).toBeNull();
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time' expect(event.processingGameTick).toBeNull();
); expect(event.payload).not.toHaveProperty('acceptedGameAt');
expect(acceptedGameAt.toString()).not.toBe('Invalid Date'); const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
await db.npcSelectionToken.update({ await db.npcSelectionToken.update({
where: { ownerUserId: delayedUserId }, where: { ownerUserId: delayedUserId },
data: { validUntil: acceptedGameAt }, data: { validUntilTick: queuedAtTick },
}); });
await db.worldState.updateMany({ await db.worldState.updateMany({
data: { clockTick: { increment: 1 } }, data: { clockTick: { increment: 1 } },
@@ -470,12 +470,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await startRuntime('npc-possession-delayed-retry-daemon'); await startRuntime('npc-possession-delayed-retry-daemon');
await expect( await expect(
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input) appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
).resolves.toEqual({ ok: true, generalId: candidate.id }); ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' });
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
attempts: 1, attempts: 1,
processingGameTick: expect.anything(),
}); });
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1); expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0);
}, 45_000); }, 45_000);
it('serializes durable enqueue before a token refresh can replace its nonce', async () => { it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
@@ -670,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.npcSelectionToken.update({ await db.npcSelectionToken.update({
where: { ownerUserId: rejectedUserId }, where: { ownerUserId: rejectedUserId },
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') }, data: { validUntilTick: -1n },
}); });
await expect( await expect(
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({ appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
@@ -715,4 +716,59 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0); expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false); expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
}, 45_000); }, 45_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'reserves and possesses an NPC in %s without changing finalized records',
async (phase) => {
await stopRuntime('prepare frozen possession');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `npc-visitor-${phase}`;
const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802);
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: { ...(original.meta as Record<string, GamePrisma.InputJsonValue>), isUnited: 2, isunited: 2 },
},
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
const records = async () => ({
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await records();
try {
await startRuntime(`npc-visitor-${phase}`);
const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor));
const candidates = await caller.join.listPossessCandidates({});
const picked = candidates.candidates[0]!;
expect(picked).toBeDefined();
await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce });
expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({
userId: visitorId,
npcState: 1,
});
expect(await records()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
await stopRuntime('verify frozen possession reload');
await startRuntime(`npc-visitor-reload-${phase}`);
expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore frozen possession');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('frozen-possession-restored');
}
},
30_000
);
}); });
+64 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveNationPermission } from '../src/router/nation/shared.js';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
@@ -9,7 +10,7 @@ import {
toPublicRealtimeEvent as convertPublicRealtimeEvent, toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js'; } from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const; const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
const refreshGrant = 'opaque-grant'; const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) => const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant); convertPublicRealtimeEvent(event, identities, () => refreshGrant);
@@ -198,6 +199,37 @@ describe('public realtime event privacy boundary', () => {
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
}); });
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
const blockedViewer = { ...viewer, canReadDiplomacy: false };
expect(
toPublicRealtimeEvent(
{
type: 'messageCreated',
at: '2026-09-04T00:00:00Z',
mailbox,
msgType: 'diplomacy',
messageId: 1,
senderId: 2,
},
[blockedViewer]
)
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
});
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => { it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
const event: RealtimeEvent = { const event: RealtimeEvent = {
type: 'messagesChanged', type: 'messagesChanged',
@@ -225,6 +257,12 @@ describe('public realtime event privacy boundary', () => {
viewer viewer
) )
).toBe(false); ).toBe(false);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
viewer
)
).toBe(true);
}); });
it('merges previous and committed identities across an ownership transition', () => { it('merges previous and committed identities across an ownership transition', () => {
@@ -251,3 +289,28 @@ describe('public realtime event privacy boundary', () => {
}); });
}); });
}); });
describe('diplomatic notification recipient roles', () => {
it.each([
['군주', 12, 'normal', {}, true],
['외교권자', 1, 'ambassador', {}, true],
['조언자', 1, 'auditor', {}, true],
['일반 장수', 1, 'normal', {}, false],
['수뇌', 11, 'normal', {}, false],
['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false],
] as const)(
'%s receives only eligible nation diplomacy invalidations',
(_, officerLevel, permission, penalty, allowed) => {
const canReadDiplomacy =
resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3;
expect(canReadDiplomacy).toBe(allowed);
const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] };
expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual(
allowed ? { type: 'messagesInvalidated', refreshGrant } : null
);
expect(
toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }])
).toBeNull();
}
);
});
+38 -22
View File
@@ -12,6 +12,7 @@ const payload = (
| 'access.general' | 'access.general'
| 'dashboard.global' | 'dashboard.global'
| 'messages.mailbox' | 'messages.mailbox'
| 'messages.diplomacyMailbox'
| 'tournament' | 'tournament'
| 'betting' | 'betting'
) => ({ ) => ({
@@ -19,7 +20,11 @@ const payload = (
changes: [ changes: [
[ [
domain, domain,
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, domain === 'front.general' || domain === 'access.general'
? 7
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
? 9999
: 0,
'1', '1',
], ],
], ],
@@ -27,15 +32,16 @@ const payload = (
const createFixture = (rows: readonly object[]) => { const createFixture = (rows: readonly object[]) => {
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]); const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
const updateMany = vi.fn().mockResolvedValue({ count: 1 }); const executeRaw = vi.fn().mockResolvedValue(1);
const incr = vi.fn().mockResolvedValue(41); const incr = vi.fn().mockResolvedValue(41);
const publish = vi.fn().mockResolvedValue(1); const publish = vi.fn().mockResolvedValue(1);
const db = { const db = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
readModelOutbox: { updateMany }, $executeRaw: executeRaw,
readModelOutbox: {},
} as unknown as ReadModelOutboxDatabase; } as unknown as ReadModelOutboxDatabase;
const redis = { incr, publish } as unknown as RedisConnector['client']; const redis = { incr, publish } as unknown as RedisConnector['client'];
return { db, redis, queryRaw, updateMany, incr, publish }; return { db, redis, queryRaw, executeRaw, incr, publish };
}; };
describe('ReadModelOutboxWorker', () => { describe('ReadModelOutboxWorker', () => {
@@ -47,7 +53,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({ expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
@@ -64,7 +70,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision'); expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
@@ -74,9 +80,7 @@ describe('ReadModelOutboxWorker', () => {
revision: 41, revision: 41,
changes: { frontStatusActorIds: [7] }, changes: { frontStatusActorIds: [7] },
}); });
expect(fixture.updateMany).toHaveBeenCalledWith( expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
);
}); });
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)( it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
@@ -89,7 +93,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled(); expect(fixture.incr).not.toHaveBeenCalled();
@@ -105,7 +109,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled(); expect(fixture.incr).not.toHaveBeenCalled();
@@ -115,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
}); });
}); });
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
type: 'messagesChanged',
mailboxes: [],
diplomacyMailboxes: [9999],
});
});
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => { it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
let releaseFirst: (() => void) | undefined; let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => { const first = new Promise<readonly object[]>((resolve) => {
@@ -150,22 +173,15 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(onError).toHaveBeenCalledWith( expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' }) expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
); );
expect(fixture.updateMany).toHaveBeenCalledWith( const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect.objectContaining({ expect(releaseQuery.sql).toContain('"available_at"');
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null }, expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
data: expect.objectContaining({
lockedAt: null,
lockOwner: null,
lastError: expect.stringContaining('redis unavailable'),
}),
})
);
}); });
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => { it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
+1 -3
View File
@@ -710,7 +710,7 @@ describe('appRouter', () => {
).rejects.toMatchObject({ code: 'FORBIDDEN' }); ).rejects.toMatchObject({ code: 'FORBIDDEN' });
}); });
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => { it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => {
const transport = new InMemoryTurnDaemonTransport(); const transport = new InMemoryTurnDaemonTransport();
const requestId = 'select-pool-reserve-http'; const requestId = 'select-pool-reserve-http';
const commandRequestId = `select-pool:user-1:${requestId}:reserve`; const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
@@ -741,8 +741,6 @@ describe('appRouter', () => {
requestId: commandRequestId, requestId: commandRequestId,
userId: 'user-1', userId: 'user-1',
seedOwnerIdentity: 'user-1', seedOwnerIdentity: 'user-1',
acceptedGameAt,
acceptedGameTick: 0,
}); });
}); });
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 903, scenarioId: 903,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'), now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: { installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5, turnTermMinutes: 5,
npcMode: 2, npcMode: 2,
showImgLevel: 3, showImgLevel: 3,
@@ -227,19 +228,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.listGeneralPoolCandidates(new Date(firstReservation.validUntil)) .listGeneralPoolCandidates(new Date(firstReservation.validUntil))
?.some((candidate) => reservedNames.has(candidate.uniqueName)) ?.some((candidate) => reservedNames.has(candidate.uniqueName))
).toBe(false); ).toBe(false);
await expect( const reserveEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` }, where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
}) });
).resolves.toMatchObject({ expect(reserveEvent).toMatchObject({
eventType: 'selectPoolReserve', eventType: 'selectPoolReserve',
status: 'SUCCEEDED', status: 'SUCCEEDED',
actorUserId: userId, actorUserId: userId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick');
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const; const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
const attempts = await Promise.allSettled([ const attempts = await Promise.allSettled([
@@ -276,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id }, result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
}); });
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
if (!initialAccess.lastRefresh) { if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp'); throw new Error('selected general must have an initial access timestamp');
} }
@@ -357,6 +359,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' }); ).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
const cooledAt = '2026-07-29T00:00:00.000Z'; const cooledAt = '2026-07-29T00:00:00.000Z';
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await expect( await expect(
turnDaemon.requestCommand({ turnDaemon.requestCommand({
type: 'patchGeneral', type: 'patchGeneral',
@@ -366,6 +369,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: { meta: {
next_change: cooledAt, next_change: cooledAt,
nextChangeAt: cooledAt, nextChangeAt: cooledAt,
next_change_tick: cooledTick,
}, },
}, },
}) })
@@ -380,16 +384,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.createCaller(buildContext('select-pool-reselect')) .createCaller(buildContext('select-pool-reselect'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName }) .join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id }); ).resolves.toEqual({ ok: true, generalId: initial.id });
await expect( const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } }) where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
).resolves.toMatchObject({ });
expect(reselectionEvent).toMatchObject({
eventType: 'selectPoolReselect', eventType: 'selectPoolReselect',
actorUserId: userId, actorUserId: userId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } }); const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
expect(updated).toMatchObject({ expect(updated).toMatchObject({
@@ -455,6 +459,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue }, data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
}); });
const secondCooledAt = '2026-07-28T00:00:00.000Z'; const secondCooledAt = '2026-07-28T00:00:00.000Z';
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await turnDaemon.requestCommand({ await turnDaemon.requestCommand({
type: 'patchGeneral', type: 'patchGeneral',
requestId: 'select-pool-full-cooldown-patch', requestId: 'select-pool-full-cooldown-patch',
@@ -463,6 +468,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: { meta: {
next_change: secondCooledAt, next_change: secondCooledAt,
nextChangeAt: secondCooledAt, nextChangeAt: secondCooledAt,
next_change_tick: secondCooledTick,
}, },
}, },
}); });
@@ -571,21 +577,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.join.selectPoolGeneral(stableInput); .join.selectPoolGeneral(stableInput);
expect(retried).toEqual(first); expect(retried).toEqual(first);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
await expect( const stableEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({
where: { where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
}, },
}) });
).resolves.toMatchObject({ expect(stableEvent).toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
attempts: 1, attempts: 1,
actorUserId: otherUserId, actorUserId: otherUserId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
}, 30_000); }, 30_000);
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => { it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
@@ -660,4 +664,50 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
error: null, error: null,
}); });
}, 30_000); }, 30_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'creates from the selection pool while %s',
async (phase) => {
await runtime!.lifecycle.stop('prepare frozen pool creation');
await daemonLoop;
await runtime!.close();
const original = await db.worldState.findFirstOrThrow();
await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } });
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: `frozen-pool-${phase}`,
});
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start();
await turnDaemon.requestStatus(10_000);
const visitorId = `pool-visitor-${phase}`;
const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } };
const readRecords = async () => ({
hall: await db.hallOfFame.count({ where: { serverId: profile } }),
old: await db.oldGeneral.count({ where: { serverId: profile } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await readRecords();
const candidates = await appRouter
.createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth))
.join.getSelectionPool();
const result = await appRouter
.createCaller(buildContext(`pool-create-${phase}`, visitorAuth))
.join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' });
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
});
expect(await readRecords()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
},
30_000
);
}); });
@@ -0,0 +1,232 @@
import { it, expect } from 'vitest';
import {
createGamePostgresConnector,
createRedisConnector,
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
} from '@sammo-ts/infra';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import type { GameApiContext } from '../src/context.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
integration.each([
'setState',
'patchState',
'setParticipants',
'setMatches',
'setBettingEntries',
'seedParticipants',
'cancel',
])(
'serializes %s behind a joining user without holding the ENGINE clock lock',
async (adminAction) => {
const url = databaseUrl!;
const connector = createGamePostgresConnector({ url });
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
await connector.connect();
await redisConnector.connect();
const db = connector.prisma;
const redis = redisConnector.client;
const profileName = 'che:tournament-lock-integration';
const keys = buildTournamentKeys(profileName);
const redisKeys = [...Object.values(keys), `${keys.stateKey}:mutation-lock`];
const prefix = 'integration:tournament-lock:';
const nextAt = new Date().toISOString();
let notifyJoin: () => void;
const joinAtDaemon = new Promise<void>((resolve) => {
notifyJoin = resolve;
});
let notifyAdmin: () => void;
const adminAtRedis = new Promise<void>((resolve) => {
notifyAdmin = resolve;
});
const transport = new DatabaseTurnDaemonTransport(db, 4_000);
try {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await db.turnDaemonLease.create({
data: {
profile: profileName,
ownerId: 'audit',
leaseUntil: new Date(Date.now() + 60000),
clockReady: true,
},
});
await db.worldState.create({
data: {
id: -991900,
scenarioCode: 'audit',
currentYear: 200,
currentMonth: 1,
tickSeconds: 60,
clockBaseTime: new Date(),
clockTick: 0n,
clockWallAnchor: new Date(),
clockPhase: 'RUNNING',
clockRevision: 1n,
config: { const: { develCost: 10 } },
},
});
await db.general.create({
data: { id: 991900, userId: 'audit-user', name: 'audit', turnTime: new Date(), gold: 1000 },
});
await redis.set(
keys.stateKey,
JSON.stringify({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
})
);
const base: Partial<GameApiContext> = {
db,
redis,
profile: { id: 'che', scenario: 'tournament-lock-integration', name: profileName },
auth: {
version: 1,
profile: profileName,
issuedAt: new Date().toISOString(),
expiresAt: '2999-01-01T00:00:00Z',
sessionId: 'audit',
user: { id: 'audit-user', username: 'audit', displayName: 'audit', roles: [] },
sanctions: {},
},
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
notifyJoin!();
await adminAtRedis;
const requestId = await transport.sendCommand(command);
return db.$transaction(async (tx) => {
// 이전 순서에서는 관리자가 DB lock을 보유하여 별도 ENGINE 연결이 막힌다.
await tx.$executeRawUnsafe("SET LOCAL lock_timeout = '500ms'");
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (command.type !== 'adjustGeneralResources') throw new Error('unexpected command');
const result = {
type: 'adjustGeneralResources' as const,
ok: true as const,
processed: 1,
missing: 0,
totalGoldDelta: -10,
totalRiceDelta: 0,
};
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result } });
return result;
});
},
},
};
const joinContext = { ...base, requestId: `${prefix}join` } as GameApiContext;
const adminRedis = new Proxy(redis, {
get(target, property) {
if (property === 'set')
return async (...args: Parameters<typeof redis.set>) => {
if (args[0] === `${keys.stateKey}:mutation-lock`) notifyAdmin!();
return redis.set(...args);
};
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const adminContext = {
...base,
requestId: `${prefix}admin`,
redis: adminRedis,
auth: { ...base.auth!, user: { ...base.auth!.user, roles: ['admin'] } },
} as GameApiContext;
const callAdmin = async (context: GameApiContext) => {
const caller = appRouter.createCaller(context).tournament;
switch (adminAction) {
case 'setState':
return caller.setState({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
});
case 'patchState':
return caller.patchState({ auto: true });
case 'setParticipants':
return caller.setParticipants([]);
case 'setMatches':
return caller.setMatches([]);
case 'setBettingEntries':
return caller.setBettingEntries([]);
case 'seedParticipants':
return caller.seedParticipants({ generalIds: [991900] });
default:
return caller.cancel();
}
};
const join = appRouter.createCaller(joinContext).tournament.join();
const joinOutcome = join.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await joinAtDaemon;
const admin = callAdmin(adminContext);
const adminOutcome = admin.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await adminAtRedis;
const [joined, managed] = await Promise.all([joinOutcome, adminOutcome]);
expect(joined).toMatchObject({ ok: true, value: { ok: true, count: 1 } });
expect(managed).toMatchObject({ ok: true, value: { ok: true } });
const revision = await redis.get(keys.sourceRevisionKey);
const engineCount = await db.inputEvent.count({
where: { requestId: { startsWith: prefix }, target: 'ENGINE' },
});
// API 입력 원장은 유지한다. 동일 요청 재실행은 Redis/환불 command를 다시 쓰지 않는다.
await expect(callAdmin(adminContext)).resolves.toMatchObject({ ok: true });
expect(await redis.get(keys.sourceRevisionKey)).toBe(revision);
expect(await db.inputEvent.count({ where: { requestId: { startsWith: prefix }, target: 'ENGINE' } })).toBe(
engineCount
);
const inputEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}admin:tournament.${adminAction}` },
});
expect(inputEvent).toMatchObject({ target: 'API', status: 'SUCCEEDED', attempts: 1 });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
const retryContext = { ...adminContext, requestId: `${prefix}retry` };
const beforeFailure = await redis.get(keys.sourceRevisionKey);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'SUSPENDED' } });
await expect(callAdmin(retryContext)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
expect(await redis.get(keys.sourceRevisionKey)).toBe(beforeFailure);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'RUNNING' } });
await expect(callAdmin(retryContext)).resolves.toMatchObject({ ok: true });
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}retry:tournament.${adminAction}` },
})
).toMatchObject({ status: 'SUCCEEDED', attempts: 2 });
} finally {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await redisConnector.disconnect();
await connector.disconnect();
}
},
15000
);
+138 -11
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -30,11 +30,31 @@ class MemoryRedis {
} }
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> { async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
const [valueKey, revisionKey] = options.keys; if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) {
const [value] = options.arguments; const current = options.keys.map((key) => this.values.get(key));
if (current.every((value) => value === undefined)) {
options.keys.forEach((key, index) => this.values.set(key, options.arguments[index]!));
return '1';
}
return current.every((value, index) => value === options.arguments[index]) ? '2' : '0';
}
const fenced = options.keys.at(-1)?.endsWith(':clock:phase') === true;
const writeCount = options.keys.length - (fenced ? 4 : 1);
if (fenced) {
const clockKeys = options.keys.slice(-3);
const expected = options.arguments.slice(-3);
if (!clockKeys.every((key, index) => this.values.get(key) === expected[index])) {
return '__CLOCK_FENCE__';
}
}
const valueKey = options.keys[0];
const revisionKey = options.keys[writeCount];
const value = options.arguments[0];
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.values.get(revisionKey) ?? '0') + 1; const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
this.values.set(valueKey, value); for (let index = 0; index < writeCount; index += 1) {
this.values.set(options.keys[index]!, options.arguments[index]!);
}
this.values.set(revisionKey, String(revision)); this.values.set(revisionKey, String(revision));
return String(revision); return String(revision);
} }
@@ -131,6 +151,8 @@ const buildContext = (options: {
develCost?: number; develCost?: number;
currentDevelCost?: number; currentDevelCost?: number;
rankRows?: Array<{ generalId: number; type: string; value: number }>; rankRows?: Array<{ generalId: number; type: string; value: number }>;
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
requestId?: string;
}): GameApiContext => { }): GameApiContext => {
const db = { const db = {
general: { general: {
@@ -144,12 +166,21 @@ const buildContext = (options: {
}, },
worldState: { worldState: {
findFirst: async () => ({ findFirst: async () => ({
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
clockPhase: options.clockPhase ?? 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
tickSeconds: 60,
config: { const: { develCost: options.develCost ?? 200 } }, config: { const: { develCost: options.develCost ?? 200 } },
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }), ...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
}), }),
}, },
} as unknown as DatabaseClient; } as unknown as DatabaseClient;
return { return {
requestId: options.requestId,
db, db,
redis: options.redis as unknown as RedisConnector['client'], redis: options.redis as unknown as RedisConnector['client'],
turnDaemon: options.transport, turnDaemon: options.transport,
@@ -246,15 +277,33 @@ describe('tournament router permissions and mutations', () => {
nextAt: '2026-07-26T01:00:00.000Z', nextAt: '2026-07-26T01:00:00.000Z',
}); });
await redis.set('sammo:che:default:tournament:participants', '[]'); await redis.set('sammo:che:default:tournament:participants', '[]');
const caller = appRouter.createCaller( const context = buildContext({
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 }) redis,
); transport,
generals: [general],
userId: 'user-1',
develCost: 200,
requestId: 'http:tournament-join',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800); expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0); expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
const snapshot = await caller.tournament.getSnapshot(); const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toHaveLength(1); expect(snapshot.participants).toHaveLength(1);
@@ -341,6 +390,83 @@ describe('tournament router permissions and mutations', () => {
expect(transport.gold.get(general.id)).toBe(2_400); expect(transport.gold.get(general.id)).toBe(2_400);
}); });
it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
const context = buildContext({
redis,
transport,
generals: [general],
userId: 'user-1',
clockPhase: 'SUSPENDED',
requestId: 'http:suspended-tournament-bet',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament bet must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true });
expect(transport.gold.get(general.id)).toBe(2_400);
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
reason: 'tournamentBet',
})
);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' })
);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
expect(transport.gold.get(general.id)).toBe(3_000);
expect(transport.commands).toHaveLength(0);
});
it('keeps another user from reading my bet identity and requires that user to own a general', async () => { it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
@@ -394,7 +520,10 @@ describe('tournament router permissions and mutations', () => {
roles: ['admin.tournament:che:default'], roles: ['admin.tournament:che:default'],
}) })
); );
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true }); await expect(adminCaller.tournament.getAdminStatus()).resolves.toMatchObject({
ok: true,
clock: { reconciliationComplete: false, latestReconciliation: null },
});
}); });
it('applies the admin role boundary to every tournament mutation', async () => { it('applies the admin role boundary to every tournament mutation', async () => {
@@ -479,9 +608,7 @@ describe('tournament router permissions and mutations', () => {
]) ])
).resolves.toEqual({ ok: true, count: 1 }); ).resolves.toEqual({ ok: true, count: 1 });
await expect( await expect(
caller.tournament.setBettingEntries([ caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }])
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
).resolves.toEqual({ ok: true, count: 1 }); ).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({ await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true, ok: true,
@@ -41,6 +41,9 @@ integration('TournamentStore Redis source revision', () => {
keys.matchesKey, keys.matchesKey,
keys.bettingKey, keys.bettingKey,
keys.sourceRevisionKey, keys.sourceRevisionKey,
keys.activeClockRevisionKey,
keys.deadlineGenerationKey,
keys.clockPhaseKey,
]); ]);
if (subscriber) { if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel); await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
@@ -124,4 +127,48 @@ integration('TournamentStore Redis source revision', () => {
}), }),
]); ]);
}); });
it('dual-writes deadline ticks and rejects a Redis clock revision race atomically', async () => {
const store = new TournamentStore(connector.client, keys);
await Promise.all([
connector.client.set(keys.activeClockRevisionKey, '7'),
connector.client.set(keys.deadlineGenerationKey, '3'),
connector.client.set(keys.clockPhaseKey, 'RUNNING'),
]);
const clockContext = {
phase: 'RUNNING' as const,
revision: 7,
deadlineGeneration: 3,
dateToTick: (date: Date) => Math.trunc(date.getTime() / 1_000),
};
const nextAt = '2026-09-03T10:00:00.000Z';
await store.withClockContext(clockContext, () =>
store.setState({
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 200,
openMonth: 1,
termSeconds: 10,
nextAt,
bettingCloseAt: '2026-09-03T09:59:50.000Z',
})
);
await expect(store.getState()).resolves.toMatchObject({
nextTick: Math.trunc(new Date(nextAt).getTime() / 1_000),
bettingCloseTick: Math.trunc(new Date('2026-09-03T09:59:50.000Z').getTime() / 1_000),
clockRevision: 7,
deadlineGeneration: 3,
});
const beforeRevision = await store.getSourceRevision();
await connector.client.set(keys.activeClockRevisionKey, '8');
await expect(
store.withClockContext(clockContext, () =>
store.setMatches([{ id: 99, stage: 7, roundIndex: 0, attackerId: 1, defenderId: 2 }])
)
).rejects.toThrow('clock revision fence failed');
await expect(store.getSourceRevision()).resolves.toBe(beforeRevision);
});
}); });
+12
View File
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
}); });
}); });
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
const fixture = buildContext({
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
result: null,
});
const result = await appRouter.createCaller(fixture.context).troop.getList();
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
});
it('creates a troop only for the general owned by the authenticated user', async () => { it('creates a troop only for the general owned by the authenticated user', async () => {
const { context, requestCommand } = buildContext({ const { context, requestCommand } = buildContext({
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
+7 -13
View File
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
describe('turn engine status projection', () => { describe('turn engine status projection', () => {
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => { it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
const activeLease = { const activeLease = {
turnDaemonLease: { $queryRaw: vi.fn(async () => [{ running: true }]),
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })), } as any;
},
};
const now = new Date('2026-08-24T00:00:00.000Z'); const now = new Date('2026-08-24T00:00:00.000Z');
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe( await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
true true
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
await expect( await expect(
loadTurnEngineRunning( loadTurnEngineRunning(
source, source,
{ turnDaemonLease: { findUnique: async () => null } }, { $queryRaw: async () => [{ running: false }] } as any,
'che:default', 'che:default',
now now
) )
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
await expect( await expect(
loadTurnEngineRunning( loadTurnEngineRunning(
source, source,
{ { $queryRaw: async () => [{ running: false }] } as any,
turnDaemonLease: {
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
},
},
'che:default', 'che:default',
now now
) )
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => { it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
let now = 1_000; let now = 1_000;
const get = vi.fn(async () => 'RUNNING' as const); const get = vi.fn(async () => 'RUNNING' as const);
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })); const queryRaw = vi.fn(async () => [{ running: true }]);
const cache = new CachedTurnEngineStatus( const cache = new CachedTurnEngineStatus(
{ get }, { get },
{ turnDaemonLease: { findUnique } }, { $queryRaw: queryRaw } as any,
'che:default', 'che:default',
2_000, 2_000,
() => now () => now
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
now += 1; now += 1;
await expect(cache.get()).resolves.toBe(true); await expect(cache.get()).resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(2); expect(get).toHaveBeenCalledTimes(2);
expect(findUnique).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
}); });
}); });
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
tryGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
await closeDb?.(); await closeDb?.();
}); });
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
const requestId = 'integration:vote-comment-timestamp:submit';
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
const context: Partial<GameApiContext> = {
requestId,
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
const acceptedId = await transport.sendCommand(command);
expect(acceptedId).toBe(engineRequestId);
await db.$transaction(async (transaction) => {
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
true
);
const event = await transaction.inputEvent.findUniqueOrThrow({
where: { requestId: acceptedId },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'PENDING',
actorUserId: fixtureUserId,
});
await transaction.inputEvent.update({
where: { requestId: acceptedId },
data: {
status: 'SUCCEEDED',
result: {
type: 'voteReward',
ok: true,
voteId: fixtureId,
generalId: fixtureId,
awardedUnique: false,
},
},
});
});
return transport.requestCommand(command);
},
},
};
const caller = appRouter.createCaller(context as GameApiContext);
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
ok: true,
wonLottery: false,
});
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => { it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>` const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone" SELECT current_setting('TIMEZONE') AS "timeZone"
+43 -26
View File
@@ -216,6 +216,40 @@ const buildContext = (options: {
}; };
describe('vote router actor and permission boundaries', () => { describe('vote router actor and permission boundaries', () => {
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
const fixture = buildContext({ requestId: 'vote-boundary' });
const transaction = vi.fn(async () => {
throw new Error('API transaction blocks the vote ENGINE clock fence');
});
Object.assign(fixture.context.db, { $transaction: transaction });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
generalId: 7,
})
);
});
it.each([
{ auth: null, code: 'UNAUTHORIZED' },
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
])(
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
async ({ auth, code }) => {
const fixture = buildContext({ auth });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).rejects.toMatchObject({ code });
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
);
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => { it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z'); const now = new Date('2026-07-26T00:00:00Z');
const time = { const time = {
@@ -230,13 +264,7 @@ describe('vote router actor and permission boundaries', () => {
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
expect(
hasPollEnded(
{ closed_at: null, end_at: now, end_tick: null },
{ ...time, now: new Date(now.getTime() + 1), tick: null }
)
).toBe(true);
}); });
it('rejects unauthenticated survey access', async () => { it('rejects unauthenticated survey access', async () => {
@@ -261,12 +289,11 @@ describe('vote router actor and permission boundaries', () => {
voteId: 1, voteId: 1,
generalId: 7, generalId: 7,
selection: [0], selection: [0],
acceptedGameTick: 100,
}); });
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe( expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false false
); );
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]); expect(fixture.changeJournal.snapshot()).toEqual([]);
expect(fixture.redisIncr).not.toHaveBeenCalled(); expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
}); });
@@ -301,8 +328,6 @@ describe('vote router actor and permission boundaries', () => {
const auth = buildAuth(['admin.survey.open']); const auth = buildAuth(['admin.survey.open']);
const fixture = buildContext({ auth }); const fixture = buildContext({ auth });
const caller = appRouter.createCaller(fixture.context); const caller = appRouter.createCaller(fixture.context);
const windowStart = Date.now();
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true }); await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
await expect( await expect(
caller.vote.createPoll({ caller.vote.createPoll({
@@ -314,8 +339,6 @@ describe('vote router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true }); ).resolves.toEqual({ ok: true });
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true }); await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true }); await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
const windowEnd = Date.now();
const mutationQueries = fixture.queryRaw.mock.calls const mutationQueries = fixture.queryRaw.mock.calls
.map(([query]) => query) .map(([query]) => query)
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query))); .filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
@@ -325,30 +348,24 @@ describe('vote router actor and permission boundaries', () => {
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL')); const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE')); const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id')); const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => { const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => {
expect(query).toBeDefined(); expect(query).toBeDefined();
const value = query?.values.at(index); expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
expect(value).toBeInstanceOf(Date);
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
return value as Date;
}; };
expect(sqlText(commentInsert!)).toContain('created_at'); expect(sqlText(commentInsert!)).toContain('created_at');
expectCurrentDateAt(commentInsert, -1); expectDbWallClock(commentInsert);
expect(sqlText(pollInsert!)).toContain('created_at'); expect(sqlText(pollInsert!)).toContain('created_at');
expect(sqlText(pollInsert!)).toContain('updated_at'); expect(sqlText(pollInsert!)).toContain('updated_at');
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2); expectDbWallClock(pollInsert);
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
expect(pollUpdatedAt).toBe(pollCreatedAt);
expect(pollUpdates).toHaveLength(3); expect(pollUpdates).toHaveLength(3);
expect(sqlText(closePreviousUpdate!)).toContain('updated_at'); expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt); expectDbWallClock(closePreviousUpdate);
expect(sqlText(editPollUpdate!)).toContain('updated_at'); expect(sqlText(editPollUpdate!)).toContain('updated_at');
expectCurrentDateAt(editPollUpdate, -2); expectDbWallClock(editPollUpdate);
expect(sqlText(closePollUpdate!)).toContain('updated_at'); expect(sqlText(closePollUpdate!)).toContain('updated_at');
expectCurrentDateAt(closePollUpdate, -2); expectDbWallClock(closePollUpdate);
}); });
it('reports the current world develcost as the legacy five-times survey reward', async () => { it('reports the current world develcost as the legacy five-times survey reward', async () => {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js'; import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js';
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const revisionRow = (overrides: Record<string, unknown> = {}) => ({ const revisionRow = (overrides: Record<string, unknown> = {}) => ({
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
db: { $queryRaw: queryRaw }, db: { $queryRaw: queryRaw },
} as unknown as GameApiContext; } as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
'sammo:map:base:hwe:scenario_2400:pg12'
);
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
'sammo:map:public:hwe:scenario_2400:pg12' 'sammo:map:public:hwe:scenario_2400:pg12'
); );
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('read_model_revision_meta'); expect(statement.sql).toContain('read_model_revision_meta');
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'"); expect(statement.sql).toContain('revision."domain" = \'map.world\'');
expect(statement.values).toEqual([]); expect(statement.values).toEqual([]);
}); });
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true }); const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true }); const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } }); expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } }); expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
expect(redis.set).toHaveBeenCalledTimes(1); expect(redis.set).toHaveBeenCalledTimes(1);
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
expect(shared).not.toHaveProperty('shownByGeneralList'); expect(shared).not.toHaveProperty('shownByGeneralList');
expect(shared).not.toHaveProperty('myCity'); expect(shared).not.toHaveProperty('myCity');
expect(shared).not.toHaveProperty('myNation'); expect(shared).not.toHaveProperty('myNation');
// 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다.
delete shared.uniqueItemLimit;
cache.set(cache.keys().next().value!, JSON.stringify(shared));
const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true });
expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(redis.set).toHaveBeenCalledTimes(2);
}); });
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => { it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
redis, redis,
db: { db: {
$queryRaw: queryRaw, $queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) }, worldState: {
findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })),
},
}, },
} as unknown as GameApiContext; } as unknown as GameApiContext;
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
expect(redis.set).not.toHaveBeenCalled(); expect(redis.set).not.toHaveBeenCalled();
}); });
}); });
describe('map unique ownership limit', () => {
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
currentYear,
meta: { scenarioMeta: { startYear: 180 } },
config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } },
});
it.each([
[179, 1, 182],
[181, 1, 182],
[182, 1, 182],
[183, 2, 189],
[189, 2, 189],
[190, 3, 199],
[200, 4, null],
])('projects year %i and the inclusive last month', (year, count, untilYear) => {
expect(resolveMapUniqueItemLimit(state(year!))).toEqual({
count,
until: untilYear === null ? null : { year: untilYear, month: 12 },
});
});
it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => {
expect(
resolveMapUniqueItemLimit(
state(181, {
maxUniqueItemLimit: [
[-1, 1],
[2, 1],
[4, 3],
[8, 4],
],
})
)
).toEqual({ count: 1, until: { year: 183, month: 12 } });
expect(
resolveMapUniqueItemLimit(
state(183, {
allItems: { horse: {}, weapon: {} },
})
)
).toEqual({ count: 2, until: null });
expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null });
});
});
+8
View File
@@ -42,6 +42,14 @@
"types": "./dist/turn/databaseHooks.d.ts", "types": "./dist/turn/databaseHooks.d.ts",
"default": "./dist/turn/databaseHooks.js" "default": "./dist/turn/databaseHooks.js"
}, },
"./turn/clockReconciliation.js": {
"types": "./dist/turn/clockReconciliation.d.ts",
"default": "./dist/turn/clockReconciliation.js"
},
"./turn/clockProjectionOutbox.js": {
"types": "./dist/turn/clockProjectionOutbox.d.ts",
"default": "./dist/turn/clockProjectionOutbox.js"
},
"./turn/inMemoryWorld.js": { "./turn/inMemoryWorld.js": {
"types": "./dist/turn/inMemoryWorld.d.ts", "types": "./dist/turn/inMemoryWorld.d.ts",
"default": "./dist/turn/inMemoryWorld.js" "default": "./dist/turn/inMemoryWorld.js"
+48 -23
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -60,27 +61,27 @@ export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
now: Date, now: Date,
nowTick: number | null nowTick: number | null
): boolean => ): boolean => {
auction.closeTick !== null && nowTick !== null void now;
? auction.closeTick < BigInt(nowTick) return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
: auction.closeAt.getTime() < now.getTime(); };
export const resolveAuctionBidTiming = ( export const resolveAuctionBidTiming = (
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>, world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingNow: Date, processingGameTick: number
acceptedGameTick?: number ): { bidAt: Date; bidTick: number } => {
): { bidAt: Date; bidTick: number } => if (!Number.isSafeInteger(processingGameTick)) {
acceptedGameTick === undefined throw new Error('Auction bid requires an authoritative daemon processing game tick.');
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) } }
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick }; return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
};
export const hasAuctionBidClosePassed = ( export const hasAuctionBidClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>, world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingNow: Date, processingGameTick: number
acceptedGameTick?: number
): boolean => { ): boolean => {
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick); const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
return hasAuctionClosePassed(auction, bidAt, bidTick); return hasAuctionClosePassed(auction, bidAt, bidTick);
}; };
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`, text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
@@ -268,8 +269,15 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.', reason: '경매가 종료되었습니다.',
}; };
} }
const processingNow = world.getGameNow(new Date()); const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick); if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionBid requires its durable input-event wall occurrence.');
}
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
if (hasAuctionClosePassed(auction, bidAt, bidTick)) { if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
return { return {
type: 'auctionBid', type: 'auctionBid',
@@ -503,13 +511,24 @@ export const createAuctionBidder = async (options: {
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => { const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta) INSERT INTO auction_bid (
auction_id,
general_id,
amount,
event_id,
event_at,
occurred_game_tick,
requested_at_wall,
meta
)
VALUES ( VALUES (
${command.auctionId}, ${command.auctionId},
${command.generalId}, ${command.generalId},
${command.amount}, ${command.amount},
${eventId}, ${eventId},
${eventAt}, ${eventAt},
${BigInt(bidTick)},
${requestedAtWall},
${JSON.stringify({ ${JSON.stringify({
tryExtendCloseDate: command.tryExtendCloseDate ?? true, tryExtendCloseDate: command.tryExtendCloseDate ?? true,
...(auction.type === 'UNIQUE_ITEM' ...(auction.type === 'UNIQUE_ITEM'
@@ -529,7 +548,7 @@ export const createAuctionBidder = async (options: {
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId}, latest_event_id = ${eventId},
latest_event_at = ${eventAt}, latest_event_at = ${eventAt},
updated_at = ${eventAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${command.auctionId} WHERE id = ${command.auctionId}
AND status = 'OPEN' AND status = 'OPEN'
AND latest_event_id = ${auction.latestEventId} AND latest_event_id = ${auction.latestEventId}
@@ -549,7 +568,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE inheritance_point UPDATE inheritance_point
SET value = value - ${morePoint}, SET value = value - ${morePoint},
updated_at = ${eventAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE user_id = ${userId} WHERE user_id = ${userId}
AND key = 'previous' AND key = 'previous'
AND value >= ${morePoint} AND value >= ${morePoint}
@@ -580,11 +599,16 @@ export const createAuctionBidder = async (options: {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at) INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt}) VALUES (
${prevUserId},
'previous',
${highestBid.amount},
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (user_id, key) ON CONFLICT (user_id, key)
DO UPDATE SET DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value, value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
` `
); );
await tx.$executeRaw( await tx.$executeRaw(
@@ -704,6 +728,7 @@ export const createAuctionBidder = async (options: {
ok: true, ok: true,
auctionId: command.auctionId, auctionId: command.auctionId,
closeAt: nextCloseAt.toISOString(), closeAt: nextCloseAt.toISOString(),
closeTick: world.dateToGameTick(nextCloseAt),
}; };
}, },
close: async (): Promise<void> => { close: async (): Promise<void> => {
+16 -16
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic'; import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
@@ -91,21 +92,17 @@ export const isAuctionFinalizeGenerationCurrent = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>, auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'> command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
): boolean => { ): boolean => {
if (command.expectedCloseTick !== undefined) {
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick); return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
}
if (command.expectedCloseAt !== undefined) {
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
}
return true;
}; };
export const hasAuctionFinalizeDeadlineArrived = ( export const hasAuctionFinalizeDeadlineArrived = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>, auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
now: Date, now: Date,
nowTick: number nowTick: number
): boolean => ): boolean => {
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick); void now;
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
};
export const buildAuctionBidderSystemMessage = (options: { export const buildAuctionBidderSystemMessage = (options: {
bidder: TurnGeneral; bidder: TurnGeneral;
@@ -128,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: options.text, text: options.text,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
@@ -293,7 +290,11 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId }; return { type: 'auctionFinalize', ok: true, auctionId };
} }
const now = world.getGameNow(new Date()); const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
if (auction.status === 'OPEN') { if (auction.status === 'OPEN') {
if (!isAuctionFinalizeGenerationCurrent(auction, command)) { if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
return { return {
@@ -303,8 +304,7 @@ export const createAuctionFinalizer = async (options: {
reason: '경매 마감 세대가 변경되었습니다.', reason: '경매 마감 세대가 변경되었습니다.',
}; };
} }
const nowTick = world.dateToGameTick(now); if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
return { return {
type: 'auctionFinalize', type: 'auctionFinalize',
ok: false, ok: false,
@@ -316,8 +316,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = 'FINALIZING', SET status = 'FINALIZING',
finalizing_at = ${now}, finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = ${now} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId} WHERE id = ${auctionId}
AND status = 'OPEN' AND status = 'OPEN'
` `
@@ -364,8 +364,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = ${status}, SET status = ${status},
finished_at = ${now}, finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = ${now} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId} WHERE id = ${auctionId}
` `
); );
+20 -4
View File
@@ -94,7 +94,11 @@ const openResourceAuction = async (
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`); return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
} }
const now = world.getGameNow(new Date()); const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60)); const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000); const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({ const auction = await db.auction.create({
@@ -113,7 +117,7 @@ const openResourceAuction = async (
}, },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
openTick: BigInt(world.dateToGameTick(now)), openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)), closeTick: BigInt(world.dateToGameTick(closeAt)),
}, },
}); });
@@ -125,6 +129,7 @@ const openResourceAuction = async (
ok: true, ok: true,
auctionId: auction.id, auctionId: auction.id,
closeAt: closeAt.toISOString(), closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
}; };
}; };
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
} }
const state = world.getState(); const state = world.getState();
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
}
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60)); const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
const now = world.getGameNow(new Date()); const now = world.gameTickToDate(processingGameTick);
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES); const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000); const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max( const extensionLimitMinutes = Math.max(
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
}, },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
openTick: BigInt(world.dateToGameTick(now)), openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)), closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId, latestEventId: eventId,
latestEventAt: now, latestEventAt: now,
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
amount: command.amount, amount: command.amount,
eventId, eventId,
eventAt: now, eventAt: now,
occurredGameTick: BigInt(processingGameTick),
requestedAtWall,
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount), meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
}, },
}, },
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
ok: true, ok: true,
auctionId: auction.id, auctionId: auction.id,
closeAt: closeAt.toISOString(), closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
}; };
}; };
+2
View File
@@ -20,6 +20,8 @@ export * from './turn/engineStateManager.js';
export * from './turn/inMemoryStateStore.js'; export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js'; export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js'; export * from './turn/databaseHooks.js';
export * from './turn/clockReconciliation.js';
export * from './turn/clockProjectionOutbox.js';
export * from './turn/joinCreateGeneralService.js'; export * from './turn/joinCreateGeneralService.js';
export * from './turn/npcPossessionService.js'; export * from './turn/npcPossessionService.js';
export * from './turn/selectPoolService.js'; export * from './turn/selectPoolService.js';
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { performance } from 'node:perf_hooks';
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js'; import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
import type { import type {
@@ -10,8 +11,9 @@ import type {
TurnDaemonStatus, TurnDaemonStatus,
} from './types.js'; } from './types.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const serializeResult = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder { export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly localQueue: TurnDaemonCommand[] = []; private readonly localQueue: TurnDaemonCommand[] = [];
@@ -31,12 +33,18 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
async drain(): Promise<TurnDaemonCommand[]> { async drain(): Promise<TurnDaemonCommand[]> {
const local = this.localQueue.splice(0, this.localQueue.length); const local = this.localQueue.splice(0, this.localQueue.length);
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
if (local.some((command) => command.type === 'shutdown')) {
return local;
}
const remote = await this.claimPending(); const remote = await this.claimPending();
return local.concat(remote); return local.concat(remote);
} }
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> { async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
while (deadlineMs === null || Date.now() < deadlineMs) { const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
while (deadline === null || performance.now() < deadline) {
const local = this.localQueue.shift(); const local = this.localQueue.shift();
if (local) { if (local) {
return local; return local;
@@ -45,7 +53,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (remote[0]) { if (remote[0]) {
return remote[0]; return remote[0];
} }
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now())); const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now()));
await delay(remaining); await delay(remaining);
} }
return null; return null;
@@ -84,30 +92,56 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return; return;
} }
const terminal = event.attempts >= this.maxAttempts; const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({ await transaction.$executeRaw(GamePrisma.sql`
where: { UPDATE input_event
requestId, SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
target: 'ENGINE', processing_at = NULL,
status: 'PROCESSING', locked_by = NULL,
lockedBy: this.workerId, lease_until = NULL,
attempts: event.attempts, completed_at = CASE
}, WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
data: { ELSE NULL
status: terminal ? 'FAILED' : 'PENDING', END,
processingAt: null, result = NULL,
lockedBy: null, error = ${message}
leaseUntil: null, WHERE request_id = ${requestId}
completedAt: terminal ? new Date() : null, AND target = 'ENGINE'::"InputEventTarget"
result: GamePrisma.DbNull, AND status = 'PROCESSING'::"InputEventStatus"
error: message, AND locked_by = ${this.workerId}
}, AND attempts = ${event.attempts}
}); `);
}); });
} }
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> { private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases(); await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => { return this.db.$transaction(async (transaction) => {
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
});
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
const gameplayAllowed =
!world ||
world.clockPhase === 'PREOPEN' ||
world.clockPhase === 'RUNNING' ||
world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended =
world?.clockPhase === 'SUSPENDED' &&
Boolean(
await transaction.clockSuspension.findFirst({
where: {
status: 'SUSPENDED',
source: 'MAINTENANCE',
sourceRevision: world.clockRevision,
},
select: { id: true },
})
);
const rows = await transaction.$queryRaw< const rows = await transaction.$queryRaw<
Array<{ Array<{
sequence: bigint; sequence: bigint;
@@ -115,6 +149,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
eventType: string; eventType: string;
payload: unknown; payload: unknown;
createdAt: Date; createdAt: Date;
acceptedGameTick: bigint | null;
acceptedClockRevision: bigint | null;
acceptedDeadlineGeneration: bigint | null;
}> }>
>(GamePrisma.sql` >(GamePrisma.sql`
SELECT SELECT
@@ -122,10 +159,78 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
"request_id" AS "requestId", "request_id" AS "requestId",
"event_type" AS "eventType", "event_type" AS "eventType",
"payload", "payload",
"created_at" AS "createdAt" "created_at" AS "createdAt",
"accepted_game_tick" AS "acceptedGameTick",
"accepted_clock_revision" AS "acceptedClockRevision",
"accepted_deadline_generation" AS "acceptedDeadlineGeneration"
FROM "input_event" FROM "input_event"
WHERE "target" = 'ENGINE'::"InputEventTarget" WHERE "target" = 'ENGINE'::"InputEventTarget"
AND "status" = 'PENDING'::"InputEventStatus" AND "status" = 'PENDING'::"InputEventStatus"
AND (
${gameplayAllowed}
OR "event_type" = 'getStatus'
OR (
${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'}
AND "event_type" IN (
'joinCreateGeneral', 'npcPossessGeneral',
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
)
)
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
OR (
${maintenanceSuspended}
AND "event_type" IN (
'inheritanceAction',
'dropItem',
'changePermission',
'appoint',
'setNationSetting',
'setNpcPolicy',
'shiftSchedule'
)
)
OR (
${maintenanceSuspended}
AND "event_type" = 'messageRespond'
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
AND EXISTS (
SELECT 1
FROM "message_action" AS pending_action
WHERE pending_action."message_id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_action."status" = 'PENDING'
AND pending_action."clock_revision" = ${currentRevision}
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
)
)
OR (
${world?.clockPhase === 'SUSPENDED'}
AND "event_type" = 'messageRespond'
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
AND EXISTS (
SELECT 1
FROM "message" AS pending_message
JOIN "message_action" AS pending_action
ON pending_action."message_id" = pending_message."id"
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
AND pending_action."action_type" = 'raiseInvader'
AND pending_action."status" = 'PENDING'
AND pending_action."clock_revision" = ${currentRevision}
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
)
AND EXISTS (
SELECT 1
FROM "clock_suspension" AS active_suspension
WHERE active_suspension."status" = 'SUSPENDED'
AND active_suspension."source" = 'UNIFICATION_WAIT'
AND active_suspension."source_revision" = ${currentRevision}
)
)
)
ORDER BY "sequence" ASC ORDER BY "sequence" ASC
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
LIMIT ${limit} LIMIT ${limit}
@@ -133,41 +238,93 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (rows.length === 0) { if (rows.length === 0) {
return []; return [];
} }
await transaction.inputEvent.updateMany({ const appliedSuspensions = currentRevision
where: { ? await transaction.clockSuspension.findMany({
sequence: { in: rows.map((row) => row.sequence) }, where: { status: 'APPLIED', targetRevision: { lte: currentRevision } },
target: 'ENGINE', orderBy: { sourceRevision: 'asc' },
status: 'PENDING', select: { sourceRevision: true, targetRevision: true, shiftTicks: true },
}, })
data: { : [];
status: 'PROCESSING', const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
processingAt: new Date(), if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
lockedBy: this.workerId, if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
leaseUntil: new Date(Date.now() + this.leaseDurationMs), return claimCoordinate.gameTick;
attempts: { increment: 1 }, }
}, if (row.acceptedClockRevision > currentRevision) return undefined;
}); let revision = row.acceptedClockRevision;
let tick = row.acceptedGameTick;
while (revision < currentRevision) {
const step = appliedSuspensions.find((entry) => entry.sourceRevision === revision);
if (!step || step.shiftTicks === null || step.targetRevision !== revision + 1n) return undefined;
tick += step.shiftTicks;
revision = step.targetRevision;
}
return tick;
};
const processableRows = rows
.map((row) => ({ row, processingGameTick: convertTick(row) }))
.filter(
(entry): entry is { row: (typeof rows)[number]; processingGameTick: bigint | null } =>
entry.processingGameTick !== undefined
);
for (const { row, processingGameTick } of processableRows) {
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'PROCESSING'::"InputEventStatus",
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
accepted_deadline_generation = COALESCE(
accepted_deadline_generation,
${world?.deadlineGeneration ?? null}
),
processing_game_tick = ${processingGameTick},
processing_clock_revision = ${currentRevision},
processing_deadline_generation = ${world?.deadlineGeneration ?? null},
locked_by = ${this.workerId},
lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${this.leaseDurationMs} * INTERVAL '1 millisecond',
attempts = attempts + 1
WHERE sequence = ${row.sequence}
`);
}
const commands: TurnDaemonCommand[] = []; const commands: TurnDaemonCommand[] = [];
for (const row of rows) { for (const { row, processingGameTick } of processableRows) {
const command = normalizeTurnDaemonCommand({ const command = normalizeTurnDaemonCommand({
requestId: row.requestId, requestId: row.requestId,
sentAt: row.createdAt.toISOString(), sentAt: row.createdAt.toISOString(),
command: row.payload as TurnDaemonCommand, command: row.payload as TurnDaemonCommand,
}); });
if (!command) { if (!command) {
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { sequence: row.sequence }, UPDATE input_event
data: { SET status = 'FAILED'::"InputEventStatus",
status: 'FAILED', error = ${`Invalid command payload for ${row.eventType}`},
error: `Invalid command payload for ${row.eventType}`, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
completedAt: new Date(), locked_by = NULL,
lockedBy: null, lease_until = NULL
leaseUntil: null, WHERE sequence = ${row.sequence}
}, `);
});
continue; continue;
} }
if (processingGameTick !== null) {
const value = Number(processingGameTick);
if (!Number.isSafeInteger(value)) {
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
error = 'Converted processing game tick is outside the safe integer range.',
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
locked_by = NULL,
lease_until = NULL
WHERE sequence = ${row.sequence}
`);
continue;
}
Reflect.set(command, 'processingGameTick', value);
}
Reflect.set(command, 'requestedAtWall', row.createdAt);
commands.push(command); commands.push(command);
} }
return commands; return commands;
@@ -175,23 +332,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
} }
private async complete(requestId: string, result: unknown): Promise<void> { private async complete(requestId: string, result: unknown): Promise<void> {
const completed = await this.db.inputEvent.updateMany({ const completed = await this.db.$executeRaw(GamePrisma.sql`
where: { UPDATE input_event
requestId, SET status = 'SUCCEEDED'::"InputEventStatus",
target: 'ENGINE', result = CAST(${serializeResult(result)} AS jsonb),
status: 'PROCESSING', completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
lockedBy: this.workerId, error = NULL,
}, locked_by = NULL,
data: { lease_until = NULL
status: 'SUCCEEDED', WHERE request_id = ${requestId}
result: asJson(result), AND target = 'ENGINE'::"InputEventTarget"
completedAt: new Date(), AND status = 'PROCESSING'::"InputEventStatus"
error: null, AND locked_by = ${this.workerId}
lockedBy: null, `);
leaseUntil: null, if (completed > 0) {
},
});
if (completed.count > 0) {
return; return;
} }
// Database hooks commit mutation results atomically with game state and // Database hooks commit mutation results atomically with game state and
@@ -212,19 +366,15 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
} }
private async recoverExpiredLeases(): Promise<void> { private async recoverExpiredLeases(): Promise<void> {
const now = new Date(); await this.db.$executeRaw(GamePrisma.sql`
await this.db.inputEvent.updateMany({ UPDATE input_event
where: { SET status = 'PENDING'::"InputEventStatus",
target: 'ENGINE', processing_at = NULL,
status: 'PROCESSING', locked_by = NULL,
leaseUntil: { lt: now }, lease_until = NULL
}, WHERE target = 'ENGINE'::"InputEventTarget"
data: { AND status = 'PROCESSING'::"InputEventStatus"
status: 'PENDING', AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
processingAt: null, `);
lockedBy: null,
leaseUntil: null,
},
});
} }
} }
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
} }
export class TurnDaemonLeaseLostError extends Error { export class TurnDaemonLeaseLostError extends Error {
constructor(profile: string) { constructor(profile: string, reason?: string) {
super(`Turn daemon lease was lost for profile "${profile}".`); super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
this.name = 'TurnDaemonLeaseLostError'; this.name = 'TurnDaemonLeaseLostError';
} }
} }
@@ -50,6 +50,7 @@ export class DatabaseTurnDaemonLease {
private expiryTimer: NodeJS.Timeout | null = null; private expiryTimer: NodeJS.Timeout | null = null;
private renewalInFlight = false; private renewalInFlight = false;
private lost = false; private lost = false;
private lossReason: string | undefined;
private constructor( private constructor(
db: GamePrismaClient, db: GamePrismaClient,
@@ -86,23 +87,24 @@ export class DatabaseTurnDaemonLease {
VALUES ( VALUES (
${this.profile}, ${this.profile},
${this.ownerId}, ${this.ownerId},
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
1, 1,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
ON CONFLICT ("profile") DO UPDATE ON CONFLICT ("profile") DO UPDATE
SET SET
"owner_id" = EXCLUDED."owner_id", "owner_id" = EXCLUDED."owner_id",
"clock_ready" = FALSE,
"lease_until" = EXCLUDED."lease_until", "lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE "fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id" WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
THEN "turn_daemon_lease"."fencing_epoch" THEN "turn_daemon_lease"."fencing_epoch"
ELSE "turn_daemon_lease"."fencing_epoch" + 1 ELSE "turn_daemon_lease"."fencing_epoch" + 1
END, END,
"heartbeat_at" = CURRENT_TIMESTAMP "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE WHERE
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id" "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
RETURNING "profile", "owner_id", "fencing_epoch" RETURNING "profile", "owner_id", "fencing_epoch"
`); `);
const row = rows[0]; const row = rows[0];
@@ -115,6 +117,7 @@ export class DatabaseTurnDaemonLease {
fencingEpoch: BigInt(row.fencing_epoch), fencingEpoch: BigInt(row.fencing_epoch),
}; };
this.lost = false; this.lost = false;
this.lossReason = undefined;
this.scheduleExpiryWatchdog(requestStartedAt); this.scheduleExpiryWatchdog(requestStartedAt);
if (this.heartbeatEnabled) { if (this.heartbeatEnabled) {
this.startHeartbeat(); this.startHeartbeat();
@@ -126,10 +129,22 @@ export class DatabaseTurnDaemonLease {
return this.token ? { ...this.token } : null; return this.token ? { ...this.token } : null;
} }
async markClockReady(): Promise<void> {
await this.db.$transaction(async (db) => {
await this.assertActive(db);
const token = this.getToken()!;
await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } });
});
}
isLost(): boolean { isLost(): boolean {
return this.lost; return this.lost;
} }
getLossError(): TurnDaemonLeaseLostError {
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
}
async renew(): Promise<boolean> { async renew(): Promise<boolean> {
const token = this.token; const token = this.token;
if (!token || this.lost || this.renewalInFlight) { if (!token || this.lost || this.renewalInFlight) {
@@ -141,17 +156,17 @@ export class DatabaseTurnDaemonLease {
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql` const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease" UPDATE "turn_daemon_lease"
SET SET
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'), "lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE WHERE
"profile" = ${token.profile} "profile" = ${token.profile}
AND "owner_id" = ${token.ownerId} AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch} AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
RETURNING "profile", "owner_id", "fencing_epoch" RETURNING "profile", "owner_id", "fencing_epoch"
`); `);
if (rows.length === 0) { if (rows.length === 0) {
this.markLost(); this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
return false; return false;
} }
if (this.lost) { if (this.lost) {
@@ -167,7 +182,7 @@ export class DatabaseTurnDaemonLease {
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> { async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
const token = this.token; const token = this.token;
if (!token || this.lost) { if (!token || this.lost) {
throw new TurnDaemonLeaseLostError(this.profile); throw this.getLossError();
} }
const db = transaction ?? this.db; const db = transaction ?? this.db;
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql` const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
@@ -177,12 +192,12 @@ export class DatabaseTurnDaemonLease {
"profile" = ${token.profile} "profile" = ${token.profile}
AND "owner_id" = ${token.ownerId} AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch} AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
FOR UPDATE FOR UPDATE
`); `);
if (rows.length === 0) { if (rows.length === 0) {
this.markLost(); this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
throw new TurnDaemonLeaseLostError(this.profile); throw this.getLossError();
} }
} }
@@ -196,7 +211,8 @@ export class DatabaseTurnDaemonLease {
} }
await this.db.$executeRaw(GamePrisma.sql` await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease" UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE WHERE
"profile" = ${token.profile} "profile" = ${token.profile}
AND "owner_id" = ${token.ownerId} AND "owner_id" = ${token.ownerId}
@@ -218,8 +234,10 @@ export class DatabaseTurnDaemonLease {
} }
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3)); const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
this.heartbeatTimer = setInterval(() => { this.heartbeatTimer = setInterval(() => {
void this.renew().catch(() => { void this.renew().catch((error: unknown) => {
this.markLost(); this.markLost(
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
);
}); });
}, intervalMs); }, intervalMs);
this.heartbeatTimer.unref(); this.heartbeatTimer.unref();
@@ -238,7 +256,9 @@ export class DatabaseTurnDaemonLease {
this.stopExpiryWatchdog(); this.stopExpiryWatchdog();
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt)); const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
this.expiryTimer = setTimeout(() => { this.expiryTimer = setTimeout(() => {
this.markLost(); this.markLost(
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
);
}, remainingMs); }, remainingMs);
this.expiryTimer.unref(); this.expiryTimer.unref();
} }
@@ -250,7 +270,9 @@ export class DatabaseTurnDaemonLease {
} }
} }
private markLost(): void { private markLost(reason: string): void {
if (this.lost) return;
this.lossReason = reason;
this.lost = true; this.lost = true;
this.stopHeartbeat(); this.stopHeartbeat();
this.stopExpiryWatchdog(); this.stopExpiryWatchdog();
@@ -1,7 +1,7 @@
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js'; import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
type Waiter = { type Waiter = {
deadlineMs: number | null; timeoutMs: number | null;
resolve: (command: TurnDaemonCommand | null) => void; resolve: (command: TurnDaemonCommand | null) => void;
timeoutId?: ReturnType<typeof setTimeout>; timeoutId?: ReturnType<typeof setTimeout>;
}; };
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
return drained; return drained;
} }
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> { async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.queue.length > 0) { if (this.queue.length > 0) {
return this.queue.shift() ?? null; return this.queue.shift() ?? null;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const waiter: Waiter = { deadlineMs, resolve }; const waiter: Waiter = { timeoutMs, resolve };
if (deadlineMs !== null) { if (timeoutMs !== null) {
const delay = Math.max(0, deadlineMs - Date.now()); const delay = Math.max(0, timeoutMs);
waiter.timeoutId = setTimeout(() => { waiter.timeoutId = setTimeout(() => {
this.removeWaiter(waiter); this.removeWaiter(waiter);
resolve(null); resolve(null);
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
return drained.concat(remote); return drained.concat(remote);
} }
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> { async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.localQueue.length > 0) { if (this.localQueue.length > 0) {
return this.localQueue.shift() ?? null; return this.localQueue.shift() ?? null;
} }
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now()); const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000); const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
if (deadlineMs !== null && blockMs === 0) { if (timeoutMs !== null && blockMs === 0) {
return null; return null;
} }
@@ -90,7 +90,20 @@ export class TurnDaemonLifecycle {
start(): Promise<void> { start(): Promise<void> {
if (!this.loopPromise) { if (!this.loopPromise) {
this.loopPromise = this.runLoop(); this.loopPromise = this.runLoop().catch(async (error: unknown) => {
// 초기화와 pause gate 실패도 관리자에게 즉시 남긴다. lease를
// 잃은 runtime은 대기 상태로 살아 있으면 안전하게 재개할 수 없다.
this.status.state = 'stopping';
this.status.running = false;
this.status.paused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown lifecycle error.';
try {
await this.hooks?.onRunError?.(error);
} catch {
// 장애 기록 실패가 원래 종료 원인을 덮어쓰지 않게 한다.
}
throw error;
});
} }
return this.loopPromise; return this.loopPromise;
} }
@@ -158,6 +171,25 @@ export class TurnDaemonLifecycle {
continue; continue;
} }
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (gameClock?.phase && gameClock.phase !== 'RUNNING' && gameClock.phase !== 'MANUAL') {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) {
this.status.nextTurnTime = gameClock.startsAt.toISOString();
await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs));
continue;
}
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) { if (this.pendingRun) {
await this.runOnce(this.pendingRun); await this.runOnce(this.pendingRun);
this.pendingRun = null; this.pendingRun = null;
@@ -169,10 +201,6 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(200); await this.clock.sleepMs(200);
continue; continue;
} }
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
const gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.mode === 'manual') { if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in // Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that // a single snapshot. Manual mode advances directly to that
@@ -209,7 +237,10 @@ export class TurnDaemonLifecycle {
continue; continue;
} }
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs)); const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime);
const command = await this.controlQueue.waitFor(
Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs)
);
if (command) { if (command) {
await this.handleCommand(command); await this.handleCommand(command);
} }
@@ -255,7 +286,7 @@ export class TurnDaemonLifecycle {
} }
private async waitForResume(): Promise<void> { private async waitForResume(): Promise<void> {
const command = await this.controlQueue.waitUntil(null); const command = await this.controlQueue.waitFor(null);
if (command) { if (command) {
await this.handleCommand(command); await this.handleCommand(command);
} }
+18 -3
View File
@@ -7,7 +7,7 @@ import type {
TurnRunResult, TurnRunResult,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra'; import type { GamePrisma } from '@sammo-ts/infra';
import type { GameClockMode } from '@sammo-ts/common'; import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export type { export type {
RunReason, RunReason,
@@ -29,6 +29,12 @@ export interface TurnDaemonCommandHandler {
export interface TurnDaemonCommandExecutionContext { export interface TurnDaemonCommandExecutionContext {
db?: GamePrisma.TransactionClient; db?: GamePrisma.TransactionClient;
clockOperationAuthority?: {
kind: 'DAEMON';
profileName: string;
ownerId: string;
fencingEpoch: bigint;
};
} }
export interface TurnDaemonCommandResponder { export interface TurnDaemonCommandResponder {
@@ -60,16 +66,25 @@ export interface TurnStateStore {
loadCheckpoint(): Promise<TurnCheckpoint | undefined>; loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
shouldHaltScheduledRuns?(): Promise<boolean>; shouldHaltScheduledRuns?(): Promise<boolean>;
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>; loadGameClock?(wallNow?: Date): Promise<{
mode: GameClockMode;
now: Date;
phase?: GameClockPhase;
revision?: number;
deadlineGeneration?: number;
startsAt?: Date;
}>;
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>; shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>; rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>; advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
projectGameDeadline?(gameTime: Date): Promise<Date>;
} }
export interface TurnDaemonControlQueue { export interface TurnDaemonControlQueue {
enqueue(command: TurnDaemonCommand): void; enqueue(command: TurnDaemonCommand): void;
drain(): Promise<TurnDaemonCommand[]>; drain(): Promise<TurnDaemonCommand[]>;
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>; waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
getDepth(): number; getDepth(): number;
} }
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
return { ...resultFromPersisted(created), alreadyApplied: false }; return { ...resultFromPersisted(created), alreadyApplied: false };
}; };
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => { export const cancelGame = async (
request: GameCancellationRequest,
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
): Promise<GameCancellationResult> => {
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.'); if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.'); if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.'); if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
earnedPoint: 0, earnedPoint: 0,
earnedPointRetentionPercent: request.earnedPointRetentionPercent, earnedPointRetentionPercent: request.earnedPointRetentionPercent,
}); });
const connector = createGamePostgresConnector({ url: request.databaseUrl }); const connector = connectorFactory({ url: request.databaseUrl });
await connector.connect(); await connector.connect();
try { try {
return await connector.prisma.$transaction( return await connector.prisma.$transaction(
+49 -13
View File
@@ -6,7 +6,7 @@ import {
type InputJsonValue, type InputJsonValue,
type TurnEngineEventCreateManyInput, type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common'; import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
import { import {
buildScenarioBootstrap, buildScenarioBootstrap,
resolveScenarioGeneralDeathMonth, resolveScenarioGeneralDeathMonth,
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
generalPoolOptions?: GeneralPoolLoaderOptions; generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean; resetTables?: boolean;
now?: Date; now?: Date;
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
wallNow?: Date;
tickSeconds?: number; tickSeconds?: number;
gameClockMode?: GameClockMode; gameClockMode?: GameClockMode;
installOptions?: ScenarioInstallOptions; installOptions?: ScenarioInstallOptions;
@@ -94,6 +96,17 @@ export const calculateInitialTurnTick = (
return clock.addTicks(baseTick, offsetTicks); return clock.addTicks(baseTick, offsetTicks);
}; };
export const resolveInitialClockPhase = (
mode: GameClockMode,
seededAtWall: Date,
scheduledOpenAtWall: Date
): GameClockPhase => {
if (mode === 'manual') {
return 'MANUAL';
}
return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING';
};
const formatDateTime = (date: Date): string => { const formatDateTime = (date: Date): string => {
const pad = (value: number): string => String(value).padStart(2, '0'); const pad = (value: number): string => String(value).padStart(2, '0');
return [ return [
@@ -229,19 +242,34 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS); : (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60)); const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
const sync = install?.sync ?? false; const sync = install?.sync ?? false;
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
const gameClockMode = options.gameClockMode ?? 'realtime'; const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume // A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands. // wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now; const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
// Opening is an exact wall instant, independent of the calendar's 12-turn
// grouping. Only the initial year/month uses the legacy calendar alignment.
const initialClockWallAnchor = requestedOpening;
const startState = resolveStartState(
scenario.startYear ?? null,
gameClockMode === 'manual' ? now : requestedOpening,
turnTermMinutes,
sync
);
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({ const initialClock = new GameClock({
baseTime: startState.startTime, baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
tick: 0, tick: 0,
mode: gameClockMode, mode: gameClockMode,
wallAnchor: initialClockWallAnchor, wallAnchor: initialClockWallAnchor,
turnSeconds: tickSeconds, turnSeconds: tickSeconds,
phase: initialClockPhase,
revision: 1,
}); });
const initialClockTick = initialClock.dateToTick(now); // The formal opening wall instant is always logical tick zero. PREOPEN may
// project signed negative observed ticks, but executable seed schedules are
// derived from this zero coordinate rather than from the seed wall time.
const initialClockTick = 0;
const { seed, warnings } = buildScenarioBootstrap({ const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition, scenario: scenarioDefinition,
@@ -299,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// monthly pre-handler recalculates the same value at each boundary. // monthly pre-handler recalculates the same value at each boundary.
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime), starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now), turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
opentime: formatDateTime(now), opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now), lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
}; };
const firstGameIdx = const firstGameIdx =
@@ -327,6 +355,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
} }
worldMeta.hiddenSeed = hiddenSeed; worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = wallNow.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
if (install?.preopenAt) { if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt); worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -420,6 +452,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
clockMode: gameClockMode, clockMode: gameClockMode,
clockWallAnchor: initialClock.wallAnchor, clockWallAnchor: initialClock.wallAnchor,
lastTurnTick: BigInt(initialClockTick), lastTurnTick: BigInt(initialClockTick),
clockPhase: initialClockPhase,
clockRevision: 1n,
deadlineGeneration: 1n,
config: asJson({ ...scenarioConfig, ...worldConfig }), config: asJson({ ...scenarioConfig, ...worldConfig }),
meta: asJson(worldMeta), meta: asJson(worldMeta),
}, },
@@ -590,12 +625,13 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
weaponCode: general.weapon ?? 'None', weaponCode: general.weapon ?? 'None',
bookCode: general.book ?? 'None', bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None', itemCode: general.item ?? 'None',
turnTime: new Date( turnTime: initialClock.tickToDate(
now.getTime() + calculateInitialTurnTick(
Math.floor( initialClock,
(typeof general.meta.initialTurnOffsetMicros === 'number' initialClockTick,
typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros ? general.meta.initialTurnOffsetMicros
: 0) / 1_000 : 0
) )
), ),
turnTick: BigInt( turnTick: BigInt(
+3 -1
View File
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
import { JosaUtil, asRecord } from '@sammo-ts/common'; import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic'; import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
@@ -253,7 +254,8 @@ export const createTournamentRewardFinalizer = async (options: {
})) }))
.filter((entry) => !!entry.userId); .filter((entry) => !!entry.userId);
for (const entry of pointUpdates) { const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId);
for (const entry of recordsFinalized ? [] : pointUpdates) {
await db.inheritancePoint.upsert({ await db.inheritancePoint.upsert({
where: { where: {
userId_key: { userId: entry.userId!, key: 'tournament' }, userId_key: { userId: entry.userId!, key: 'tournament' },
@@ -8,6 +8,7 @@ import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
import { buildCommandEnv } from './reservedTurnCommands.js'; import { buildCommandEnv } from './reservedTurnCommands.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import type { TurnEvent } from './types.js'; import type { TurnEvent } from './types.js';
import { reconcileClockSuspensionInTransaction, type ClockReconciliationResult } from './clockReconciliation.js';
type ActionableMessageType = 'scout' | 'raiseInvader'; type ActionableMessageType = 'scout' | 'raiseInvader';
@@ -17,6 +18,10 @@ interface MessageRow {
type: string; type: string;
time: Date; time: Date;
validUntil: Date; validUntil: Date;
actionType: string;
actionStatus: string;
createdGameTick: bigint;
expiresGameTick: bigint | null;
message: unknown; message: unknown;
} }
@@ -93,15 +98,21 @@ const invalidateMessageIds = async (
db: GamePrisma.TransactionClient, db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld, world: InMemoryTurnWorld,
ids: number[], ids: number[],
now: Date now: Date,
authoritativeGameTick?: bigint
): Promise<void> => { ): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
if (uniqueIds.length === 0) return; if (uniqueIds.length === 0) return;
const resolvedGameTick = authoritativeGameTick ?? BigInt(world.dateToGameTick(now));
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick },
});
await db.message.updateMany({ await db.message.updateMany({
where: { id: { in: uniqueIds } }, where: { id: { in: uniqueIds } },
data: { data: {
validUntil: now, validUntil: now,
validUntilTick: BigInt(world.dateToGameTick(now)), validUntilTick: resolvedGameTick,
}, },
}); });
}; };
@@ -112,40 +123,53 @@ const validateActor = async (options: {
requestId?: string; requestId?: string;
userId: string; userId: string;
generalId: number; generalId: number;
}): Promise<Date> => { }): Promise<{ processingGameTick: number }> => {
const actor = options.world.getGeneralById(options.generalId); const actor = options.world.getGeneralById(options.generalId);
if (!actor || actor.userId !== options.userId) { if (!actor || actor.userId !== options.userId) {
throw new Error('messageRespond general owner does not match command user.'); throw new Error('messageRespond general owner does not match command user.');
} }
if (!options.requestId) return new Date(); if (!options.requestId) {
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
}
const event = await options.db.inputEvent.findUnique({ const event = await options.db.inputEvent.findUnique({
where: { requestId: options.requestId }, where: { requestId: options.requestId },
select: { actorUserId: true, target: true, eventType: true, createdAt: true }, select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
}); });
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`); if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') { if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
throw new Error('ENGINE input event actor or type does not match messageRespond.'); throw new Error('ENGINE input event actor or type does not match messageRespond.');
} }
return event.createdAt; const processingGameTick = event.processingGameTick;
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
throw new Error('messageRespond requires an authoritative processing game tick.');
}
return { processingGameTick: Number(processingGameTick) };
}; };
const fetchMessageForUpdate = async ( const fetchMessageForUpdate = async (
db: GamePrisma.TransactionClient, db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
messageId: number, messageId: number,
now: Date currentGameTick: number
): Promise<MessageRow | null> => { ): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql` const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, time, valid_until AS "validUntil", message SELECT
FROM message envelope.id,
WHERE id = ${messageId} envelope.mailbox,
AND ( envelope.type,
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick}) envelope.time,
OR (valid_until_tick IS NULL AND valid_until > ${now}) envelope.valid_until AS "validUntil",
) action.action_type AS "actionType",
action.status AS "actionStatus",
action.created_game_tick AS "createdGameTick",
action.expires_game_tick AS "expiresGameTick",
envelope.message
FROM message AS envelope
JOIN message_action AS action ON action.message_id = envelope.id
WHERE envelope.id = ${messageId}
AND action.status = 'PENDING'
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
LIMIT 1 LIMIT 1
FOR UPDATE FOR UPDATE OF envelope, action
`); `);
return rows[0] ?? null; return rows[0] ?? null;
}; };
@@ -164,10 +188,17 @@ const respondToScout = async (options: {
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' }; return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
} }
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) { if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }; return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
} }
// 등용장은 발신 장수의 현재 소속이 아니라 발송 당시 국가에 귀속된다.
// 멸망 처리 도입 전에 남은 편지도 수락/거절 전에 영구 만료한다.
if (!world.getNationById(payload.src.nationId)) {
await invalidateMessageIds(db, world, [row.id], now);
return { ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' };
}
const sourceNationName = payload.src.nationName; const sourceNationName = payload.src.nationName;
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로'); const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
if (response) { if (response) {
@@ -185,18 +216,17 @@ const respondToScout = async (options: {
} }
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT id SELECT envelope.id
FROM message FROM message AS envelope
WHERE mailbox = ${payload.src.generalId} JOIN message_action AS action ON action.message_id = envelope.id
AND type = 'private' WHERE envelope.mailbox = ${payload.src.generalId}
AND dest = mailbox AND envelope.type = 'private'
AND id <> ${row.id} AND envelope.dest = envelope.mailbox
AND ( AND envelope.id <> ${row.id}
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))}) AND action.status = 'PENDING'
OR (valid_until_tick IS NULL AND valid_until > ${now}) AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
) AND action.action_type = 'scout'
AND message->'option'->>'action' = 'scout' FOR UPDATE OF envelope, action
FOR UPDATE
`); `);
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now); await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
world.queueMessage({ world.queueMessage({
@@ -251,6 +281,18 @@ const respondToRaiseInvader = async (options: {
payload: MessagePayload; payload: MessagePayload;
now: Date; now: Date;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>; loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
clockOperationAuthority?: {
kind: 'DAEMON';
profileName: string;
ownerId: string;
fencingEpoch: bigint;
};
reconcileUnificationWait?: (input: {
db: GamePrisma.TransactionClient;
suspensionId: string;
profileName: string;
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
}) => Promise<ClockReconciliationResult>;
}): Promise<ActionableMessageResponseResult> => { }): Promise<ActionableMessageResponseResult> => {
const { db, world, reservedTurns, actorId, response, row, payload, now } = options; const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
@@ -272,6 +314,22 @@ const respondToRaiseInvader = async (options: {
if (!reservedTurns) { if (!reservedTurns) {
throw new Error('RaiseInvader message response requires the reserved-turn store.'); throw new Error('RaiseInvader message response requires the reserved-turn store.');
} }
const suspensionId =
typeof state.meta.unificationClockSuspensionId === 'string' ? state.meta.unificationClockSuspensionId : null;
if (!suspensionId || !options.clockOperationAuthority) {
throw new Error('RaiseInvader requires a daemon-authorized UNIFICATION_WAIT suspension.');
}
const reconcile =
options.reconcileUnificationWait ??
((input) => reconcileClockSuspensionInTransaction({ ...input, allowUnificationWait: true }));
const alignment = await reconcile({
db,
suspensionId,
profileName: options.clockOperationAuthority.profileName,
authority: options.clockOperationAuthority,
});
world.applyClockReconciliation(alignment);
const alignedState = world.getState();
const args = asRecord(payload.option).args; const args = asRecord(payload.option).args;
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) { if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' }; return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
@@ -281,22 +339,44 @@ const respondToRaiseInvader = async (options: {
reservedTurns, reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()), env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId, loadArchivedNationMaxId: options.loadArchivedNationMaxId,
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
}); });
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} }; const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler( await handler(
args, args,
{ {
year: state.currentYear, year: alignedState.currentYear,
month: state.currentMonth, month: alignedState.currentMonth,
startyear: asNumber(state.meta.startYear, state.currentYear), startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear),
currentEventID: 0, currentEventID: 0,
// Ref uses the frozen game_env.turntime while unification is paused. // The exact reconciliation snapshot is the authority even when the
// `now` is the realtime game projection and can be hours ahead after // turn rate stays unchanged. Using the pre-reconciliation cursor here
// a long response wait, delaying every newly summoned invader turn. // would create immediately overdue invader turns after a long wait.
turnTime: state.lastTurnTime, turnTime: alignedState.lastTurnTime,
}, },
event event
); );
const resolvedGameTick = BigInt(alignment.alignedTick);
if (resolvedGameTick < row.createdGameTick) {
throw new Error(
`RaiseInvader resolved tick ${resolvedGameTick} precedes prompt tick ${row.createdGameTick}; clock authority is inconsistent.`
);
}
const promptRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT message_id AS id
FROM message_action
WHERE action_type = 'raiseInvader'
AND status = 'PENDING'
AND created_game_tick = ${row.createdGameTick}
FOR UPDATE
`);
await invalidateMessageIds(
db,
world,
promptRows.map(({ id }) => id),
world.gameTickToDate(alignment.alignedTick),
resolvedGameTick
);
return { ok: true, action: 'raiseInvader', reason: 'success' }; return { ok: true, action: 'raiseInvader', reason: 'success' };
}; };
@@ -311,14 +391,27 @@ export const respondToActionableMessage = async (options: {
messageId: number; messageId: number;
response: boolean; response: boolean;
loadArchivedNationMaxId?: (serverId: string) => Promise<number>; loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
clockOperationAuthority?: {
kind: 'DAEMON';
profileName: string;
ownerId: string;
fencingEpoch: bigint;
};
reconcileUnificationWait?: (input: {
db: GamePrisma.TransactionClient;
suspensionId: string;
profileName: string;
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
}) => Promise<ClockReconciliationResult>;
}): Promise<ActionableMessageResponseResult> => { }): Promise<ActionableMessageResponseResult> => {
const acceptedAt = await validateActor(options); const accepted = await validateActor(options);
const now = options.world.getGameNow(acceptedAt); const now = options.world.gameTickToDate(accepted.processingGameTick);
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now); const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' }; if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message); const payload = parsePayload(row.message);
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' }; if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
const action = asRecord(payload.option).action; const action = asRecord(payload.option).action;
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
if (action === 'scout') { if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now }); return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
} }
+45 -2
View File
@@ -1149,6 +1149,8 @@ export class GeneralAI {
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel])); const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
let userChiefCount = 0; let userChiefCount = 0;
let ambassadorCount = 0;
const assignedAmbassadorIds = new Set<number>();
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0); const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes); const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
const minNpcKillturn = 36; const minNpcKillturn = 36;
@@ -1162,6 +1164,9 @@ export class GeneralAI {
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`); const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) { if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
userChiefCount += 1; userChiefCount += 1;
if (ambassadorCount < 2) {
ambassadorCount += 1;
assignedAmbassadorIds.add(chief.id);
chief.meta = { ...chief.meta, permission: 'ambassador' }; chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({ this.promotionPatches.push({
generalId: chief.id, generalId: chief.id,
@@ -1169,6 +1174,15 @@ export class GeneralAI {
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0), officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador', permission: 'ambassador',
}); });
} else if (asRecord(chief.meta).permission === 'ambassador') {
chief.meta = { ...chief.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'normal',
});
}
} }
} }
@@ -1222,7 +1236,7 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 }); this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
effectiveOfficerLevel.set(oldChief.id, 1); effectiveOfficerLevel.set(oldChief.id, 1);
} }
const permission = penalty.noAmbassador === true ? undefined : 'ambassador'; const permission = penalty.noAmbassador !== true && ambassadorCount < 2 ? 'ambassador' : undefined;
candidate.officerLevel = 11; candidate.officerLevel = 11;
candidate.meta = { candidate.meta = {
...candidate.meta, ...candidate.meta,
@@ -1238,6 +1252,10 @@ export class GeneralAI {
effectiveOfficerLevel.set(candidate.id, 11); effectiveOfficerLevel.set(candidate.id, 11);
chiefSet |= 1 << 11; chiefSet |= 1 << 11;
userChiefCount += 1; userChiefCount += 1;
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(candidate.id);
}
break; break;
} }
} }
@@ -1307,10 +1325,16 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 }); this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
} }
const permission = const permission =
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined; nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true && ambassadorCount < 2
? 'ambassador'
: undefined;
if (nextChief.npcState < 2) { if (nextChief.npcState < 2) {
userChiefCount += 1; userChiefCount += 1;
} }
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(nextChief.id);
}
this.promotionPatches.push({ this.promotionPatches.push({
generalId: nextChief.id, generalId: nextChief.id,
officerLevel: chiefLevel, officerLevel: chiefLevel,
@@ -1331,6 +1355,25 @@ export class GeneralAI {
chiefSet |= 1 << chiefLevel; chiefSet |= 1 << chiefLevel;
} }
for (const candidate of generals) {
if (
asRecord(candidate.meta).permission !== 'ambassador' ||
assignedAmbassadorIds.has(candidate.id) ||
this.promotionPatches.some(
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
)
) {
continue;
}
candidate.meta = { ...candidate.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: candidate.id,
officerLevel: effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel,
officerCity: readMetaNumber(asRecord(candidate.meta), 'officer_city', 0),
permission: 'normal',
});
}
if (chiefSet !== initialChiefSet) { if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = { this.promotionNationMeta = {
...this.nation.meta, ...this.nation.meta,
+23
View File
@@ -5,6 +5,8 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js'; import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
import { createTurnDaemonRuntime } from './turnDaemon.js'; import { createTurnDaemonRuntime } from './turnDaemon.js';
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js'; import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonCliOptions { export interface TurnDaemonCliOptions {
profile?: string; profile?: string;
@@ -89,6 +91,27 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
pauseGateIntervalMs, pauseGateIntervalMs,
adminActionIntervalMs, adminActionIntervalMs,
gameClockMode, gameClockMode,
}).catch(async (error: unknown) => {
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
// lifecycle hook이 아직 없으므로 여기서 별도로 관리자에게 기록한다.
if (!(error instanceof TurnDaemonLeaseUnavailableError)) {
try {
const gate = await createGatewayProfileGate({
databaseUrl,
gatewayDatabaseUrl,
profileName,
incidentContext: () => ({ stage: 'startup' }),
});
try {
await gate.markPaused(error);
} finally {
await gate.close();
}
} catch {
/* 원래 시작 실패를 보존한다. */
}
}
throw error;
}); });
const memoryReporter = createTurnDaemonMemoryReporter({ const memoryReporter = createTurnDaemonMemoryReporter({
@@ -0,0 +1,391 @@
import { createHash } from 'node:crypto';
import {
ChangeJournal,
GameClock,
formatServerDateTime,
parseGameClockPhase,
readTurnRecovery,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_PUBLIC, resolveMessageTargetIcon, sendMessage } from '@sammo-ts/logic';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
persistMessageEnvelope,
writeReadModelChangeJournal,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
export interface ClockProjectionRedis {
get(key: string): Promise<string | null>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
interface ClaimedOutboxRow {
id: bigint;
}
interface DbWallRow {
wallNow: Date;
}
interface ProjectionPayload {
version: 1;
profileName: string;
suspensionId: string;
sourceRevision: number;
targetRevision: number;
deadlineGeneration: number;
shiftTicks: number;
projectionDeltaMilliseconds: number;
clockBaseTime: string;
ticksPerSecond: number;
}
interface TournamentProjectionState {
stage?: number;
nextAt?: string;
nextTick?: number;
bettingCloseAt?: string;
bettingCloseTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
[key: string]: unknown;
}
const APPLY_CLOCK_PROJECTION_SCRIPT = `
local active = redis.call('GET', KEYS[1])
if active == ARGV[2] then
if redis.call('GET', KEYS[5]) == ARGV[4] and redis.call('GET', KEYS[2]) == ARGV[3] then
return 2
end
return -3
end
if active and active ~= ARGV[1] then
return -1
end
if ARGV[5] ~= '__NONE__' and redis.call('GET', KEYS[4]) ~= ARGV[5] then
return -2
end
redis.call('DEL', KEYS[3])
local count = tonumber(ARGV[7])
local offset = 8
for index = 1, count do
redis.call('ZADD', KEYS[3], ARGV[offset], ARGV[offset + 1])
offset = offset + 2
end
if ARGV[5] ~= '__NONE__' then
redis.call('SET', KEYS[4], ARGV[6])
end
redis.call('SET', KEYS[1], ARGV[2])
redis.call('SET', KEYS[2], ARGV[3])
redis.call('SET', KEYS[5], ARGV[4])
redis.call('SET', KEYS[6], 'RUNNING')
return 1
`;
const safeInteger = (value: unknown, label: string): number => {
const result = typeof value === 'bigint' ? Number(value) : value;
if (typeof result !== 'number' || !Number.isSafeInteger(result)) {
throw new Error(`${label} must be a safe integer.`);
}
return result;
};
const canonicalize = (value: unknown): unknown => {
if (typeof value === 'bigint') return value.toString();
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)])
);
}
return value;
};
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
`);
if (!rows[0]?.wallNow) throw new Error('Failed to read PostgreSQL wall time for the projection outbox.');
return rows[0].wallNow;
};
const parsePayload = (value: GamePrisma.JsonValue): ProjectionPayload => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Clock projection outbox payload must be an object.');
}
const payload = value as Record<string, unknown>;
if (payload.version !== 1 || typeof payload.profileName !== 'string' || typeof payload.suspensionId !== 'string') {
throw new Error('Clock projection outbox payload identity is invalid.');
}
if (typeof payload.clockBaseTime !== 'string') {
throw new Error('Clock projection outbox is missing its projection base.');
}
return {
version: 1,
profileName: payload.profileName,
suspensionId: payload.suspensionId,
sourceRevision: safeInteger(payload.sourceRevision, 'sourceRevision'),
targetRevision: safeInteger(payload.targetRevision, 'targetRevision'),
deadlineGeneration: safeInteger(payload.deadlineGeneration, 'deadlineGeneration'),
shiftTicks: safeInteger(payload.shiftTicks, 'shiftTicks'),
projectionDeltaMilliseconds: safeInteger(payload.projectionDeltaMilliseconds, 'projectionDeltaMilliseconds'),
clockBaseTime: payload.clockBaseTime,
ticksPerSecond: safeInteger(payload.ticksPerSecond, 'ticksPerSecond'),
};
};
const claimNext = async (db: GamePrismaClient, workerId: string) =>
db.$transaction(async (transaction) => {
const rows = await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
SELECT id
FROM clock_projection_outbox
WHERE available_at <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
AND (
status IN ('PENDING', 'FAILED')
OR (status = 'APPLYING' AND locked_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds')
)
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
`);
const id = rows[0]?.id;
if (id === undefined) return null;
const lockedAt = await readDbWall(transaction);
return transaction.clockProjectionOutbox.update({
where: { id },
data: {
status: 'APPLYING',
attempts: { increment: 1 },
lockedAt,
lockedBy: workerId,
lastError: null,
},
});
});
const projectTournamentState = (
raw: string | null,
payload: ProjectionPayload,
clock: GameClock
): { expected: string; next: string } | null => {
if (!raw) return null;
const parsed = JSON.parse(raw) as TournamentProjectionState;
const active = typeof parsed.stage === 'number' && parsed.stage > 0;
if (active && parsed.nextAt && !Number.isSafeInteger(parsed.nextTick)) {
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
}
if (active && parsed.bettingCloseAt && !Number.isSafeInteger(parsed.bettingCloseTick)) {
throw new Error('Active tournament bettingCloseAt lacks the authoritative bettingCloseTick dual-write.');
}
const next: TournamentProjectionState = {
...parsed,
clockRevision: payload.targetRevision,
deadlineGeneration: payload.deadlineGeneration,
};
if (Number.isSafeInteger(parsed.nextTick)) {
next.nextTick = parsed.nextTick! + payload.shiftTicks;
next.nextAt = clock.tickToDate(next.nextTick).toISOString();
}
if (Number.isSafeInteger(parsed.bettingCloseTick)) {
next.bettingCloseTick = parsed.bettingCloseTick! + payload.shiftTicks;
next.bettingCloseAt = clock.tickToDate(next.bettingCloseTick).toISOString();
}
return { expected: raw, next: JSON.stringify(next) };
};
const recordFailure = async (db: GamePrismaClient, outboxId: bigint, error: unknown): Promise<void> => {
const message = error instanceof Error ? error.message : String(error);
await db.$executeRaw(GamePrisma.sql`
UPDATE clock_projection_outbox
SET status = 'FAILED',
locked_at = NULL,
locked_by = NULL,
last_error = ${message.slice(0, 4_000)},
available_at = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '1 second'
WHERE id = ${outboxId} AND status = 'APPLYING'
`);
};
export const applyNextClockProjection = async (options: {
db: GamePrismaClient;
redis: ClockProjectionRedis;
workerId: string;
}): Promise<'IDLE' | 'APPLIED' | 'RECOVERED'> => {
if (!options.workerId.trim()) throw new Error('Clock projection worker ID is required.');
const outbox = await claimNext(options.db, options.workerId);
if (!outbox) return 'IDLE';
try {
const payload = parsePayload(outbox.payload);
if (checksum(outbox.payload) !== outbox.checksum) {
throw new Error('Clock projection outbox checksum verification failed.');
}
if (outbox.targetRevision !== BigInt(payload.targetRevision)) {
throw new Error('Clock projection payload revision differs from its outbox row.');
}
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: outbox.worldStateId } });
if (
parseGameClockPhase(world.clockPhase) !== 'RECONCILING' ||
world.clockRevision !== outbox.targetRevision ||
world.deadlineGeneration !== BigInt(payload.deadlineGeneration) ||
!world.clockBaseTime
) {
throw new Error('Clock projection DB phase/revision/generation fence failed.');
}
const clock = new GameClock({
baseTime: new Date(payload.clockBaseTime),
tick: safeInteger(world.clockTick, 'world clock tick'),
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor ?? new Date(),
turnSeconds: world.tickSeconds,
phase: 'RECONCILING',
revision: payload.targetRevision,
});
if (clock.ticksPerSecond !== payload.ticksPerSecond) {
throw new Error('Clock projection rate differs from the durable outbox payload.');
}
const auctions = await options.db.auction.findMany({
where: { status: { in: ['OPEN', 'FINALIZING'] } },
orderBy: { id: 'asc' },
select: { id: true, closeTick: true },
});
const timers = auctions.map((auction) => {
if (auction.closeTick === null) {
throw new Error(`Active auction ${auction.id} lacks closeTick during projection rebuild.`);
}
return { score: safeInteger(auction.closeTick, `auction ${auction.id} closeTick`), id: String(auction.id) };
});
const prefix = `sammo:${payload.profileName}`;
const tournamentKey = `${prefix}:tournament:state`;
const tournament = projectTournamentState(await options.redis.get(tournamentKey), payload, clock);
const result = await options.redis.eval(APPLY_CLOCK_PROJECTION_SCRIPT, {
keys: [
`${prefix}:clock:active-revision`,
`${prefix}:clock:deadline-generation`,
`${prefix}:auction:timer`,
tournamentKey,
`${prefix}:clock:projection-checksum`,
`${prefix}:clock:phase`,
],
arguments: [
String(payload.sourceRevision),
String(payload.targetRevision),
String(payload.deadlineGeneration),
outbox.checksum,
tournament?.expected ?? '__NONE__',
tournament?.next ?? '__NONE__',
String(timers.length),
...timers.flatMap(({ score, id }) => [String(score), id]),
],
});
const applied = Number(result);
if (applied === -1) throw new Error('Redis active clock revision does not match the outbox source revision.');
if (applied === -2) throw new Error('Redis tournament state changed while rebuilding its projection.');
if (applied === -3) throw new Error('Redis target revision exists without the expected projection checksum.');
if (applied !== 1 && applied !== 2)
throw new Error(`Unexpected Redis clock projection result: ${String(result)}`);
await options.db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
SELECT id FROM world_state WHERE id = ${outbox.worldStateId} FOR UPDATE
`);
const finalized = await transaction.worldState.updateMany({
where: {
id: outbox.worldStateId,
clockPhase: 'RECONCILING',
clockRevision: outbox.targetRevision,
deadlineGeneration: BigInt(payload.deadlineGeneration),
},
data: { clockPhase: 'RUNNING' },
});
if (finalized.count !== 1) {
throw new Error('Clock projection final RUNNING transition fence failed.');
}
const appliedAt = await readDbWall(transaction);
// RUNNING 전이와 같은 transaction에 남겨 재시도 시 전체 공지를 중복 발송하지 않는다.
const recovery = readTurnRecovery(world);
const journal = new ChangeJournal();
journal.mark('world.content').mark('map.world');
if (recovery) {
const recoveredClock = new GameClock({
baseTime: world.clockBaseTime!,
tick: Number(world.clockTick),
wallAnchor: world.clockWallAnchor!,
mode: 'realtime',
turnSeconds: world.tickSeconds,
recovery,
});
const endsAt = recoveredClock.tickToWallDate(recovery.endTick);
const system = {
generalId: 0,
generalName: '시스템',
nationId: 0,
nationName: '',
color: '#000000',
icon: resolveMessageTargetIcon(),
};
await sendMessage(
{ insertMessage: (draft) => persistMessageEnvelope(transaction, draft) },
{
msgType: 'public',
src: system,
dest: system,
text: `서버 재개에 따른 2배속 복구 시간: ${formatServerDateTime(recovery.startWallAt)} ~ ${formatServerDateTime(endsAt)} (한국 시각). 시작 전까지 대기하며, 종료 시 정상 속도로 진행합니다.`,
time: appliedAt,
validUntil: new Date('9999-12-31T00:00:00Z'),
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: endsAt.toISOString(),
},
}
);
journal.mark('messages.mailbox', MESSAGE_MAILBOX_PUBLIC);
}
await writeReadModelChangeJournal(transaction, journal.snapshot());
await transaction.clockProjectionOutbox.update({
where: { id: outbox.id },
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
});
if (outbox.suspensionId) {
await transaction.clockSuspension.update({
where: { id: outbox.suspensionId },
data: { status: 'APPLIED' },
});
}
});
return applied === 2 ? 'RECOVERED' : 'APPLIED';
} catch (error) {
await recordFailure(options.db, outbox.id, error);
throw error;
}
};
export const loadClockReconciliationReadiness = async (db: GamePrismaClient) => {
const world = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
});
const incompleteOutboxCount = await db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } });
if (!world) {
return { ready: false, phase: null, revision: null, deadlineGeneration: null, incompleteOutboxCount };
}
const phase = parseGameClockPhase(world.clockPhase);
return {
ready: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
phase,
revision: safeInteger(world.clockRevision, 'clock revision'),
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
incompleteOutboxCount,
};
};
@@ -0,0 +1,1141 @@
import { createHash } from 'node:crypto';
import {
GAME_TICKS_PER_TURN,
MAX_SAFE_GAME_TICK,
GameClock,
buildClockAlignmentPlan,
parseClockAlignmentPolicy,
parseGameClockPhase,
readTurnRecovery,
readSerializedTurnRecovery,
serializeTurnRecovery,
type TurnRecoveryWindow,
type ClockAlignmentPolicy,
} from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
export type ClockSuspensionSource = 'MAINTENANCE' | 'OPEN_DELAY' | 'UNIFICATION_WAIT' | 'RECOVERY';
export type ClockOperationAuthority =
| { kind: 'DAEMON'; profileName: string; ownerId: string; fencingEpoch: bigint }
| { kind: 'OFFLINE'; profileName: string; reason: string };
export interface ClockSuspensionResult {
suspensionId: string;
phase: 'SUSPENDED';
sourceRevision: number;
targetRevision: number;
cutTick: number;
cutWallAt: Date;
}
export interface ClockReconciliationResult {
suspensionId: string;
phase: 'RECONCILING';
sourceRevision: number;
targetRevision: number;
deadlineGeneration: number;
gapTicks: number;
catchUpTicks: number;
shiftTicks: number;
alignedTick: number;
resumeWallAt: Date;
recovery?: TurnRecoveryWindow | null;
resumeAnchor?: Date;
}
interface DbWallRow {
wallNow: Date;
}
interface LeaseFenceRow {
ownerId: string;
fencingEpoch: bigint;
valid: boolean;
}
interface IdRow {
id: number;
}
interface TextIdRow {
id: string;
}
interface ParticipantSnapshot {
key: string;
policy: 'SHIFT' | 'KEEP' | 'REBUILD';
checksum: string;
count: number;
}
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const safeNumber = (value: bigint, label: string): number => {
const result = Number(value);
if (!Number.isSafeInteger(result)) {
throw new Error(`${label} is outside the JavaScript safe integer range: ${value}`);
}
return result;
};
const canonicalize = (value: unknown): unknown => {
if (typeof value === 'bigint') return value.toString();
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)])
);
}
return value;
};
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
const aggregateChecksum = (participants: readonly ParticipantSnapshot[]): string =>
checksum(participants.map(({ key, policy, checksum: value, count }) => ({ key, policy, checksum: value, count })));
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
`);
const wallNow = rows[0]?.wallNow;
if (!wallNow || Number.isNaN(wallNow.getTime())) {
throw new Error('Failed to read the PostgreSQL wall clock.');
}
return wallNow;
};
export const readClockDatabaseWall = readDbWall;
const isRetryableSerializableClockError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
const value = error as { code?: unknown; message?: unknown; meta?: unknown };
const meta =
value.meta && typeof value.meta === 'object'
? (value.meta as { code?: unknown; message?: unknown })
: undefined;
const code = typeof value.code === 'string' ? value.code : '';
const databaseCode = typeof meta?.code === 'string' ? meta.code : '';
const message = [value.message, meta?.message]
.filter((entry): entry is string => typeof entry === 'string')
.join(' ');
return (
code === 'P2034' ||
code === '40001' ||
databaseCode === '40001' ||
message.includes('40001') ||
message.includes('could not serialize access')
);
};
const runSerializableClockOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
const maxAttempts = 3;
for (let attempt = 1; ; attempt += 1) {
try {
return await operation();
} catch (error) {
if (attempt >= maxAttempts || !isRetryableSerializableClockError(error)) {
throw error;
}
await new Promise<void>((resolve) => setTimeout(resolve, attempt * 10));
}
}
};
const verifyAuthority = async (db: GamePrisma.TransactionClient, authority: ClockOperationAuthority): Promise<void> => {
const rows = await db.$queryRaw<LeaseFenceRow[]>(GamePrisma.sql`
SELECT owner_id AS "ownerId",
fencing_epoch AS "fencingEpoch",
lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid
FROM turn_daemon_lease
WHERE profile = ${authority.profileName}
FOR UPDATE
`);
const lease = rows[0];
if (authority.kind === 'OFFLINE') {
if (!authority.reason.trim()) {
throw new Error('Offline clock operations require an audit reason.');
}
if (lease?.valid) {
throw new Error(`Clock operation requires the ${authority.profileName} daemon lease to be offline.`);
}
return;
}
if (!lease?.valid || lease.ownerId !== authority.ownerId || lease.fencingEpoch !== authority.fencingEpoch) {
throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`);
}
};
const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
const rows = await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE
`);
if (rows.length !== 1) {
throw new Error(`Clock reconciliation requires exactly one world_state row; found ${rows.length}.`);
}
return rows[0]!.id;
};
const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise<void> => {
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM auction
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
ORDER BY id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT bid.id
FROM auction_bid AS bid
JOIN auction ON auction.id = bid.auction_id
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
ORDER BY bid.id FOR UPDATE OF bid
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT message_id AS id FROM message_action
WHERE status = 'PENDING'
ORDER BY message_id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
`);
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
SELECT id FROM select_pool WHERE general_id IS NULL ORDER BY id FOR UPDATE
`);
await db.$queryRaw<TextIdRow[]>(GamePrisma.sql`
SELECT owner_user_id AS id FROM select_npc_token ORDER BY owner_user_id FOR UPDATE
`);
};
// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다.
const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean =>
Boolean(db.worldState.fields?.clockRecoveryStartTick);
const readParticipantSnapshots = async (
db: GamePrisma.TransactionClient,
worldStateId: number,
cutTick: bigint
): Promise<ParticipantSnapshot[]> => {
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
await Promise.all([
db.worldState.findUniqueOrThrow({
where: { id: worldStateId },
select: {
clockTick: true,
...(supportsRecoveryColumns(db)
? {
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
}
: {}),
clockRevision: true,
deadlineGeneration: true,
lastTurnTick: true,
meta: true,
},
}),
db.general.findMany({
orderBy: { id: 'asc' },
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
}),
db.auction.findMany({
where: { status: { in: ['OPEN', 'FINALIZING'] } },
orderBy: { id: 'asc' },
select: { id: true, status: true, openTick: true, closeTick: true },
}),
db.auctionBid.findMany({
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
orderBy: { id: 'asc' },
select: { id: true, occurredGameTick: true },
}),
db.messageAction.findMany({
where: { status: 'PENDING' },
orderBy: { messageId: 'asc' },
select: {
messageId: true,
createdGameTick: true,
expiresGameTick: true,
clockRevision: true,
deadlineGeneration: true,
},
}),
db.inheritanceLedger.findMany({
orderBy: { id: 'asc' },
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
}),
db.votePoll.findMany({
where: { closedAt: null },
orderBy: { id: 'asc' },
select: { id: true, startTick: true, endTick: true },
}),
db.selectPoolEntry.findMany({
where: { generalId: null },
orderBy: { id: 'asc' },
select: { id: true, reservedUntilTick: true },
}),
db.npcSelectionToken.findMany({
orderBy: { ownerUserId: 'asc' },
select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true },
}),
db.inputEvent.findMany({
where: { status: { in: ['PENDING', 'PROCESSING'] } },
orderBy: { sequence: 'asc' },
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
}),
]);
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
key,
policy,
checksum: checksum(rows),
count: rows.length,
});
const meta = world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta : {};
return [
snapshot('world-clock', 'REBUILD', [
{
clockTick: world.clockTick,
...serializeTurnRecovery(readTurnRecovery(world)),
clockRevision: world.clockRevision,
deadlineGeneration: world.deadlineGeneration,
},
]),
snapshot('turn-cursor', 'SHIFT', [{ lastTurnTick: world.lastTurnTick }]),
snapshot(
'general-next-turn',
'SHIFT',
generals.map(({ id, turnTick }) => ({ id, turnTick }))
),
snapshot(
'general-recent-war-occurrence',
'KEEP',
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
),
snapshot(
'selection-reselection-deadline',
'SHIFT',
generals.flatMap(({ id, meta: generalMeta }) => {
const raw =
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
? Reflect.get(generalMeta, 'next_change_tick')
: null;
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
})
),
snapshot(
'auction-open-occurrence',
'KEEP',
auctions.map(({ id, openTick }) => ({ id, openTick }))
),
snapshot(
'auction-deadline',
'SHIFT',
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
),
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
snapshot(
'auction-finalizing-recovery',
'REBUILD',
auctions.map(({ id, status }) => ({ id, status }))
),
snapshot(
'message-action-occurrence',
'KEEP',
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
),
snapshot(
'message-action-expiry',
'SHIFT',
messages
.filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick)
.map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick }))
),
snapshot(
'message-action-clock-coordinate',
'REBUILD',
messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({
messageId,
clockRevision,
deadlineGeneration,
}))
),
snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects),
snapshot(
'vote-start-occurrence',
'KEEP',
votes.map(({ id, startTick }) => ({ id, startTick }))
),
snapshot(
'vote-end-deadline',
'SHIFT',
votes.map(({ id, endTick }) => ({ id, endTick }))
),
snapshot('select-pool-reservation', 'SHIFT', pool),
snapshot('npc-selection-window', 'SHIFT', npcTokens),
snapshot('daemon-command-coordinate', 'KEEP', commands),
snapshot('movable-json-rule-anchors', 'SHIFT', [
{
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
turntime: Reflect.get(meta, 'turntime'),
starttime: Reflect.get(meta, 'starttime'),
tnmt_time: Reflect.get(meta, 'tnmt_time'),
},
]),
];
};
const persistInitialParticipants = async (
db: GamePrisma.TransactionClient,
suspensionId: string,
participants: readonly ParticipantSnapshot[]
): Promise<void> => {
for (const participant of participants) {
await db.clockReconciliationParticipant.create({
data: {
suspensionId,
participantKey: participant.key,
policy: participant.policy,
beforeChecksum: participant.checksum,
afterChecksum: participant.checksum,
affectedCount: 0,
},
});
}
};
/**
* Locks every registered participant before an enclosing transaction mutates
* the world into a suspended state. The caller must already hold the daemon,
* clock-operation, general-access, and world-row lock prefix.
*/
export const prepareClockSuspensionUnderHeldLocks = async (options: {
db: GamePrisma.TransactionClient;
cutTick: number;
cutWallAt?: Date;
}): Promise<{ cutWallAt: Date }> => {
if (!Number.isSafeInteger(options.cutTick)) {
throw new Error(`Clock suspension cut tick is outside the safe integer range: ${options.cutTick}.`);
}
const cutWallAt = options.cutWallAt ? new Date(options.cutWallAt.getTime()) : await readDbWall(options.db);
await lockParticipants(options.db, BigInt(options.cutTick));
return { cutWallAt };
};
/**
* Persists the ledger after all suspension-boundary gameplay writes have been
* staged in the same transaction. Lock acquisition belongs to
* prepareClockSuspensionUnderHeldLocks and must happen first.
*/
export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
db: GamePrisma.TransactionClient;
suspensionId: string;
worldStateId: number;
profileName: string;
source: ClockSuspensionSource;
cutTick: number;
cutWallAt: Date;
rateTicksPerSecond: number;
sourceRevision: number;
normalTickAtCutWall?: number;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
}): Promise<void> => {
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
throw new Error('Clock suspension ID must contain 1-64 characters.');
}
const policy = options.policy ?? 'EXACT';
const catchUpTicks = options.catchUpTicks ?? 0;
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
}
const existing = await options.db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
if (existing) {
if (
existing.worldStateId !== options.worldStateId ||
existing.source !== options.source ||
existing.sourceRevision !== BigInt(options.sourceRevision)
) {
throw new Error(`Clock suspension ID ${options.suspensionId} is already bound to another operation.`);
}
return;
}
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: options.worldStateId } });
if (
parseGameClockPhase(world.clockPhase) !== 'SUSPENDED' ||
world.clockRevision !== BigInt(options.sourceRevision)
) {
throw new Error('Clock suspension ledger requires a matching durable SUSPENDED world revision.');
}
const participants = await readParticipantSnapshots(options.db, options.worldStateId, BigInt(options.cutTick));
await options.db.clockSuspension.create({
data: {
id: options.suspensionId,
worldStateId: options.worldStateId,
source: options.source,
policy,
status: 'SUSPENDED',
sourceRevision: BigInt(options.sourceRevision),
targetRevision: BigInt(options.sourceRevision + 1),
cutTick: BigInt(options.cutTick),
cutWallAt: options.cutWallAt,
rateTicksPerSecond: options.rateTicksPerSecond,
catchUpTicks: BigInt(catchUpTicks),
participantChecksumBefore: aggregateChecksum(participants),
detail: asJson({
authority: 'DAEMON',
profileName: options.profileName,
normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick),
}),
},
});
await persistInitialParticipants(options.db, options.suspensionId, participants);
};
const shiftMetaDate = (value: unknown, deltaMilliseconds: number): unknown => {
if (typeof value !== 'string' || !value.trim()) return value;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Date(parsed.getTime() + deltaMilliseconds).toISOString();
};
const shiftedMeta = (value: GamePrisma.JsonValue, deltaMilliseconds: number): GamePrisma.InputJsonValue => {
const meta = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
for (const key of ['lastTurnTime', 'turntime', 'starttime', 'tnmt_time'] as const) {
if (Object.hasOwn(meta, key)) {
Reflect.set(meta, key, shiftMetaDate(Reflect.get(meta, key), deltaMilliseconds));
}
}
return asJson(meta);
};
const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTicks: number): void => {
if (!Number.isSafeInteger(shiftTicks) || shiftTicks < 0) {
throw new Error(`Invalid reconciliation shift: ${shiftTicks}`);
}
// Checksums retain stringified values for audit; actual row ranges are
// checked by PostgreSQL BIGINT and the world aligned tick is checked by the
// shared GameClock plan. The sentinel expiry is deliberately never shifted.
if (participants.some((participant) => !participant.checksum)) {
throw new Error('Participant snapshot is incomplete.');
}
};
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
FROM general
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
`),
db.auction.aggregate({
_max: { closeTick: true },
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
}),
db.messageAction.aggregate({
_max: { expiresGameTick: true },
where: { status: 'PENDING', expiresGameTick: { not: null } },
}),
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
db.selectPoolEntry.aggregate({
_max: { reservedUntilTick: true },
where: { generalId: null, reservedUntilTick: { not: null } },
}),
db.npcSelectionToken.aggregate({ _max: { validUntilTick: true }, where: { validUntilTick: { not: null } } }),
db.npcSelectionToken.aggregate({
_max: { pickMoreFromTick: true },
where: { pickMoreFromTick: { not: null } },
}),
]);
const values: Array<[string, bigint | null]> = [
['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick],
['message_action.expires_game_tick', message._max.expiresGameTick],
['vote_poll.end_tick', vote._max.endTick],
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
['select_npc_token.pick_more_from_tick', npcMore._max.pickMoreFromTick],
];
for (const [label, value] of values) {
if (value !== null && value > maximum) {
throw new Error(`${label} would exceed the safe game tick range after reconciliation.`);
}
}
};
const applyParticipantShift = async (
db: GamePrisma.TransactionClient,
worldStateId: number,
cutTick: bigint,
alignedTick: bigint,
targetRevision: bigint,
targetGeneration: bigint,
shiftTicks: bigint,
projectionDeltaMilliseconds: number,
resumeWallAt: Date
): Promise<Map<string, number>> => {
const affected = new Map<string, number>();
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { meta: true } });
const cursor = await db.worldState.updateMany({
where: { id: worldStateId, lastTurnTick: { not: null } },
data: { lastTurnTick: { increment: shiftTicks } },
});
affected.set('turn-cursor', cursor.count);
affected.set(
'general-next-turn',
await db.$executeRaw(GamePrisma.sql`
UPDATE general
SET turn_tick = turn_tick + ${shiftTicks},
turn_time = turn_time + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE turn_tick IS NOT NULL
`)
);
affected.set(
'selection-reselection-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE general
SET meta = jsonb_set(
jsonb_set(
jsonb_set(
meta,
'{next_change_tick}',
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
true
),
'{next_change}',
to_jsonb(((meta->>'next_change')::timestamp
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
true
),
'{nextChangeAt}',
to_jsonb(((meta->>'nextChangeAt')::timestamp
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
true
)
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
`)
);
affected.set(
'auction-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE auction
SET close_tick = close_tick + ${shiftTicks},
close_at = close_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
AND close_tick IS NOT NULL
`)
);
affected.set(
'message-action-clock-coordinate',
(
await db.messageAction.updateMany({
where: { status: 'PENDING' },
data: {
clockRevision: targetRevision,
deadlineGeneration: targetGeneration,
},
})
).count
);
affected.set(
'message-action-expiry',
await db.$executeRaw(GamePrisma.sql`
WITH shifted AS (
UPDATE message_action
SET expires_game_tick = expires_game_tick + ${shiftTicks},
clock_revision = ${targetRevision},
deadline_generation = ${targetGeneration},
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE status = 'PENDING'
AND expires_game_tick IS NOT NULL
AND expires_game_tick >= ${cutTick}
RETURNING message_id, expires_game_tick
)
UPDATE message AS envelope
SET valid_until_tick = shifted.expires_game_tick,
valid_until = envelope.valid_until
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
FROM shifted
WHERE envelope.id = shifted.message_id
`)
);
affected.set(
'vote-end-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE vote_poll
SET end_tick = end_tick + ${shiftTicks},
end_at = end_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE closed_at IS NULL AND end_tick IS NOT NULL
`)
);
affected.set(
'select-pool-reservation',
await db.$executeRaw(GamePrisma.sql`
UPDATE select_pool
SET reserved_until_tick = reserved_until_tick + ${shiftTicks},
reserved_until = reserved_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
WHERE general_id IS NULL AND reserved_until_tick IS NOT NULL
`)
);
affected.set(
'npc-selection-window',
await db.$executeRaw(GamePrisma.sql`
UPDATE select_npc_token
SET valid_until_tick = CASE
WHEN valid_until_tick IS NULL THEN NULL ELSE valid_until_tick + ${shiftTicks} END,
valid_until = CASE
WHEN valid_until_tick IS NULL THEN valid_until
ELSE valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END,
pick_more_from_tick = CASE
WHEN pick_more_from_tick IS NULL THEN NULL ELSE pick_more_from_tick + ${shiftTicks} END,
pick_more_from = CASE
WHEN pick_more_from_tick IS NULL THEN pick_more_from
ELSE pick_more_from + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
`)
);
await db.worldState.update({
where: { id: worldStateId },
data: {
clockTick: alignedTick,
clockWallAnchor: resumeWallAt,
clockPhase: 'RECONCILING',
clockRevision: targetRevision,
deadlineGeneration: targetGeneration,
meta: shiftedMeta(world.meta, projectionDeltaMilliseconds),
},
});
affected.set('world-clock', 1);
affected.set('movable-json-rule-anchors', 1);
affected.set('auction-finalizing-recovery', 0);
return affected;
};
export const startClockSuspension = async (options: {
db: GamePrismaClient;
suspensionId: string;
source: ClockSuspensionSource;
authority: ClockOperationAuthority;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
recoverDurableObservation?: boolean;
}): Promise<ClockSuspensionResult> => {
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
throw new Error('Clock suspension ID must contain 1-64 characters.');
}
const policy = options.policy ?? 'EXACT';
const catchUpTicks = options.catchUpTicks ?? 0;
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
}
return runSerializableClockOperation(() =>
options.db.$transaction(
async (db) => {
await verifyAuthority(db, options.authority);
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
const worldStateId = await lockWorld(db);
const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
if (existing) {
if (
existing.worldStateId !== worldStateId ||
existing.source !== options.source ||
existing.policy !== policy
) {
throw new Error(
`Clock suspension ID ${options.suspensionId} is already bound to another operation.`
);
}
if (existing.status !== 'SUSPENDED') {
throw new Error(
`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`
);
}
return {
suspensionId: existing.id,
phase: 'SUSPENDED' as const,
sourceRevision: safeNumber(existing.sourceRevision, 'source revision'),
targetRevision: safeNumber(existing.targetRevision, 'target revision'),
cutTick: safeNumber(existing.cutTick, 'cut tick'),
cutWallAt: existing.cutWallAt,
};
}
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const phase = parseGameClockPhase(world.clockPhase);
if (phase !== 'RUNNING') {
throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`);
}
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
throw new Error('Clock suspension requires a fully initialized logical game clock.');
}
if (
options.recoverDurableObservation &&
(options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS')
) {
throw new Error('Only daemon outage recovery may use the durable observation.');
}
const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db);
const storedTick = safeNumber(world.clockTick, 'world clock tick');
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
const clock = new GameClock({
baseTime: world.clockBaseTime,
tick: storedTick,
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor,
recovery: readTurnRecovery(world),
turnSeconds: world.tickSeconds,
phase,
revision: sourceRevision,
});
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
await lockParticipants(db, BigInt(cutTick));
await db.worldState.update({
where: { id: worldStateId },
data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt },
});
const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick));
await db.clockSuspension.create({
data: {
id: options.suspensionId,
worldStateId,
source: options.source,
policy,
status: 'SUSPENDED',
sourceRevision: BigInt(sourceRevision),
targetRevision: BigInt(sourceRevision + 1),
cutTick: BigInt(cutTick),
cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds,
catchUpTicks: BigInt(catchUpTicks),
participantChecksumBefore: aggregateChecksum(participants),
detail: asJson({
authority: options.authority.kind,
profileName: options.authority.profileName,
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
}),
},
});
await persistInitialParticipants(db, options.suspensionId, participants);
return {
suspensionId: options.suspensionId,
phase: 'SUSPENDED',
sourceRevision,
targetRevision: sourceRevision + 1,
cutTick,
cutWallAt,
};
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
)
);
};
/**
* Continues a suspension inside a transaction whose caller already verified
* daemon authority and acquired the clock/general-access lock prefix.
*/
export const reconcileClockSuspensionInTransaction = async (options: {
db: GamePrisma.TransactionClient;
suspensionId: string;
profileName: string;
allowUnificationWait?: boolean;
authority?: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> => {
const db = options.db;
const worldStateId = await lockWorld(db);
const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } });
if (suspension.worldStateId !== worldStateId) {
throw new Error('Clock suspension belongs to another world state.');
}
if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') {
if (
suspension.gapTicks === null ||
suspension.shiftTicks === null ||
suspension.alignedTick === null ||
!suspension.resumeWallAt
) {
throw new Error('Persisted clock reconciliation result is incomplete.');
}
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
return {
suspensionId: suspension.id,
phase: 'RECONCILING' as const,
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
targetRevision: safeNumber(suspension.targetRevision, 'target revision'),
deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'),
gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'),
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
resumeWallAt: suspension.resumeWallAt,
recovery: readSerializedTurnRecovery(suspension.detail),
resumeAnchor:
suspension.detail &&
typeof suspension.detail === 'object' &&
!Array.isArray(suspension.detail) &&
typeof suspension.detail.resumeAnchor === 'string'
? new Date(suspension.detail.resumeAnchor)
: (world.clockWallAnchor ?? suspension.resumeWallAt),
};
}
if (suspension.status !== 'SUSPENDED') {
throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`);
}
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const phase = parseGameClockPhase(world.clockPhase);
if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) {
throw new Error('Clock reconciliation phase or source revision fence failed.');
}
const worldMeta =
world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta)
? (world.meta as Record<string, unknown>)
: {};
const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0);
if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) {
if (!options.allowUnificationWait || options.authority?.kind !== 'DAEMON') {
throw new Error('Unification wait requires the daemon-authorized atomic alignment-and-invader workflow.');
}
await verifyAuthority(db, options.authority);
}
const cutTick = safeNumber(suspension.cutTick, 'cut tick');
await lockParticipants(db, suspension.cutTick);
if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') {
throw new Error('A clock reconciliation wall override is allowed only in tests.');
}
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY';
const upgradeMaintenance =
options.upgradeMaintenancePolicy === true &&
supportsRecoveryColumns(db) &&
suspension.source === 'MAINTENANCE' &&
suspension.policy !== 'RECOVER_TURNS';
const effectivePolicy = legacyUnificationWait
? 'TURN_BOUNDARY'
: upgradeMaintenance
? 'RECOVER_TURNS'
: parseClockAlignmentPolicy(suspension.policy);
const alignmentCutTick = legacyUnificationWait
? safeNumber(world.lastTurnTick!, 'unification execution boundary')
: cutTick;
const plan = buildClockAlignmentPlan({
policy: effectivePolicy,
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
cutTick: alignmentCutTick,
cutWall: suspension.cutWallAt,
resumeWall: resumeWallAt,
ticksPerSecond: suspension.rateTicksPerSecond,
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
normalTick: (() => {
const detail =
suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {};
const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick;
return (
normalAtCut +
Math.trunc(
((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000
)
);
})(),
});
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
assertShiftFits(before, plan.shiftTicks);
await assertScheduleRanges(db, plan.shiftTicks);
const projectionDeltaMilliseconds = Math.trunc((plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond);
if (!Number.isSafeInteger(projectionDeltaMilliseconds)) {
throw new Error('Clock reconciliation projection delta is outside the safe integer range.');
}
const targetGeneration = world.deadlineGeneration + 1n;
const affected = await applyParticipantShift(
db,
worldStateId,
suspension.cutTick,
BigInt(plan.alignedTick),
BigInt(plan.targetRevision),
targetGeneration,
BigInt(plan.shiftTicks),
projectionDeltaMilliseconds,
plan.resumeAnchor ?? resumeWallAt
);
if (supportsRecoveryColumns(db)) {
await db.worldState.update({
where: { id: worldStateId },
data: {
clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null,
clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null,
clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null,
},
});
} else if (plan.recovery) {
throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.');
}
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
for (const participant of before) {
const next = afterByKey.get(participant.key);
if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`);
if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) {
throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`);
}
await db.clockReconciliationParticipant.upsert({
where: {
suspensionId_participantKey: {
suspensionId: suspension.id,
participantKey: participant.key,
},
},
create: {
suspensionId: suspension.id,
participantKey: participant.key,
policy: participant.policy,
beforeChecksum: participant.checksum,
afterChecksum: next.checksum,
affectedCount: affected.get(participant.key) ?? 0,
},
update: {
policy: participant.policy,
beforeChecksum: participant.checksum,
afterChecksum: next.checksum,
affectedCount: affected.get(participant.key) ?? 0,
},
});
}
const outboxPayload = {
version: 1,
profileName: options.profileName,
suspensionId: suspension.id,
sourceRevision: plan.sourceRevision,
targetRevision: plan.targetRevision,
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
shiftTicks: plan.shiftTicks,
projectionDeltaMilliseconds,
clockBaseTime: world.clockBaseTime!.toISOString(),
ticksPerSecond: suspension.rateTicksPerSecond,
};
await db.clockProjectionOutbox.create({
data: {
worldStateId,
suspensionId: suspension.id,
targetRevision: BigInt(plan.targetRevision),
status: 'PENDING',
payload: asJson(outboxPayload),
checksum: checksum(outboxPayload),
},
});
await db.clockSuspension.update({
where: { id: suspension.id },
data: {
status: 'RECONCILING',
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
resumeWallAt,
gapTicks: BigInt(plan.gapTicks),
catchUpTicks: BigInt(plan.catchUpTicks),
detail: asJson({
...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {}),
...serializeTurnRecovery(plan.recovery ?? null),
resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(),
...(legacyUnificationWait || upgradeMaintenance
? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick }
: {}),
}),
shiftTicks: BigInt(plan.shiftTicks),
alignedTick: BigInt(plan.alignedTick),
participantChecksumBefore: aggregateChecksum(before),
participantChecksumAfter: aggregateChecksum(after),
},
});
return {
suspensionId: suspension.id,
phase: 'RECONCILING',
sourceRevision: plan.sourceRevision,
targetRevision: plan.targetRevision,
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
gapTicks: plan.gapTicks,
catchUpTicks: plan.catchUpTicks,
shiftTicks: plan.shiftTicks,
alignedTick: plan.alignedTick,
resumeWallAt,
recovery: plan.recovery ?? null,
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
};
};
/** Finalizes an atomic unification workflow after an optional rate change. */
export const refreshClockProjectionForFinalClockUnderHeldLocks = async (options: {
db: GamePrisma.TransactionClient;
suspensionId: string;
clockBaseTime: Date;
tickSeconds: number;
}): Promise<void> => {
if (GAME_TICKS_PER_TURN % options.tickSeconds !== 0) {
throw new Error(`Final clock rate cannot represent an integer tick: ${options.tickSeconds}.`);
}
const outbox = await options.db.clockProjectionOutbox.findFirstOrThrow({
where: { suspensionId: options.suspensionId, status: 'PENDING' },
orderBy: { id: 'asc' },
});
const payload =
outbox.payload && typeof outbox.payload === 'object' && !Array.isArray(outbox.payload)
? { ...(outbox.payload as Record<string, unknown>) }
: null;
if (!payload || payload.suspensionId !== options.suspensionId) {
throw new Error('Unification clock projection outbox payload is invalid.');
}
payload.clockBaseTime = options.clockBaseTime.toISOString();
payload.ticksPerSecond = GAME_TICKS_PER_TURN / options.tickSeconds;
await options.db.clockProjectionOutbox.update({
where: { id: outbox.id },
data: { payload: asJson(payload), checksum: checksum(payload) },
});
};
export const reconcileClockSuspension = async (options: {
db: GamePrismaClient;
suspensionId: string;
authority: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> =>
runSerializableClockOperation(() =>
options.db.$transaction(
async (db) => {
await verifyAuthority(db, options.authority);
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
return reconcileClockSuspensionInTransaction({
db,
suspensionId: options.suspensionId,
profileName: options.authority.profileName,
authority: options.authority,
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
});
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
)
);
+20 -3
View File
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
type: z.literal('auctionFinalize'), type: z.literal('auctionFinalize'),
auctionId: zFiniteNumber, auctionId: zFiniteNumber,
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(), expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
expectedCloseTick: zSafeInteger.optional(), expectedCloseTick: zSafeInteger,
}); });
const zAuctionOpen = z.object({ const zAuctionOpen = z.object({
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
auctionId: zFiniteNumber, auctionId: zFiniteNumber,
generalId: zFiniteNumber, generalId: zFiniteNumber,
amount: zFiniteNumber, amount: zFiniteNumber,
acceptedGameTick: zSafeInteger.optional(),
tryExtendCloseDate: z.boolean().optional(), tryExtendCloseDate: z.boolean().optional(),
}); });
@@ -132,6 +131,15 @@ const zMessageRespond = z.object({
response: z.boolean(), response: z.boolean(),
}); });
const zSyncDiplomaticResponse = z.object({
type: z.literal('syncDiplomaticResponse'),
userId: z.string().min(1),
generalId: z.number().int().positive(),
messageId: z.number().int().positive(),
nationIds: z.array(z.number().int().positive()).max(4),
cityIds: z.array(z.number().int().positive()).max(256),
});
const zVacation = z.object({ const zVacation = z.object({
type: z.literal('vacation'), type: z.literal('vacation'),
userId: z.string().min(1), userId: z.string().min(1),
@@ -427,7 +435,7 @@ const zSelectPoolReserve = z
requestId: z.string().optional(), requestId: z.string().optional(),
userId: z.string().min(1), userId: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]), seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp), acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(), acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
}) })
.strict(); .strict();
@@ -610,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope)
return { ...command, requestId: envelope.requestId }; return { ...command, requestId: envelope.requestId };
}; };
const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => {
const command = parseWith(zSyncDiplomaticResponse, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => { const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
const command = parseWith(zVacation, envelope.command); const command = parseWith(zVacation, envelope.command);
if (!command) { if (!command) {
@@ -860,6 +876,7 @@ const normalizers: CommandNormalizerMap = {
buildNationCandidate: normalizeBuildNationCandidate, buildNationCandidate: normalizeBuildNationCandidate,
instantRetreat: normalizeInstantRetreat, instantRetreat: normalizeInstantRetreat,
messageRespond: normalizeMessageRespond, messageRespond: normalizeMessageRespond,
syncDiplomaticResponse: normalizeSyncDiplomaticResponse,
vacation: normalizeVacation, vacation: normalizeVacation,
setMySetting: normalizeSetMySetting, setMySetting: normalizeSetMySetting,
dropItem: normalizeDropItem, dropItem: normalizeDropItem,

Some files were not shown because too many files have changed in this diff Show More