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`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
지침 순으로 적용합니다.
목표는 `../ref/sam`의 PHP 서비스를 TypeScript 런타임으로 호환 이관하는
것입니다. 내부 구조를 더 깔끔하게 만드는 일보다 기존 결과, 상태 전이, 권한,
화면과 운영 경계를 보존하는 일이 우선입니다.
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
## 현재 기준과 저장소 경계
@@ -41,7 +42,9 @@ commit 또는 삭제를 하지 말아 주세요.
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
판정하지 말아 주세요.
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요.
- 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
확정하지 않습니다.
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
운영 장애 복구를 자동으로 증명하지 않습니다.
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
@@ -95,6 +98,20 @@ priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
Ref 비교 전에 대상 계약도 구분해 주세요.
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
@@ -105,7 +122,7 @@ ref 계측이 필요하면 다음을 지켜 주세요.
확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 호환성 우선순위
## 계승 계약의 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
@@ -113,10 +130,12 @@ ref 계측이 필요하면 다음을 지켜 주세요.
4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계약일 수 있습니다. 보안 또는 데이터 손상 위험이
아니면 우선 같은 동작을 재현하고 개선 제안은 분리해 주세요. 허용하는 차이는
사용자 경험, 저장 상태와 후속 턴 결과에 영향이 없다는 근거를 mapping/report에
남겨 주세요.
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
이름만으로 완화하지 않습니다.
## 인증과 권한
@@ -282,6 +301,11 @@ pnpm test
pnpm build
```
루트 `pnpm test``pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
+11
View File
@@ -175,6 +175,17 @@ pnpm build
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는 격리된 서비스를 준비한 뒤 실행합니다.
```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: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",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "pnpm -w lint:target app/game-api",
"lint:fix": "pnpm -w lint:target:fix app/game-api",
"test": "vitest run --config vitest.config.ts",
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
},
+3 -2
View File
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
generalId: number,
input: OpenAuctionInput,
requestId?: string
): Promise<{ auctionId: number; closeAt: string }> => {
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen',
...(requestId ? { requestId } : {}),
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
const closeAt = new Date(result.closeAt);
const gameTime = await loadCurrentGameTime(ctx.db);
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 {
auctionId: result.auctionId,
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 => {
if (closeTick !== null && closeTick !== undefined) {
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value;
}
return time.dateToTick(closeAt) ?? closeAt.getTime();
void time;
void closeAt;
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return value;
};
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
if (row.status === 'FINALIZING') {
// 마감 판정은 이미 끝났으므로 원래 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);
};
+113 -56
View File
@@ -1,4 +1,7 @@
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector,
createRedisConnector,
type GamePrismaClient,
@@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
@@ -24,13 +28,27 @@ interface RedisTimerClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
zRem(key: string, values: string | string[]): 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;
interface AuctionFinalizeDeadline {
closeAt: Date;
closeTick: bigint | null;
closeTick: bigint;
}
interface AuctionFinalizeCommand {
@@ -38,19 +56,10 @@ interface AuctionFinalizeCommand {
requestId: string;
auctionId: number;
expectedCloseAt: string;
expectedCloseTick?: number;
expectedCloseTick: number;
}
interface AuctionFinalizeEventRecord {
target: string;
eventType: string;
payload: unknown;
status: string;
result: unknown;
}
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
if (closeTick === null) return undefined;
const readSafeCloseTick = (closeTick: bigint): number => {
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) {
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
@@ -63,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
deadline: AuctionFinalizeDeadline,
retry = 0
): string => {
const generation =
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()}`;
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
@@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = (
requestId,
auctionId,
expectedCloseAt: deadline.closeAt.toISOString(),
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
});
const isMatchingAuctionFinalizeEvent = (
@@ -95,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
const expectedGenerationMatches =
payloadRecord?.expectedCloseTick !== undefined
? payloadRecord.expectedCloseTick === command.expectedCloseTick
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
return (
event.target === 'ENGINE' &&
event.eventType === command.type &&
@@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
};
const popDueAuctionIds = async (
export const popDueAuctionIds = async (
redis: RedisTimerClient,
timerKey: string,
nowMs: number,
batchSize: number
batchSize: number,
clockFence?: {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
}
): 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 } });
if (ids.length > 0) {
await redis.zRem(timerKey, ids);
@@ -157,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
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 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({
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 },
orderBy: { sequence: 'desc' },
@@ -182,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
timers.push({
score:
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),
value: String(row.id),
});
@@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: {
nowMs: number;
nowTick?: number | null;
historyNowMs?: number;
expectedClockRevision?: number;
expectedDeadlineGeneration?: number;
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
const auctionId = Number(id);
@@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: {
}
const now = new Date(nowMs);
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({
where: { id: auctionId },
select: { status: true, closeAt: true, closeTick: true },
@@ -221,10 +264,10 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const };
}
if (current.status === 'OPEN') {
const isDue =
current.closeTick !== null && nowTick !== null
? current.closeTick <= BigInt(nowTick)
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
if (current.closeTick === null || nowTick === null) {
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
}
const isDue = current.closeTick <= BigInt(nowTick);
if (!isDue) {
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 };
}
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
let existing: AuctionFinalizeEventRecord | null = null;
let existingRequestId = requestId;
for (const candidateRequestId of candidateRequestIds) {
existing = await transaction.inputEvent.findUnique({
where: { requestId: candidateRequestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
if (existing) {
existingRequestId = candidateRequestId;
break;
}
}
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
const existing = await transaction.inputEvent.findUnique({
where: { requestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
if (!existing) {
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
await transaction.inputEvent.create({
@@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: {
return { status: 'PENDING' as const };
}
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') {
return { status: 'PENDING' as const };
}
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
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';
}
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);
await redis.zAdd(timerKey, [
{
@@ -315,18 +353,30 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
]);
let nextResyncAt = Date.now();
let nextResyncAt = performance.now();
const pendingFinalizationIds = new Set<number>();
try {
while (!control.signal.aborted) {
const operationalNowMs = Date.now();
const operationalElapsedMs = performance.now();
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime();
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);
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
}
if (pendingFinalizationIds.size > 0) {
const reconciliation = await reconcilePendingAuctionTimers({
@@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
if (historyTrimBefore > 0) {
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) {
for (const id of dueIds) {
try {
@@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
nowMs: gameNowMs,
nowTick: gameTime.tick,
historyNowMs: operationalNowMs,
...(clockFence
? {
expectedClockRevision: clockFence.revision,
expectedDeadlineGeneration: clockFence.generation,
}
: {}),
});
if (outcome === 'PENDING') {
pendingFinalizationIds.add(Number(id));
@@ -395,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
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 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';
// 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 { 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 { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
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> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
if (durableCommand.type === 'npcPossessGeneral') {
delete durableCommand.acceptedGameAt;
}
// Rolling-upgrade compatibility: older API versions supplied game
// 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') {
const existing = await this.db.inputEvent.findUnique({
where: { requestId },
@@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
try {
if (command.type === 'npcPossessGeneral' && this.db.$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: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({
where: {
ownerUserId: command.userId,
nonce: command.tokenNonce,
validUntil: { gte: acceptedGameAt },
validUntilTick: { gte: coordinate.gameTick },
},
select: { pickResult: true },
});
@@ -126,18 +133,20 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
) {
return '선택한 장수가 목록에 없습니다.';
}
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
acceptedGameAt: acceptedGameAt.toISOString(),
};
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
await this.createInputEvent(transaction, durableCommand, requestId);
return null;
});
if (rejectionReason) {
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
}
} 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) {
if (error instanceof RejectedNpcPossessionCommandError) {
@@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
private async createInputEvent(
db: DatabaseClient,
command: TurnDaemonCommand,
requestId: string,
createdAt?: Date
requestId: string
): Promise<void> {
await db.inputEvent.create({
data: {
@@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
eventType: command.type,
payload: asJson(command),
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> {
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
while (Date.now() < deadline) {
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
while (performance.now() < deadline) {
const event = await this.db.inputEvent.findUnique({
where: { requestId },
select: { status: true, result: true, error: true },
@@ -204,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
if (event?.status === 'FAILED') {
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;
}
+74 -50
View File
@@ -1,6 +1,11 @@
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';
@@ -29,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>;
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') {
@@ -85,6 +88,9 @@ const insertPendingIfAbsent = async (
actor_user_id,
status,
attempts,
accepted_game_tick,
accepted_clock_revision,
accepted_deadline_generation,
created_at
)
VALUES (
@@ -95,6 +101,9 @@ const insertPendingIfAbsent = async (
${options.actorUserId},
'PENDING'::"InputEventStatus",
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (request_id) DO NOTHING
@@ -153,20 +162,22 @@ const claimInputEvent = async (
requestId: string,
payloadIdentity: ApiInputPayloadIdentity
): Promise<void> => {
await db.inputEvent.update({
where: { requestId },
data: {
payload: asJson(payloadIdentity),
status: 'PROCESSING',
result: GamePrisma.DbNull,
error: null,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: null,
},
});
await db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
status = 'PROCESSING'::"InputEventStatus",
result = NULL,
error = NULL,
attempts = attempts + 1,
locked_by = NULL,
lease_until = NULL,
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processing_game_tick = NULL,
processing_clock_revision = NULL,
processing_deadline_generation = NULL,
completed_at = NULL
WHERE request_id = ${requestId}
`);
};
const markUnexpectedFailure = async (
@@ -177,6 +188,7 @@ const markUnexpectedFailure = async (
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
error: unknown;
acquireClockFence: boolean;
}
): Promise<void> => {
if (!db.$transaction) return;
@@ -184,6 +196,9 @@ const markUnexpectedFailure = async (
try {
await db.$transaction(async (transaction) => {
if (options.acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
@@ -192,20 +207,19 @@ const markUnexpectedFailure = async (
// late failure recorder must never replace its durable success.
return;
}
await transaction.inputEvent.update({
where: { requestId: options.requestId },
data: {
payload: asJson(options.payloadIdentity),
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: new Date(),
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
status = 'FAILED'::"InputEventStatus",
result = NULL,
error = ${message},
attempts = attempts + 1,
locked_by = NULL,
lease_until = NULL,
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE request_id = ${options.requestId}
`);
});
} catch {
// Preserve the transaction failure that the caller actually observed. If
@@ -220,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
eventType: string;
payload: unknown;
actorUserId?: string | null;
acquireClockFence?: boolean;
execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => {
const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null;
const acquireClockFence = options.acquireClockFence !== false;
const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) {
return execute(db);
@@ -233,6 +249,9 @@ export const executeInputEvent = async <T>(options: {
let outcome: InputEventOutcome<T>;
try {
outcome = await db.$transaction(async (transaction) => {
if (acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
const row = await lockInputEvent(transaction, requestId);
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
@@ -258,36 +277,41 @@ export const executeInputEvent = async <T>(options: {
try {
const value = await execute(transaction);
const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson(durableResult),
error: null,
completedAt: new Date(),
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'SUCCEEDED'::"InputEventStatus",
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
error = NULL,
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE request_id = ${requestId}
`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value };
} catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
completedAt: new Date(),
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
result = NULL,
error = ${message},
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE request_id = ${requestId}
`);
return { kind: 'failed', error };
}
});
} catch (error) {
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
await markUnexpectedFailure(db, {
requestId,
eventType,
actorUserId,
payloadIdentity,
error,
acquireClockFence,
});
}
throw error;
}
+141 -87
View File
@@ -1,9 +1,13 @@
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
import {
enqueuePrivateMessageWebPush,
GamePrisma,
persistMessageEnvelope,
type MessageGameContext,
} from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
export interface MessageView {
id: number;
@@ -22,7 +26,9 @@ interface MessageRow {
src: number;
dest: number;
time: Date;
valid_until: Date;
created_at_wall: Date;
action_status: string | null;
expires_game_tick: bigint | null;
message: unknown;
}
@@ -48,70 +54,66 @@ const formatMessageTime = (value: Date): string => {
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
};
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
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 toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
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 {
id: row.id,
msgType: row.type,
src: payload.src,
dest: row.type === 'public' ? null : payload.dest,
text: payload.text,
option: payload.option ?? null,
time: formatMessageTime(new Date(row.time)),
option:
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> => {
const gameTime = await loadCurrentGameTime(db);
const toTickOrNull = (date: Date): bigint | null => {
// Ref represents its unlimited 9999-12-31 message lifetime with the
// largest safe game tick instead of falling back to a wall-clock-only row.
if (date.getUTCFullYear() >= 9000) {
return BigInt(MAX_SAFE_GAME_TICK);
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
let gameContext: MessageGameContext | null = null;
if (typeof action === 'string' && action !== '') {
const gameTime = await loadCurrentGameTime(db);
if (
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 {
const tick = gameTime.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
let expiresGameTick: bigint | null = null;
if (draft.validUntil.getUTCFullYear() < 9000) {
const expires = gameTime.dateToTick(draft.validUntil);
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
expiresGameTick = BigInt(expires);
}
};
const rows = await db.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to insert message row.');
gameContext = {
occurredGameTick: BigInt(gameTime.tick),
clockRevision: BigInt(gameTime.revision),
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
expiresGameTick,
};
}
const id = await persistMessageEnvelope(db, draft, gameContext);
await enqueuePrivateMessageWebPush(db, draft, 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: {
db: DatabaseClient;
mailbox: number;
@@ -120,19 +122,20 @@ export const fetchMessagesFromMailbox = async (params: {
fromSeq: number;
}): Promise<MessageView[]> => {
const fromSeq = Math.max(params.fromSeq, 0);
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND ${messageValidityPredicate(gameTime)}
AND id >= ${fromSeq}
ORDER BY id DESC
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.mailbox = ${params.mailbox}
AND m.type = ${params.msgType}
AND m.id >= ${fromSeq}
ORDER BY m.id DESC
LIMIT ${params.limit}
`;
return rows.map(toMessageView);
return loadMessageViews(params.db, rows);
};
export const fetchOldMessagesFromMailbox = async (params: {
@@ -142,28 +145,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
toSeq: number;
limit: number;
}): Promise<MessageView[]> => {
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND ${messageValidityPredicate(gameTime)}
AND id < ${params.toSeq}
ORDER BY id DESC
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.mailbox = ${params.mailbox}
AND m.type = ${params.msgType}
AND m.id < ${params.toSeq}
ORDER BY m.id DESC
LIMIT ${params.limit}
`;
return rows.map(toMessageView);
return loadMessageViews(params.db, rows);
};
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id}
AND ${messageValidityPredicate(gameTime)}
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.id = ${id}
LIMIT 1
`;
const row = rows[0];
@@ -172,20 +177,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message),
};
};
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
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[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id}
AND ${messageValidityPredicate(gameTime)}
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
m.created_at_wall, m.message,
ma.status AS action_status, ma.expires_game_tick
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
FOR UPDATE
FOR UPDATE OF m, ma, world
`;
const row = rows[0];
if (!row) return null;
@@ -193,7 +207,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
id: row.id,
mailbox: row.mailbox,
msgType: row.type,
time: new Date(row.time),
time: new Date(row.created_at_wall ?? row.time),
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)));
if (uniqueIds.length === 0) return;
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({
where: { id: { in: uniqueIds } },
data: {
validUntil: gameTime.now,
// A partially migrated profile can still carry a legacy logical
// 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),
validUntilTick: BigInt(gameTime.tick),
},
});
};
@@ -233,8 +247,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
END
) || jsonb_build_object('invalid', true),
true
)
),
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
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 = (
auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null }
): boolean =>
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
interface AuctionBidRow {
id: number;
@@ -433,7 +430,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
@@ -448,7 +444,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
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 };
@@ -511,7 +507,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
throwIfCommandRejected(result);
@@ -526,7 +521,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
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 };
@@ -650,7 +645,6 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
throwIfCommandRejected(result);
@@ -665,7 +659,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt);
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 };
+40 -5
View File
@@ -1,5 +1,5 @@
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 { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
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({
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
.query(async ({ ctx, input }) => {
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
async ({ ctx, input }) => {
requireUserId(ctx.auth);
await getMyGeneral(ctx);
const [world, rows] = await Promise.all([
@@ -66,7 +100,8 @@ export const bettingRouter = router({
year: world.currentYear,
month: world.currentMonth,
};
}),
}
),
getDetail: authedProcedure
.input(z.object({ bettingId: z.number().int().positive() }))
@@ -141,7 +176,7 @@ export const bettingRouter = router({
if (betting.finished) {
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);
if (betting.closeYearMonth <= yearMonth) {
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 { insertMessage } from '../../messages/store.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 { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -306,8 +306,6 @@ export const diplomacyRouter = router({
nationColor: destNation.color,
},
};
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({
data: {
srcNationId: srcNation.id,
@@ -316,7 +314,6 @@ export const diplomacyRouter = router({
state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue,
},
@@ -332,7 +329,7 @@ export const diplomacyRouter = router({
src: srcTarget,
dest: destTarget,
text,
time: letterDate,
time: created.date,
});
return { id: created.id };
@@ -371,7 +368,7 @@ export const diplomacyRouter = router({
);
const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux);
let messageText: string;
if (input.agree) {
@@ -458,7 +455,7 @@ export const diplomacyRouter = router({
);
const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux);
aux.reason = {
who: me.id,
@@ -519,7 +516,7 @@ export const diplomacyRouter = router({
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation);
const messageDest = buildNationTarget(otherNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
const messageTime = await readDatabaseWallTime(ctx.db);
let resultState: 'ACTIVATED' | 'CANCELLED';
let messageText: string;
+8 -9
View File
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
return started && !ended;
};
const zGeneralSettings = z.object({
const zGeneralSettings = z.strictObject({
tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(),
use_treatment: 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_promotion: 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),
// 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_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
troopLeaderFirstTurn,
accessLog,
rankRows,
gameTime,
] = await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true },
}),
loadCurrentGameTime(ctx.db),
]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes(
general.turnTime,
parsedLastExecuted,
worldState?.tickSeconds ?? 0
gameTime.now
),
crewTypeId: general.crewTypeId,
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
loadCurrentGameTime(ctx.db, now),
]);
// vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock is held at the future open anchor, so comparing them with
// JavaScript wall time inside a timestamp-without-time-zone predicate can
// hide an otherwise active poll. Resolve the same tick-first clock contract
// used by voting instead; hasVoted only affects the notice, not activity.
// logical clock advances through negative ticks before the opening anchor,
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
// predicate can hide an otherwise active poll. Resolve the same tick-first
// clock contract used by voting; hasVoted only affects the notice, not activity.
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
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 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> => {
if (typeof raw === 'string') {
@@ -302,7 +304,7 @@ export const inheritRouter = router({
.input(
z.object({
type: z.enum(BUFF_KEYS),
level: z.number().int().min(1).max(5),
level: z.number().int(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -310,6 +312,12 @@ export const inheritRouter = router({
if (!userId) {
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, {
action: 'buyHiddenBuff',
+46 -32
View File
@@ -4,6 +4,11 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
} from '@sammo-ts/infra';
import {
isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS,
@@ -302,6 +307,7 @@ export const joinRouter = router({
}
const config = asRecord(worldState.config);
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
const configConst = asRecord(config.const);
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
@@ -348,7 +354,8 @@ export const joinRouter = router({
return {
rules: {
stat: resolveJoinStat(worldState),
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
allowDirectCreation: (blockGeneralCreate & 1) === 0,
allowCustomName: (blockGeneralCreate & 2) === 0,
},
user: {
id: ctx.auth?.user.id ?? '',
@@ -391,15 +398,12 @@ export const joinRouter = router({
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const gameTime = await loadCurrentGameTime(ctx.db);
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionReservationCommandResult(result);
}),
@@ -429,7 +433,6 @@ export const joinRouter = router({
});
}
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -438,8 +441,6 @@ export const joinRouter = router({
uniqueName: input.uniqueName,
personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon
? {
ownerPicture: selectedIcon.picture,
@@ -469,15 +470,12 @@ export const joinRouter = router({
input.clientRequestId,
'reselect'
);
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
@@ -581,30 +579,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
try {
const gameTime = await loadCurrentGameTime(ctx.db);
if (gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
return await ctx.db.$transaction!(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
SELECT clock_phase AS "clockPhase"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (!clockRows[0]) {
throw new TRPCError({
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) {
if (error instanceof NpcPossessionError) {
+1
View File
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
: null,
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
npcPossessionEnabled: worldState.config.npcMode === 1,
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
myGeneral,
+94 -34
View File
@@ -1,11 +1,19 @@
import { TRPCError } from '@trpc/server';
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 { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
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 {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -20,13 +28,13 @@ import {
fetchOldMessagesFromMailbox,
fetchMessageById,
insertMessage,
tombstoneMessages,
tombstoneMessagesWithinDeleteWindow,
type MessageView,
} from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.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']);
@@ -231,7 +239,7 @@ export const messagesRouter = router({
})),
};
}),
readLatest: authedProcedure
readLatest: wallAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -264,7 +272,7 @@ export const messagesRouter = router({
`;
return { ok: true };
}),
delete: authedProcedure
delete: wallAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -289,17 +297,16 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) {
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 shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [
message.id,
...(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 =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId
@@ -309,9 +316,9 @@ export const messagesRouter = router({
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
return { ok: true, deletedIds: ids };
return { ok: true, deletedIds };
}),
respond: authedProcedure
respond: engineAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
@@ -342,29 +349,80 @@ export const messagesRouter = router({
}
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,
actor: general,
messageId: input.messageId,
response: input.response,
requestId: `messages.respond.diplomatic:${input.messageId}`,
eventType: 'messages.respond.diplomatic',
payload: input,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const transactionContext = { ...ctx, db: transaction, changeJournal };
const transactionGeneral = ownsChangeJournal
? await getOwnedGeneral(transactionContext, input.generalId)
: general;
const result = await respondToDiplomaticMessage({
db: transaction,
actor: transactionGeneral,
messageId: input.messageId,
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);
for (const generalId of result.affectedGeneralRecordIds) {
ctx.changeJournal?.mark('records.general', generalId);
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
for (const nationId of result.affectedNationIds) {
ctx.changeJournal?.mark('nation.content', nationId);
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const synchronized = await ctx.turnDaemon.requestCommand({
type: 'syncDiplomaticResponse',
userId: ctx.auth.user.id,
generalId: general.id,
messageId: input.messageId,
nationIds: response.affectedNationIds,
cityIds: response.affectedCityIds,
});
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
const synchronizationReason =
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
});
}
}
for (const cityId of result.affectedCityIds) {
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 };
return { result: response.result, reason: response.reason };
}),
getOld: authedProcedure
.input(
@@ -420,7 +478,7 @@ export const messagesRouter = router({
...messageBuckets,
};
}),
send: accessAuthedInputProcedure(
send: accessWallAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
@@ -436,7 +494,9 @@ export const messagesRouter = router({
}
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');
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 { 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 { 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 { 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 => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -44,6 +46,63 @@ const adminProcedure = authedProcedure.use(({ ctx, 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({
stage: 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));
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 }) => {
await getMyGeneral(ctx);
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 }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setState({
...input,
type: input.type as TournamentType,
@@ -281,7 +343,7 @@ export const tournamentRouter = router({
}),
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
const current = await store.getState();
if (!current) {
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 }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(input);
return { ok: true, count: input.length };
});
}),
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setMatches(input);
return { ok: true, count: input.length };
});
}),
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
await store.setBettingEntries(input);
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);
return { ok: true, count: participants.length };
});
@@ -394,7 +456,7 @@ export const tournamentRouter = router({
join: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state || state.stage !== 1 || state.participantsLockedAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
@@ -459,7 +521,7 @@ export const tournamentRouter = router({
}),
cancel: adminProcedure.mutation(async ({ ctx }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -513,7 +575,10 @@ export const tournamentRouter = router({
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(
z.object({
targetId: z.number().int().positive(),
@@ -523,7 +588,7 @@ export const tournamentRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => {
return withTournamentBetClockMutation(ctx, store, async () => {
const state = await store.getState();
if (!state || state.stage !== 6) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
@@ -558,6 +623,7 @@ export const tournamentRouter = router({
const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
});
@@ -573,6 +639,7 @@ export const tournamentRouter = router({
const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet',
adjustments: [
{
@@ -584,6 +651,7 @@ export const tournamentRouter = router({
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
});
@@ -600,11 +668,13 @@ export const tournamentRouter = router({
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
+4 -12
View File
@@ -23,6 +23,7 @@ import {
resolveRefreshScoreText,
resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { loadTraitNames } from '../nation/shared.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -72,7 +73,7 @@ export const troopRouter = router({
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({
where: { id: me.nationId },
select: { id: true, name: true, color: true, level: true, meta: true },
@@ -121,6 +122,7 @@ export const troopRouter = router({
select: { id: true, name: true },
}),
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
loadCurrentGameTime(ctx.db),
]);
if (!nation) {
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 =>
names.get(code)?.name ?? 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
.map((troop) => {
const leader = generalMap.get(troop.troopLeaderId);
@@ -348,8 +341,7 @@ export const troopRouter = router({
killTurn: readNumber(meta.killturn ?? meta.killTurn),
remainingMinutes: resolveRemainingMinutes(
general.turnTime,
lastExecuted,
worldState?.tickSeconds ?? 0
gameTime.now
),
troopId: general.troopId,
troop: {
+1
View File
@@ -446,6 +446,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
nationGenerals,
realNationCount: nations.filter((entry) => entry.id > 0).length,
inputOptions,
generalActionModules: moduleBundle.general,
});
};
+15 -21
View File
@@ -102,18 +102,16 @@ export const hasPollEnded = (
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
Boolean(
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
try {
const tick = time.dateToTick(date);
return tick === null ? null : BigInt(tick);
} catch {
return null;
}
const tick = time.dateToTick(date);
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
return BigInt(tick);
};
type VoteListRow = {
@@ -358,7 +356,6 @@ export const voteRouter = router({
voteId: input.voteId,
generalId: general.id,
selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
throwIfCommandRejected(rewardResult);
@@ -399,8 +396,6 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null;
const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment (
vote_id,
@@ -418,7 +413,7 @@ export const voteRouter = router({
${general.name},
${nationName},
${input.text},
${createdAt}
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
`);
@@ -451,7 +446,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const operationalAt = new Date();
let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) {
@@ -464,7 +458,8 @@ export const voteRouter = router({
if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql`
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
`);
}
@@ -497,8 +492,8 @@ export const voteRouter = router({
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
${toGameTickOrNull(gameTime, endAt)},
${operationalAt},
${operationalAt}
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
`);
@@ -573,7 +568,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const updatedAt = new Date();
if (
input.title === undefined &&
@@ -596,7 +590,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = ${updatedAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId}
`);
@@ -609,10 +603,10 @@ export const voteRouter = router({
.input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
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}
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 directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
const readNumber = (value: unknown, fallback = 0): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
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 level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
return Math.max(0, Math.min(level, maxLevel));
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx);
const [nations, generals, cities] = await Promise.all([
const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
ctx.db.nation.findMany({
select: {
id: true,
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
npcState: true,
nationId: true,
cityId: true,
leadership: true,
strength: true,
intel: true,
dedication: true,
officerLevel: true,
meta: true,
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
select: { id: true, name: true, nationId: true },
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)
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
}
const citiesByNation = new Map<number, typeof cities>();
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) {
const list = citiesByNation.get(city.nationId) ?? [];
list.push(city);
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
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 {
id: nation.id,
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.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,
ambassadorNames: secretPermissions
.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 { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.js';
import { loadClockReadiness } from './services/clockReadiness.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) {
@@ -420,12 +421,17 @@ export const createGameApiServer = async () => {
request.raw.on('aborted', close);
});
app.get('/healthz', async () => ({
ok: true,
profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
}));
app.get('/healthz', async (_request, reply) => {
const clock = await loadClockReadiness(postgres.prisma);
if (!clock.reconciliationComplete) reply.code(503);
return {
ok: clock.reconciliationComplete,
profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
clock,
};
});
try {
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 { 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 { DatabaseClient } from '../context.js';
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
};
export const flushDeferredGeneralAccessBatch = async (
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>,
db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
batchId: string,
entries: readonly DeferredGeneralAccessEntry[]
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
await markBatchProcessed(db, batchId);
return { states: [], refreshLimit: 0 };
}
if (!db.$transaction) {
throw new Error('Deferred general access persistence requires transaction support.');
}
const worldState = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
}))
);
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 (
INSERT INTO "general_access_batch" ("id")
VALUES (${batchId})
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
END,
resolved."weight"
FROM resolved
ORDER BY resolved."general_id"
ON CONFLICT ("general_id") DO UPDATE SET
"user_id" = EXCLUDED."user_id",
"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 "general_access_log" AS access_log ON access_log."general_id" = actor."id"
ORDER BY actor."id"
`);
`);
});
return {
states,
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';
@@ -7,6 +13,9 @@ export interface CurrentGameTime {
wallNow: Date;
tick: number | null;
mode: GameClockMode | null;
phase?: GameClockPhase | null;
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean;
startsAt: Date | null;
dateToTick(date: Date): number | null;
@@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow,
tick: null,
mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true,
startsAt: null,
dateToTick: () => null,
@@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockMode: true,
clockWallAnchor: true,
tickSeconds: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
},
});
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
@@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow,
tick: null,
mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true,
startsAt: null,
dateToTick: () => null,
};
}
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);
if (!Number.isSafeInteger(storedTick)) {
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({
baseTime: state.clockBaseTime,
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
turnSeconds: state.tickSeconds,
phase,
revision,
});
const tick = clock.nowTick(wallNow);
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
const running = phase === 'RUNNING' && mode === 'realtime';
return {
now: clock.tickToDate(tick),
wallNow,
tick,
mode,
phase,
revision,
deadlineGeneration,
running,
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date),
};
};
+2 -1
View File
@@ -1,5 +1,5 @@
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';
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
throw new Error('Traffic access persistence requires transaction support.');
}
await db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
const periodKey = input.year * 12 + input.month - 1;
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
GamePrisma.sql`
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
export const resolveRemainingMinutes = (
turnTime: Date,
lastExecuted: Date | null,
turnTermSeconds: number
currentGameTime: Date | null
): number | null => {
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null;
let nextTurnMillis = turnTime.getTime();
if (nextTurnMillis < lastExecuted.getTime()) {
nextTurnMillis += turnTermSeconds * 1_000;
}
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
};
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 { GamePrisma } from '@sammo-ts/infra';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
interface TurnDaemonLeaseSource {
turnDaemonLease: {
findUnique(input: {
where: { profile: string };
select: { leaseUntil: true };
}): Promise<{ leaseUntil: Date } | null>;
};
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
}
export const loadTurnEngineRunning = async (
source: ProfileStatusSource | undefined,
db: TurnDaemonLeaseSource,
profileName: string,
now = new Date()
now?: Date
): Promise<boolean | null> => {
if (!source) return null;
try {
const status = await source.get(profileName);
if (status === null) return null;
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
const lease = await db.turnDaemonLease.findUnique({
where: { profile: profileName },
select: { leaseUntil: true },
});
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
const wallNow = now
? GamePrisma.sql`${now}`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
SELECT EXISTS (
SELECT 1
FROM turn_daemon_lease
WHERE profile = ${profileName}
AND lease_until > ${wallNow}
) AS running
`);
return rows[0]?.running ?? false;
} catch {
return null;
}
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
private readonly db: TurnDaemonLeaseSource,
private readonly profileName: string,
private readonly cacheMs = 2_000,
private readonly now = () => Date.now()
private readonly now = () => performance.now()
) {}
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 { performance } from 'node:perf_hooks';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
`);
if (rows.length === 0) return [];
const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({
where: { id: { in: ids } },
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
});
await tx.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
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({
where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' },
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
});
for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
const expired = await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
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}
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
`);
if (expired > 0) {
continue;
}
try {
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
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) {
const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: {
availableAt: new Date(Date.now() + delaySeconds * 1_000),
lockedAt: null,
lockOwner: null,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
},
});
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = ${errorText}
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
this.onError(error);
}
}
if (Date.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000;
if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = performance.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS (
SELECT "id"
+6
View File
@@ -8,6 +8,9 @@ export interface TournamentKeys {
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
activeClockRevisionKey: string;
deadlineGenerationKey: string;
clockPhaseKey: string;
}
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
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 { 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 type { TournamentKeys } from './keys.js';
@@ -37,8 +38,12 @@ const zTournamentState = z
openMonth: z.number().int(),
termSeconds: z.number(),
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(),
bettingCloseAt: z.string().optional(),
bettingCloseTick: z.number().int().safe().optional(),
winnerId: z.number().int().optional(),
bettingSettled: 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;
};
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 {
private clockContext: TournamentClockContext | null = null;
constructor(
private readonly redis: RedisClientLike,
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> {
if (!this.redis.del) {
return operation();
@@ -144,8 +199,8 @@ export class TournamentStore {
const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
@@ -174,11 +229,15 @@ export class TournamentStore {
}
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> {
return this.writeWithSourceRevision(this.keys.stateKey, state);
return this.writeWithSourceRevision(
this.keys.stateKey,
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
);
}
async getParticipants(): Promise<TournamentParticipantEntry[]> {
+4
View File
@@ -9,8 +9,12 @@ export interface TournamentState {
openMonth: number;
termSeconds: number;
nextAt: string;
nextTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
bettingId?: number;
bettingCloseAt?: string;
bettingCloseTick?: number;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
+106 -34
View File
@@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import type { TurnDaemonTransport } from '../daemon/transport.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 {
applyGroupMatch,
@@ -595,41 +596,63 @@ export const processTournamentTick = async (options: {
prisma: GamePrismaClient;
daemonTransport: TurnDaemonTransport;
now?: () => number;
clockContext?: TournamentClockContext;
}): Promise<TournamentState | null> => {
const { store, prisma, daemonTransport } = options;
const now = options.now ?? Date.now;
let processedState: TournamentState | null = null;
await store.withMutationLock(async () => {
const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
return;
}
const nextAt = new Date(state.nextAt).getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
return;
}
const processWithLock = async (): Promise<void> =>
store.withMutationLock(async () => {
const state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
return;
}
if (
options.clockContext &&
(state.clockRevision !== options.clockContext.revision ||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
) {
throw new Error('Tournament state does not match the active clock revision.');
}
const nextAt = new Date(state.nextAt).getTime();
const 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)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return;
}
if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return;
}
const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
}
processedState =
(await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
})) ?? nextState;
});
const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
}
processedState =
(await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
})) ?? nextState;
});
if (options.clockContext) {
await store.withClockContext(options.clockContext, processWithLock);
} else {
await processWithLock();
}
return processedState;
};
@@ -656,16 +679,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
try {
while (!control.signal.aborted) {
const state = await store.getState();
let state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
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 gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) {
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow));
const gameNow = gameTime.now.getTime();
const notDue = clockContext
? 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;
}
@@ -675,6 +742,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
prisma: postgres.prisma,
daemonTransport,
now: () => gameNow,
clockContext,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -700,7 +768,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
lastError: message,
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);
+4 -1
View File
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
const resolveScheduledBaseMs = (state: TournamentState): number => {
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 =>
+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 =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
}
const createInputEventMiddleware = (acquireClockFence: boolean) =>
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
}
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput();
const changeJournal = new ChangeJournal();
let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try {
const response = await executeInputEvent({
db: ctx.db,
requestId,
eventType: path,
payload,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const result = await next({
ctx: {
...ctx,
db: transaction,
changeJournal,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
},
});
if (!result.ok) {
throw result.error;
}
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
executedResult = result;
return result.data;
},
});
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,
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput();
const changeJournal = new ChangeJournal();
let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try {
const response = await executeInputEvent({
db: ctx.db,
requestId,
eventType: path,
payload,
actorUserId: ctx.auth?.user.id,
acquireClockFence,
execute: async (transaction) => {
const result = await next({
ctx: {
...ctx,
db: transaction,
changeJournal,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
},
});
if (!result.ok) {
throw result.error;
}
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
executedResult = result;
return result.data;
},
});
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 }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
export const authedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
export const wallAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.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) =>
t.procedure
.use(requireAuthMiddleware)
+1
View File
@@ -80,6 +80,7 @@ export interface TurnCommandInputField {
min?: number;
max?: number;
step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue;
options?: TurnCommandOption[];
optionSource?: TurnCommandOptionSource;
+145 -11
View File
@@ -18,7 +18,7 @@ import type {
TriggerValue,
UnitSetDefinition,
} 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 { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.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 type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
import {
buildTurnCommandInputFields,
loadTurnCommandSpecs,
@@ -40,6 +41,7 @@ export interface TurnCommandAvailability {
key: string;
name: string;
turnDurationText?: string;
costText?: string;
reqArg: boolean;
possible: boolean;
status: AvailabilityStatus;
@@ -326,9 +328,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
const config = asRecord(worldState.config);
const constValues = asRecord(config.const);
const meta = asRecord(worldState.meta);
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
return {
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
// daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
@@ -554,7 +559,10 @@ export const buildRecruitmentCommandInfo = (options: {
avoid: crewType.avoid,
baseCost: displayCost.gold,
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);
};
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 =>
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 = (
env: CommandEnv,
specs: TurnCommandSpec[],
options: { foundingAvailable?: boolean } = {}
options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
): CommandEntry[] => {
const entries: CommandEntry[] = [];
for (const spec of specs) {
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 = {
category: spec.category,
definition,
reqArg: spec.reqArg,
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
inputFields: buildTurnCommandInputFields(spec),
inputFields,
};
if (spec.key === 'che_포상') {
@@ -711,23 +768,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
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 = (
entries: CommandEntry[],
ctx: ConstraintContext,
view: StateView,
includeTurnDuration = false
includeTurnDuration = false,
env: CommandEnv,
scope: 'general' | 'nation',
currentYearMonth: number
): TurnCommandGroup[] => {
const groups = new Map<string, TurnCommandAvailability[]>();
for (const entry of entries) {
const availability = entry.evaluate
const baseAvailability = entry.evaluate
? entry.evaluate(ctx, view)
: 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 costText = getCommandCostText(entry, ctx, view, env);
const value: TurnCommandAvailability = {
key: entry.definition.key,
name: entry.definition.name,
...(turnDurationText ? { turnDurationText } : {}),
...(costText ? { costText } : {}),
reqArg: entry.reqArg,
inputFields: entry.inputFields,
...availability,
@@ -785,6 +904,7 @@ export const buildTurnCommandTable = async (options: {
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
realNationCount?: number;
inputOptions?: TurnCommandInputOptions;
generalActionModules?: TurnCommandEnv['generalActionModules'];
}): Promise<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
const general = mapGeneralRow(options.general);
@@ -803,7 +923,10 @@ export const buildTurnCommandTable = async (options: {
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 {
general: generalSpecs,
@@ -817,19 +940,30 @@ export const buildTurnCommandTable = async (options: {
? undefined
: 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 {
general: buildGroups(
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
ctx,
view
view,
false,
env,
'general',
currentYearMonth
),
nation: buildGroups(
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
ctx,
view,
true
true,
env,
'nation',
currentYearMonth
),
inputOptions: options.inputOptions ?? {
cities: [],
+15 -6
View File
@@ -56,27 +56,36 @@ export const buildRefGeneralTargetOptions = (options: {
}): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
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 isTroopExit = action === 'che_부대탈퇴지시';
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 = [
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
entry.troopId
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
: '탑승 부대 없음',
action === 'che_발령' || action === 'che_포상' || action === 'che_몰수'
? null
: entry.troopId
? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value));
if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
}
return {
value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
options.cityNames.get(entry.cityId) ?? '재야'
})`,
label,
description: details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }),
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
currentMonth: 1,
tickSeconds: 600,
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 },
};
const lifecycleGeneral: TurnGeneral = {
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
currentYear: lifecycleState.currentYear,
currentMonth: lifecycleState.currentMonth,
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,
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
},
+8 -11
View File
@@ -96,6 +96,7 @@ const buildContext = (options: {
ok: true as const,
auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
};
}
return {
@@ -103,6 +104,7 @@ const buildContext = (options: {
ok: true as const,
auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
};
});
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
@@ -112,14 +114,10 @@ const buildContext = (options: {
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
...(options.clockTick === undefined
? {}
: {
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick ?? 100),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
config: {
const: {
auctionName: ['청룡', '백호', '주작', '현무'],
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
tick: 72_000_001,
})
).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 () => {
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31,
generalId: 7,
amount: 110,
acceptedGameTick: 100,
tryExtendCloseDate: false,
});
});
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000),
closeTick: 200n,
},
];
}
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31,
generalId: 7,
amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true,
});
});
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { amount: 100 },
status,
closeAt,
closeTick: 0n,
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
},
});
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
return auction;
};
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
buildAuctionFinalizeRequestId(auction.id, {
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
if (auction.closeTick === null || auction.closeTick === undefined) {
throw new Error(`auction ${auction.id} fixture requires closeTick`);
}
return buildAuctionFinalizeRequestId(auction.id, {
closeAt: auction.closeAt,
closeTick: auction.closeTick ?? null,
closeTick: auction.closeTick,
});
};
const memoryRedis = () => ({
zRangeByScore: vi.fn(async () => []),
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
await expect(
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
requestId,
target: 'ENGINE',
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',
attempts: 3,
error: 'simulated terminal failure',
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
await expect(
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
await expect(
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
});
it('creates a new generation after an earlier close was extended', async () => {
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({
data: {
requestId: priorRequestId,
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
})
).resolves.toBe('PENDING');
@@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => {
currentMonth: 1,
tickSeconds: 600,
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 },
};
const buildGeneral = (options: {
@@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
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,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -415,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 200,
eventId: `auction-durable-bid:${auction.id}`,
eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
},
});
const requestId = requestIdFor(auction);
@@ -425,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(auction.id),
nowMs: Date.now(),
nowTick: 0,
});
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
@@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { remainCloseDateExtensionCnt: 1 },
status: 'OPEN',
closeAt: logicalPastCloseAt,
closeTick: 0n,
},
});
extensionAuctionId = extensionAuction.id;
@@ -505,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 50,
eventId: `auction-extension-bid:${extensionAuction.id}`,
eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
meta: { tryExtendCloseDate: true },
},
});
@@ -516,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history',
id: String(extensionAuction.id),
nowMs: Date.now(),
nowTick: 0,
});
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 { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
const buildRedis = () => ({
@@ -33,7 +33,7 @@ const buildDb = (options: {
$executeRaw: vi.fn(async () => options.updated),
auction: {
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: {
@@ -55,6 +55,38 @@ const buildDb = (options: {
};
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', () => {
const now = new Date('2026-07-30T12:00:00.000Z');
const time = {
@@ -201,11 +233,12 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 36_000_000,
})
).resolves.toBe('RESCHEDULED');
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();
});
@@ -235,7 +268,7 @@ describe('auction worker clock-shift race', () => {
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
const redis = buildRedis();
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 nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
@@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs,
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => {
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
},
});
@@ -318,6 +353,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).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 () => {
const redis = buildRedis();
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 },
@@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => {
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
status: 'PENDING',
result: null,
@@ -359,6 +396,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
@@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: logicalNowMs,
nowTick: 72_000_000,
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 () => {
const redis = buildRedis();
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 = {
requestId,
target: 'ENGINE' as const,
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'PENDING' as const,
result: null,
};
@@ -411,6 +450,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -423,7 +463,7 @@ describe('auction worker clock-shift race', () => {
it('creates one bounded successor after a terminal event failure', async () => {
const redis = buildRedis();
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 { db, transaction } = buildDb({
updated: 0,
@@ -433,7 +473,7 @@ describe('auction worker clock-shift race', () => {
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'FAILED',
result: null,
},
@@ -448,6 +488,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => {
requestId: retryRequestId,
auctionId: 7,
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 () => {
const redis = buildRedis();
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
const closeAt = new Date('2026-07-30T11:30:00.000Z');
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const previousRequestId = 'auction:finalize:7:tick:36000000';
const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({
updated: 0,
auction: { status: 'OPEN', closeAt },
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
existingEvents: [
{
requestId: previousRequestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
payload: {
type: 'auctionFinalize',
requestId: previousRequestId,
auctionId: 7,
expectedCloseTick: 36_000_000,
},
status: 'SUCCEEDED',
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
},
@@ -495,6 +541,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
@@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => {
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
},
});
@@ -527,6 +575,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).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;
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 () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
@@ -211,6 +214,29 @@ describe('buildTurnCommandTable', () => {
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
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 () => {
@@ -239,6 +265,13 @@ describe('buildTurnCommandTable', () => {
'che_물자조달',
'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.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 () => {
const buildScenarioTable = async (scenarioId: number) => {
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 () => {
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
buildTurnCommandTable({
@@ -444,15 +595,17 @@ describe('buildTurnCommandTable', () => {
nationGenerals: null,
});
const appointment = table.general
.flatMap((group) => group.values)
.find((command) => command.key === 'che_임관');
for (const commandKey of ['che_임관', 'che_등용']) {
const command = table.general
.flatMap((group) => group.values)
.find((candidate) => candidate.key === commandKey);
expect(appointment).toMatchObject({
possible: false,
status: 'blocked',
reason: '랜덤 임관만 가능합니다',
});
expect(command).toMatchObject({
possible: false,
status: 'blocked',
reason: '랜덤 임관만 가능합니다',
});
}
});
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
@@ -525,7 +678,11 @@ describe('buildTurnCommandTable', () => {
magicCoef: 0,
cost: 12,
rice: 10,
requirements: [{ type: 'ReqTech', tech: 2000 }],
requirements: [
{ type: 'ReqTech', tech: 2000 },
{ type: 'ReqRegions', regions: ['중원'] },
{ type: 'ReqCities', cities: ['TestCity'] },
],
attackCoef: {},
defenceCoef: {},
info: ['강력하지만 기술이 필요합니다.'],
@@ -583,7 +740,12 @@ describe('buildTurnCommandTable', () => {
special: true,
attack: 175,
defence: 225,
info: ['강력하지만 기술이 필요합니다.'],
info: [
'강력하지만 기술이 필요합니다.',
'기술력 2000 이상 필요',
'중원 지역 소유시 가능',
'TestCity 소유시 가능',
],
});
});
});
+13 -1
View File
@@ -87,7 +87,19 @@ describe('Ref command general targets', () => {
troopId: 3,
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 () => {
const auth = buildAuth(userId, '생성사용자', 4242);
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.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
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),
$executeRaw: db.$executeRaw.bind(db),
};
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1),
};
Object.assign(db, {
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
});
await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
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_outbox');
});
+1 -10
View File
@@ -1,6 +1,5 @@
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 { RedisConnector } from '@sammo-ts/infra';
@@ -88,7 +87,7 @@ const 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;
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
const db = {
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>',
textDetail:
'<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.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 () => {
+94 -1
View File
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
leadership: 70,
strength: 60,
intel: 50,
crew: 100,
experience: 1_000,
meta: { killturn: 3 },
penalty: {},
@@ -124,6 +125,9 @@ const globalGenerals = [
name: '군주',
dedication: 900,
officerLevel: 12,
leadership: 80,
strength: 80,
intel: 80,
meta: { killturn: 4, owner_name: '통일유저' },
experience: 10_000,
}),
@@ -132,6 +136,9 @@ const globalGenerals = [
name: '외교관',
dedication: 800,
officerLevel: 1,
leadership: 70,
strength: 80,
intel: 40,
meta: { killturn: 2, permission: 'ambassador' },
experience: 5_000,
}),
@@ -140,6 +147,9 @@ const globalGenerals = [
name: '제재외교관',
dedication: 700,
officerLevel: 1,
leadership: 70,
strength: 40,
intel: 80,
meta: { killturn: 1, permission: 'ambassador' },
penalty: { noTopSecret: true },
experience: 2_000,
@@ -149,6 +159,9 @@ const globalGenerals = [
name: '조언자',
dedication: 600,
officerLevel: 1,
leadership: 30,
strength: 70,
intel: 70,
meta: { killturn: 5, permission: 'auditor' },
experience: 1_000,
}),
@@ -161,6 +174,8 @@ const globalGenerals = [
dedication: 500,
officerLevel: 12,
leadership: 80,
strength: 70,
intel: 65,
meta: { killturn: 0 },
experience: 20_000,
}),
@@ -221,7 +236,12 @@ const createContext = (
worldState: {
findFirst: vi.fn(async () => ({
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]]);
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);
for (const privateField of [
'userId',
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
id: 1,
generalCount: 4,
cityCount: 1,
forceEstimate: {
totalGeneralCount: 4,
userCombatGeneralCount: 1,
userTroops: 8_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 2,
},
ambassadorNames: ['군주', '외교관'],
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]?.officers[0]).toMatchObject({
officerLevel: 12,
general: { id: 10, name: '군주' },
});
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 () => {
+21 -11
View File
@@ -3,37 +3,47 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.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: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
clockTick: 36_000_000n,
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
clockTick: 0n,
clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600,
clockPhase: phase,
clockRevision: 1n,
deadlineGeneration: 1n,
})),
},
}) as unknown as DatabaseClient;
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 preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
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'),
tick: 36_000_000,
tick: -108_000_000,
mode: 'realtime',
phase: 'PREOPEN',
running: false,
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({
now: new Date('2026-08-21T10:00:05.000Z'),
tick: 36_300_000,
now: new Date('2026-08-21T11:00:05.000Z'),
tick: 300_000,
running: true,
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'));
expect(result).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'),
tick: 36_000_000,
now: new Date('2026-08-21T11:00:00.000Z'),
tick: 0,
running: false,
startsAt: null,
});
+12 -12
View File
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
});
expect(transaction).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[] };
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
expect(periodStatement.values).toContain(now);
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('ON CONFLICT (period_id, general_id) DO NOTHING');
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('general_access_log.refresh + EXCLUDED.refresh');
expect(accessStatement.values).toContain(7);
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1);
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(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 () => {
@@ -322,18 +326,14 @@ describe('general access tracking', () => {
: [{ id: 41 }];
}),
$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');
}
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
return 1;
}),
$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 = {
general: {
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
expect(resolveRefreshScoreText(12_800)).toBe('헐...');
});
it('matches the Ref remaining-minute calculation and one-turn rollover', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7);
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull();
it('matches the Ref remaining-minute calculation against the current game clock', () => {
const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
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', () => {
+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 = {
type: 'voteReward' as const,
requestId: 'vote-reward',
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
selection: [0],
acceptedGameTick: 100,
};
const currentCommand = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
};
const create = async () => {
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
};
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
);
await expect(
transport.sendCommand({
...persistedPayload,
acceptedGameTick: 101,
})
transport.sendCommand(currentCommand)
).resolves.toBe('vote-reward');
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
await expect(
transport.sendCommand({
...persistedPayload,
...currentCommand,
...changedIdentity,
acceptedGameTick: 101,
})
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
}
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null;
refreshScore?: number;
refreshScoreTotal?: number;
gameClockNow?: Date;
rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
@@ -164,6 +165,10 @@ const createContext = (options: {
currentMonth: 1,
tickSeconds: 600,
config: { const: { upgradeLimit: 20 } },
clockBaseTime: options.gameClockNow ?? null,
clockTick: options.gameClockNow ? 0n : null,
clockMode: 'manual',
clockWallAnchor: options.gameClockNow ?? null,
})),
},
logEntry: {
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
troopLeaderAction: '휴식',
refreshScore: 3,
refreshScoreTotal: 1_141,
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
retirementYear: 70,
defenceTrain: 80,
killTurn: 6,
remainingMinutes: null,
remainingMinutes: 7,
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
refreshScore: { current: 3, total: 1_141, text: '열심' },
},
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0,
use_auto_nation_promotion: 0,
use_auto_nation_finance: 0,
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
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 () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
currentMonth: 4,
tickSeconds: 600,
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 },
};
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
currentYear: 200,
currentMonth: 4,
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,
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 () => {
const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' },
@@ -18,6 +18,7 @@ import {
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const journalGeneralIds = [9_980_081, 9_980_082] as const;
const clockScenarioCode = 'input-event-boundary';
const journalBoundaryRouter = router({
mutate: procedure
@@ -56,6 +57,21 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({
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 () => {
@@ -68,6 +84,7 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
});
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
await close?.();
});
@@ -518,6 +535,10 @@ integration('API input event boundary', () => {
expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
expect(event.acceptedGameTick).toBeNull();
expect(event.acceptedClockRevision).toBeNull();
expect(event.acceptedDeadlineGeneration).toBeNull();
expect(event.processingGameTick).toBeNull();
await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId);
+47 -3
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.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({
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 order: string[] = [];
const queryRaw = vi.fn(async (query: { sql?: string }) => {
@@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => {
status: 'PENDING',
result: null,
attempts: 0,
acceptedGameTick: 100n,
acceptedClockRevision: 3n,
acceptedDeadlineGeneration: 2n,
},
];
}
@@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => {
});
const transaction = {
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => {
order.push('accepted');
$executeRaw: vi.fn(async (query: { sql?: string }) => {
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;
}),
$executeRawUnsafe: vi.fn(async (statement: string) => {
@@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([
'transaction-begin',
'clock-fence',
'accepted',
'locked',
'processing',
@@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([
'transaction-begin',
'clock-fence',
'accepted',
'locked',
'processing',
@@ -126,4 +150,24 @@ describe('API input-event change journal boundary', () => {
expect(fixture.redisPublish).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 { appRouter } from '../src/router.js';
@@ -42,11 +42,16 @@ const buildContext = (
turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
},
$queryRaw: vi.fn(async () => [{ running: true }]),
} as unknown as DatabaseClient,
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
}) as unknown as GameApiContext;
describe('lobby season state', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
const result = await appRouter
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
@@ -96,15 +101,18 @@ describe('lobby season state', () => {
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 wallNow = new Date('2099-08-21T10:30:00.000Z');
vi.useFakeTimers();
vi.setSystemTime(wallNow);
const result = await appRouter
.createCaller(
buildContext(
{},
{
baseTime: new Date('2026-08-21T09:00:00.000Z'),
tick: 36_000_000n,
baseTime: wallAnchor,
tick: 0n,
mode: 'realtime',
wallAnchor,
}
@@ -112,13 +120,22 @@ describe('lobby season state', () => {
)
.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.clockRunning).toBe(false);
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
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 () => {
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
@@ -44,8 +44,8 @@ const buildContext = (
tickSeconds: 3600,
...(options.preopenClock
? {
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
clockTick: -180_000_000n,
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
clockTick: 0n,
clockMode: 'realtime',
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 () => {
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 });
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
@@ -1,7 +1,7 @@
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 integration = describe.skipIf(!databaseUrl);
@@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => {
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 () => {
const rollback = new Error('rollback message tombstone fixture');
await expect(
@@ -70,6 +115,7 @@ integration('message deletion tombstone persistence', () => {
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.validUntil).toEqual(validUntil);
expect(row.tombstonedAtWall).not.toBeNull();
expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.',
option: { invalid: true },
@@ -81,4 +127,43 @@ integration('message deletion tombstone persistence', () => {
})
).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(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 () => {
@@ -228,6 +228,84 @@ describe('messages router missing-flow compatibility', () => {
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 () => {
const ambassador = {
...general,
@@ -259,7 +337,7 @@ describe('messages router missing-flow compatibility', () => {
});
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 () => {
@@ -288,7 +366,6 @@ describe('messages router missing-flow compatibility', () => {
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
@@ -314,7 +391,6 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('national');
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 () => {
@@ -555,44 +631,50 @@ describe('messages router missing-flow compatibility', () => {
});
it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
return [
{
id: 21,
mailbox: general.id,
type: 'private',
src: general.id,
dest: 8,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
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 { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(executeRaw).toHaveBeenCalledOnce();
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([
{ 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 () => {
const queryRaw = vi.fn(async () => [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
let rawCall = 0;
const queryRaw = vi.fn(async () => {
rawCall += 1;
if (rawCall > 1) return [{ id: 25 }];
return [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
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 result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(executeRaw).toHaveBeenCalledOnce();
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
@@ -713,8 +801,11 @@ describe('messages router missing-flow compatibility', () => {
action,
reason: 'success',
}));
const transaction = vi.fn(async () => {
throw new Error('ENGINE message response must not enter the API input-event transaction.');
});
const { caller } = buildContext(
{ $queryRaw: vi.fn(async () => [messageRow]) },
{ $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
{ turnDaemon: { requestCommand } }
);
@@ -734,6 +825,7 @@ describe('messages router missing-flow compatibility', () => {
response: true,
})
);
expect(transaction).not.toHaveBeenCalled();
}
);
@@ -864,8 +956,18 @@ describe('messages router missing-flow compatibility', () => {
const nationUpdate = vi.fn(async () => ({}));
const logCreateMany = 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 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(
{
general: {
@@ -941,13 +1043,22 @@ describe('messages router missing-flow compatibility', () => {
currentYear: 200,
currentMonth: 3,
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 },
message: { updateMany: messageUpdateMany },
messageAction: { updateMany: messageActionUpdateMany },
$queryRaw: queryRaw,
},
{ changeJournal }
{ changeJournal, turnDaemon: { requestCommand } }
);
return {
caller,
@@ -960,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => {
messageUpdateMany,
cityUpdate,
changeJournal,
requestCommand,
};
};
@@ -973,6 +1085,14 @@ describe('messages router missing-flow compatibility', () => {
});
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.nationUpdate).toHaveBeenCalledWith(
expect.objectContaining({
@@ -987,7 +1107,7 @@ describe('messages router missing-flow compatibility', () => {
);
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
where: { id: { in: [31] } },
data: { validUntil: expect.any(Date), validUntilTick: 0n },
data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
});
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
});
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071;
const concurrentBettingId = 990_072;
const phaseBettingId = 990_073;
const generalId = 9_971;
const otherGeneralId = 9_972;
const phaseGeneralId = 9_973;
const nationId = 990_071;
const otherNationId = 990_072;
const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user';
const phaseUserId = 'nation-betting-router-phase-user';
const auth: GameSessionTokenPayload = {
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', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.inputEvent.deleteMany({
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
});
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
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.createMany({
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
turnTime: new Date('0200-01-01T00:00:00.000Z'),
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({
@@ -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({
data: {
id: concurrentBettingId,
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
data: [
{ userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 },
{ userId: phaseUserId, key: 'previous', value: 500 },
],
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.inputEvent.deleteMany({
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
});
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
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.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
).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 () => {
await expect(
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 {
buildNpcSelectionTokenSeed,
createTurnDaemonRuntime,
seedScenarioToDatabase,
type TurnDaemonRuntime,
} from '@sammo-ts/game-engine';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import {
acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector,
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
const delayedUserId = 'npc-possession-integration-delayed';
const cleanupUserId = 'npc-possession-integration-cleanup';
const raceUserId = 'npc-possession-integration-race';
const preopenUserId = 'npc-possession-integration-preopen';
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
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 cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
const redisClient = {
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.inputEvent.deleteMany();
await db.logEntry.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' } });
await db.general.createMany({
data: Array.from({ length: 24 }, (_, index) => ({
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await closeDb?.();
}, 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();
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([
appRouter.createCaller(buildContext('npc-possession-token-a')).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).toBeLessThanOrEqual(5);
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.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
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);
}, 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 () => {
const reservation = await appRouter
.createCaller(buildContext('npc-possession-token-current'))
@@ -403,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
});
}, 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
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
.join.listPossessCandidates({});
@@ -423,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
).rejects.toMatchObject({ code: 'TIMEOUT' });
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
const acceptedGameAt = new Date(
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
);
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
expect(event.acceptedGameTick).toBeNull();
expect(event.processingGameTick).toBeNull();
expect(event.payload).not.toHaveProperty('acceptedGameAt');
const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
await db.npcSelectionToken.update({
where: { ownerUserId: delayedUserId },
data: { validUntil: acceptedGameAt },
data: { validUntilTick: queuedAtTick },
});
await db.worldState.updateMany({
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 expect(
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({
status: 'SUCCEEDED',
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);
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 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 publish = vi.fn().mockResolvedValue(1);
const db = {
$queryRaw: queryRaw,
readModelOutbox: { updateMany },
$executeRaw: executeRaw,
readModelOutbox: {},
} as unknown as ReadModelOutboxDatabase;
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', () => {
@@ -47,7 +48,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
@@ -64,7 +65,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
@@ -74,9 +75,7 @@ describe('ReadModelOutboxWorker', () => {
revision: 41,
changes: { frontStatusActorIds: [7] },
});
expect(fixture.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
);
expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
});
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
@@ -89,7 +88,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
@@ -105,7 +104,7 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
@@ -150,22 +149,15 @@ describe('ReadModelOutboxWorker', () => {
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
);
expect(fixture.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
data: expect.objectContaining({
lockedAt: null,
lockOwner: null,
lastError: expect.stringContaining('redis unavailable'),
}),
})
);
const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(releaseQuery.sql).toContain('"available_at"');
expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
});
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' });
});
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 requestId = 'select-pool-reserve-http';
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
@@ -741,8 +741,6 @@ describe('appRouter', () => {
requestId: commandRequestId,
userId: 'user-1',
seedOwnerIdentity: 'user-1',
acceptedGameAt,
acceptedGameTick: 0,
});
});
@@ -1418,6 +1416,20 @@ describe('appRouter', () => {
message: expect.stringContaining('랜덤 임관만 가능합니다'),
});
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 () => {
@@ -1462,6 +1474,28 @@ describe('appRouter', () => {
})
).resolves.toMatchObject({ ok: true });
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 () => {
@@ -227,19 +227,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
?.some((candidate) => reservedNames.has(candidate.uniqueName))
).toBe(false);
await expect(
db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
})
).resolves.toMatchObject({
const reserveEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
});
expect(reserveEvent).toMatchObject({
eventType: 'selectPoolReserve',
status: 'SUCCEEDED',
actorUserId: userId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
processingGameTick: expect.anything(),
});
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 attempts = await Promise.allSettled([
@@ -357,6 +355,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
const cooledAt = '2026-07-29T00:00:00.000Z';
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await expect(
turnDaemon.requestCommand({
type: 'patchGeneral',
@@ -366,6 +365,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: {
next_change: 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'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id });
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
).resolves.toMatchObject({
const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
});
expect(reselectionEvent).toMatchObject({
eventType: 'selectPoolReselect',
actorUserId: userId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
processingGameTick: expect.anything(),
});
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
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 },
});
const secondCooledAt = '2026-07-28T00:00:00.000Z';
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await turnDaemon.requestCommand({
type: 'patchGeneral',
requestId: 'select-pool-full-cooldown-patch',
@@ -463,6 +464,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: {
next_change: secondCooledAt,
nextChangeAt: secondCooledAt,
next_change_tick: secondCooledTick,
},
},
});
@@ -571,21 +573,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.join.selectPoolGeneral(stableInput);
expect(retried).toEqual(first);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
await expect(
db.inputEvent.findUniqueOrThrow({
where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
},
})
).resolves.toMatchObject({
const stableEvent = await db.inputEvent.findUniqueOrThrow({
where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
},
});
expect(stableEvent).toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: otherUserId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
processingGameTick: expect.anything(),
});
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
}, 30_000);
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 { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -30,11 +30,31 @@ class MemoryRedis {
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
const [valueKey, revisionKey] = options.keys;
const [value] = options.arguments;
if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) {
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');
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));
return String(revision);
}
@@ -131,6 +151,8 @@ const buildContext = (options: {
develCost?: number;
currentDevelCost?: number;
rankRows?: Array<{ generalId: number; type: string; value: number }>;
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
requestId?: string;
}): GameApiContext => {
const db = {
general: {
@@ -144,12 +166,21 @@ const buildContext = (options: {
},
worldState: {
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 } },
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
}),
},
} as unknown as DatabaseClient;
return {
requestId: options.requestId,
db,
redis: options.redis as unknown as RedisConnector['client'],
turnDaemon: options.transport,
@@ -341,6 +372,83 @@ describe('tournament router permissions and mutations', () => {
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 () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -394,7 +502,10 @@ describe('tournament router permissions and mutations', () => {
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 () => {
@@ -479,9 +590,7 @@ describe('tournament router permissions and mutations', () => {
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setBettingEntries([
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }])
).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true,
@@ -41,6 +41,9 @@ integration('TournamentStore Redis source revision', () => {
keys.matchesKey,
keys.bettingKey,
keys.sourceRevisionKey,
keys.activeClockRevisionKey,
keys.deadlineGenerationKey,
keys.clockPhaseKey,
]);
if (subscriber) {
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', () => {
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
const activeLease = {
turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
},
};
$queryRaw: vi.fn(async () => [{ running: true }]),
} as any;
const now = new Date('2026-08-24T00:00:00.000Z');
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
true
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
await expect(
loadTurnEngineRunning(
source,
{ turnDaemonLease: { findUnique: async () => null } },
{ $queryRaw: async () => [{ running: false }] } as any,
'che:default',
now
)
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
await expect(
loadTurnEngineRunning(
source,
{
turnDaemonLease: {
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
},
},
{ $queryRaw: async () => [{ running: false }] } as any,
'che:default',
now
)
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
let now = 1_000;
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(
{ get },
{ turnDaemonLease: { findUnique } },
{ $queryRaw: queryRaw } as any,
'che:default',
2_000,
() => now
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
now += 1;
await expect(cache.get()).resolves.toBe(true);
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: 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, now: new Date(now.getTime() + 1), tick: null }
)
).toBe(true);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
});
it('rejects unauthenticated survey access', async () => {
@@ -261,7 +255,6 @@ describe('vote router actor and permission boundaries', () => {
voteId: 1,
generalId: 7,
selection: [0],
acceptedGameTick: 100,
});
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false
@@ -301,8 +294,6 @@ describe('vote router actor and permission boundaries', () => {
const auth = buildAuth(['admin.survey.open']);
const fixture = buildContext({ auth });
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.createPoll({
@@ -314,8 +305,6 @@ describe('vote router actor and permission boundaries', () => {
).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 });
const windowEnd = Date.now();
const mutationQueries = fixture.queryRaw.mock.calls
.map(([query]) => 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 editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
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();
const value = query?.values.at(index);
expect(value).toBeInstanceOf(Date);
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
return value as Date;
expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
};
expect(sqlText(commentInsert!)).toContain('created_at');
expectCurrentDateAt(commentInsert, -1);
expectDbWallClock(commentInsert);
expect(sqlText(pollInsert!)).toContain('created_at');
expect(sqlText(pollInsert!)).toContain('updated_at');
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
expect(pollUpdatedAt).toBe(pollCreatedAt);
expectDbWallClock(pollInsert);
expect(pollUpdates).toHaveLength(3);
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
expectDbWallClock(closePreviousUpdate);
expect(sqlText(editPollUpdate!)).toContain('updated_at');
expectCurrentDateAt(editPollUpdate, -2);
expectDbWallClock(editPollUpdate);
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 () => {
+1
View File
@@ -8,5 +8,6 @@ export default defineConfig({
environment: 'node',
globals: true,
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",
"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": {
"types": "./dist/turn/inMemoryWorld.d.ts",
"default": "./dist/turn/inMemoryWorld.js"
@@ -103,8 +111,8 @@
"build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .",
"dev": "tsdown -c ./tsdown.config.ts --watch",
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "pnpm -w lint:target app/game-engine",
"lint:fix": "pnpm -w lint:target:fix app/game-engine",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.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 },
now: Date,
nowTick: number | null
): boolean =>
auction.closeTick !== null && nowTick !== null
? auction.closeTick < BigInt(nowTick)
: auction.closeAt.getTime() < now.getTime();
): boolean => {
void now;
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
};
export const resolveAuctionBidTiming = (
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
processingNow: Date,
acceptedGameTick?: number
): { bidAt: Date; bidTick: number } =>
acceptedGameTick === undefined
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingGameTick: number
): { bidAt: Date; bidTick: number } => {
if (!Number.isSafeInteger(processingGameTick)) {
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
}
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
};
export const hasAuctionBidClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
processingNow: Date,
acceptedGameTick?: number
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingGameTick: number
): boolean => {
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
return hasAuctionClosePassed(auction, bidAt, bidTick);
};
@@ -268,8 +268,15 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.',
};
}
const processingNow = world.getGameNow(new Date());
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick);
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
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)) {
return {
type: 'auctionBid',
@@ -503,13 +510,24 @@ export const createAuctionBidder = async (options: {
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw(
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 (
${command.auctionId},
${command.generalId},
${command.amount},
${eventId},
${eventAt},
${BigInt(bidTick)},
${requestedAtWall},
${JSON.stringify({
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
...(auction.type === 'UNIQUE_ITEM'
@@ -529,7 +547,7 @@ export const createAuctionBidder = async (options: {
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId},
latest_event_at = ${eventAt},
updated_at = ${eventAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${command.auctionId}
AND status = 'OPEN'
AND latest_event_id = ${auction.latestEventId}
@@ -549,7 +567,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql`
UPDATE inheritance_point
SET value = value - ${morePoint},
updated_at = ${eventAt}
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE user_id = ${userId}
AND key = 'previous'
AND value >= ${morePoint}
@@ -580,11 +598,16 @@ export const createAuctionBidder = async (options: {
await tx.$executeRaw(
GamePrisma.sql`
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)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
`
);
await tx.$executeRaw(
@@ -704,6 +727,7 @@ export const createAuctionBidder = async (options: {
ok: true,
auctionId: command.auctionId,
closeAt: nextCloseAt.toISOString(),
closeTick: world.dateToGameTick(nextCloseAt),
};
},
close: async (): Promise<void> => {
+15 -16
View File
@@ -91,21 +91,17 @@ export const isAuctionFinalizeGenerationCurrent = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
): boolean => {
if (command.expectedCloseTick !== undefined) {
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
}
if (command.expectedCloseAt !== undefined) {
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
}
return true;
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
};
export const hasAuctionFinalizeDeadlineArrived = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
now: Date,
nowTick: number
): boolean =>
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
): boolean => {
void now;
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
};
export const buildAuctionBidderSystemMessage = (options: {
bidder: TurnGeneral;
@@ -293,7 +289,11 @@ export const createAuctionFinalizer = async (options: {
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 (!isAuctionFinalizeGenerationCurrent(auction, command)) {
return {
@@ -303,8 +303,7 @@ export const createAuctionFinalizer = async (options: {
reason: '경매 마감 세대가 변경되었습니다.',
};
}
const nowTick = world.dateToGameTick(now);
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
return {
type: 'auctionFinalize',
ok: false,
@@ -316,8 +315,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql`
UPDATE auction
SET status = 'FINALIZING',
finalizing_at = ${now},
updated_at = ${now}
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId}
AND status = 'OPEN'
`
@@ -364,8 +363,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql`
UPDATE auction
SET status = ${status},
finished_at = ${now},
updated_at = ${now}
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId}
`
);
+20 -4
View File
@@ -94,7 +94,11 @@ const openResourceAuction = async (
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 closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({
@@ -113,7 +117,7 @@ const openResourceAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)),
},
});
@@ -125,6 +129,7 @@ const openResourceAuction = async (
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
};
};
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
}
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 now = world.getGameNow(new Date());
const now = world.gameTickToDate(processingGameTick);
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max(
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
},
status: 'OPEN',
closeAt,
openTick: BigInt(world.dateToGameTick(now)),
openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId,
latestEventAt: now,
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
amount: command.amount,
eventId,
eventAt: now,
occurredGameTick: BigInt(processingGameTick),
requestedAtWall,
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
},
},
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
ok: true,
auctionId: auction.id,
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/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
export * from './turn/clockReconciliation.js';
export * from './turn/clockProjectionOutbox.js';
export * from './turn/joinCreateGeneralService.js';
export * from './turn/npcPossessionService.js';
export * from './turn/selectPoolService.js';
@@ -1,5 +1,6 @@
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 type {
@@ -10,8 +11,9 @@ import type {
TurnDaemonStatus,
} 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 serializeResult = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly localQueue: TurnDaemonCommand[] = [];
@@ -35,8 +37,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return local.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
while (deadlineMs === null || Date.now() < deadlineMs) {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
while (deadline === null || performance.now() < deadline) {
const local = this.localQueue.shift();
if (local) {
return local;
@@ -45,7 +48,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (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);
}
return null;
@@ -84,30 +87,50 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
return;
}
const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
attempts: event.attempts,
},
data: {
status: terminal ? 'FAILED' : 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
completedAt: terminal ? new Date() : null,
result: GamePrisma.DbNull,
error: message,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
processing_at = NULL,
locked_by = NULL,
lease_until = NULL,
completed_at = CASE
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
result = NULL,
error = ${message}
WHERE request_id = ${requestId}
AND target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND locked_by = ${this.workerId}
AND attempts = ${event.attempts}
`);
});
}
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases();
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<
Array<{
sequence: bigint;
@@ -115,6 +138,9 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
eventType: string;
payload: unknown;
createdAt: Date;
acceptedGameTick: bigint | null;
acceptedClockRevision: bigint | null;
acceptedDeadlineGeneration: bigint | null;
}>
>(GamePrisma.sql`
SELECT
@@ -122,10 +148,71 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
"request_id" AS "requestId",
"event_type" AS "eventType",
"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"
WHERE "target" = 'ENGINE'::"InputEventTarget"
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
FOR UPDATE SKIP LOCKED
LIMIT ${limit}
@@ -133,41 +220,93 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
if (rows.length === 0) {
return [];
}
await transaction.inputEvent.updateMany({
where: {
sequence: { in: rows.map((row) => row.sequence) },
target: 'ENGINE',
status: 'PENDING',
},
data: {
status: 'PROCESSING',
processingAt: new Date(),
lockedBy: this.workerId,
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
attempts: { increment: 1 },
},
});
const appliedSuspensions = currentRevision
? await transaction.clockSuspension.findMany({
where: { status: 'APPLIED', targetRevision: { lte: currentRevision } },
orderBy: { sourceRevision: 'asc' },
select: { sourceRevision: true, targetRevision: true, shiftTicks: true },
})
: [];
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
return claimCoordinate.gameTick;
}
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[] = [];
for (const row of rows) {
for (const { row, processingGameTick } of processableRows) {
const command = normalizeTurnDaemonCommand({
requestId: row.requestId,
sentAt: row.createdAt.toISOString(),
command: row.payload as TurnDaemonCommand,
});
if (!command) {
await transaction.inputEvent.update({
where: { sequence: row.sequence },
data: {
status: 'FAILED',
error: `Invalid command payload for ${row.eventType}`,
completedAt: new Date(),
lockedBy: null,
leaseUntil: null,
},
});
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'FAILED'::"InputEventStatus",
error = ${`Invalid command payload for ${row.eventType}`},
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
locked_by = NULL,
lease_until = NULL
WHERE sequence = ${row.sequence}
`);
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);
}
return commands;
@@ -175,23 +314,20 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async complete(requestId: string, result: unknown): Promise<void> {
const completed = await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
},
data: {
status: 'SUCCEEDED',
result: asJson(result),
completedAt: new Date(),
error: null,
lockedBy: null,
leaseUntil: null,
},
});
if (completed.count > 0) {
const completed = await this.db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'SUCCEEDED'::"InputEventStatus",
result = CAST(${serializeResult(result)} AS jsonb),
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
error = NULL,
locked_by = NULL,
lease_until = NULL
WHERE request_id = ${requestId}
AND target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND locked_by = ${this.workerId}
`);
if (completed > 0) {
return;
}
// 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> {
const now = new Date();
await this.db.inputEvent.updateMany({
where: {
target: 'ENGINE',
status: 'PROCESSING',
leaseUntil: { lt: now },
},
data: {
status: 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
},
});
await this.db.$executeRaw(GamePrisma.sql`
UPDATE input_event
SET status = 'PENDING'::"InputEventStatus",
processing_at = NULL,
locked_by = NULL,
lease_until = NULL
WHERE target = 'ENGINE'::"InputEventTarget"
AND status = 'PROCESSING'::"InputEventStatus"
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
`);
}
}
@@ -86,9 +86,9 @@ export class DatabaseTurnDaemonLease {
VALUES (
${this.profile},
${this.ownerId},
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
1,
CURRENT_TIMESTAMP
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT ("profile") DO UPDATE
SET
@@ -99,10 +99,10 @@ export class DatabaseTurnDaemonLease {
THEN "turn_daemon_lease"."fencing_epoch"
ELSE "turn_daemon_lease"."fencing_epoch" + 1
END,
"heartbeat_at" = CURRENT_TIMESTAMP
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"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"
`);
const row = rows[0];
@@ -141,13 +141,13 @@ export class DatabaseTurnDaemonLease {
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease"
SET
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP
"lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
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"
`);
if (rows.length === 0) {
@@ -177,7 +177,7 @@ export class DatabaseTurnDaemonLease {
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
AND "fencing_epoch" = ${token.fencingEpoch}
AND "lease_until" > CURRENT_TIMESTAMP
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
FOR UPDATE
`);
if (rows.length === 0) {
@@ -196,7 +196,8 @@ export class DatabaseTurnDaemonLease {
}
await this.db.$executeRaw(GamePrisma.sql`
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
"profile" = ${token.profile}
AND "owner_id" = ${token.ownerId}
@@ -1,7 +1,7 @@
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
type Waiter = {
deadlineMs: number | null;
timeoutMs: number | null;
resolve: (command: TurnDaemonCommand | null) => void;
timeoutId?: ReturnType<typeof setTimeout>;
};
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
return drained;
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.queue.length > 0) {
return this.queue.shift() ?? null;
}
return new Promise((resolve) => {
const waiter: Waiter = { deadlineMs, resolve };
if (deadlineMs !== null) {
const delay = Math.max(0, deadlineMs - Date.now());
const waiter: Waiter = { timeoutMs, resolve };
if (timeoutMs !== null) {
const delay = Math.max(0, timeoutMs);
waiter.timeoutId = setTimeout(() => {
this.removeWaiter(waiter);
resolve(null);
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
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) {
return this.localQueue.shift() ?? null;
}
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
if (deadlineMs !== null && blockMs === 0) {
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
if (timeoutMs !== null && blockMs === 0) {
return null;
}
@@ -172,7 +172,20 @@ export class TurnDaemonLifecycle {
const nowMs = this.clock.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') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
@@ -209,7 +222,7 @@ export class TurnDaemonLifecycle {
continue;
}
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
if (command) {
await this.handleCommand(command);
}
@@ -255,7 +268,7 @@ export class TurnDaemonLifecycle {
}
private async waitForResume(): Promise<void> {
const command = await this.controlQueue.waitUntil(null);
const command = await this.controlQueue.waitFor(null);
if (command) {
await this.handleCommand(command);
}
+16 -3
View File
@@ -7,7 +7,7 @@ import type {
TurnRunResult,
} from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameClockMode } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export type {
RunReason,
@@ -29,6 +29,12 @@ export interface TurnDaemonCommandHandler {
export interface TurnDaemonCommandExecutionContext {
db?: GamePrisma.TransactionClient;
clockOperationAuthority?: {
kind: 'DAEMON';
profileName: string;
ownerId: string;
fencingEpoch: bigint;
};
}
export interface TurnDaemonCommandResponder {
@@ -60,7 +66,14 @@ export interface TurnStateStore {
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
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>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
@@ -69,7 +82,7 @@ export interface TurnStateStore {
export interface TurnDaemonControlQueue {
enqueue(command: TurnDaemonCommand): void;
drain(): Promise<TurnDaemonCommand[]>;
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
getDepth(): number;
}
+34 -9
View File
@@ -6,7 +6,7 @@ import {
type InputJsonValue,
type TurnEngineEventCreateManyInput,
} 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 {
buildScenarioBootstrap,
resolveScenarioGeneralDeathMonth,
@@ -94,6 +94,17 @@ export const calculateInitialTurnTick = (
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 pad = (value: number): string => String(value).padStart(2, '0');
return [
@@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const initialClock = new GameClock({
baseTime: startState.startTime,
tick: 0,
mode: gameClockMode,
wallAnchor: initialClockWallAnchor,
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({
scenario: scenarioDefinition,
@@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
}
worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
clockMode: gameClockMode,
clockWallAnchor: initialClock.wallAnchor,
lastTurnTick: BigInt(initialClockTick),
clockPhase: initialClockPhase,
clockRevision: 1n,
deadlineGeneration: 1n,
config: asJson({ ...scenarioConfig, ...worldConfig }),
meta: asJson(worldMeta),
},
@@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
weaponCode: general.weapon ?? 'None',
bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None',
turnTime: new Date(
now.getTime() +
Math.floor(
(typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0) / 1_000
)
turnTime: initialClock.tickToDate(
calculateInitialTurnTick(
initialClock,
initialClockTick,
typeof general.meta.initialTurnOffsetMicros === 'number'
? general.meta.initialTurnOffsetMicros
: 0
)
),
turnTick: BigInt(
calculateInitialTurnTick(
@@ -8,6 +8,7 @@ import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
import type { TurnEvent } from './types.js';
import { reconcileClockSuspensionInTransaction, type ClockReconciliationResult } from './clockReconciliation.js';
type ActionableMessageType = 'scout' | 'raiseInvader';
@@ -17,6 +18,10 @@ interface MessageRow {
type: string;
time: Date;
validUntil: Date;
actionType: string;
actionStatus: string;
createdGameTick: bigint;
expiresGameTick: bigint | null;
message: unknown;
}
@@ -93,15 +98,21 @@ const invalidateMessageIds = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
ids: number[],
now: Date
now: Date,
authoritativeGameTick?: bigint
): Promise<void> => {
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
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({
where: { id: { in: uniqueIds } },
data: {
validUntil: now,
validUntilTick: BigInt(world.dateToGameTick(now)),
validUntilTick: resolvedGameTick,
},
});
};
@@ -112,40 +123,53 @@ const validateActor = async (options: {
requestId?: string;
userId: string;
generalId: number;
}): Promise<Date> => {
}): Promise<{ processingGameTick: number }> => {
const actor = options.world.getGeneralById(options.generalId);
if (!actor || actor.userId !== options.userId) {
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({
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.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== '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 (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
messageId: number,
now: Date
currentGameTick: number
): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
FROM message
WHERE id = ${messageId}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
SELECT
envelope.id,
envelope.mailbox,
envelope.type,
envelope.time,
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
FOR UPDATE
FOR UPDATE OF envelope, action
`);
return rows[0] ?? null;
};
@@ -164,7 +188,7 @@ const respondToScout = async (options: {
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
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: '유효하지 않은 등용장입니다.' };
}
@@ -185,18 +209,17 @@ const respondToScout = async (options: {
}
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
SELECT id
FROM message
WHERE mailbox = ${payload.src.generalId}
AND type = 'private'
AND dest = mailbox
AND id <> ${row.id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
OR (valid_until_tick IS NULL AND valid_until > ${now})
)
AND message->'option'->>'action' = 'scout'
FOR UPDATE
SELECT envelope.id
FROM message AS envelope
JOIN message_action AS action ON action.message_id = envelope.id
WHERE envelope.mailbox = ${payload.src.generalId}
AND envelope.type = 'private'
AND envelope.dest = envelope.mailbox
AND envelope.id <> ${row.id}
AND action.status = 'PENDING'
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
AND action.action_type = 'scout'
FOR UPDATE OF envelope, action
`);
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
world.queueMessage({
@@ -251,6 +274,18 @@ const respondToRaiseInvader = async (options: {
payload: MessagePayload;
now: Date;
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> => {
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
@@ -272,6 +307,22 @@ const respondToRaiseInvader = async (options: {
if (!reservedTurns) {
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;
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
@@ -281,22 +332,44 @@ const respondToRaiseInvader = async (options: {
reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
clockWallNow: alignment.resumeWallAt,
});
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler(
args,
{
year: state.currentYear,
month: state.currentMonth,
startyear: asNumber(state.meta.startYear, state.currentYear),
year: alignedState.currentYear,
month: alignedState.currentMonth,
startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear),
currentEventID: 0,
// Ref uses the frozen game_env.turntime while unification is paused.
// `now` is the realtime game projection and can be hours ahead after
// a long response wait, delaying every newly summoned invader turn.
turnTime: state.lastTurnTime,
// The exact reconciliation snapshot is the authority even when the
// turn rate stays unchanged. Using the pre-reconciliation cursor here
// would create immediately overdue invader turns after a long wait.
turnTime: alignedState.lastTurnTime,
},
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' };
};
@@ -311,14 +384,27 @@ export const respondToActionableMessage = async (options: {
messageId: number;
response: boolean;
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> => {
const acceptedAt = await validateActor(options);
const now = options.world.getGameNow(acceptedAt);
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
const accepted = await validateActor(options);
const now = options.world.gameTickToDate(accepted.processingGameTick);
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message);
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
const action = asRecord(payload.option).action;
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
}
@@ -488,6 +488,23 @@ export class GeneralAI {
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 {
this.updateInstance();
if (!this.worldRef) {
@@ -1198,6 +1215,13 @@ export class GeneralAI {
) {
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';
candidate.officerLevel = 11;
candidate.meta = {
@@ -1,6 +1,6 @@
import type { GeneralAI } from '../core.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 { resolveNationIncome } from './helpers.js';
@@ -99,7 +99,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
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(
'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth },
+1 -3
View File
@@ -61,10 +61,9 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
NPC전방발령: true,
};
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital';
export type UserRulerAutomationFeature = 'war' | 'promotion' | 'finance' | 'capital';
const USER_RULER_AUTOMATION_META_KEY = {
diplomacy: 'use_auto_nation_diplomacy',
war: 'use_auto_nation_war',
promotion: 'use_auto_nation_promotion',
finance: 'use_auto_nation_finance',
@@ -72,7 +71,6 @@ const USER_RULER_AUTOMATION_META_KEY = {
} as const satisfies Record<UserRulerAutomationFeature, string>;
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
: 'diplomacy',
: 'war',
: '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'),
auctionId: zFiniteNumber,
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
expectedCloseTick: zSafeInteger.optional(),
expectedCloseTick: zSafeInteger,
});
const zAuctionOpen = z.object({
@@ -60,7 +60,6 @@ const zAuctionBid = z.object({
auctionId: zFiniteNumber,
generalId: zFiniteNumber,
amount: zFiniteNumber,
acceptedGameTick: zSafeInteger.optional(),
tryExtendCloseDate: z.boolean().optional(),
});
@@ -132,6 +131,15 @@ const zMessageRespond = z.object({
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({
type: z.literal('vacation'),
userId: z.string().min(1),
@@ -142,12 +150,11 @@ const zSetMySetting = z.object({
type: z.literal('setMySetting'),
userId: z.string().min(1),
generalId: zFiniteNumber,
settings: z.object({
settings: z.strictObject({
tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(),
use_treatment: 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_promotion: z.number().int().optional(),
use_auto_nation_finance: z.number().int().optional(),
@@ -428,7 +435,7 @@ const zSelectPoolReserve = z
requestId: z.string().optional(),
userId: z.string().min(1),
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(),
})
.strict();
@@ -611,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope)
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 command = parseWith(zVacation, envelope.command);
if (!command) {
@@ -861,6 +876,7 @@ const normalizers: CommandNormalizerMap = {
buildNationCandidate: normalizeBuildNationCandidate,
instantRetreat: normalizeInstantRetreat,
messageRespond: normalizeMessageRespond,
syncDiplomaticResponse: normalizeSyncDiplomaticResponse,
vacation: normalizeVacation,
setMySetting: normalizeSetMySetting,
dropItem: normalizeDropItem,
+260 -32
View File
@@ -1,10 +1,13 @@
import {
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
writeReadModelChangeJournal,
enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents,
persistMessageEnvelope,
type InputJsonValue,
type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput,
@@ -54,12 +57,22 @@ import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.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 {
hooks: TurnDaemonHooks;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>;
}
export interface CommittedReadModelChangeReceipt {
@@ -121,6 +134,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clock_base_time',
'clockMode',
'clock_mode',
'clockPhase',
'clock_phase',
'clockRevision',
'clock_revision',
'clockTick',
'clock_tick',
'clockWallAnchor',
@@ -134,6 +151,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'last_turn_tick',
'lastTurnTime',
'last_turn_time',
'deadlineGeneration',
'deadline_generation',
'lease',
'leaseOwner',
'lease_owner',
@@ -1121,9 +1140,19 @@ export const createDatabaseTurnHooks = async (
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? 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()),
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 (
prisma: GamePrisma.TransactionClient
): Promise<{
@@ -1156,6 +1185,129 @@ export const createDatabaseTurnHooks = async (
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
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;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
@@ -1260,6 +1412,35 @@ export const createDatabaseTurnHooks = async (
END
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) {
@@ -1684,37 +1865,18 @@ export const createDatabaseTurnHooks = async (
await sendMessage(
{
insertMessage: async (draft: MessageRecordDraft) => {
const toTickOrNull = (date: Date): bigint | null => {
try {
return BigInt(world.dateToGameTick(date));
} catch {
// Legacy messages may use year 9999 as an
// effectively-unbounded expiry, beyond the
// safe JavaScript tick range.
return null;
}
};
const rows = await prisma.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (
mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message
)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to persist turn message.');
}
const clock = world.getGameClockState();
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
const expiresGameTick =
typeof action !== 'string' || draft.validUntil.getUTCFullYear() >= 9000
? null
: BigInt(world.dateToGameTick(draft.validUntil));
const id = await persistMessageEnvelope(prisma, draft, {
occurredGameTick: BigInt(world.dateToGameTick(draft.time)),
clockRevision: BigInt(clock.revision),
deadlineGeneration: BigInt(clock.deadlineGeneration),
expiresGameTick,
});
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
return id;
@@ -1727,6 +1889,41 @@ export const createDatabaseTurnHooks = async (
if (options?.reservedTurns && 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) {
await prisma.inputEvent.update({
where: { requestId: commandCompletion.requestId },
@@ -1812,6 +2009,11 @@ export const createDatabaseTurnHooks = async (
},
executeCommand: async (requestId, execute) => {
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 =
(
await transaction.logEntry.findFirst({
@@ -1819,7 +2021,19 @@ export const createDatabaseTurnHooks = async (
select: { id: true },
})
)?.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);
return { result, persisted };
}, transactionOptions);
@@ -1835,6 +2049,20 @@ export const createDatabaseTurnHooks = async (
return takeCommittedReceipt()?.changes ?? null;
},
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(),
};
};
+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';
export type GatewayAdminActionStatus = 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
@@ -119,23 +119,28 @@ export const createGatewayAdminActionConsumer = async (
continue;
}
const terminal = result.status !== 'PARTIAL';
const updated = await prisma.gatewayRuntimeAction.updateMany({
where: {
id: action.id,
status: { in: ['REQUESTED', 'PARTIAL'] },
},
data: {
status: result.status,
detail: result.detail ?? null,
handler: 'turn-daemon',
handledAt: terminal ? new Date() : null,
attempts: { increment: 1 },
nextAttemptAt: terminal
? null
: new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6))),
},
});
if (terminal && updated.count > 0) {
const retryDelayMs = Math.min(60_000, 1_000 * 2 ** Math.min(action.attempts, 6));
const updated = await prisma.$queryRaw<Array<{ id: string }>>(GatewayPrisma.sql`
UPDATE gateway_runtime_action
SET status = ${result.status}::"GatewayRuntimeActionStatus",
detail = ${result.detail ?? null},
handler = 'turn-daemon',
handled_at = CASE
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
ELSE NULL
END,
attempts = attempts + 1,
next_attempt_at = CASE
WHEN ${terminal} THEN NULL
ELSE (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${retryDelayMs} * INTERVAL '1 millisecond'
END,
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);
}
}
@@ -1,3 +1,5 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
@@ -15,6 +17,7 @@ export interface GatewayProfileGate {
}
const DEFAULT_CACHE_MS = 2000;
const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as const;
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({
@@ -42,7 +45,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return {
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> {
const now = Date.now();
const now = performance.now();
if (now - lastCheckedAt < (options.cacheMs ?? DEFAULT_CACHE_MS)) {
return cachedPause;
}
@@ -53,8 +56,11 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null;
try {
await prisma.gatewayProfile.update({
where: { profileName: options.profileName },
await prisma.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
},
data: {
status: 'PAUSED',
lastError: message,

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