Author SHA1 Message Date
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
Hide_D 1a2c7ba775 접속 집계 교착으로 인한 턴 정지를 방지 2026-09-03 01:58:45 +00:00
Hide_D 97a8b1daef 가오픈 장수 턴의 오픈 전 적체 방지 2026-09-03 00:36:56 +00:00
Hide_D b1984669d4 공백지 출병 시 재야 장수의 수비 참여를 차단
defenderGenerals 선정이 nationId === defenderCity.nationId만 비교해
공백지(nationId 0)와 재야 장수(nationId 0)가 0 === 0으로 같은 세력으로
판정되었다. nationId 0는 실제 국가가 아니라 무소속을 뜻하므로
general.nationId !== 0 조건을 추가해 war/aftermath.ts의 수비국 장수
조건과 같은 의미로 맞춘다. 공백지 공격/공성/점령과 경로 탐색의 공백지
허용은 그대로 두고, 병력·훈련·사기를 갖춘 재야 장수는 수비하지 않고
국가 소속 수비 장수는 계속 수비함을 회귀 테스트로 고정한다.
2026-09-02 14:59:00 +00:00
Hide_D 77299ffe41 랜덤 임관 기수의 등용 실행을 차단 2026-09-01 23:39:14 +00:00
Hide_D 89a33a8349 턴 실패 뒤 중복 휴식 로그를 제거한다 2026-09-01 13:24:37 +00:00
Hide_D 76da41a00b fix(test): 사령턴 병종 fixture 계약을 보완 2026-09-01 10:52:03 +00:00
Hide_D 63e29743d9 fix(game-ui): 명장일람 유니크 아이템 설명 복구 2026-09-01 05:55:27 +00:00
Hide_D 426e1127ef fix: 사령턴 이후 자율행동 상태를 갱신한다 2026-09-01 05:35:46 +00:00
Hide_D 9c82eb0cf5 fix: 빙의 전용 화면과 가오픈 갱신을 보정한다 2026-09-01 02:38:02 +00:00
Hide_D dd84e6d6b3 내무부 모바일 정렬과 스크롤을 바로잡음 2026-08-31 23:34:33 +00:00
Hide_D 8e0e7291de 내무부 500px 레이아웃을 바로잡음 2026-08-31 18:50:13 +00:00
Hide_D 50a443d94b 인사부 후보 정렬과 추방 확인 개선 2026-08-31 18:42:33 +00:00
Hide_D bf13dabc01 독립 페이지 하단 버튼 크기를 통일 2026-08-31 14:55:52 +00:00
Hide_D 7080ae5504 징병 모병 병종 활성화 조건을 표시 2026-08-31 14:29:40 +00:00
Hide_D 96f6d85cf4 개선: 토너먼트 베팅 입력 가독성을 높임 2026-08-31 13:54:29 +00:00
Hide_D 3ef86448d7 fix: 커맨드 실패 결과 색상 복구 2026-08-31 11:43:56 +00:00
Hide_D bd826b1ff8 수정: 인사 명령에서 유저장을 먼저 정렬 2026-08-31 11:40:16 +00:00
Hide_D 0bc4be9a13 fix:장수 생성 순서 버튼 변경 2026-08-31 11:29:54 +00:00
Hide_D 898d756036 다중 턴 명령 쿨타임을 불가로 표시 2026-08-31 08:35:46 +00:00
Hide_D 29014e9c2d 도시정보 적국 NPC 소속을 표시 2026-08-31 05:07:47 +00:00
Hide_D 99a477b47e fix(tooling): lint 검사를 저메모리 하이브리드로 전환 2026-08-31 04:58:57 +00:00
Hide_D 8d88603305 fix(tooling): ESLint 메모리 사용량을 제한 2026-08-31 04:25:17 +00:00
Hide_D d4e83661ca fix(game-ui): 인사 명령 장수 선택 정보를 정리 2026-08-31 02:49:36 +00:00
Hide_D 70a4980a31 fix(game-ui): 증축 감축을 즉시 예약 2026-08-31 00:44:43 +00:00
Hide_D f3eaab1fa9 예약 커맨드 비용 표시 복원 2026-08-30 23:35:23 +00:00
Hide_D 71b9449903 지도 도시 포인터 대상을 아이콘으로 한정 2026-08-30 22:30:07 +00:00
Hide_D 6162e56583 fix(game-ui): 로비 새 탭 이동을 지원한다 2026-08-30 17:22:19 +00:00
Hide_D fa7898b6a2 fix(game-ui): 징병 모병 최종 자금을 표시한다 2026-08-30 17:15:40 +00:00
Hide_D c769d915a6 fix(game-ui): 메인 지도 연도 제한 안내를 복원한다 2026-08-30 12:57:48 +00:00
Hide_D 4e4dff55a1 fix: 세력일람 NPC 기호 중복 표시 수정 2026-08-30 12:40:54 +00:00
Hide_D dd66da14f4 장수 목록 기본 정렬 순서를 통일 2026-08-30 12:19:17 +00:00
Hide_D c56610b179 test: 현재 실패한 월간 경계 검증을 교정한다
Ref의 반복 월 경계 처리와 Core의 장기 backlog 재개 순서를 반영하고, 제거된 시나리오 catalog 검사에서 남은 미사용 선언을 정리한다.
2026-08-30 07:50:39 +00:00
Hide_D 24e57ee94c fix: 불가침 제의 기한을 20년으로 제한 2026-08-30 07:24:33 +00:00
Hide_D d68209879b 카카오 OTP 만료 시각을 한국 시간으로 표시 2026-08-30 01:37:58 +00:00
Hide_D 7377aa62ed 사령부 턴 시각을 UTC+9로 표시 2026-08-30 01:35:32 +00:00
Hide_D 11fca1ed8b 사령부 지도 입력창 너비를 통일 2026-08-30 01:27:06 +00:00
Hide_D 254d321e8b fix(game-ui): 세력일람 병력을 추정 정보로 교체 2026-08-29 14:43:52 +00:00
Hide_D 52a75ca9b7 CHE 아이템 상태 동결로 인한 턴 정지 수정 2026-08-29 08:56:40 +00:00
Hide_D 23678df9de fix(game-ui): CR 테마에 맞춰 도시 배경 크기 조정 2026-08-29 04:36:49 +00:00
Hide_D 95d5246d4b fix(game-ui): 턴 이동 버튼 너비를 채움 2026-08-29 04:23:52 +00:00
Hide_D 342994f77b 빌드와 테스트 간단 출력을 기본으로 전환 2026-08-29 04:18:43 +00:00
Hide_D 71ee529d35 fix(game-ui): 천통국 베팅 금액 초안을 유지 2026-08-29 03:31:00 +00:00
Hide_D 20a2812d65 feat(game-ui): 세력일람에 총 병사를 표시 2026-08-29 03:30:55 +00:00
Hide_D 26952fcdc7 fix(game-ui): 메인 턴과 장수 상태 표시를 보완 2026-08-29 03:30:50 +00:00
Hide_D fa9fe71539 feat: 예약 명령 처리 로직 개선 및 완료 콜백 추가 2026-08-28 15:55:39 +00:00
Hide_D e6873f813a fix: 내 정보&설정에서 설정 저장 로직을 개선하고 불필요한 속성 제거 2026-08-28 14:18:06 +00:00
Hide_D b251120e45 fix(test): monthlyCatalogCoverage 테스트에서 불필요한 시나리오 리소스 검증 제거 2026-08-28 14:14:56 +00:00
Hide_D eb755757ec fix: 자동 외교 설정을 제거하고 관련된 모든 부분을 업데이트 2026-08-28 13:58:18 +00:00
Hide_D ddbfa0afa9 fix(game-ui): iPhone Safari 확인 조작을 안정화 2026-08-28 12:25:57 +00:00
Hide_D b45fe5ba39 Merge branch 'fix/general-turn-repeat-12-20260828' 2026-08-28 11:21:38 +00:00
Hide_D 59f0a051d8 fix: 메인 턴 반복 범위를 12턴으로 복원한다 2026-08-28 11:20:16 +00:00
Hide_D 8ad10e6b2c docs: 출시 후 Ref 비교 기준을 개념 계약으로 조정 2026-08-28 07:07:06 +00:00
Hide_D c741ef4be5 fix: 메인 모바일 겹침과 전용 아이콘 배경을 바로잡는다 2026-08-28 06:56:44 +00:00
Hide_D f43a1db136 fix: 자동 참모 교체 시 기존 참모를 해임한다 2026-08-28 06:20:13 +00:00
Hide_D ab0f1b9cd5 fix(game-ui): 턴 입력기 모드를 서버별로 기억한다 2026-08-28 01:12:34 +00:00
Hide_D ca3e028603 merge: 유산 설명 줄바꿈 복원을 main에 반영 2026-08-27 18:19:20 +00:00
Hide_D 212c13bf16 fix: 유산 설명의 줄바꿈을 복원
전투 특기와 유니크 경매 설명의 레거시 br 표기를 안전한 줄 단위 DOM으로 변환한다. Chromium 회귀로 실제 줄바꿈과 리터럴 태그 비노출을 검증한다.
2026-08-27 18:17:15 +00:00
Hide_D 4b06330753 fix(game-ui): 모바일 자율 행동을 한 줄로 표시한다 2026-08-27 18:14:32 +00:00
Hide_D 6a60882005 merge: 메인 자율행동 표시를 main에 반영한다 2026-08-27 16:38:02 +00:00
Hide_D 9e5e580134 feat: 메인에 자율행동 설정을 표시한다
Ref와 Gateway의 옵션 순서와 유효 기간 문구를 따라 8열 마지막 셀에 상태를 더한다.\n활성 상태의 hover와 keyboard focus에서 상세 tooltip을 보이고, 비활성 상태는 표준으로 표시한다.
2026-08-27 16:37:47 +00:00
Hide_D 68870be5e5 merge: 모바일 국가색 선택기를 통합한다 2026-08-27 15:44:28 +00:00
Hide_D 7c07742690 fix(game-ui): 모바일 국가색 선택기를 추가한다
건국과 국기 변경의 색상 입력을 앱 내부 listbox로 렌더링해 모바일 OS 선택기에서도 항목별 국가색을 직접 확인할 수 있게 한다. 키보드 탐색과 닫기 동작, 대비색, 터치 행 크기와 예약 payload를 Chromium 회귀로 고정한다.
2026-08-27 15:43:01 +00:00
336 changed files with 18589 additions and 2550 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "off"
},
"plugins": ["typescript"],
"options": {
"typeAware": true,
"respectEslintDisableDirectives": true
},
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": {
"arguments": false,
"attributes": false
}
}
]
},
"ignorePatterns": [
"**/dist/**",
"**/node_modules/**",
"**/*.d.ts",
"**/*.js",
"**/*.mjs",
"**/*.cjs",
"**/*.vue",
"legacy/**",
"packages/infra/prisma/client/**",
"**/generated/**",
".pnpm-store/**"
]
}
+33 -9
View File
@@ -6,9 +6,10 @@
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간 `AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
지침 순으로 적용합니다. 지침 순으로 적용합니다.
목표는 `../ref/sam`의 PHP 서비스를 TypeScript 런타임으로 호환 이관하는 `../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
것입니다. 내부 구조를 더 깔끔하게 만드는 일보다 기존 결과, 상태 전이, 권한, 기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
화면과 운영 경계를 보존하는 일이 우선입니다. 이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
## 현재 기준과 저장소 경계 ## 현재 기준과 저장소 경계
@@ -41,7 +42,9 @@ commit 또는 삭제를 하지 말아 주세요.
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고 판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
판정하지 말아 주세요. 판정하지 말아 주세요.
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요. - 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
확정하지 않습니다.
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는 - green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
운영 장애 복구를 자동으로 증명하지 않습니다. 운영 장애 복구를 자동으로 증명하지 않습니다.
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다. - 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
@@ -95,6 +98,20 @@ priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준 `의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요. commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
Ref 비교 전에 대상 계약도 구분해 주세요.
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
ref 계측이 필요하면 다음을 지켜 주세요. ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요. - `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
@@ -105,7 +122,7 @@ ref 계측이 필요하면 다음을 지켜 주세요.
확인해 주세요. 확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요. - ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 호환성 우선순위 ## 계승 계약의 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서 1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한 2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
@@ -113,10 +130,12 @@ ref 계측이 필요하면 다음을 지켜 주세요.
4. 문구, 로그, 화면 흐름과 룩앤필 4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항 5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계약일 수 있습니다. 보안 또는 데이터 손상 위험이 레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
아니면 우선 같은 동작을 재현하고 개선 제안은 분리해 주세요. 허용하는 차이는 위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
사용자 경험, 저장 상태와 후속 턴 결과에 영향이 없다는 근거를 mapping/report에 차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
남겨 주세요. 상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
이름만으로 완화하지 않습니다.
## 인증과 권한 ## 인증과 권한
@@ -282,6 +301,11 @@ pnpm test
pnpm build pnpm build
``` ```
루트 `pnpm test``pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요. - 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해 - package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra 주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
+11
View File
@@ -175,6 +175,17 @@ pnpm build
pnpm test:integration pnpm test:integration
``` ```
`pnpm lint`는 ESLint의 일반 TypeScript/Vue 검사, Oxlint의 type-aware TypeScript
Promise 검사, ESLint의 type-aware Vue Promise 검사를 메모리 경계에 맞게 나누어
실행합니다. cache를 제외한 전체 검증은 `pnpm lint:uncached`, package 하나만
검사할 때는 기존처럼 `pnpm --filter <package-name> lint`를 사용합니다.
루트 `test``build`는 Codex와 일반 개발 검증에서 성공 로그를 줄이는 간단 출력이
기본입니다. 테스트별 진행과 전체 Turbo task 로그가 필요한 실패 진단에는
`pnpm test-verbose`, `pnpm build-verbose`를 사용합니다. Gateway 서버 빌드 워커는
관리자 operation 화면에 진행 상황을 제공하기 위해 별도의 streaming 상세 출력
정책을 계속 사용합니다.
PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다. PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다.
```sh ```sh
+2 -2
View File
@@ -20,8 +20,8 @@
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js", "worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js",
"worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-api",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-api",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json" "typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
}, },
+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,
}; };
}; };
+8 -7
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;
const value = Number(closeTick); void closeAt;
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`); if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
return value; const value = Number(closeTick);
} if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return time.dateToTick(closeAt) ?? closeAt.getTime(); return value;
}; };
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);
}; };
+113 -56
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; select: { target: true, eventType: true, payload: true, status: true, result: true },
let existingRequestId = requestId; });
for (const candidateRequestId of candidateRequestIds) { const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
existing = await transaction.inputEvent.findUnique({
where: { requestId: candidateRequestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
if (existing) {
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';
+9 -1
View File
@@ -65,7 +65,15 @@ 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'
| '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
+29 -20
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,18 +133,20 @@ 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 { } else {
await this.createInputEvent(this.db, durableCommand, requestId); if (this.db.$transaction) {
await this.db.$transaction(async (transaction) => {
await this.createInputEvent(transaction, durableCommand, requestId);
});
} else {
await this.createInputEvent(this.db, durableCommand, requestId);
}
} }
} catch (error) { } catch (error) {
if (error instanceof RejectedNpcPossessionCommandError) { if (error instanceof RejectedNpcPossessionCommandError) {
@@ -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;
} }
+141 -87
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,66 @@ 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 actionUnavailable =
actionStatus !== null &&
(actionStatus !== 'PENDING' ||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
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)), actionUnavailable && payload.option && typeof payload.option === 'object'
? { ...payload.option, used: true, invalid: true }
: (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 gameTime = await loadCurrentGameTime(db); const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
const toTickOrNull = (date: Date): bigint | null => { let gameContext: MessageGameContext | null = null;
// Ref represents its unlimited 9999-12-31 message lifetime with the if (typeof action === 'string' && action !== '') {
// largest safe game tick instead of falling back to a wall-clock-only row. const gameTime = await loadCurrentGameTime(db);
if (date.getUTCFullYear() >= 9000) { if (
return BigInt(MAX_SAFE_GAME_TICK); gameTime.tick === null ||
gameTime.revision === null ||
gameTime.revision === undefined ||
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 = {
const rows = await db.$queryRaw<Array<{ id: number }>>` occurredGameTick: BigInt(gameTime.tick),
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message) clockRevision: BigInt(gameTime.revision),
VALUES ( deadlineGeneration: BigInt(gameTime.deadlineGeneration),
${draft.mailbox}, expiresGameTick,
${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 +122,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 +145,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 +177,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 +207,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 +216,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 +247,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);
};
+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: '이미 마감된 베팅입니다' });
+5 -8
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';
@@ -306,8 +306,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 +314,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 +329,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 +368,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 +455,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 +516,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;
+8 -9
View File
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
return started && !ended; return started && !ended;
}; };
const zGeneralSettings = z.object({ const zGeneralSettings = z.strictObject({
tnmt: z.number().int().optional(), tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(), defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(), use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
use_auto_nation_war: z.number().int().min(0).max(1).optional(), use_auto_nation_war: z.number().int().min(0).max(1).optional(),
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(), use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
use_auto_nation_finance: z.number().int().min(0).max(1).optional(), use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
@@ -244,7 +243,6 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다. // Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다. // 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0), use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0), use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0), use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
troopLeaderFirstTurn, troopLeaderFirstTurn,
accessLog, accessLog,
rankRows, rankRows,
gameTime,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true }, select: { type: true, value: true },
}), }),
loadCurrentGameTime(ctx.db),
]); ]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
parsedLastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
loadCurrentGameTime(ctx.db, now), loadCurrentGameTime(ctx.db, now),
]); ]);
// vote_poll timestamps are logical game-wall values. During PREOPEN the // vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock is held at the future open anchor, so comparing them with // logical clock advances through negative ticks before the opening anchor,
// JavaScript wall time inside a timestamp-without-time-zone predicate can // so comparing them with JavaScript wall time inside a timestamp-without-time-zone
// hide an otherwise active poll. Resolve the same tick-first clock contract // predicate can hide an otherwise active poll. Resolve the same tick-first
// used by voting instead; hasVoted only affects the notice, not activity. // clock contract used by voting; hasVoted only affects the notice, not activity.
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null; const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId); const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
+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',
+46 -32
View File
@@ -4,6 +4,11 @@ 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,
@@ -302,6 +307,7 @@ export const joinRouter = router({
} }
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
const configConst = asRecord(config.const); const configConst = asRecord(config.const);
const availableSpecialWar = asStringArray(configConst.availableSpecialWar); const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
@@ -348,7 +354,8 @@ export const joinRouter = router({
return { return {
rules: { rules: {
stat: resolveJoinStat(worldState), stat: resolveJoinStat(worldState),
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0, allowDirectCreation: (blockGeneralCreate & 1) === 0,
allowCustomName: (blockGeneralCreate & 2) === 0,
}, },
user: { user: {
id: ctx.auth?.user.id ?? '', id: ctx.auth?.user.id ?? '',
@@ -391,15 +398,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);
}), }),
@@ -429,7 +433,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 } : {}),
@@ -438,8 +441,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,
@@ -469,15 +470,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');
}), }),
@@ -581,30 +579,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
}); });
} }
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
try { try {
const gameTime = await loadCurrentGameTime(ctx.db); return await ctx.db.$transaction!(async (transaction) => {
if (gameTime.tick === null) { await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
throw new TRPCError({ const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
code: 'PRECONDITION_FAILED', SELECT clock_phase AS "clockPhase"
message: 'Game clock is not initialized.', FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (!clockRows[0]) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
const gameTime = await loadCurrentGameTime(transaction);
if (!worldState || gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
});
}
return reserveNpcPossessionCandidates({
db: transaction,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
createdGameTick: gameTime.tick,
}); });
}
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
acceptedGameTick: gameTime.tick,
}); });
} catch (error) { } catch (error) {
if (error instanceof NpcPossessionError) { if (error instanceof NpcPossessionError) {
+1
View File
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
: null, : null,
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
npcPossessionEnabled: worldState.config.npcMode === 1, npcPossessionEnabled: worldState.config.npcMode === 1,
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '', scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
myGeneral, myGeneral,
+94 -34
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']);
@@ -231,7 +239,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 +272,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 +297,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
@@ -309,9 +316,9 @@ export const messagesRouter = router({
? 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(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
return { ok: true, deletedIds: ids }; return { ok: true, deletedIds };
}), }),
respond: authedProcedure respond: engineAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -342,29 +349,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}`,
messageId: input.messageId, eventType: 'messages.respond.diplomatic',
response: input.response, 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,
response: input.response,
});
markMessageMailboxes(transactionContext, result.affectedMailboxes);
for (const generalId of result.affectedGeneralRecordIds) {
changeJournal.mark('records.general', generalId);
}
for (const nationId of result.affectedNationIds) {
changeJournal.mark('nation.content', nationId);
}
for (const cityId of result.affectedCityIds) {
changeJournal.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
changeJournal.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
changeJournal.mark('dashboard.global');
}
if (ownsChangeJournal) {
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
}
return {
result: result.result,
reason: result.reason,
affectedNationIds: result.affectedNationIds,
affectedCityIds: result.affectedCityIds,
};
},
}); });
markMessageMailboxes(ctx, result.affectedMailboxes); if (journalPersisted) {
for (const generalId of result.affectedGeneralRecordIds) { ctx.readModelOutbox?.wake();
ctx.changeJournal?.mark('records.general', generalId);
} }
for (const nationId of result.affectedNationIds) { if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
ctx.changeJournal?.mark('nation.content', nationId); 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 ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
});
}
} }
for (const cityId of result.affectedCityIds) { return { result: response.result, reason: response.reason };
ctx.changeJournal?.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('dashboard.global');
}
return { result: result.result, reason: result.reason };
}), }),
getOld: authedProcedure getOld: authedProcedure
.input( .input(
@@ -420,7 +478,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 +494,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;
+83 -13
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, 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')) {
@@ -44,6 +46,63 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => {
return next(); return next();
}); });
const withTournamentClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
profile: { name: string };
},
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, () => 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 zTournamentState = z.object({ const zTournamentState = z.object({
stage: z.number().int().min(0), stage: z.number().int().min(0),
phase: z.number().int().min(0), phase: z.number().int().min(0),
@@ -143,7 +202,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 +333,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 +343,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 +359,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 +409,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 };
}); });
@@ -394,7 +456,7 @@ export const tournamentRouter = router({
join: authedProcedure.mutation(async ({ ctx }) => { join: authedProcedure.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: '참가 신청 기간이 아닙니다.' });
@@ -459,7 +521,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 +575,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 +588,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 +623,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 +639,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 +651,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 +668,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 -12
View File
@@ -23,6 +23,7 @@ import {
resolveRefreshScoreText, resolveRefreshScoreText,
resolveRemainingMinutes, resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js'; } from '../../services/generalBasicCardProjection.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -72,7 +73,7 @@ export const troopRouter = router({
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
} }
const [nation, troops, generals, cities, worldState] = await Promise.all([ const [nation, troops, generals, cities, worldState, gameTime] = await Promise.all([
ctx.db.nation.findUnique({ ctx.db.nation.findUnique({
where: { id: me.nationId }, where: { id: me.nationId },
select: { id: true, name: true, color: true, level: true, meta: true }, select: { id: true, name: true, color: true, level: true, meta: true },
@@ -121,6 +122,7 @@ export const troopRouter = router({
select: { id: true, name: true }, select: { id: true, name: true },
}), }),
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }), ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
loadCurrentGameTime(ctx.db),
]); ]);
if (!nation) { if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
@@ -214,15 +216,6 @@ export const troopRouter = router({
const traitName = (code: string, names: Map<string, { name: string }>): string => const traitName = (code: string, names: Map<string, { name: string }>): string =>
names.get(code)?.name ?? sanitizeInternalDisplayCode(code); names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code); const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
const worldMeta = asRecord(worldState?.meta);
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
const lastExecuted =
rawLastExecuted instanceof Date
? rawLastExecuted
: typeof rawLastExecuted === 'string'
? new Date(rawLastExecuted)
: null;
const mappedTroops = troops const mappedTroops = troops
.map((troop) => { .map((troop) => {
const leader = generalMap.get(troop.troopLeaderId); const leader = generalMap.get(troop.troopLeaderId);
@@ -348,8 +341,7 @@ export const troopRouter = router({
killTurn: readNumber(meta.killturn ?? meta.killTurn), killTurn: readNumber(meta.killturn ?? meta.killTurn),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
lastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
troopId: general.troopId, troopId: general.troopId,
troop: { troop: {
+1
View File
@@ -446,6 +446,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
nationGenerals, nationGenerals,
realNationCount: nations.filter((entry) => entry.id > 0).length, realNationCount: nations.filter((entry) => entry.id > 0).length,
inputOptions, inputOptions,
generalActionModules: moduleBundle.general,
}); });
}; };
+15 -21
View File
@@ -102,18 +102,16 @@ 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_tick < BigInt(time.tick) poll.end_at &&
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime())); (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
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); if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
return tick === null ? null : BigInt(tick); return BigInt(tick);
} catch {
return null;
}
}; };
type VoteListRow = { type VoteListRow = {
@@ -358,7 +356,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);
@@ -399,8 +396,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 +413,7 @@ export const voteRouter = router({
${general.name}, ${general.name},
${nationName}, ${nationName},
${input.text}, ${input.text},
${createdAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -451,7 +446,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 +458,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 +492,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 +568,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 +590,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 +603,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
`); `);
+87 -1
View File
@@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js';
const zDirectorySort = z.number().int().min(1).max(15).default(9); const zDirectorySort = z.number().int().min(1).max(15).default(9);
const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
const readNumber = (value: unknown, fallback = 0): number => const readNumber = (value: unknown, fallback = 0): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback; typeof value === 'number' && Number.isFinite(value) ? value : fallback;
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
return left < right ? -1 : 1; return left < right ? -1 : 1;
}; };
const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => {
if (leadership < 40) {
return strength + intel < 40 ? '무능' : '무지';
}
const maxStat = Math.max(leadership, strength, intel);
const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership);
if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) {
return '만능';
}
if (strength >= 60 && intel < strength * 0.8) {
return '무';
}
if (intel >= 60 && strength < intel * 0.8) {
return '지';
}
if (leadership >= 60 && strength + intel < leadership) {
return '통';
}
return '평범';
};
const resolveExperienceLevel = (experience: number, maxLevel: number): number => { const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)); const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
return Math.max(0, Math.min(level, maxLevel)); return Math.max(0, Math.min(level, maxLevel));
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
const [nations, generals, cities] = await Promise.all([ const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
ctx.db.nation.findMany({ ctx.db.nation.findMany({
select: { select: {
id: true, id: true,
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
npcState: true, npcState: true,
nationId: true, nationId: true,
cityId: true, cityId: true,
leadership: true,
strength: true,
intel: true,
dedication: true, dedication: true,
officerLevel: true, officerLevel: true,
meta: true, meta: true,
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
select: { id: true, name: true, nationId: true }, select: { id: true, name: true, nationId: true },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}), }),
ctx.db.generalAccessLog.findMany({
select: { generalId: true, refreshScoreTotal: true },
}),
ctx.db.worldState.findFirst({
select: { tickSeconds: true, meta: true },
}),
]); ]);
const directoryNations = nations.some((nation) => nation.id === 0) const directoryNations = nations.some((nation) => nation.id === 0)
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
} }
const citiesByNation = new Map<number, typeof cities>(); const citiesByNation = new Map<number, typeof cities>();
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const)); const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
const accessScoreByGeneralId = new Map(
accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const)
);
const worldMeta = asRecord(worldState?.meta);
const autorunUser = asRecord(worldMeta.autorun_user);
const worldKillturn = readNumber(worldMeta.killturn);
const turnMinutes = readNumber(worldState?.tickSeconds) / 60;
const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0;
const activeKillturnThreshold = worldKillturn - autorunLimitTurns;
for (const city of cities) { for (const city of cities) {
const list = citiesByNation.get(city.nationId) ?? []; const list = citiesByNation.get(city.nationId) ?? [];
list.push(city); list.push(city);
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
false false
), ),
})); }));
const analyzedGenerals = nationGenerals.map((general) => {
const killturn = readMetaNumber(general.meta, 'killturn');
const inactive = general.npcState < 2 && killturn < activeKillturnThreshold;
const accessScore = accessScoreByGeneralId.get(general.id) ?? 0;
return {
id: general.id,
name: general.name,
npcState: general.npcState,
leadership: general.leadership,
type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel),
inactive,
accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal',
killturn,
} as const;
});
const combatGenerals = analyzedGenerals.filter(
(general) => general.type !== '무능' && general.type !== '무지'
);
const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive);
const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5);
const generalGroups = directoryGeneralTypes
.map((type) => ({
type,
label: `${type}`,
generals: analyzedGenerals
.filter((general) => general.type === type)
.sort((left, right) => {
const leftScore = accessScoreByGeneralId.get(left.id) ?? 0;
const rightScore = accessScoreByGeneralId.get(right.id) ?? 0;
return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id;
})
.map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general),
}))
.filter((group) => group.generals.length > 0);
return { return {
id: nation.id, id: nation.id,
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null, rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length), generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
cityCount: nationCities.length, cityCount: nationCities.length,
forceEstimate: {
totalGeneralCount: analyzedGenerals.length,
userCombatGeneralCount: userCombatGenerals.length,
userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
npcCombatGeneralCount: npcCombatGenerals.length,
npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length,
},
generalGroups,
officers, officers,
ambassadorNames: secretPermissions ambassadorNames: secretPermissions
.filter(({ permission }) => permission === 4) .filter(({ permission }) => permission === 4)
+12 -6
View File
@@ -50,6 +50,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) {
@@ -420,12 +421,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);
profile: config.profileName, if (!clock.reconciliationComplete) reply.code(503);
postgresPool: postgres.getPoolStats(), return {
accountIconReconciliation: accountIconResetReconciler.getHealth(), ok: clock.reconciliationComplete,
})); profile: config.profileName,
postgresPool: postgres.getPoolStats(),
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,
})),
},
};
};
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
}; };
export const flushDeferredGeneralAccessBatch = async ( export const flushDeferredGeneralAccessBatch = async (
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>, db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
batchId: string, batchId: string,
entries: readonly DeferredGeneralAccessEntry[] entries: readonly DeferredGeneralAccessEntry[]
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => { ): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
await markBatchProcessed(db, batchId); await markBatchProcessed(db, batchId);
return { states: [], refreshLimit: 0 }; return { states: [], refreshLimit: 0 };
} }
if (!db.$transaction) {
throw new Error('Deferred general access persistence requires transaction support.');
}
const worldState = await db.worldState.findFirst({ const worldState = await db.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { select: {
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
})) }))
); );
const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1; const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1;
const states = await db.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql` const states = await db.$transaction(async (transaction) => {
// The turn flush and synchronous access path must enter through the same
// schema-scoped lock before either traffic or access rows are mutated.
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
return transaction.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
WITH inserted_batch AS ( WITH inserted_batch AS (
INSERT INTO "general_access_batch" ("id") INSERT INTO "general_access_batch" ("id")
VALUES (${batchId}) VALUES (${batchId})
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
END, END,
resolved."weight" resolved."weight"
FROM resolved FROM resolved
ORDER BY resolved."general_id"
ON CONFLICT ("general_id") DO UPDATE SET ON CONFLICT ("general_id") DO UPDATE SET
"user_id" = EXCLUDED."user_id", "user_id" = EXCLUDED."user_id",
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"), "last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
@@ -394,7 +402,8 @@ export const flushDeferredGeneralAccessBatch = async (
LEFT JOIN access_updates ON access_updates."general_id" = actor."id" LEFT JOIN access_updates ON access_updates."general_id" = actor."id"
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id" LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
ORDER BY actor."id" ORDER BY actor."id"
`); `);
});
return { return {
states, states,
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit), refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
+39 -3
View File
@@ -1,4 +1,10 @@
import { GameClock, type GameClockMode } from '@sammo-ts/common'; import {
GameClock,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
type GameClockPhase,
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
@@ -7,6 +13,9 @@ export interface CurrentGameTime {
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;
startsAt: Date | null; startsAt: Date | null;
dateToTick(date: Date): number | null; dateToTick(date: Date): number | null;
@@ -19,6 +28,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,
@@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: 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 +55,53 @@ 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,
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase,
revision,
}); });
const tick = clock.nowTick(wallNow); const tick = clock.nowTick(wallNow);
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime(); const running = phase === 'RUNNING' && mode === 'realtime';
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, startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date), dateToTick: (date) => clock.dateToTick(date),
}; };
}; };
+2 -1
View File
@@ -1,5 +1,5 @@
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js'; import type { GameApiContext } from '../context.js';
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
throw new Error('Traffic access persistence requires transaction support.'); throw new Error('Traffic access persistence requires transaction support.');
} }
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
const periodKey = input.year * 12 + input.month - 1; const periodKey = input.year * 12 + input.month - 1;
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>( const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
GamePrisma.sql` GamePrisma.sql`
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
export const resolveRemainingMinutes = ( export const resolveRemainingMinutes = (
turnTime: Date, turnTime: Date,
lastExecuted: Date | null, currentGameTime: Date | null
turnTermSeconds: number
): number | null => { ): number | null => {
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null; if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
let nextTurnMillis = turnTime.getTime(); return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
if (nextTurnMillis < lastExecuted.getTime()) {
nextTurnMillis += turnTermSeconds * 1_000;
}
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
}; };
export interface NextTurnMonthOffsetInput { export interface NextTurnMonthOffsetInput {
@@ -0,0 +1,84 @@
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.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> => {
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;
+106 -34
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,41 +596,63 @@ 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> =>
const state = await store.getState(); store.withMutationLock(async () => {
if (!state || (!state.auto && !needsSettlement(state))) { const state = await store.getState();
return; if (!state || (!state.auto && !needsSettlement(state))) {
} return;
const nextAt = new Date(state.nextAt).getTime(); }
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { if (
return; 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 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;
}
if (needsSettlement(state)) { if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return; return;
} }
const worldState = await prisma.worldState.findFirst(); const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament'; const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state; let nextState = state;
if (isBattleStage(state.stage)) { if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) { } else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now); nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
} }
processedState = processedState =
(await settleTournamentOutcome({ (await settleTournamentOutcome({
store, store,
daemonTransport, daemonTransport,
state: nextState, state: nextState,
})) ?? 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,7 +768,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
lastError: message, lastError: message,
lastErrorAt: failedAt, lastErrorAt: failedAt,
}; };
await store.setState(nextState); if (clockContext) {
await store.withClockContext(clockContext, () => store.setState(nextState));
} else {
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 =>
+71 -52
View File
@@ -62,61 +62,68 @@ 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) =>
if (type !== 'mutation' || !ctx.db.$transaction) { t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
return next(); if (type !== 'mutation' || !ctx.db.$transaction) {
} return next();
}
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex); const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput(); const payload = await getRawInput();
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
let journalPersisted = false; let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined; let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try { try {
const response = await executeInputEvent({ const response = await executeInputEvent({
db: ctx.db, db: ctx.db,
requestId, requestId,
eventType: path, eventType: path,
payload, payload,
actorUserId: ctx.auth?.user.id, actorUserId: ctx.auth?.user.id,
execute: async (transaction) => { acquireClockFence,
const result = await next({ execute: async (transaction) => {
ctx: { const result = await next({
...ctx, ctx: {
db: transaction, ...ctx,
changeJournal, db: transaction,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId), changeJournal,
}, turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
}); },
if (!result.ok) { });
throw result.error; if (!result.ok) {
} throw result.error;
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); }
executedResult = result; journalPersisted = Boolean(
return result.data; await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
}, );
}); executedResult = result;
if (journalPersisted) { return result.data;
ctx.readModelOutbox?.wake(); },
}
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
}); });
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
});
}
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)
+1
View File
@@ -80,6 +80,7 @@ export interface TurnCommandInputField {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue; constValue?: TurnCommandOptionValue;
options?: TurnCommandOption[]; options?: TurnCommandOption[];
optionSource?: TurnCommandOptionSource; optionSource?: TurnCommandOptionSource;
+145 -11
View File
@@ -18,7 +18,7 @@ import type {
TriggerValue, TriggerValue,
UnitSetDefinition, UnitSetDefinition,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic'; import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
@@ -26,6 +26,7 @@ import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/log
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
import { import {
buildTurnCommandInputFields, buildTurnCommandInputFields,
loadTurnCommandSpecs, loadTurnCommandSpecs,
@@ -40,6 +41,7 @@ export interface TurnCommandAvailability {
key: string; key: string;
name: string; name: string;
turnDurationText?: string; turnDurationText?: string;
costText?: string;
reqArg: boolean; reqArg: boolean;
possible: boolean; possible: boolean;
status: AvailabilityStatus; status: AvailabilityStatus;
@@ -326,9 +328,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const constValues = asRecord(config.const); const constValues = asRecord(config.const);
const meta = asRecord(worldState.meta);
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
return { return {
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), // daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0), trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1), trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
@@ -554,7 +559,10 @@ export const buildRecruitmentCommandInfo = (options: {
avoid: crewType.avoid, avoid: crewType.avoid,
baseCost: displayCost.gold, baseCost: displayCost.gold,
baseRice: displayCost.rice, baseRice: displayCost.rice,
info: [...crewType.info], info: [
...crewType.info,
...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean),
],
}; };
}), }),
})); }));
@@ -646,6 +654,40 @@ const evaluateDefinition = (
return evaluateAvailability(constraints, ctx, view, reqArg); return evaluateAvailability(constraints, ctx, view, reqArg);
}; };
const readNextAvailableTurn = (meta: Readonly<Record<string, unknown>>, actionName: string): number | null => {
const raw = meta[`next_execute_${actionName}`];
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string') {
const parsed = Number(raw);
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
};
const evaluateCooldown = (
definition: GeneralActionDefinition,
scope: 'general' | 'nation',
ctx: ConstraintContext,
view: StateView,
currentYearMonth: number
): AvailabilityCore | null => {
const owner =
scope === 'general'
? (view.get({ kind: 'general', id: ctx.actorId }) as General | null)
: ctx.nationId === undefined
? null
: (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null);
if (!owner) return null;
const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name);
if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null;
return {
possible: false,
status: 'blocked',
reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`,
};
};
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore => const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs; AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
@@ -656,18 +698,33 @@ const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위
const buildEntries = ( const buildEntries = (
env: CommandEnv, env: CommandEnv,
specs: TurnCommandSpec[], specs: TurnCommandSpec[],
options: { foundingAvailable?: boolean } = {} options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
): CommandEntry[] => { ): CommandEntry[] => {
const entries: CommandEntry[] = []; const entries: CommandEntry[] = [];
for (const spec of specs) { for (const spec of specs) {
const definition = spec.createDefinition(env); const definition = spec.createDefinition(env);
const inputFields = buildTurnCommandInputFields(spec).map((field) => {
if (spec.key !== 'che_불가침제의') return field;
if (field.key === 'year' && options.currentYear !== undefined) {
return {
...field,
min: options.currentYear + 1,
max: resolveNonAggressionMaxEndYear(options.currentYear),
defaultValue: options.currentYear + 1,
};
}
if (field.key === 'month' && options.currentMonth !== undefined) {
return { ...field, defaultValue: options.currentMonth };
}
return field;
});
const entry: CommandEntry = { const entry: CommandEntry = {
category: spec.category, category: spec.category,
definition, definition,
reqArg: spec.reqArg, reqArg: spec.reqArg,
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {}, availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
inputFields: buildTurnCommandInputFields(spec), inputFields,
}; };
if (spec.key === 'che_포상') { if (spec.key === 'che_포상') {
@@ -711,23 +768,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined; return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined;
}; };
const formatCost = (gold: number | undefined, rice: number | undefined): string | undefined => {
const parts = [
gold && gold > 0 ? `${gold.toLocaleString('ko-KR')}` : null,
rice && rice > 0 ? `${rice.toLocaleString('ko-KR')}` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? parts.join(' · ') : undefined;
};
const getCommandCostText = (
entry: CommandEntry,
ctx: ConstraintContext,
view: StateView,
env: CommandEnv
): string | undefined => {
const hint = entry.definition.getCostHint?.(ctx, view);
if (hint?.formula) return hint.formula;
const hintedCost = hint ? formatCost(hint.gold, hint.rice) : undefined;
if (hintedCost) return hintedCost;
const develCost = env.develCost;
const general = view.get({ kind: 'general', id: ctx.actorId }) as General | null;
switch (entry.definition.key) {
case 'che_단련':
case 'che_숙련전환':
return formatCost(develCost, develCost);
case 'che_첩보':
return formatCost(develCost * 3, develCost * 3);
case 'che_이동':
case 'che_인재탐색':
return formatCost(develCost, 0);
case 'che_강행':
return formatCost(develCost * 5, 0);
case 'che_사기진작':
return formatCost(Math.round((general?.crew ?? 0) / 100), 0);
case 'che_출병':
return formatCost(0, Math.round((general?.crew ?? 0) / 100));
case 'che_천도':
return `금·쌀 ${(develCost * 5).toLocaleString('ko-KR')} × 2^거리`;
case 'cr_인구이동':
return `금·쌀 ${develCost.toLocaleString('ko-KR')} × 인구[만]`;
case 'che_증축': {
const cost = develCost * 500 + 60_000;
return formatCost(cost, cost);
}
case 'che_감축': {
const recovery = develCost * 500 + 30_000;
return `${recovery.toLocaleString('ko-KR')} · 쌀 ${recovery.toLocaleString('ko-KR')} 회수`;
}
default:
return undefined;
}
};
const buildGroups = ( const buildGroups = (
entries: CommandEntry[], entries: CommandEntry[],
ctx: ConstraintContext, ctx: ConstraintContext,
view: StateView, view: StateView,
includeTurnDuration = false includeTurnDuration = false,
env: CommandEnv,
scope: 'general' | 'nation',
currentYearMonth: number
): TurnCommandGroup[] => { ): TurnCommandGroup[] => {
const groups = new Map<string, TurnCommandAvailability[]>(); const groups = new Map<string, TurnCommandAvailability[]>();
for (const entry of entries) { for (const entry of entries) {
const availability = entry.evaluate const baseAvailability = entry.evaluate
? entry.evaluate(ctx, view) ? entry.evaluate(ctx, view)
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs); : evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
const availability =
baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown'
? baseAvailability
: (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability);
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined; const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
const costText = getCommandCostText(entry, ctx, view, env);
const value: TurnCommandAvailability = { const value: TurnCommandAvailability = {
key: entry.definition.key, key: entry.definition.key,
name: entry.definition.name, name: entry.definition.name,
...(turnDurationText ? { turnDurationText } : {}), ...(turnDurationText ? { turnDurationText } : {}),
...(costText ? { costText } : {}),
reqArg: entry.reqArg, reqArg: entry.reqArg,
inputFields: entry.inputFields, inputFields: entry.inputFields,
...availability, ...availability,
@@ -785,6 +904,7 @@ export const buildTurnCommandTable = async (options: {
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */ /** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
realNationCount?: number; realNationCount?: number;
inputOptions?: TurnCommandInputOptions; inputOptions?: TurnCommandInputOptions;
generalActionModules?: TurnCommandEnv['generalActionModules'];
}): Promise<TurnCommandTable> => { }): Promise<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
const general = mapGeneralRow(options.general); const general = mapGeneralRow(options.general);
@@ -803,7 +923,10 @@ export const buildTurnCommandTable = async (options: {
mode: 'precheck', mode: 'precheck',
}; };
const env = buildCommandEnv(options.worldState); const env = {
...buildCommandEnv(options.worldState),
...(options.generalActionModules ? { generalActionModules: options.generalActionModules } : {}),
};
const scenarioConst = asRecord(options.worldState.config).const; const scenarioConst = asRecord(options.worldState.config).const;
const { const {
general: generalSpecs, general: generalSpecs,
@@ -817,19 +940,30 @@ export const buildTurnCommandTable = async (options: {
? undefined ? undefined
: options.realNationCount < resolveMaxNation(options.worldState), : options.realNationCount < resolveMaxNation(options.worldState),
}); });
const nationEntries = buildEntries(env, nationSpecs); const nationEntries = buildEntries(env, nationSpecs, {
currentYear: options.worldState.currentYear,
currentMonth: options.worldState.currentMonth,
});
const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1;
return { return {
general: buildGroups( general: buildGroups(
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS), projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
ctx, ctx,
view view,
false,
env,
'general',
currentYearMonth
), ),
nation: buildGroups( nation: buildGroups(
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
ctx, ctx,
view, view,
true true,
env,
'nation',
currentYearMonth
), ),
inputOptions: options.inputOptions ?? { inputOptions: options.inputOptions ?? {
cities: [], cities: [],
+15 -6
View File
@@ -56,27 +56,36 @@ export const buildRefGeneralTargetOptions = (options: {
}): RefGeneralTargetOptions => { }): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => { const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined; const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
const cityName = options.cityNames.get(entry.cityId) ?? '재야';
const troopLabel = entry.troopId
? `${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
: '부대 없음';
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id); const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
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 = (() => {
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})();
const details = [ const details = [
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`, entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`, entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
entry.troopId action === 'che_발령' || action === 'che_포상' || action === 'che_몰수'
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}` ? null
: '탑승 부대 없음', : entry.troopId
? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value)); ].filter((value): value is string => Boolean(value));
if (isTroopExit) { if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가'); details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
} }
return { return {
value: entry.id, value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${ label,
options.cityNames.get(entry.cityId) ?? '재야'
})`,
description: details.join(' · '), description: details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }), ...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }), ...(entry.gold === undefined ? {} : { gold: entry.gold }),
@@ -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,
}, },
+8 -11
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'),
? {} clockTick: BigInt(options.clockTick ?? 100),
: { clockMode: 'manual',
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'), clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick),
clockMode: 'manual',
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' }],
},
});
});
});
+172 -10
View File
@@ -102,6 +102,9 @@ const buildNation = (): NationRow =>
}) as unknown as NationRow; }) as unknown as NationRow;
describe('buildTurnCommandTable', () => { describe('buildTurnCommandTable', () => {
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
it('projects the general and chief reserved-turn categories and command order from Ref', async () => { it('projects the general and chief reserved-turn categories and command order from Ref', async () => {
const table = await buildTurnCommandTable({ const table = await buildTurnCommandTable({
worldState: buildWorldState(), worldState: buildWorldState(),
@@ -211,6 +214,29 @@ describe('buildTurnCommandTable', () => {
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual( expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
expect.objectContaining({ turnDurationText: expect.any(String) }) expect.objectContaining({ turnDurationText: expect.any(String) })
); );
const generalCosts = Object.fromEntries(
table.general.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
);
expect(generalCosts).toMatchObject({
che_농지개간: '금 100',
che_기술연구: '금 100',
che_정착장려: '쌀 200',
che_주민선정: '쌀 200',
che_화계: '금 500 · 쌀 500',
che_첩보: '금 300 · 쌀 300',
che_사기진작: '금 1',
che_출병: '쌀 1',
che_이동: '금 100',
che_강행: '금 500',
});
const nationCosts = Object.fromEntries(
table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
);
expect(nationCosts).toMatchObject({
che_천도: '금·쌀 500 × 2^거리',
che_증축: '금 110,000 · 쌀 110,000',
che_감축: '금 80,000 · 쌀 80,000 회수',
});
}); });
it('projects scenario-specific command categories instead of the default profile', async () => { it('projects scenario-specific command categories instead of the default profile', async () => {
@@ -239,6 +265,13 @@ describe('buildTurnCommandTable', () => {
'che_물자조달', 'che_물자조달',
'cr_맹훈련', 'cr_맹훈련',
]); ]);
expect(
Object.fromEntries(table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText])))
).toMatchObject({
cr_인구이동: '금·쌀 100 × 인구[만]',
event_대검병연구: '금 50,000 · 쌀 50,000',
event_화륜차연구: '금 100,000 · 쌀 100,000',
});
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']); expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([ expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
'휴식', '휴식',
@@ -256,6 +289,103 @@ describe('buildTurnCommandTable', () => {
}); });
}); });
it('blocks both speciality resets while their cooldown remains and opens them at the boundary month', async () => {
const cooldownGeneral = {
...buildGeneral(),
specialCode: 'che_상재',
special2Code: 'che_신산',
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 36,
'next_execute_전투 특기 초기화': '37',
},
} as GeneralRow;
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: cooldownGeneral,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
expect(findCommand(table, 'che_내정특기초기화')).toMatchObject({
possible: true,
status: 'available',
});
expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: {
...buildGeneral(),
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 37,
'next_execute_전투 특기 초기화': 37,
},
} as GeneralRow,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) {
expect(findCommand(table, key)).toMatchObject({
possible: false,
status: 'blocked',
reason: '특기가 없습니다.',
});
}
});
it('projects a multi-turn nation command cooldown before target input', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: {
...buildNation(),
meta: { next_execute_피장파장: 37 },
} as NationRow,
nationGenerals: null,
});
expect(findCommand(table, 'che_피장파장')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('projects costs from the current world develcost used by command execution', async () => {
const worldState = buildWorldState();
(worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120;
const table = await buildTurnCommandTable({
worldState,
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const costs = Object.fromEntries(
[...table.general, ...table.nation].flatMap(({ values }) =>
values.map(({ key, costText }) => [key, costText])
)
);
expect(costs).toMatchObject({
che_농지개간: '금 120',
che_화계: '금 600 · 쌀 600',
che_천도: '금·쌀 600 × 2^거리',
che_증축: '금 120,000 · 쌀 120,000',
});
});
it('projects the real 904/905/910/912 world command profiles into the API table', async () => { it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
const buildScenarioTable = async (scenarioId: number) => { const buildScenarioTable = async (scenarioId: number) => {
const scenario = await loadScenarioDefinitionById(scenarioId); const scenario = await loadScenarioDefinitionById(scenarioId);
@@ -337,6 +467,27 @@ describe('buildTurnCommandTable', () => {
} }
}); });
it('projects the Ref twenty-year non-aggression end-year window', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const proposal = table.nation
.flatMap((group) => group.values)
.find((command) => command.key === 'che_불가침제의');
expect(proposal?.inputFields.find((field) => field.key === 'year')).toMatchObject({
kind: 'number',
min: 4,
max: 23,
defaultValue: 4,
});
expect(proposal?.inputFields.find((field) => field.key === 'month')).toMatchObject({ defaultValue: 1 });
});
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
buildTurnCommandTable({ buildTurnCommandTable({
@@ -444,15 +595,17 @@ describe('buildTurnCommandTable', () => {
nationGenerals: null, nationGenerals: null,
}); });
const appointment = table.general for (const commandKey of ['che_임관', 'che_등용']) {
.flatMap((group) => group.values) const command = table.general
.find((command) => command.key === 'che_임관'); .flatMap((group) => group.values)
.find((candidate) => candidate.key === commandKey);
expect(appointment).toMatchObject({ expect(command).toMatchObject({
possible: false, possible: false,
status: 'blocked', status: 'blocked',
reason: '랜덤 임관만 가능합니다', reason: '랜덤 임관만 가능합니다',
}); });
}
}); });
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => { it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
@@ -525,7 +678,11 @@ describe('buildTurnCommandTable', () => {
magicCoef: 0, magicCoef: 0,
cost: 12, cost: 12,
rice: 10, rice: 10,
requirements: [{ type: 'ReqTech', tech: 2000 }], requirements: [
{ type: 'ReqTech', tech: 2000 },
{ type: 'ReqRegions', regions: ['중원'] },
{ type: 'ReqCities', cities: ['TestCity'] },
],
attackCoef: {}, attackCoef: {},
defenceCoef: {}, defenceCoef: {},
info: ['강력하지만 기술이 필요합니다.'], info: ['강력하지만 기술이 필요합니다.'],
@@ -583,7 +740,12 @@ describe('buildTurnCommandTable', () => {
special: true, special: true,
attack: 175, attack: 175,
defence: 225, defence: 225,
info: ['강력하지만 기술이 필요합니다.'], info: [
'강력하지만 기술이 필요합니다.',
'기술력 2000 이상 필요',
'중원 지역 소유시 가능',
'TestCity 소유시 가능',
],
}); });
}); });
}); });
+13 -1
View File
@@ -87,7 +87,19 @@ describe('Ref command general targets', () => {
troopId: 3, troopId: 3,
description: expect.stringContaining('탑승 부대 청룡대'), description: expect.stringContaining('탑승 부대 청룡대'),
}); });
expect(detailed.generalTargets.che_포상?.[0]?.description).toContain('금 5,000 · 쌀 4,000 · 병력 1,000'); expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (부대 없음 · 업)',
'부대원 (청룡대 · 업)',
'부대장 (청룡대 (부대장) · 업)',
]);
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
}); });
}); });
@@ -238,6 +238,7 @@ integration('generic general creation through the durable turn daemon', () => {
it('commits one complete ref-shaped general and survives a daemon reload', async () => { it('commits one complete ref-shaped general and survives a daemon reload', async () => {
const auth = buildAuth(userId, '생성사용자', 4242); const auth = buildAuth(userId, '생성사용자', 4242);
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig(); const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
expect(config.rules.allowDirectCreation).toBe(true);
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔'); expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999'); expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
@@ -114,6 +114,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
}, },
}), }),
}, },
$transaction: db.$transaction.bind(db),
$queryRaw: db.$queryRaw.bind(db), $queryRaw: db.$queryRaw.bind(db),
$executeRaw: db.$executeRaw.bind(db), $executeRaw: db.$executeRaw.bind(db),
}; };
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1), $executeRaw: vi.fn(async () => 1),
}; };
Object.assign(db, {
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
});
await expect( await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [ flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] }); ).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"'); expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset'); expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"'); expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"'); expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
expect(statement.sql).toContain('INSERT INTO "general_access_log"'); expect(statement.sql).toContain('INSERT INTO "general_access_log"');
expect(statement.sql).toContain('ORDER BY resolved."general_id"');
expect(statement.sql).not.toContain('read_model_revision'); expect(statement.sql).not.toContain('read_model_revision');
expect(statement.sql).not.toContain('read_model_outbox'); expect(statement.sql).not.toContain('read_model_outbox');
}); });
+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 () => {
+94 -1
View File
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
leadership: 70, leadership: 70,
strength: 60, strength: 60,
intel: 50, intel: 50,
crew: 100,
experience: 1_000, experience: 1_000,
meta: { killturn: 3 }, meta: { killturn: 3 },
penalty: {}, penalty: {},
@@ -124,6 +125,9 @@ const globalGenerals = [
name: '군주', name: '군주',
dedication: 900, dedication: 900,
officerLevel: 12, officerLevel: 12,
leadership: 80,
strength: 80,
intel: 80,
meta: { killturn: 4, owner_name: '통일유저' }, meta: { killturn: 4, owner_name: '통일유저' },
experience: 10_000, experience: 10_000,
}), }),
@@ -132,6 +136,9 @@ const globalGenerals = [
name: '외교관', name: '외교관',
dedication: 800, dedication: 800,
officerLevel: 1, officerLevel: 1,
leadership: 70,
strength: 80,
intel: 40,
meta: { killturn: 2, permission: 'ambassador' }, meta: { killturn: 2, permission: 'ambassador' },
experience: 5_000, experience: 5_000,
}), }),
@@ -140,6 +147,9 @@ const globalGenerals = [
name: '제재외교관', name: '제재외교관',
dedication: 700, dedication: 700,
officerLevel: 1, officerLevel: 1,
leadership: 70,
strength: 40,
intel: 80,
meta: { killturn: 1, permission: 'ambassador' }, meta: { killturn: 1, permission: 'ambassador' },
penalty: { noTopSecret: true }, penalty: { noTopSecret: true },
experience: 2_000, experience: 2_000,
@@ -149,6 +159,9 @@ const globalGenerals = [
name: '조언자', name: '조언자',
dedication: 600, dedication: 600,
officerLevel: 1, officerLevel: 1,
leadership: 30,
strength: 70,
intel: 70,
meta: { killturn: 5, permission: 'auditor' }, meta: { killturn: 5, permission: 'auditor' },
experience: 1_000, experience: 1_000,
}), }),
@@ -161,6 +174,8 @@ const globalGenerals = [
dedication: 500, dedication: 500,
officerLevel: 12, officerLevel: 12,
leadership: 80, leadership: 80,
strength: 70,
intel: 65,
meta: { killturn: 0 }, meta: { killturn: 0 },
experience: 20_000, experience: 20_000,
}), }),
@@ -221,7 +236,12 @@ const createContext = (
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
config: { const: { maxLevel: 100, maxDedLevel: 30 } }, config: { const: { maxLevel: 100, maxDedLevel: 30 } },
meta: { isUnited: options.isUnited ?? 0 }, meta: {
isUnited: options.isUnited ?? 0,
killturn: 4,
autorun_user: { limit_minutes: 60 },
},
tickSeconds: 3_600,
})), })),
}, },
}; };
@@ -279,6 +299,20 @@ describe('legacy global nation/general directories', () => {
); );
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
for (const result of results) { for (const result of results) {
const serializedNationRows = JSON.stringify(result.nations);
for (const privateField of [
'totalCrew',
'crew',
'leadership',
'strength',
'intel',
'killturn',
'refreshScoreTotal',
'meta',
'penalty',
]) {
expect(serializedNationRows).not.toContain(`"${privateField}"`);
}
const serializedGeneralRows = JSON.stringify(result.generals); const serializedGeneralRows = JSON.stringify(result.generals);
for (const privateField of [ for (const privateField of [
'userId', 'userId',
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
id: 1, id: 1,
generalCount: 4, generalCount: 4,
cityCount: 1, cityCount: 1,
forceEstimate: {
totalGeneralCount: 4,
userCombatGeneralCount: 1,
userTroops: 8_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 2,
},
ambassadorNames: ['군주', '외교관'], ambassadorNames: ['군주', '외교관'],
auditorCount: 1, auditorCount: 1,
}); });
expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([
['만능장', ['군주']],
['무장', ['외교관']],
['지장', ['제재외교관']],
['무지장', ['조언자']],
]);
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']); expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
expect(result[1]?.officers[0]).toMatchObject({ expect(result[1]?.officers[0]).toMatchObject({
officerLevel: 12, officerLevel: 12,
general: { id: 10, name: '군주' }, general: { id: 10, name: '군주' },
}); });
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 }); expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
expect(JSON.stringify(result)).not.toContain('totalCrew');
});
it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => {
const categoryStats = [
['만능장', 80, 80, 80],
['통장', 70, 20, 20],
['무장', 70, 80, 40],
['지장', 70, 40, 80],
['평범장', 60, 50, 50],
['무지장', 30, 50, 50],
['무능장', 30, 10, 10],
] as const;
const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) =>
directoryGeneral({
id: 101 + index,
name,
nationId: 1,
leadership,
strength,
intel,
meta: { killturn: 4 },
})
);
const [nation] = await appRouter
.createCaller(
createContext({
directory: {
nations: [nationRows[0]!],
generals: categoryGenerals,
cities: [{ id: 1, name: '허창', nationId: 1 }],
},
}).context
)
.world.getNationDirectory();
expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name));
expect(nation?.forceEstimate).toEqual({
totalGeneralCount: 7,
userCombatGeneralCount: 5,
userTroops: 35_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 0,
});
}); });
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => { it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
+21 -11
View File
@@ -3,37 +3,47 @@ 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 () => ({
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'), clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
clockTick: 36_000_000n, clockTick: 0n,
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('holds a realtime clock at its persisted tick until the future wall anchor', async () => { it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase(); const db = buildDatabase();
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z')); const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
expect(preopen).toMatchObject({ expect(preopen).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'), now: new Date('2026-08-21T10:30:00.000Z'),
wallNow: new Date('2026-08-21T10:30:00.000Z'), wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: 36_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-21T10:00:05.000Z'), now: new Date('2026-08-21T11:00:05.000Z'),
tick: 36_300_000, tick: 300_000,
running: true, running: true,
startsAt: null, startsAt: null,
}); });
@@ -43,8 +53,8 @@ describe('current game time projection', () => {
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z')); const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
expect(result).toMatchObject({ expect(result).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'), now: new Date('2026-08-21T11:00:00.000Z'),
tick: 36_000_000, tick: 0,
running: false, running: false,
startsAt: null, startsAt: null,
}); });
+12 -12
View File
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
}); });
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(executeRaw).toHaveBeenCalledTimes(2); expect(executeRaw).toHaveBeenCalledTimes(3);
const lockStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(lockStatement.sql).toContain('pg_advisory_xact_lock');
expect(lockStatement.values).toContain('general-access:persistence');
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(periodStatement.sql).toContain('INSERT INTO traffic_period'); expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
expect(periodStatement.values).toContain(now); expect(periodStatement.values).toContain(now);
expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const memberStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general'); expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING'); expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
expect(memberStatement.sql).toContain('SET online = traffic_period.online'); expect(memberStatement.sql).toContain('SET online = traffic_period.online');
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] }; const accessStatement = executeRaw.mock.calls[2]![0] as { sql: string; values: unknown[] };
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
expect(accessStatement.values).toContain(7); expect(accessStatement.values).toContain(7);
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true); await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0); expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now); expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(now);
}); });
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => { it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
@@ -322,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: {
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
expect(resolveRefreshScoreText(12_800)).toBe('헐...'); expect(resolveRefreshScoreText(12_800)).toBe('헐...');
}); });
it('matches the Ref remaining-minute calculation and one-turn rollover', () => { it('matches the Ref remaining-minute calculation against the current game clock', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z'); const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7); expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); expect(resolveRemainingMinutes(new Date('2026-08-13T00:19:00.000Z'), currentGameTime)).toBe(0);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:20:00.000Z'), null)).toBeNull();
}); });
it('moves the first reserved month only after the general turn bucket has passed', () => { it('moves the first reserved month only after the general turn bucket has passed', () => {
+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);
} }
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
gameClockNow?: Date;
rankRows?: Array<{ generalId?: number; type: string; value: number }>; rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
@@ -164,6 +165,10 @@ const createContext = (options: {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
config: { const: { upgradeLimit: 20 } }, config: { const: { upgradeLimit: 20 } },
clockBaseTime: options.gameClockNow ?? null,
clockTick: options.gameClockNow ? 0n : null,
clockMode: 'manual',
clockWallAnchor: options.gameClockNow ?? null,
})), })),
}, },
logEntry: { logEntry: {
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
troopLeaderAction: '휴식', troopLeaderAction: '휴식',
refreshScore: 3, refreshScore: 3,
refreshScoreTotal: 1_141, refreshScoreTotal: 1_141,
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
}); });
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
retirementYear: 70, retirementYear: 70,
defenceTrain: 80, defenceTrain: 80,
killTurn: 6, killTurn: 6,
remainingMinutes: null, remainingMinutes: 7,
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' }, troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
refreshScore: { current: 3, total: 1_141, text: '열심' }, refreshScore: { current: 3, total: 1_141, text: '열심' },
}, },
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
defence_train: 80, defence_train: 80,
use_treatment: 21, use_treatment: 21,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0, use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
expect(requestCommand).not.toHaveBeenCalled(); expect(requestCommand).not.toHaveBeenCalled();
}); });
it('rejects the removed automatic diplomacy setting before dispatching it to ENGINE', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
await expect(
appRouter.createCaller(fixture.context).general.setMySetting({
use_auto_nation_diplomacy: 1,
} as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(requestCommand).not.toHaveBeenCalled();
});
it('sends settings directly to ENGINE without creating an API input event', async () => { it('sends settings directly to ENGINE without creating an API input event', async () => {
const transaction = vi.fn(async () => { const transaction = vi.fn(async () => {
throw new Error('API transaction must not run'); throw new Error('API transaction must not run');
@@ -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',
]);
});
}); });
+22 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DatabaseClient, GameApiContext } from '../src/context.js'; import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
@@ -42,11 +42,16 @@ 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;
describe('lobby season state', () => { describe('lobby season state', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
const result = await appRouter const result = await appRouter
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited })) .createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
@@ -96,15 +101,18 @@ describe('lobby season state', () => {
expect(result.turnEngineRunning).toBe(false); expect(result.turnEngineRunning).toBe(false);
}); });
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => { it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
const wallAnchor = new Date('2099-08-21T11:00:00.000Z'); const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
const wallNow = new Date('2099-08-21T10:30:00.000Z');
vi.useFakeTimers();
vi.setSystemTime(wallNow);
const result = await appRouter const result = await appRouter
.createCaller( .createCaller(
buildContext( buildContext(
{}, {},
{ {
baseTime: new Date('2026-08-21T09:00:00.000Z'), baseTime: wallAnchor,
tick: 36_000_000n, tick: 0n,
mode: 'realtime', mode: 'realtime',
wallAnchor, wallAnchor,
} }
@@ -112,13 +120,22 @@ describe('lobby season state', () => {
) )
.lobby.info(); .lobby.info();
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z'); expect(result.serverTime).toBe(wallNow.toISOString());
expect(result.clockMode).toBe('realtime'); expect(result.clockMode).toBe('realtime');
expect(result.clockRunning).toBe(false); expect(result.clockRunning).toBe(false);
expect(result.clockStartsAt).toBe(wallAnchor.toISOString()); expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime()); expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
}); });
it('reports direct creation and possession as independent acquisition modes', async () => {
const result = await appRouter
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
.lobby.info();
expect(result.directGeneralCreationEnabled).toBe(false);
expect(result.npcPossessionEnabled).toBe(true);
});
it('preserves zero as the first official game index', async () => { it('preserves zero as the first official game index', async () => {
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info(); const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
@@ -44,8 +44,8 @@ const buildContext = (
tickSeconds: 3600, tickSeconds: 3600,
...(options.preopenClock ...(options.preopenClock
? { ? {
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'), clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
clockTick: -180_000_000n, clockTick: 0n,
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
} }
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
}); });
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => { it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z')); vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
const context = buildContext({ hasVoted: true, preopenClock: true }); const context = buildContext({ hasVoted: true, preopenClock: true });
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({ await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
@@ -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);
});
}); });
+187 -67
View File
@@ -210,7 +210,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 +228,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 +337,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,7 +366,6 @@ 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.mailbox', entityId: 9000 },
@@ -314,7 +391,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,44 +631,50 @@ 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 () => {
id: 21, rawCall += 1;
mailbox: general.id, if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
type: 'private', return [
src: general.id, {
dest: 8, id: 21,
time: new Date(), mailbox: general.id,
valid_until: new Date('9999-12-31T00:00:00Z'), type: 'private',
message: { src: general.id,
src: { dest: 8,
generalId: general.id, time: new Date(),
generalName: general.name, valid_until: new Date('9999-12-31T00:00:00Z'),
nationId: 1, message: {
nationName: '위', src: {
color: '#fff', generalId: general.id,
icon: '', generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
}, },
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
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,43 +683,49 @@ 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 () => {
id: 25, rawCall += 1;
mailbox: 9001, if (rawCall > 1) return [{ id: 25 }];
type: 'diplomacy', return [
src: 9001, {
dest: 9002, id: 25,
time: new Date(), mailbox: 9001,
valid_until: new Date('9999-12-31T00:00:00Z'), type: 'diplomacy',
message: { src: 9001,
src: { dest: 9002,
generalId: general.id, time: new Date(),
generalName: general.name, valid_until: new Date('9999-12-31T00:00:00Z'),
nationId: 1, message: {
nationName: '위', src: {
color: '#fff', generalId: general.id,
icon: '', generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
}, },
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
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 +801,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 +825,7 @@ describe('messages router missing-flow compatibility', () => {
response: true, response: true,
}) })
); );
expect(transaction).not.toHaveBeenCalled();
} }
); );
@@ -864,8 +956,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 +1043,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,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => {
messageUpdateMany, messageUpdateMany,
cityUpdate, cityUpdate,
changeJournal, changeJournal,
requestCommand,
}; };
}; };
@@ -973,6 +1085,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 +1107,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({
@@ -1,13 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
buildNpcSelectionTokenSeed,
createTurnDaemonRuntime,
seedScenarioToDatabase,
type TurnDaemonRuntime,
} from '@sammo-ts/game-engine';
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector, createGamePostgresConnector,
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
const delayedUserId = 'npc-possession-integration-delayed'; const delayedUserId = 'npc-possession-integration-delayed';
const cleanupUserId = 'npc-possession-integration-cleanup'; const cleanupUserId = 'npc-possession-integration-cleanup';
const raceUserId = 'npc-possession-integration-race'; const raceUserId = 'npc-possession-integration-race';
const preopenUserId = 'npc-possession-integration-preopen';
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
const assertDedicatedDatabase = (rawUrl: string): void => { const assertDedicatedDatabase = (rawUrl: string): void => {
@@ -93,6 +88,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705); const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706); const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707); const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => { const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
const redisClient = { const redisClient = {
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.inputEvent.deleteMany(); await db.inputEvent.deleteMany();
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
await db.npcSelectionToken.deleteMany(); await db.npcSelectionToken.deleteMany();
await db.worldState.updateMany({
data: {
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date(),
},
});
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
await db.general.createMany({ await db.general.createMany({
data: Array.from({ length: 24 }, (_, index) => ({ data: Array.from({ length: 24 }, (_, index) => ({
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await closeDb?.(); await closeDb?.();
}, 30_000); }, 30_000);
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => { it('reserves at most five type-2 NPCs and preserves Ref refresh/keep timing', async () => {
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig(); const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
expect(config.npcPossession).toEqual({ enabled: true }); expect(config.npcPossession).toEqual({ enabled: true });
const worldState = await db.worldState.findFirstOrThrow();
const acceptedGameTick = Number(worldState.clockTick);
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
throw new Error('NPC possession integration hidden seed is missing');
}
const selectable = await db.general.findMany({
where: { userId: null, npcState: 2 },
orderBy: { id: 'asc' },
select: { id: true, leadership: true, strength: true, intel: true },
});
const weights = Object.fromEntries(
selectable.map((candidate) => [
String(candidate.id),
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
])
);
const rng = new RandUtil(
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
);
const expectedCandidateIds = new Set<number>();
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
}
const [first, concurrentSameOwner] = await Promise.all([ const [first, concurrentSameOwner] = await Promise.all([
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
@@ -240,9 +217,6 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(first.candidates.length).toBeGreaterThan(0); expect(first.candidates.length).toBeGreaterThan(0);
expect(first.candidates.length).toBeLessThanOrEqual(5); expect(first.candidates.length).toBeLessThanOrEqual(5);
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length); expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
[...expectedCandidateIds].sort((left, right) => left - right)
);
expect(first.pickMoreSeconds).toBe(0); expect(first.pickMoreSeconds).toBe(0);
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true); expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
const rows = await db.general.findMany({ const rows = await db.general.findMany({
@@ -281,6 +255,49 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false); expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
}, 30_000); }, 30_000);
it('refreshes possession candidates while the realtime game tick is negative before opening', async () => {
await stopRuntime('preopen candidate clock boundary');
const worldState = await db.worldState.findFirstOrThrow();
const originalClock = {
clockBaseTime: worldState.clockBaseTime,
clockTick: worldState.clockTick,
clockMode: worldState.clockMode,
clockWallAnchor: worldState.clockWallAnchor,
};
const openAt = new Date('2099-08-01T00:00:00.000Z');
try {
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
await db.worldState.update({
where: { id: worldState.id },
data: {
clockBaseTime: openAt,
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: openAt,
},
});
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-07-31T23:59:30.000Z'));
const first = await appRouter
.createCaller(buildContext('npc-possession-preopen-first', preopenAuth))
.join.listPossessCandidates({});
expect(first.pickMoreSeconds).toBe(0);
vi.advanceTimersByTime(30_000);
const refreshed = await appRouter
.createCaller(buildContext('npc-possession-preopen-refresh', preopenAuth))
.join.listPossessCandidates({ refresh: true, keepIds: [] });
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
await db.worldState.update({ where: { id: worldState.id }, data: originalClock });
await startRuntime('npc-possession-integration-daemon-resumed');
}
}, 30_000);
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => { it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
const reservation = await appRouter const reservation = await appRouter
.createCaller(buildContext('npc-possession-token-current')) .createCaller(buildContext('npc-possession-token-current'))
@@ -403,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({});
@@ -423,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 } },
@@ -453,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 () => {
+13 -21
View File
@@ -27,15 +27,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 +48,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 +65,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 +75,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 +88,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 +104,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();
@@ -150,22 +149,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 () => {
+37 -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,
}); });
}); });
@@ -1418,6 +1416,20 @@ describe('appRouter', () => {
message: expect.stringContaining('랜덤 임관만 가능합니다'), message: expect.stringContaining('랜덤 임관만 가능합니다'),
}); });
expect(randomOnlyWrites).toHaveLength(0); expect(randomOnlyWrites).toHaveLength(0);
await expect(
randomOnlyCaller.turns.reserved.setGeneral({
generalId: general.id,
turnIndex: 0,
action: 'che_등용',
args: { destGeneralId: 2 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('랜덤 임관만 가능합니다'),
});
expect(randomOnlyWrites).toHaveLength(0);
}); });
it('checks nation treaty-term reservation permission but not full diplomacy state', async () => { it('checks nation treaty-term reservation permission but not full diplomacy state', async () => {
@@ -1462,6 +1474,28 @@ describe('appRouter', () => {
}) })
).resolves.toMatchObject({ ok: true }); ).resolves.toMatchObject({ ok: true });
expect(validTermWrites).toHaveLength(1); expect(validTermWrites).toHaveLength(1);
const excessiveTermWrites: unknown[] = [];
const excessiveTermCaller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
general,
nationTurnWrites: excessiveTermWrites,
})
);
await expect(
excessiveTermCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_불가침제의',
args: { destNationId: 2, year: 211, month: 1 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('기한은 210년 이하'),
});
expect(excessiveTermWrites).toHaveLength(0);
}); });
it('validates every bulk reservation permission before writing any turn', async () => { it('validates every bulk reservation permission before writing any turn', async () => {
@@ -227,19 +227,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` }, });
}) expect(reserveEvent).toMatchObject({
).resolves.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([
@@ -357,6 +355,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 +365,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 +380,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 +455,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 +464,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 +573,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`, },
}, });
}) expect(stableEvent).toMatchObject({
).resolves.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 () => {
+117 -8
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,
@@ -341,6 +372,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 +502,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 +590,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);
});
}); });
+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);
}); });
}); });
+8 -25
View File
@@ -230,13 +230,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,7 +255,6 @@ 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
@@ -301,8 +294,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 +305,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 +314,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
View File
@@ -8,5 +8,6 @@ export default defineConfig({
environment: 'node', environment: 'node',
globals: true, globals: true,
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
reporters: [process.env.SAMMO_TEST_REPORTER === 'verbose' ? 'default' : 'minimal'],
}, },
}); });
+10 -2
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"
@@ -103,8 +111,8 @@
"build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .", "build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .",
"dev": "tsdown -c ./tsdown.config.ts --watch", "dev": "tsdown -c ./tsdown.config.ts --watch",
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js", "start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-engine",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-engine",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs", "profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs",
"profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs", "profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs",
+46 -22
View File
@@ -60,27 +60,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);
}; };
@@ -268,8 +268,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 +510,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 +547,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 +567,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 +598,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 +727,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> => {
+15 -16
View File
@@ -91,21 +91,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;
@@ -293,7 +289,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 +303,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 +315,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 +363,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[] = [];
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
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 +48,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 +87,50 @@ 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 },
});
const gameplayAllowed = !world || 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 +138,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 +148,71 @@ 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 (
${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 +220,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 +314,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 +348,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,
},
});
} }
} }
@@ -86,9 +86,9 @@ 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
@@ -99,10 +99,10 @@ export class DatabaseTurnDaemonLease {
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];
@@ -141,13 +141,13 @@ 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) {
@@ -177,7 +177,7 @@ 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) {
@@ -196,7 +196,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}
@@ -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;
} }
@@ -172,7 +172,20 @@ export class TurnDaemonLifecycle {
const nowMs = this.clock.nowMs(); const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs); const wallNow = new Date(nowMs);
const gameClock = await this.stateStore.loadGameClock?.(wallNow); 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 === '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 +222,7 @@ export class TurnDaemonLifecycle {
continue; continue;
} }
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs)); const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
if (command) { if (command) {
await this.handleCommand(command); await this.handleCommand(command);
} }
@@ -255,7 +268,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);
} }
+16 -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,7 +66,14 @@ 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;
}>;
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>;
@@ -69,7 +82,7 @@ export interface TurnStateStore {
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;
} }
+34 -9
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,
@@ -94,6 +94,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 [
@@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// 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 initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const initialClock = new GameClock({ const initialClock = new GameClock({
baseTime: startState.startTime, baseTime: startState.startTime,
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,
@@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
} }
worldMeta.hiddenSeed = hiddenSeed; worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.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 +441,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,13 +614,14 @@ 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,
? general.meta.initialTurnOffsetMicros typeof general.meta.initialTurnOffsetMicros === 'number'
: 0) / 1_000 ? general.meta.initialTurnOffsetMicros
) : 0
)
), ),
turnTick: BigInt( turnTick: BigInt(
calculateInitialTurnTick( calculateInitialTurnTick(
@@ -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,7 +188,7 @@ 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: '유효하지 않은 등용장입니다.' };
} }
@@ -185,18 +209,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 +274,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 +307,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 +332,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.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 +384,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 });
} }
@@ -488,6 +488,23 @@ export class GeneralAI {
return message; return message;
} }
syncGeneralAfterNationAction(general: TurnGeneral): void {
const meta = { ...general.meta } as Record<string, unknown>;
for (const key of [
'fullLeadership',
'fullStrength',
'fullIntelligence',
'effectiveLeadership',
'effectiveStrength',
'effectiveIntelligence',
]) {
if (Object.prototype.hasOwnProperty.call(this.general.meta, key)) {
meta[key] = this.general.meta[key];
}
}
this.general = { ...general, meta: meta as TurnGeneral['meta'] };
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance(); this.updateInstance();
if (!this.worldRef) { if (!this.worldRef) {
@@ -1198,6 +1215,13 @@ export class GeneralAI {
) { ) {
continue; continue;
} }
const oldChief = this.chiefGenerals[11];
if (oldChief && oldChief.id !== candidate.id) {
oldChief.officerLevel = 1;
oldChief.meta = { ...oldChief.meta, officer_city: 0 };
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
effectiveOfficerLevel.set(oldChief.id, 1);
}
const permission = penalty.noAmbassador === true ? undefined : 'ambassador'; const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
candidate.officerLevel = 11; candidate.officerLevel = 11;
candidate.meta = { candidate.meta = {
@@ -1,6 +1,6 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js'; import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js';
import { resolveDiplomacyMessageValidUntilTick } from '@sammo-ts/logic'; import { resolveDiplomacyMessageValidUntilTick, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
import { isNeighbor } from '@sammo-ts/logic/world/distance.js'; import { isNeighbor } from '@sammo-ts/logic/world/distance.js';
import { resolveNationIncome } from './helpers.js'; import { resolveNationIncome } from './helpers.js';
@@ -99,7 +99,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
return null; return null;
} }
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth)); const maxEndMonth = joinYearMonth(resolveNonAggressionMaxEndYear(ai.world.currentYear), 12);
const [targetYear, targetMonth] = parseYearMonth(Math.min(Math.floor(yearMonth + diplomatMonth), maxEndMonth));
const result = ai.buildNationCandidate( const result = ai.buildNationCandidate(
'che_불가침제의', 'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth }, { destNationId, year: targetYear, month: targetMonth },
+1 -3
View File
@@ -61,10 +61,9 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
NPC전방발령: true, NPC전방발령: true,
}; };
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital'; export type UserRulerAutomationFeature = 'war' | 'promotion' | 'finance' | 'capital';
const USER_RULER_AUTOMATION_META_KEY = { const USER_RULER_AUTOMATION_META_KEY = {
diplomacy: 'use_auto_nation_diplomacy',
war: 'use_auto_nation_war', war: 'use_auto_nation_war',
promotion: 'use_auto_nation_promotion', promotion: 'use_auto_nation_promotion',
finance: 'use_auto_nation_finance', finance: 'use_auto_nation_finance',
@@ -72,7 +71,6 @@ const USER_RULER_AUTOMATION_META_KEY = {
} as const satisfies Record<UserRulerAutomationFeature, string>; } as const satisfies Record<UserRulerAutomationFeature, string>;
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = { const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
: 'diplomacy',
: 'war', : 'war',
: 'capital', : 'capital',
}; };
@@ -0,0 +1,341 @@
import { createHash } from 'node:crypto';
import { GameClock, parseGameClockPhase } from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
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);
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,1044 @@
import { createHash } from 'node:crypto';
import {
GAME_TICKS_PER_TURN,
MAX_SAFE_GAME_TICK,
GameClock,
buildClockAlignmentPlan,
parseClockAlignmentPolicy,
parseGameClockPhase,
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;
}
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
`);
};
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,
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,
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;
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 }),
},
});
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;
}): 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.');
}
const cutWallAt = 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,
turnSeconds: world.tickSeconds,
phase,
revision: sourceRevision,
});
const cutTick = 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,
}),
},
});
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;
}): 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,
};
}
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 plan = buildClockAlignmentPlan({
policy: parseClockAlignmentPolicy(suspension.policy),
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
cutTick,
cutWall: suspension.cutWallAt,
resumeWall: resumeWallAt,
ticksPerSecond: suspension.rateTicksPerSecond,
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
});
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,
resumeWallAt
);
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',
resumeWallAt,
gapTicks: BigInt(plan.gapTicks),
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,
};
};
/** 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;
}): 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 } : {}),
});
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
)
);
+21 -5
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),
@@ -142,12 +150,11 @@ const zSetMySetting = z.object({
type: z.literal('setMySetting'), type: z.literal('setMySetting'),
userId: z.string().min(1), userId: z.string().min(1),
generalId: zFiniteNumber, generalId: zFiniteNumber,
settings: z.object({ settings: z.strictObject({
tnmt: z.number().int().optional(), tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(), defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(), use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: z.number().int().optional(),
use_auto_nation_war: z.number().int().optional(), use_auto_nation_war: z.number().int().optional(),
use_auto_nation_promotion: z.number().int().optional(), use_auto_nation_promotion: z.number().int().optional(),
use_auto_nation_finance: z.number().int().optional(), use_auto_nation_finance: z.number().int().optional(),
@@ -428,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();
@@ -611,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) {
@@ -861,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,
+260 -32
View File
@@ -1,10 +1,13 @@
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector, createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma, GamePrisma,
writeReadModelChangeJournal, writeReadModelChangeJournal,
enqueuePrivateMessageWebPush, enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents, enqueueWebPushOutboxEvents,
persistMessageEnvelope,
type InputJsonValue, type InputJsonValue,
type ReadModelJournalWriteResult, type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput, type TurnEngineCityUpdateInput,
@@ -54,12 +57,22 @@ import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js'; import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js';
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js'; import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
import {
persistClockSuspensionLedgerUnderHeldLocks,
prepareClockSuspensionUnderHeldLocks,
readClockDatabaseWall,
refreshClockProjectionForFinalClockUnderHeldLocks,
} from './clockReconciliation.js';
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>; close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>;
} }
export interface CommittedReadModelChangeReceipt { export interface CommittedReadModelChangeReceipt {
@@ -121,6 +134,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clock_base_time', 'clock_base_time',
'clockMode', 'clockMode',
'clock_mode', 'clock_mode',
'clockPhase',
'clock_phase',
'clockRevision',
'clock_revision',
'clockTick', 'clockTick',
'clock_tick', 'clock_tick',
'clockWallAnchor', 'clockWallAnchor',
@@ -134,6 +151,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'last_turn_tick', 'last_turn_tick',
'lastTurnTime', 'lastTurnTime',
'last_turn_time', 'last_turn_time',
'deadlineGeneration',
'deadline_generation',
'lease', 'lease',
'leaseOwner', 'leaseOwner',
'lease_owner', 'lease_owner',
@@ -1121,9 +1140,19 @@ export const createDatabaseTurnHooks = async (
clockMode: state.clockMode ?? 'manual', clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: asJson(world.getWorldConfig()), config: asJson(world.getWorldConfig()),
meta: asJson(state.meta), meta: asJson(state.meta),
}; };
const writesGeneralAccess =
accessScoreResetGeneralIds.length > 0 ||
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) => typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
const persist = async ( const persist = async (
prisma: GamePrisma.TransactionClient prisma: GamePrisma.TransactionClient
): Promise<{ ): Promise<{
@@ -1156,6 +1185,129 @@ export const createDatabaseTurnHooks = async (
// world mutation. A stale daemon can finish calculating, but it can // world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch. // never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma); await options?.turnDaemonLease?.assertActive(prisma);
await acquireGameSchemaAdvisoryXactLock(prisma, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (writesGeneralAccess) {
// General-access API writers take this lock before touching
// world rows. Clock operations use the same global order.
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
}
const persistedClock = await prisma.$queryRaw<
Array<{
clock_phase: string;
clock_revision: bigint;
deadline_generation: bigint;
clock_initialized: boolean;
opening_reached: boolean;
}>
>(GamePrisma.sql`
SELECT clock_phase,
clock_revision,
deadline_generation,
clock_base_time IS NOT NULL
AND clock_tick IS NOT NULL
AND clock_wall_anchor IS NOT NULL
AND last_turn_tick IS NOT NULL AS clock_initialized,
clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached
FROM world_state
WHERE id = ${state.id}
FOR UPDATE
`);
const durableClock = persistedClock[0];
if (!durableClock) {
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
}
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
const expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
// Match worldLoader's dual-read boundary: a legacy row is not an
// authoritative RUNNING clock merely because the newly-added phase
// column has its database default. Its first fenced flush installs
// the complete MANUAL snapshot atomically.
const durablePhase = durableClock.clock_initialized ? durableClock.clock_phase : 'MANUAL';
const stateMeta = asRecord(state.meta);
const unificationSuspensionId =
typeof stateMeta.unificationClockSuspensionId === 'string'
? stateMeta.unificationClockSuspensionId
: null;
const openingPhaseTransition =
durableClock.clock_initialized &&
durablePhase === 'PREOPEN' &&
expectedPhase === 'RUNNING' &&
durableClock.opening_reached;
const unificationSuspensionTransition =
durableClock.clock_initialized &&
durablePhase === 'RUNNING' &&
expectedPhase === 'SUSPENDED' &&
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) === 2 &&
Boolean(unificationSuspensionId);
const completionPhaseTransition =
durableClock.clock_initialized &&
durablePhase === 'RUNNING' &&
expectedPhase === 'COMPLETED' &&
Number(stateMeta.isunited ?? stateMeta.isUnited ?? 0) >= 2;
if (
(!openingPhaseTransition &&
!unificationSuspensionTransition &&
!completionPhaseTransition &&
durablePhase !== expectedPhase) ||
durableClock.clock_revision !== expectedRevision ||
durableClock.deadline_generation !== expectedGeneration
) {
throw new Error(
`Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` +
`found ${durablePhase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.`
);
}
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
const unificationCutTick = unificationCutWallAt
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
: null;
const suspensionPreparation =
unificationCutTick !== null
? await prepareClockSuspensionUnderHeldLocks({
db: prisma,
cutTick: unificationCutTick,
cutWallAt: unificationCutWallAt!,
})
: null;
if (commandCompletion) {
const commandFence = await prisma.$queryRaw<
Array<{
status: string;
processing_clock_revision: bigint | null;
processing_deadline_generation: bigint | null;
}>
>(GamePrisma.sql`
SELECT status,
processing_clock_revision,
processing_deadline_generation
FROM input_event
WHERE request_id = ${commandCompletion.requestId}
AND target = 'ENGINE'::"InputEventTarget"
FOR UPDATE
`);
const event = commandFence[0];
const unificationRevisionTransition =
commandCompletion.result.type === 'messageRespond' &&
commandCompletion.result.ok &&
commandCompletion.result.action === 'raiseInvader' &&
expectedPhase === 'RECONCILING' &&
event?.processing_clock_revision !== null &&
event?.processing_deadline_generation !== null &&
event?.processing_clock_revision + 1n === expectedRevision &&
event?.processing_deadline_generation + 1n === expectedGeneration;
if (
!event ||
event.status !== 'PROCESSING' ||
(!unificationRevisionTransition &&
(event.processing_clock_revision !== expectedRevision ||
event.processing_deadline_generation !== expectedGeneration))
) {
throw new Error(
`Input event processing clock fence changed before commit: ${commandCompletion.requestId}.`
);
}
}
let neutralAuctionsToCreate = pendingNeutralAuctions; let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) { if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey = const latestRegistrationKey =
@@ -1260,6 +1412,35 @@ export const createDatabaseTurnHooks = async (
END END
WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL
`); `);
await prisma.$executeRaw(GamePrisma.sql`
UPDATE select_pool
SET reserved_until = CASE
WHEN reserved_until_tick IS NULL THEN reserved_until
ELSE CAST(${baseTime} AS timestamp)
+ (reserved_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((reserved_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
* INTERVAL '1 millisecond'
END
WHERE reserved_until_tick IS NOT NULL
`);
await prisma.$executeRaw(GamePrisma.sql`
UPDATE select_npc_token
SET valid_until = CASE
WHEN valid_until_tick IS NULL THEN valid_until
ELSE CAST(${baseTime} AS timestamp)
+ (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
* INTERVAL '1 millisecond'
END,
pick_more_from = CASE
WHEN pick_more_from_tick IS NULL THEN pick_more_from
ELSE CAST(${baseTime} AS timestamp)
+ (pick_more_from_tick / ${ticksPerSecond}) * INTERVAL '1 second'
+ (((pick_more_from_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond})
* INTERVAL '1 millisecond'
END
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
`);
} }
for (const betting of pendingNationBettingOpens) { for (const betting of pendingNationBettingOpens) {
@@ -1684,37 +1865,18 @@ export const createDatabaseTurnHooks = async (
await sendMessage( await sendMessage(
{ {
insertMessage: async (draft: MessageRecordDraft) => { insertMessage: async (draft: MessageRecordDraft) => {
const toTickOrNull = (date: Date): bigint | null => { const clock = world.getGameClockState();
try { const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
return BigInt(world.dateToGameTick(date)); const expiresGameTick =
} catch { typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
// Legacy messages may use year 9999 as an ? null
// effectively-unbounded expiry, beyond the : BigInt(world.dateToGameTick(draft.validUntil));
// safe JavaScript tick range. const id = await persistMessageEnvelope(prisma, draft, {
return null; occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
} clockRevision: BigInt(clock.revision),
}; deadlineGeneration: BigInt(clock.deadlineGeneration),
const rows = await prisma.$queryRaw<Array<{ id: number }>>` expiresGameTick,
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 persist turn message.');
}
await enqueuePrivateMessageWebPush(prisma, draft, id); await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox); persistedMessageMailboxes.push(draft.mailbox);
return id; return id;
@@ -1727,6 +1889,41 @@ export const createDatabaseTurnHooks = async (
if (options?.reservedTurns && persistedReservedTurnChanges) { if (options?.reservedTurns && persistedReservedTurnChanges) {
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges); await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
} }
if (suspensionPreparation && unificationSuspensionId) {
const cutTick = unificationCutTick!;
await prisma.worldState.update({
where: { id: state.id },
data: {
clockTick: BigInt(cutTick),
clockWallAnchor: suspensionPreparation.cutWallAt,
},
});
await persistClockSuspensionLedgerUnderHeldLocks({
db: prisma,
suspensionId: unificationSuspensionId,
worldStateId: state.id,
profileName: options?.profileName ?? 'default',
source: 'UNIFICATION_WAIT',
cutTick,
cutWallAt: suspensionPreparation.cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
sourceRevision: state.clockRevision ?? 1,
});
}
if (
commandCompletion?.result.type === 'messageRespond' &&
commandCompletion.result.ok &&
commandCompletion.result.action === 'raiseInvader' &&
unificationSuspensionId &&
state.clockPhase === 'RECONCILING'
) {
await refreshClockProjectionForFinalClockUnderHeldLocks({
db: prisma,
suspensionId: unificationSuspensionId,
clockBaseTime: state.clockBaseTime ?? state.lastTurnTime,
tickSeconds: state.tickSeconds,
});
}
if (commandCompletion) { if (commandCompletion) {
await prisma.inputEvent.update({ await prisma.inputEvent.update({
where: { requestId: commandCompletion.requestId }, where: { requestId: commandCompletion.requestId },
@@ -1812,6 +2009,11 @@ export const createDatabaseTurnHooks = async (
}, },
executeCommand: async (requestId, execute) => { executeCommand: async (requestId, execute) => {
const committed = await prisma.$transaction(async (transaction) => { const committed = await prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
const leaseToken = options?.turnDaemonLease?.getToken();
const directLogFloor = const directLogFloor =
( (
await transaction.logEntry.findFirst({ await transaction.logEntry.findFirst({
@@ -1819,7 +2021,19 @@ export const createDatabaseTurnHooks = async (
select: { id: true }, select: { id: true },
}) })
)?.id ?? 0; )?.id ?? 0;
const result = await execute({ db: transaction }); const result = await execute({
db: transaction,
...(leaseToken
? {
clockOperationAuthority: {
kind: 'DAEMON' as const,
profileName: leaseToken.profile,
ownerId: leaseToken.ownerId,
fencingEpoch: leaseToken.fencingEpoch,
},
}
: {}),
});
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor); const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
return { result, persisted }; return { result, persisted };
}, transactionOptions); }, transactionOptions);
@@ -1835,6 +2049,20 @@ export const createDatabaseTurnHooks = async (
return takeCommittedReceipt()?.changes ?? null; return takeCommittedReceipt()?.changes ?? null;
}, },
takeCommittedReadModelChangeReceipt: takeCommittedReceipt, takeCommittedReadModelChangeReceipt: takeCommittedReceipt,
applyClockProjection: async (redis, workerId) => {
await applyNextClockProjection({ db: prisma, redis, workerId });
const clock = await prisma.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true },
});
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
},
synchronizeClockAuthority: () =>
prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
}, transactionOptions),
close: () => connector.disconnect(), close: () => connector.disconnect(),
}; };
}; };
+23 -18
View File
@@ -1,4 +1,4 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra'; import { createGatewayPostgresConnector, GatewayPrisma } from '@sammo-ts/infra';
import { isRecord } from '@sammo-ts/common'; import { isRecord } from '@sammo-ts/common';
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED'; export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
continue; continue;
} }
const terminal = result.status !== 'PARTIAL'; const terminal = result.status !== 'PARTIAL';
const updated = await prisma.gatewayRuntimeAction.updateMany({ const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
where: { const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
id: action.id, UPDATE gateway_runtime_action
status: { in: ['REQUESTED', 'PARTIAL'] }, SET status = ${result.status}::"GatewayRuntimeActionStatus",
}, detail = ${result.detail ?? null},
data: { handler = 'turn-daemon',
status: result.status, handled_at = CASE
detail: result.detail ?? null, WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
handler: 'turn-daemon', ELSE NULL
handledAt: terminal ? new Date() : null, END,
attempts: { increment: 1 }, attempts = attempts + 1,
nextAttemptAt: terminal next_attempt_at = CASE
? null WHEN ${terminal} THEN NULL
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))), ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
}, + ${retryDelayMs} * INTERVAL '1 millisecond'
}); END,
if (terminal && updated.count > 0) { updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${action.id}
AND status IN ('REQUESTED'::"GatewayRuntimeActionStatus", 'PARTIAL'::"GatewayRuntimeActionStatus")
RETURNING id
`);
if (terminal && updated.length > 0) {
await options.onActionApplied?.(actionRecord, result); await options.onActionApplied?.(actionRecord, result);
} }
} }
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common'; import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra'; import { createGatewayPostgresConnector } from '@sammo-ts/infra';
@@ -15,6 +17,7 @@ export interface GatewayProfileGate {
} }
const DEFAULT_CACHE_MS = 2000; const DEFAULT_CACHE_MS = 2000;
const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as const;
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => { export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({ const connector = createGatewayPostgresConnector({
@@ -42,7 +45,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return { return {
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다. // 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> { async shouldPause(): Promise<boolean> {
const now = Date.now(); const now = performance.now();
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) { if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
return cachedPause; return cachedPause;
} }
@@ -53,8 +56,11 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
async markPaused(error?: unknown): Promise<void> { async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null; const message = error instanceof Error ? error.message : error ? String(error) : null;
try { try {
await prisma.gatewayProfile.update({ await prisma.gatewayProfile.updateMany({
where: { profileName: options.profileName }, where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
},
data: { data: {
status: 'PAUSED', status: 'PAUSED',
lastError: message, lastError: message,

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