121 Commits
Author SHA1 Message Date
Hide_D 957a599608 외교 제의 만료와 처리 완료를 삭제 표시에서 분리 2026-09-10 06:03:47 +00:00
Hide_D 73e1743a3d 복구 2배속의 게임·실제 시간 표시 설정과 시계 빠른 전환 추가 2026-09-10 04:22:18 +00:00
Hide_D 3a5d4e7833 메인 턴 당기기와 미루기를 1턴 기본 분할 버튼으로 변경 2026-09-10 02:50:19 +00:00
Hide_D 3f4e5095b5 외교 조회 권한자의 문서와 시스템 메시지 도착 알림 복원 2026-09-10 02:38:23 +00:00
Hide_D a00f46dfc8 관리자 서버 진단에 lease 상태와 영속 장애 이력 추가 2026-09-09 23:36:16 +00:00
Hide_D 9c73085353 현재 복구 migration 기준으로 manifest 검증 기대값 갱신 2026-09-09 23:05:39 +00:00
Hide_D 604b951eff 대기 후 복구 migration과 릴리스 manifest 기준 일치 2026-09-09 23:05:21 +00:00
Hide_D 09f4e60df4 lease 상실 데몬을 종료하고 관리자에게 즉시 오류 기록 2026-09-09 23:04:02 +00:00
Hide_D d191960ea1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-09 22:54:38 +00:00
Hide_D c1fd79da19 메인 지도 연월 툴팁에 유니크 보유 한도 표시 2026-09-08 00:49:39 +00:00
Hide_D d8c9a63915 NPC와 알림 메시지의 장수 아이콘 경로를 공통 처리 2026-09-07 15:28:24 +00:00
Hide_D c39527bdd7 중단 후 대기와 2배속 복구 경계 및 전체 공지 적용 2026-09-07 14:37:55 +00:00
Hide_D d57fd3e553 설문 제목과 알림을 닫을 수 있는 새 창으로 열기 2026-09-07 12:42:27 +00:00
Hide_D bdb4e22028 장비 구매 목록을 시나리오 JSON 순서로 표시 2026-09-07 12:30:47 +00:00
Hide_D b2be86a9a2 fix: 신규 오픈 시각과 게임 시계 원점을 정확히 보존 2026-09-07 01:48:23 +00:00
Hide_D 69f461612d fix: 등용장을 발송 국가 멸망 시 함께 만료 2026-09-06 23:55:20 +00:00
Hide_D 9d5447dd3f fix: 토너먼트 관리자 요청의 Redis와 DB 잠금 순서 통일 2026-09-06 23:32:24 +00:00
Hide_D 27049dae1f fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정 2026-09-06 15:39:30 +00:00
Hide_D 09dfe96ff1 fix: 설문조사 창 닫기 버튼이 팝업을 닫도록 수정 2026-09-06 15:11:05 +00:00
Hide_D b14d8d52f6 feat: 정지·종료 기수의 장수 참여를 허용하고 확정 기록 보호 2026-09-06 13:39:30 +00:00
Hide_D 4d64978cb6 fix: 후계자 없는 군주 사망 시 국가 멸망을 함께 처리 2026-09-06 12:09:09 +00:00
Hide_D d3443ec7f5 refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현 2026-09-06 10:04:33 +00:00
Hide_D 63da0921cb fix: 재개 시 미처리 턴을 실행하고 장기 지연은 12턴씩 보정 2026-09-06 08:06:00 +00:00
Hide_D 6dcc914f94 fix: 운영 중단 복구 시 장수의 지정 턴 시각을 보존 2026-09-06 07:37:51 +00:00
Hide_D d3b2bcd155 fix: 보유 장비 판매 선택지와 판매가 표시를 복원
구매 pool 밖 보유 유니크도 장비 이름으로 판매를 선택할 수 있게 한다. 기존 슬롯명 판매 brief와 None 예약 인자는 유지하고 API 및 Chromium 회귀를 검증한다.
2026-09-06 07:20:31 +00:00
Hide_D ce7d15eba9 feat: 환경 설정으로 메뉴를 확장하고 전용 아이콘 변경을 이동 2026-09-05 14:10:19 +00:00
Hide_D 8488bf1aae fix: 사령부 발령 후보의 부대와 능력치 정보를 보강 2026-09-05 13:48:45 +00:00
Hide_D 71d7abcda4 docs: Qwen 보조 분석 지침 제거 2026-09-05 13:29:21 +00:00
Hide_D 332bd9a3dd 메인 장수 정보에서 상세 통계와 숙련도 표시를 제외 2026-09-05 13:21:08 +00:00
Hide_D 3437cd99f4 fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정 2026-09-05 00:55:33 +00:00
Hide_D 1d53e6f226 docs: 공통 작업 지침 통합 및 충돌 정리 2026-09-04 23:54:53 +00:00
Hide_D 0157ff6a6b 인게임 예약 명령과 장수 상태 표시를 바로잡음 2026-09-04 19:16:56 +00:00
Hide_D 5d220de739 장시간 Ref 행렬과 NPC 회귀 비용을 줄인다 2026-09-04 08:59:10 +00:00
Hide_D 07897d9765 통합 테스트를 실행 권위별로 분리한다 2026-09-04 06:13:31 +00:00
Hide_D 01fdb51b6e 전투 차등 통합 테스트를 일괄 실행한다 2026-09-04 05:53:08 +00:00
Hide_D 746bbe7e7d 토너먼트 참가 트랜잭션 교착을 해소한다 2026-09-04 05:43:08 +00:00
Hide_D f009c50df5 외교 메시지 응답과 알림 권한 정리 2026-09-04 04:39:00 +00:00
Hide_D 38e516de6d 피장파장 연속 실행 판정을 복구한다 2026-09-04 02:02:36 +00:00
Hide_D ffbd51a2ef docs(clock): record lifecycle recovery boundaries 2026-09-03 21:08:48 +00:00
Hide_D b77276f202 fix(test): make load runner dependency explicit 2026-09-03 21:08:48 +00:00
Hide_D 424f0b66ec test(clock): prevent terminal lifecycle starvation 2026-09-03 21:08:48 +00:00
Hide_D 51f8be492f fix(game): complete clock after invader ending 2026-09-03 20:16:28 +00:00
Hide_D 156f4e68b8 chore(release): advance game schema head 2026-09-03 19:41:42 +00:00
Hide_D a2b0b997a2 fix(clock): preserve UTC leases and unification recovery 2026-09-03 19:40:14 +00:00
Hide_D 62006fb2d4 fix(game): sync diplomatic responses into turn world 2026-09-03 18:18:22 +00:00
Hide_D 03e47feef1 fix(game-api): separate engine message responses 2026-09-03 17:57:17 +00:00
Hide_D 1cb66f76fb fix(game-engine): sync clock authority during pause 2026-09-03 17:50:26 +00:00
Hide_D b250e908f4 test(clock): add lifecycle deploy checkpoint 2026-09-03 17:30:24 +00:00
Hide_D 0aa549a6c5 test(clock): exercise suspended lifecycle actions 2026-09-03 17:29:18 +00:00
Hide_D 000e7e8fa5 일반 메시지 WALL_TIME 저장 회귀 수정 2026-09-03 16:29:48 +00:00
Hide_D abceee8315 시간 도메인과 정지 중 메시지·베팅 경계 정리 2026-09-03 16:01:19 +00:00
Hide_D 10cddbb565 10사용자 장기 수명주기 검증 도구 추가 2026-09-03 13:30:00 +00:00
Hide_D 24a63d338c fix: 게임 시계 완료 감사 경계 보강 2026-09-03 11:14:01 +00:00
Hide_D c10a71fbcc test: 시계 릴리스 게이트와 격리 fixture 완성 2026-09-03 10:31:11 +00:00
Hide_D 69ac6028df feat: 통일 대기 시계를 원자적 reconciliation으로 전환 2026-09-03 10:18:44 +00:00
Hide_D a9f23703d9 feat: 게이트웨이 시계 생명주기 권위 연결 2026-09-03 09:49:53 +00:00
Hide_D a3e2bf90ae feat: 시계 reconciliation 워커와 명령 경계 완성 2026-09-03 09:40:48 +00:00
Hide_D ae7d55ef47 feat: 게임 시계 reconciliation 권위 기반 추가 2026-09-03 08:44:43 +00:00
Hide_D b91dcbcaaa fix: 유산 강화 범위 오류를 한국어로 안내 2026-09-03 07:29:35 +00:00
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
435 changed files with 27144 additions and 14881 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/**"
]
}
+70 -220
View File
@@ -1,39 +1,29 @@
# core2026 작업 지침 # core2026 작업 지침
## 적용 범위와 목표 ## 적용 범위와 문서 역할
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운 이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간 AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
지침 순으로 적용합니다. 보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
목표는 `../ref/sam`의 PHP 서비스를 TypeScript 런타임으로 호환 이관하는 상위 지침의 상대 경로는 `sam_rebuild/`, 이 파일의 경로는 `core2026/` 기준입니다.
것입니다. 내부 구조를 더 깔끔하게 만드는 일보다 기존 결과, 상태 전이, 권한, 독립 checkout에서 상위 작업공간 문서를 참조할 수 없으면 실제 작업공간 위치를
화면과 운영 경계를 보존하는 일이 우선입니다. 확인하고, 확인하지 못한 규칙이나 Ref 근거를 읽었다고 보고하지 않습니다.
## 현재 기준과 저장소 경계 | 작업 | 상세 기준 |
| -------------------------- | ------------------------------------------------------------------------------------------ |
| 구조·파일 책임 | [README](README.md#저장소-구성), [개발자 핸드북](docs/developer/index.md) |
| Ref 비교·계측·분류 | [상위 호환성 계약](../AGENTS.md#호환성-계약), [매핑](../docs/ref-core2026-mapping.md) |
| 테스트 준비·실행·오류 복구 | [테스트 정책](docs/testing-policy.md) |
| CSS·실제 Chromium 비교 | [CSS 구조](docs/frontend-css-architecture.md), [호환 검증](docs/frontend-legacy-parity.md) |
| DB 이관 | [이관 절차](tools/legacy-db-migration/README.md) |
| 배포 대상 선택 | [환경 라우팅](../docs/docker-environment-routing.md) |
| prefix·빌드 변수 | [Caddy 계약](docs/e2e-caddy-routing.md) |
| Gateway·profile 릴리스 | [릴리스 운영](docs/release-operations.md) |
- `core2026/`은 제품 구현 저장소입니다. gateway/game frontend·API, game engine, PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
공통 package, Prisma와 검증 도구가 실제로 존재합니다. 매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`니다. 각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
`devel`은 변경하지 않는 기준선이고 비교 fixture·계측은 `ng_compare`에만
두어 주세요.
- 아키텍처 매핑과 보고서는 별도 상위 저장소의
`../docs/ref-core2026-mapping.md`, `../report/`에 있습니다. 두 Git 경계의
commit과 상태를 혼동하지 말아 주세요.
- 이미지의 운영 소유자는 외부 Caddy의 `/image/*`입니다. 제품 저장소에 무단
복제하거나 앱 경로로 rewrite하지 말아 주세요.
작업 시작과 종료에 최소한 다음을 확인해 주세요.
```sh
git status --short --branch
git remote -v
git rev-parse HEAD
```
상위 매핑/보고서를 바꾸면 상위 저장소에서도 같은 검사를 별도로 수행해 주세요.
dirty 파일은 사용자의 변경일 수 있으므로 관련 없는 수정, 정리, stage,
commit 또는 삭제를 하지 말아 주세요.
## 완료 상태를 해석하는 법 ## 완료 상태를 해석하는 법
@@ -41,7 +31,9 @@ commit 또는 삭제를 하지 말아 주세요.
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고 판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
판정하지 말아 주세요. 판정하지 말아 주세요.
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요. - 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
확정하지 않습니다.
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는 - green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
운영 장애 복구를 자동으로 증명하지 않습니다. 운영 장애 복구를 자동으로 증명하지 않습니다.
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다. - 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
@@ -52,71 +44,19 @@ commit 또는 삭제를 하지 말아 주세요.
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재 기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
코드와 commit으로 다시 확인해 주세요. 코드와 commit으로 다시 확인해 주세요.
## 실제 구조와 책임 ## 도메인 조립과 비교
- `app/gateway-frontend`: 가입·로그인, 로비, 계정, 관리자 운영 UI
- `app/gateway-api`: 계정·세션·profile 정책, operation queue와 PM2
orchestration
- `app/game-frontend`: profile별 게임 SPA와 ref 호환 UI
- `app/game-api`: tRPC/SSE, 조회·입력 API와 battle/auction/tournament worker
- `app/game-engine`: turn daemon, scheduler, 월간 lifecycle와 DB flush
- `packages/common`: 타입, 직렬화, RNG와 공통 유틸리티
- `packages/logic`: 전투·명령·월간 action과 typed action module 도메인 로직
- `packages/infra`: gateway/game Prisma schema, migration과 client
- `tools/integration-tests`: PostgreSQL/Redis 및 ref↔core 차등
- `tools/frontend-legacy-parity`: 실제 Chromium 비교
- `tools/legacy-db-migration`: 장기보존 데이터 CLI 이관
`tools/build-scripts/build-server.mjs`는 현재 profile resource 복사
placeholder입니다. 이를 완성된 배포 bundle이나 검증된 profile build로 설명하지
않습니다. 운영 build/reset/open은 gateway operation, commit별 worktree와
orchestrator 실행 경로를 조사해 주세요.
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold, 장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지 priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()` 말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는 `RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언 `GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md` 상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
있습니다.
## 레거시 매핑과 비교 차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
기능을 변경하기 전에 다음을 end-to-end로 연결해 주세요. 되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
따릅니다.
1. ref entry point와 호출 순서
2. PHP domain class, SQL read/write, template/CSS/JS와 사용자 출력
3. core service/router/action/loader/flush와 frontend 호출부
4. session/auth, validation, transaction, 비동기 경계와 오류 복구
5. RNG 생성·소비와 persistence 순서
`../docs/ref-core2026-mapping.md`에는 1:1 대응을 억지로 만들지 말고 1:N/N:1
소유권과 변환 지점을 기록해 주세요. `확인`, `부분 확인`, `가설`, `미구현`,
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
- 기존 `ng_compare` 이력과 분기 기준을 조사하고 reset/overwrite하지 말아 주세요.
- 계측은 최소·deterministic·가능하면 read-only로 두고 test 환경 guard를
사용해 주세요.
- 계측 유무가 결과, RNG 소비, DB mutation과 출력 순서를 바꾸지 않는지
확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
3. API 요청·응답·오류, session과 인증
4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계약일 수 있습니다. 보안 또는 데이터 손상 위험이
아니면 우선 같은 동작을 재현하고 개선 제안은 분리해 주세요. 허용하는 차이는
사용자 경험, 저장 상태와 후속 턴 결과에 영향이 없다는 근거를 mapping/report에
남겨 주세요.
## 인증과 권한 ## 인증과 권한
@@ -163,18 +103,18 @@ API request
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`, 게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에 `RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가 추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
소비하는 RNG call을 생략하지 말아 주세요. 소비하는 RNG call을 생략하지 말아 주세요.
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state 전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기 side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요. 전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
## 프론트엔드와 룩앤필 ## 프론트엔드와 룩앤필
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는 UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity, 따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
- gateway `/gateway/`, game `/che/``/hwe/` prefix에서 direct navigation, - gateway `/gateway/`, game `/che/``/hwe/` prefix에서 direct navigation,
refresh, tRPC, SSE와 asset URL을 확인해 주세요. refresh, tRPC, SSE와 asset URL을 확인해 주세요.
@@ -191,20 +131,10 @@ layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을 - `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
일괄 disable하지 말아 주세요. 일괄 disable하지 말아 주세요.
UI를 변경하면 실제 Chromium을 사용해 주세요. Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
- ref와 core에 같은 Chromium, viewport, device scale, zoom, locale, font,
image와 로그인/test data를 사용해 주세요.
- 전체·영역 screenshot과 `getBoundingClientRect()`,
`getComputedStyle()`을 수집해 주세요.
- `hover()`, focus, pointer down/up, checked/selected/disabled, dropdown,
modal과 transition 상태를 실제 interaction으로 만들어 주세요.
- pixel diff mask는 시간·난수 등 불가피한 영역만 최소화하고 이유를 기록해 주세요.
- auth redirect만 확인하고 visual parity라고 주장하지 말아 주세요.
- 민감정보가 보이는 artifact는 저장하거나 report에 넣지 말아 주세요.
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은 CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
`docs/frontend-legacy-parity.md`를 따라 주세요. `docs/frontend-legacy-parity.md`를 따릅니다.
## DB와 레거시 데이터 이관 ## DB와 레거시 데이터 이관
@@ -244,89 +174,29 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가 worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요. 명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
현재 외부 호스트 계약은 `0.0.0.0` bind`/gateway/`, `/che/`, `/hwe/` 컨테이너 서비스는 필요한 listener를 `0.0.0.0` bind합니다. 로컬 E2E의
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지 `/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요. 대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다. HTTPS 검증과 구분합니다.
```sh
VITE_APP_BASE_PATH=/gateway \
VITE_GATEWAY_API_URL=/gateway/api/trpc \
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
pnpm --filter @sammo-ts/gateway-frontend build
```
profile별 정확한 포트와 game frontend/API 변수는
`docs/e2e-caddy-routing.md`에서 확인하고 현재 인프라와 대조해 주세요.
## 개발과 검증 절차 ## 개발과 검증 절차
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요. 상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요. 검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다. 명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요. [테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
기본 정적 검사는 실제 루트 script를 사용해 주세요. - 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
```sh `packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
CI=1 pnpm typecheck - Vitest filter에 불필요한 `--`를 넣지 않습니다.
pnpm check:architecture
pnpm lint
pnpm test
pnpm build
```
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
adapter로 분리합니다.
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts` 예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요. - frontend package의 `test` placeholder UI 검증이 아닙니다. 해당 Playwright
해당 Playwright script 또는 legacy parity suite를 사용해 주세요. script 또는 legacy parity suite를 실행합니다.
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use` - ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
(nvm 환경은 `nvm use`) 후 오래된 report만 보고 명령을 가정하지 않습니다.
`pnpm test:bootstrap`을 실행해 install, Prisma client와 - 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
common → logic → infra → game-engine → API 산출물을 준비해 주세요. 이미
install된 checkout은 `pnpm test:prepare`를 사용합니다. Playwright
`webServer` 실패를 이 준비가 끝나기 전 제품 회귀로 분류하지 말아 주세요.
- E2E port가 다른 worktree와 겹치면 설정 파일을 임시 수정하지 말고
`PLAYWRIGHT_FRONTEND_PORT`, `PLAYWRIGHT_GATEWAY_FRONTEND_PORT` 또는
`FRONTEND_PARITY_*_PORT`를 지정합니다. 같은 game frontend `dist`를 쓰는
profile production run은 직렬로 실행합니다.
- 전체 lint/test의 기존 실패가 있으면 targeted 결과와 baseline 재현 결과를
구분하고, 관련 없는 기대값을 완화해 숨기지 말아 주세요.
외부 서비스 없는 기본 integration:
```sh
pnpm test:integration
```
전용 PostgreSQL/Redis를 준비한 조건부 전체 경계:
```sh
pnpm test:integration:conditional
```
ref 명령과 UI entry point:
```sh
pnpm check:legacy:general
pnpm check:legacy:nation
pnpm test:e2e:frontend-legacy
```
각 command가 요구하는 ref checkout, Docker, DB URL, secret과 fixture는 관련
docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report나 제안 문서만 보고
실행하지 말아 주세요.
## 코드 스타일 ## 코드 스타일
@@ -350,42 +220,22 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
## 문서와 보고서 ## 문서와 보고서
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요. 변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계 보고서의 필수 내용과 저장소별 commit 기록은
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약 [상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
- `docs/*`: core2026 내부 구현·운영 상세
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
## Git, worktree와 commit ## Git, worktree와 commit
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다. [상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에 [기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요. 충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요. 않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
- ref 비교 변경과 core 제품 변경, 상위 mapping/report 변경은 각 Git
저장소에서 별도 commit해 주세요.
- 생성물, `.env`, DB volume, log, coverage, screenshot, test-results와
secret을 commit하지 말아 주세요.
- worktree 정리는 clean status와
`git merge-base --is-ancestor HEAD <baseline>`을 모두 확인한 뒤 non-force
제거해 주세요. 이름, 나이 또는 uncommitted 여부만으로 삭제하지 말아 주세요.
- 사용자가 commit을 요청하지 않은 일반 작업은 diff와 권장 commit 경계를
준비하되 임의로 commit하지 말아 주세요.
## Qwen 보조 분석 Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만 worktree 정리가 요청 범위라면 clean status와
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을 `git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
보내지 말아 주세요. 이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
- 응답 budget은 1284096 token 범위로 제한해 주세요.
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
로컬 분석을 계속해 주세요.
+20 -6
View File
@@ -1,8 +1,9 @@
# 삼국지 모의전투 HiDCHe core2026 # 삼국지 모의전투 HiDCHe core2026
`core2026``../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm `core2026` PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작ref 구현과 실제 실행 발전하는 TypeScript pnpm 모노레포입니다. 계승 영역Ref의 동작·상태·권한과
결과를 기준으로 검증합니다. 비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
## 저장소 구성 ## 저장소 구성
@@ -175,6 +176,17 @@ pnpm build
pnpm test:integration pnpm test:integration
``` ```
`pnpm lint`는 ESLint의 일반 TypeScript/Vue 검사, Oxlint의 type-aware TypeScript
Promise 검사, ESLint의 type-aware Vue Promise 검사를 메모리 경계에 맞게 나누어
실행합니다. cache를 제외한 전체 검증은 `pnpm lint:uncached`, package 하나만
검사할 때는 기존처럼 `pnpm --filter <package-name> lint`를 사용합니다.
루트 `test``build`는 Codex와 일반 개발 검증에서 성공 로그를 줄이는 간단 출력이
기본입니다. 테스트별 진행과 전체 Turbo task 로그가 필요한 실패 진단에는
`pnpm test-verbose`, `pnpm build-verbose`를 사용합니다. Gateway 서버 빌드 워커는
관리자 operation 화면에 진행 상황을 제공하기 위해 별도의 streaming 상세 출력
정책을 계속 사용합니다.
PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다. PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다.
```sh ```sh
@@ -241,9 +253,11 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
`game → gateway`입니다. 양쪽 migration directory 이름은 공유 `game → gateway`입니다. 양쪽 migration directory 이름은 공유
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다. `_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한 활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE, `/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다. [환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다. `build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의 완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
+2 -2
View File
@@ -20,8 +20,8 @@
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js", "worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js",
"worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-api",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-api",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json" "typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
}, },
+3 -2
View File
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
generalId: number, generalId: number,
input: OpenAuctionInput, input: OpenAuctionInput,
requestId?: string requestId?: string
): Promise<{ auctionId: number; closeAt: string }> => { ): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'auctionOpen', type: 'auctionOpen',
...(requestId ? { requestId } : {}), ...(requestId ? { requestId } : {}),
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
const closeAt = new Date(result.closeAt); const closeAt = new Date(result.closeAt);
const gameTime = await loadCurrentGameTime(ctx.db); const gameTime = await loadCurrentGameTime(ctx.db);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) }, { score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
]); ]);
return { return {
auctionId: result.auctionId, auctionId: result.auctionId,
closeAt: result.closeAt, closeAt: result.closeAt,
closeTick: result.closeTick,
}; };
}; };
+8 -7
View File
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
} }
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => { export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
if (closeTick !== null && closeTick !== undefined) { void time;
const value = Number(closeTick); void closeAt;
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`); if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
return value; const value = Number(closeTick);
} if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
return time.dateToTick(closeAt) ?? closeAt.getTime(); return value;
}; };
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => { export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
if (row.status === 'FINALIZING') { if (row.status === 'FINALIZING') {
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다. // 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
return time.tick ?? time.now.getTime(); if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
return time.tick;
} }
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick); return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
}; };
+113 -56
View File
@@ -1,4 +1,7 @@
import { import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector, createGamePostgresConnector,
createRedisConnector, createRedisConnector,
type GamePrismaClient, type GamePrismaClient,
@@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import { buildAuctionTimerKeys } from './keys.js'; import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js'; import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
@@ -24,13 +28,27 @@ interface RedisTimerClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>; zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
zRem(key: string, values: string | string[]): Promise<number>; zRem(key: string, values: string | string[]): Promise<number>;
zRemRangeByScore(key: string, min: number, max: number): Promise<number>; zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
} }
const POP_DUE_AUCTIONS_SCRIPT = `
if redis.call('GET', KEYS[2]) ~= ARGV[1]
or redis.call('GET', KEYS[3]) ~= ARGV[2]
or redis.call('GET', KEYS[4]) ~= 'RUNNING' then
return { '__CLOCK_FENCE__' }
end
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4])
if #ids > 0 then
redis.call('ZREM', KEYS[1], unpack(ids))
end
return ids
`;
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1; const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
interface AuctionFinalizeDeadline { interface AuctionFinalizeDeadline {
closeAt: Date; closeAt: Date;
closeTick: bigint | null; closeTick: bigint;
} }
interface AuctionFinalizeCommand { interface AuctionFinalizeCommand {
@@ -38,19 +56,10 @@ interface AuctionFinalizeCommand {
requestId: string; requestId: string;
auctionId: number; auctionId: number;
expectedCloseAt: string; expectedCloseAt: string;
expectedCloseTick?: number; expectedCloseTick: number;
} }
interface AuctionFinalizeEventRecord { const readSafeCloseTick = (closeTick: bigint): number => {
target: string;
eventType: string;
payload: unknown;
status: string;
result: unknown;
}
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
if (closeTick === null) return undefined;
const value = Number(closeTick); const value = Number(closeTick);
if (!Number.isSafeInteger(value)) { if (!Number.isSafeInteger(value)) {
throw new Error(`Auction close tick is unsafe: ${closeTick}`); throw new Error(`Auction close tick is unsafe: ${closeTick}`);
@@ -63,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
deadline: AuctionFinalizeDeadline, deadline: AuctionFinalizeDeadline,
retry = 0 retry = 0
): string => { ): string => {
const generation = const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
const base = `auction:finalize:${auctionId}:${generation}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
return retry > 0 ? `${base}:retry:${retry}` : base; return retry > 0 ? `${base}:retry:${retry}` : base;
}; };
@@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = (
requestId, requestId,
auctionId, auctionId,
expectedCloseAt: deadline.closeAt.toISOString(), expectedCloseAt: deadline.closeAt.toISOString(),
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }), expectedCloseTick: readSafeCloseTick(deadline.closeTick),
}); });
const isMatchingAuctionFinalizeEvent = ( const isMatchingAuctionFinalizeEvent = (
@@ -95,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
payload !== null && typeof payload === 'object' && !Array.isArray(payload) payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>) ? (payload as Record<string, unknown>)
: null; : null;
const expectedGenerationMatches = const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
payloadRecord?.expectedCloseTick !== undefined
? payloadRecord.expectedCloseTick === command.expectedCloseTick
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
return ( return (
event.target === 'ENGINE' && event.target === 'ENGINE' &&
event.eventType === command.type && event.eventType === command.type &&
@@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId; return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
}; };
const popDueAuctionIds = async ( export const popDueAuctionIds = async (
redis: RedisTimerClient, redis: RedisTimerClient,
timerKey: string, timerKey: string,
nowMs: number, nowMs: number,
batchSize: number batchSize: number,
clockFence?: {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
}
): Promise<string[]> => { ): Promise<string[]> => {
if (clockFence) {
if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.');
const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, {
keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey],
arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)],
});
if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.');
if (result[0] === '__CLOCK_FENCE__') return [];
return result.map(String);
}
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } }); const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
if (ids.length > 0) { if (ids.length > 0) {
await redis.zRem(timerKey, ids); await redis.zRem(timerKey, ids);
@@ -157,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') { if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
continue; continue;
} }
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick }; const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline); const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
const bases = [...new Set([canonicalBase, legacyBase])];
const events = await options.db.inputEvent.findMany({ const events = await options.db.inputEvent.findMany({
where: { where: {
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]), OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
}, },
select: { requestId: true, target: true, eventType: true, payload: true, status: true }, select: { requestId: true, target: true, eventType: true, payload: true, status: true },
orderBy: { sequence: 'desc' }, orderBy: { sequence: 'desc' },
@@ -182,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
timers.push({ timers.push({
score: score:
row.status === 'FINALIZING' row.status === 'FINALIZING'
? (options.gameTime.tick ?? options.gameTime.now.getTime()) ? (() => {
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
return options.gameTime.tick;
})()
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick), : resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
value: String(row.id), value: String(row.id),
}); });
@@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: {
nowMs: number; nowMs: number;
nowTick?: number | null; nowTick?: number | null;
historyNowMs?: number; historyNowMs?: number;
expectedClockRevision?: number;
expectedDeadlineGeneration?: number;
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => { }): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options; const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
const auctionId = Number(id); const auctionId = Number(id);
@@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: {
} }
const now = new Date(nowMs); const now = new Date(nowMs);
const outcome = await db.$transaction(async (transaction) => { const outcome = await db.$transaction(async (transaction) => {
if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
const [world] = await transaction.$queryRaw<
Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }>
>(GamePrisma.sql`
SELECT
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (
!world ||
world.clockPhase !== 'RUNNING' ||
world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) ||
world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1)
) {
return { status: 'RESCHEDULED' as const, clockFenceFailed: true };
}
}
const current = await transaction.auction.findUnique({ const current = await transaction.auction.findUnique({
where: { id: auctionId }, where: { id: auctionId },
select: { status: true, closeAt: true, closeTick: true }, select: { status: true, closeAt: true, closeTick: true },
@@ -221,10 +264,10 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const }; return { status: 'IGNORED' as const };
} }
if (current.status === 'OPEN') { if (current.status === 'OPEN') {
const isDue = if (current.closeTick === null || nowTick === null) {
current.closeTick !== null && nowTick !== null throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
? current.closeTick <= BigInt(nowTick) }
: current.closeTick === null && current.closeAt.getTime() <= now.getTime(); const isDue = current.closeTick <= BigInt(nowTick);
if (!isDue) { if (!isDue) {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick }; return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
} }
@@ -233,24 +276,15 @@ export const processDueAuctionId = async (options: {
return { status: 'IGNORED' as const }; return { status: 'IGNORED' as const };
} }
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick }; const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) { for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry); const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry); const existing = await transaction.inputEvent.findUnique({
const candidateRequestIds = [...new Set([requestId, legacyRequestId])]; where: { requestId },
let existing: AuctionFinalizeEventRecord | null = null; select: { target: true, eventType: true, payload: true, status: true, result: true },
let existingRequestId = requestId; });
for (const candidateRequestId of candidateRequestIds) { const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
existing = await transaction.inputEvent.findUnique({
where: { requestId: candidateRequestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
if (existing) {
existingRequestId = candidateRequestId;
break;
}
}
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
if (!existing) { if (!existing) {
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId); const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
await transaction.inputEvent.create({ await transaction.inputEvent.create({
@@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: {
return { status: 'PENDING' as const }; return { status: 'PENDING' as const };
} }
if (!isMatchingAuctionFinalizeEvent(existing, command)) { if (!isMatchingAuctionFinalizeEvent(existing, command)) {
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`); throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
} }
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') { if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
return { status: 'PENDING' as const }; return { status: 'PENDING' as const };
} }
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) { if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
throw new Error( throw new Error(
`Auction remained ${current.status} after successful durable event: ${existingRequestId}` `Auction remained ${current.status} after successful durable event: ${requestId}`
); );
} }
} }
@@ -284,6 +318,10 @@ export const processDueAuctionId = async (options: {
return 'PENDING'; return 'PENDING';
} }
if (outcome.status === 'RESCHEDULED') { if (outcome.status === 'RESCHEDULED') {
if ('clockFenceFailed' in outcome) {
await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]);
return 'RESCHEDULED';
}
const gameTime = await loadCurrentGameTime(db, now); const gameTime = await loadCurrentGameTime(db, now);
await redis.zAdd(timerKey, [ await redis.zAdd(timerKey, [
{ {
@@ -315,18 +353,30 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() }, { name: 'auction-worker-postgres', run: () => postgres.disconnect() },
]); ]);
let nextResyncAt = Date.now(); let nextResyncAt = performance.now();
const pendingFinalizationIds = new Set<number>(); const pendingFinalizationIds = new Set<number>();
try { try {
while (!control.signal.aborted) { while (!control.signal.aborted) {
const operationalNowMs = Date.now(); const operationalNowMs = Date.now();
const operationalElapsedMs = performance.now();
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs)); const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime(); const gameNowMs = gameTime.now.getTime();
const dueScore = gameTime.tick ?? gameNowMs; const dueScore = gameTime.tick ?? gameNowMs;
if (operationalNowMs >= nextResyncAt) { if (gameTime.phase && gameTime.phase !== 'RUNNING') {
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue;
}
const clockFence = gameTime.phase
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
: null;
if (gameTime.phase && !clockFence) {
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
continue;
}
if (operationalElapsedMs >= nextResyncAt) {
await seedAuctionTimers(postgres.prisma, redis.client, keys); await seedAuctionTimers(postgres.prisma, redis.client, keys);
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs; nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
} }
if (pendingFinalizationIds.size > 0) { if (pendingFinalizationIds.size > 0) {
const reconciliation = await reconcilePendingAuctionTimers({ const reconciliation = await reconcilePendingAuctionTimers({
@@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
if (historyTrimBefore > 0) { if (historyTrimBefore > 0) {
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore); await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
} }
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100); const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined);
if (dueIds.length > 0) { if (dueIds.length > 0) {
for (const id of dueIds) { for (const id of dueIds) {
try { try {
@@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
nowMs: gameNowMs, nowMs: gameNowMs,
nowTick: gameTime.tick, nowTick: gameTime.tick,
historyNowMs: operationalNowMs, historyNowMs: operationalNowMs,
...(clockFence
? {
expectedClockRevision: clockFence.revision,
expectedDeadlineGeneration: clockFence.generation,
}
: {}),
}); });
if (outcome === 'PENDING') { if (outcome === 'PENDING') {
pendingFinalizationIds.add(Number(id)); pendingFinalizationIds.add(Number(id));
@@ -395,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
await closeResources(); await closeResources();
} }
}; };
import { performance } from 'node:perf_hooks';
+12 -1
View File
@@ -65,7 +65,18 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>; type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>; type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'; type WorldClockFields =
| 'clockBaseTime'
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'clockRecoveryStartTick'
| 'clockRecoveryEndTick'
| 'clockRecoveryStartWallAt'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
| 'deadlineGeneration';
type GeneralClockFields = 'turnTick' | 'recentWarTick'; type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma // Transitional API fixtures may still model the pre-clock row. Runtime Prisma
+29 -20
View File
@@ -1,10 +1,15 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra'; import {
acquireGameSchemaAdvisoryXactLock,
readInputEventClockCoordinate,
type DatabaseClient,
type GamePrisma,
} from '@sammo-ts/infra';
import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
@@ -82,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
async sendCommand(command: TurnDaemonCommand): Promise<string> { async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
if (durableCommand.type === 'npcPossessGeneral') { // Rolling-upgrade compatibility: older API versions supplied game
delete durableCommand.acceptedGameAt; // coordinates. They are deliberately not persisted as command facts;
} // the daemon assigns the authoritative processing coordinate while
// claiming the input event under the clock fence.
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
if (command.type === 'npcPossessGeneral') { if (command.type === 'npcPossessGeneral') {
const existing = await this.db.inputEvent.findUnique({ const existing = await this.db.inputEvent.findUnique({
where: { requestId }, where: { requestId },
@@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
try { try {
if (command.type === 'npcPossessGeneral' && this.db.$transaction) { if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
const rejectionReason = await this.db.$transaction(async (transaction) => { const rejectionReason = await this.db.$transaction(async (transaction) => {
const coordinate = await readInputEventClockCoordinate(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`); await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
const token = await transaction.npcSelectionToken.findFirst({ const token = await transaction.npcSelectionToken.findFirst({
where: { where: {
ownerUserId: command.userId, ownerUserId: command.userId,
nonce: command.tokenNonce, nonce: command.tokenNonce,
validUntil: { gte: acceptedGameAt }, validUntilTick: { gte: coordinate.gameTick },
}, },
select: { pickResult: true }, select: { pickResult: true },
}); });
@@ -126,18 +133,20 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
) { ) {
return '선택한 장수가 목록에 없습니다.'; return '선택한 장수가 목록에 없습니다.';
} }
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = { await this.createInputEvent(transaction, durableCommand, requestId);
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
acceptedGameAt: acceptedGameAt.toISOString(),
};
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
return null; return null;
}); });
if (rejectionReason) { if (rejectionReason) {
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason); throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
} }
} else { } else {
await this.createInputEvent(this.db, durableCommand, requestId); if (this.db.$transaction) {
await this.db.$transaction(async (transaction) => {
await this.createInputEvent(transaction, durableCommand, requestId);
});
} else {
await this.createInputEvent(this.db, durableCommand, requestId);
}
} }
} catch (error) { } catch (error) {
if (error instanceof RejectedNpcPossessionCommandError) { if (error instanceof RejectedNpcPossessionCommandError) {
@@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
private async createInputEvent( private async createInputEvent(
db: DatabaseClient, db: DatabaseClient,
command: TurnDaemonCommand, command: TurnDaemonCommand,
requestId: string, requestId: string
createdAt?: Date
): Promise<void> { ): Promise<void> {
await db.inputEvent.create({ await db.inputEvent.create({
data: { data: {
@@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
eventType: command.type, eventType: command.type,
payload: asJson(command), payload: asJson(command),
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
...(createdAt ? { createdAt } : {}), // PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
// authoritative game coordinate when the daemon claims it.
}, },
}); });
} }
@@ -192,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
} }
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> { private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
while (Date.now() < deadline) { while (performance.now() < deadline) {
const event = await this.db.inputEvent.findUnique({ const event = await this.db.inputEvent.findUnique({
where: { requestId }, where: { requestId },
select: { status: true, result: true, error: true }, select: { status: true, result: true, error: true },
@@ -204,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
if (event?.status === 'FAILED') { if (event?.status === 'FAILED') {
throw new FailedTurnDaemonCommandError(requestId, event.error); throw new FailedTurnDaemonCommandError(requestId, event.error);
} }
await delay(Math.min(50, Math.max(1, deadline - Date.now()))); await delay(Math.min(50, Math.max(1, deadline - performance.now())));
} }
return null; return null;
} }
+74 -50
View File
@@ -1,6 +1,11 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra'; import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
acquireGameSchemaAdvisoryXactLock,
type DatabaseClient as InfraDatabaseClient,
} from '@sammo-ts/infra';
import type { DatabaseClient } from './context.js'; import type { DatabaseClient } from './context.js';
@@ -29,8 +34,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>; $executeRawUnsafe(query: string): Promise<number>;
}; };
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string => const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => { JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') { if (typeof entry === 'bigint') {
@@ -85,6 +88,9 @@ const insertPendingIfAbsent = async (
actor_user_id, actor_user_id,
status, status,
attempts, attempts,
accepted_game_tick,
accepted_clock_revision,
accepted_deadline_generation,
created_at created_at
) )
VALUES ( VALUES (
@@ -95,6 +101,9 @@ const insertPendingIfAbsent = async (
${options.actorUserId}, ${options.actorUserId},
'PENDING'::"InputEventStatus", 'PENDING'::"InputEventStatus",
0, 0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
ON CONFLICT (request_id) DO NOTHING ON CONFLICT (request_id) DO NOTHING
@@ -153,20 +162,22 @@ const claimInputEvent = async (
requestId: string, requestId: string,
payloadIdentity: ApiInputPayloadIdentity payloadIdentity: ApiInputPayloadIdentity
): Promise<void> => { ): Promise<void> => {
await db.inputEvent.update({ await db.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
payload: asJson(payloadIdentity), status = 'PROCESSING'::"InputEventStatus",
status: 'PROCESSING', result = NULL,
result: GamePrisma.DbNull, error = NULL,
error: null, attempts = attempts + 1,
attempts: { increment: 1 }, locked_by = NULL,
lockedBy: null, lease_until = NULL,
leaseUntil: null, processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), processing_game_tick = NULL,
completedAt: null, processing_clock_revision = NULL,
}, processing_deadline_generation = NULL,
}); completed_at = NULL
WHERE request_id = ${requestId}
`);
}; };
const markUnexpectedFailure = async ( const markUnexpectedFailure = async (
@@ -177,6 +188,7 @@ const markUnexpectedFailure = async (
actorUserId: string | null; actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity; payloadIdentity: ApiInputPayloadIdentity;
error: unknown; error: unknown;
acquireClockFence: boolean;
} }
): Promise<void> => { ): Promise<void> => {
if (!db.$transaction) return; if (!db.$transaction) return;
@@ -184,6 +196,9 @@ const markUnexpectedFailure = async (
try { try {
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
if (options.acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, options); await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId); const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options); const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
@@ -192,20 +207,19 @@ const markUnexpectedFailure = async (
// late failure recorder must never replace its durable success. // late failure recorder must never replace its durable success.
return; return;
} }
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId: options.requestId }, UPDATE input_event
data: { SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
payload: asJson(options.payloadIdentity), status = 'FAILED'::"InputEventStatus",
status: 'FAILED', result = NULL,
result: GamePrisma.DbNull, error = ${message},
error: message, attempts = attempts + 1,
attempts: { increment: 1 }, locked_by = NULL,
lockedBy: null, lease_until = NULL,
leaseUntil: null, processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
processingAt: new Date(), completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${options.requestId}
}, `);
});
}); });
} catch { } catch {
// Preserve the transaction failure that the caller actually observed. If // Preserve the transaction failure that the caller actually observed. If
@@ -220,10 +234,12 @@ export const executeInputEvent = async <T>(options: {
eventType: string; eventType: string;
payload: unknown; payload: unknown;
actorUserId?: string | null; actorUserId?: string | null;
acquireClockFence?: boolean;
execute(db: DatabaseClient): Promise<T>; execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => { }): Promise<T> => {
const { db, requestId, eventType, payload, execute } = options; const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null; const actorUserId = options.actorUserId ?? null;
const acquireClockFence = options.acquireClockFence !== false;
const payloadIdentity = createApiInputPayloadIdentity(payload); const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) { if (!db.$transaction) {
return execute(db); return execute(db);
@@ -233,6 +249,9 @@ export const executeInputEvent = async <T>(options: {
let outcome: InputEventOutcome<T>; let outcome: InputEventOutcome<T>;
try { try {
outcome = await db.$transaction(async (transaction) => { outcome = await db.$transaction(async (transaction) => {
if (acquireClockFence) {
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
}
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity }); await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
const row = await lockInputEvent(transaction, requestId); const row = await lockInputEvent(transaction, requestId);
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity }); const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
@@ -258,36 +277,41 @@ export const executeInputEvent = async <T>(options: {
try { try {
const value = await execute(transaction); const value = await execute(transaction);
const durableResult = canonicalJsonValue(value); const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET status = 'SUCCEEDED'::"InputEventStatus",
status: 'SUCCEEDED', result = CAST(${JSON.stringify(durableResult)} AS jsonb),
result: asJson(durableResult), error = NULL,
error: null, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${requestId}
}, `);
});
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value }; return { kind: 'executed', value };
} catch (error) { } catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`); await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.'; const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({ await transaction.$executeRaw(GamePrisma.sql`
where: { requestId }, UPDATE input_event
data: { SET status = 'FAILED'::"InputEventStatus",
status: 'FAILED', result = NULL,
result: GamePrisma.DbNull, error = ${message},
error: message, completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
completedAt: new Date(), WHERE request_id = ${requestId}
}, `);
});
return { kind: 'failed', error }; return { kind: 'failed', error };
} }
}); });
} catch (error) { } catch (error) {
if (businessStarted && !(error instanceof DuplicateInputEventError)) { if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error }); await markUnexpectedFailure(db, {
requestId,
eventType,
actorUserId,
payloadIdentity,
error,
acquireClockFence,
});
} }
throw error; throw error;
} }
+30 -2
View File
@@ -1,5 +1,6 @@
import type { GameApiContext, WorldStateRow } from '../context.js'; import type { GameApiContext, WorldStateRow } from '../context.js';
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
export type MapCityCompact = [number, number, number, number, number, number]; export type MapCityCompact = [number, number, number, number, number, number];
@@ -16,6 +17,7 @@ export type BaseMapResult = {
initialLevel: number; initialLevel: number;
increaseYears: number; increaseYears: number;
}; };
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
cityList: MapCityCompact[]; cityList: MapCityCompact[];
nationList: MapNationCompact[]; nationList: MapNationCompact[];
}; };
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
const BASE_MAP_TTL_SECONDS = 30; const BASE_MAP_TTL_SECONDS = 30;
const PUBLIC_MAP_TTL_SECONDS = 600; const PUBLIC_MAP_TTL_SECONDS = 600;
const resolveStartYear = (worldState: WorldStateRow): number => { const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta); const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = scenarioMeta.startYear; const startYear = scenarioMeta.startYear;
@@ -87,6 +89,28 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe
}; };
}; };
// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다.
export const resolveMapUniqueItemLimit = (
worldState: Pick<WorldStateRow, 'config' | 'meta' | 'currentYear'>
): BaseMapResult['uniqueItemLimit'] => {
const config = resolveUniqueConfig(asRecord(asRecord(worldState.config).const));
const startYear = resolveStartYear(worldState);
const relativeYear = worldState.currentYear - startYear;
const slotCount = Object.keys(config.allItems).length;
let count = Math.min(1, slotCount);
for (const [targetYear, targetCount] of config.maxUniqueItemLimit) {
const nextCount = Math.min(targetCount, slotCount);
if (relativeYear < targetYear) {
if (nextCount !== count) {
return { count, until: { year: startYear + targetYear - 1, month: 12 } };
}
} else {
count = nextCount;
}
}
return { count, until: null };
};
const normalizeNumberRecord = (value: unknown): Record<number, number> => { const normalizeNumberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) { if (!isRecord(value)) {
return {}; return {};
@@ -154,7 +178,10 @@ const loadBaseMap = async (
} }
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) as BaseMapResult; const parsed = JSON.parse(cached) as BaseMapResult;
if (parsed.uniqueItemLimit) {
return parsed;
}
} catch { } catch {
// Ignore cache parse errors. // Ignore cache parse errors.
} }
@@ -207,6 +234,7 @@ const loadBaseMap = async (
year: worldState.currentYear, year: worldState.currentYear,
month: worldState.currentMonth, month: worldState.currentMonth,
techLevelLimit: resolveTechLevelLimit(worldState), techLevelLimit: resolveTechLevelLimit(worldState),
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
cityList, cityList,
nationList, nationList,
}; };
+148 -87
View File
@@ -1,9 +1,13 @@
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import {
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; enqueuePrivateMessageWebPush,
GamePrisma,
persistMessageEnvelope,
type MessageGameContext,
} from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime } from '../services/gameClock.js';
export interface MessageView { export interface MessageView {
id: number; id: number;
@@ -22,7 +26,9 @@ interface MessageRow {
src: number; src: number;
dest: number; dest: number;
time: Date; time: Date;
valid_until: Date; created_at_wall: Date;
action_status: string | null;
expires_game_tick: bigint | null;
message: unknown; message: unknown;
} }
@@ -48,70 +54,73 @@ const formatMessageTime = (value: Date): string => {
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; )} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
}; };
const messageValidityPredicate = (gameTime: CurrentGameTime) => { const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
if (gameTime.tick === null) {
// A legacy or partially migrated profile has no authoritative logical
// tick. Rows that already carry a tick still need the wall-time
// fallback used by the clock migration.
return GamePrisma.sql`valid_until > ${gameTime.now}`;
}
return GamePrisma.sql`(
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)`;
};
const toMessageView = (row: MessageRow): MessageView => {
const payload = parsePayload(row.message); const payload = parsePayload(row.message);
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
const actionState =
actionStatus === null
? null
: actionStatus === 'PENDING'
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
? 'expired'
: 'pending'
: actionStatus === 'RESOLVED'
? 'resolved'
: 'unavailable';
return { return {
id: row.id, id: row.id,
msgType: row.type, msgType: row.type,
src: payload.src, src: payload.src,
dest: row.type === 'public' ? null : payload.dest, dest: row.type === 'public' ? null : payload.dest,
text: payload.text, text: payload.text,
option: payload.option ?? null, option:
time: formatMessageTime(new Date(row.time)), actionState !== null && payload.option && typeof payload.option === 'object'
? { ...payload.option, actionState, used: actionState !== 'pending' }
: (payload.option ?? null),
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
}; };
}; };
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => { export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
const gameTime = await loadCurrentGameTime(db); const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
const toTickOrNull = (date: Date): bigint | null => { let gameContext: MessageGameContext | null = null;
// Ref represents its unlimited 9999-12-31 message lifetime with the if (typeof action === 'string' && action !== '') {
// largest safe game tick instead of falling back to a wall-clock-only row. const gameTime = await loadCurrentGameTime(db);
if (date.getUTCFullYear() >= 9000) { if (
return BigInt(MAX_SAFE_GAME_TICK); gameTime.tick === null ||
gameTime.revision === null ||
gameTime.revision === undefined ||
gameTime.deadlineGeneration === null ||
gameTime.deadlineGeneration === undefined
) {
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
} }
try { let expiresGameTick: bigint | null = null;
const tick = gameTime.dateToTick(date); if (draft.validUntil.getUTCFullYear() < 9000) {
return tick === null ? null : BigInt(tick); const expires = gameTime.dateToTick(draft.validUntil);
} catch { if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
return null; expiresGameTick = BigInt(expires);
} }
}; gameContext = {
const rows = await db.$queryRaw<Array<{ id: number }>>` occurredGameTick: BigInt(gameTime.tick),
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message) clockRevision: BigInt(gameTime.revision),
VALUES ( deadlineGeneration: BigInt(gameTime.deadlineGeneration),
${draft.mailbox}, expiresGameTick,
${draft.msgType}, };
${draft.srcId},
${draft.destId},
${draft.time},
${toTickOrNull(draft.time)},
${draft.validUntil},
${toTickOrNull(draft.validUntil)},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) {
throw new Error('Failed to insert message row.');
} }
const id = await persistMessageEnvelope(db, draft, gameContext);
await enqueuePrivateMessageWebPush(db, draft, id); await enqueuePrivateMessageWebPush(db, draft, id);
return id; return id;
}; };
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
const gameTime = await loadCurrentGameTime(db);
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
return rows.map((row) => toMessageView(row, currentGameTick));
};
export const fetchMessagesFromMailbox = async (params: { export const fetchMessagesFromMailbox = async (params: {
db: DatabaseClient; db: DatabaseClient;
mailbox: number; mailbox: number;
@@ -120,19 +129,20 @@ export const fetchMessagesFromMailbox = async (params: {
fromSeq: number; fromSeq: number;
}): Promise<MessageView[]> => { }): Promise<MessageView[]> => {
const fromSeq = Math.max(params.fromSeq, 0); const fromSeq = Math.max(params.fromSeq, 0);
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>` const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE mailbox = ${params.mailbox} ma.status AS action_status, ma.expires_game_tick
AND type = ${params.msgType} FROM message m
AND ${messageValidityPredicate(gameTime)} LEFT JOIN message_action ma ON ma.message_id = m.id
AND id >= ${fromSeq} WHERE m.mailbox = ${params.mailbox}
ORDER BY id DESC AND m.type = ${params.msgType}
AND m.id >= ${fromSeq}
ORDER BY m.id DESC
LIMIT ${params.limit} LIMIT ${params.limit}
`; `;
return rows.map(toMessageView); return loadMessageViews(params.db, rows);
}; };
export const fetchOldMessagesFromMailbox = async (params: { export const fetchOldMessagesFromMailbox = async (params: {
@@ -142,28 +152,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
toSeq: number; toSeq: number;
limit: number; limit: number;
}): Promise<MessageView[]> => { }): Promise<MessageView[]> => {
const gameTime = await loadCurrentGameTime(params.db);
const rows = await params.db.$queryRaw<MessageRow[]>` const rows = await params.db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE mailbox = ${params.mailbox} ma.status AS action_status, ma.expires_game_tick
AND type = ${params.msgType} FROM message m
AND ${messageValidityPredicate(gameTime)} LEFT JOIN message_action ma ON ma.message_id = m.id
AND id < ${params.toSeq} WHERE m.mailbox = ${params.mailbox}
ORDER BY id DESC AND m.type = ${params.msgType}
AND m.id < ${params.toSeq}
ORDER BY m.id DESC
LIMIT ${params.limit} LIMIT ${params.limit}
`; `;
return rows.map(toMessageView); return loadMessageViews(params.db, rows);
}; };
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => { export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db);
const rows = await db.$queryRaw<MessageRow[]>` const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE id = ${id} ma.status AS action_status, ma.expires_game_tick
AND ${messageValidityPredicate(gameTime)} FROM message m
LEFT JOIN message_action ma ON ma.message_id = m.id
WHERE m.id = ${id}
LIMIT 1 LIMIT 1
`; `;
const row = rows[0]; const row = rows[0];
@@ -172,20 +184,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
id: row.id, id: row.id,
mailbox: row.mailbox, mailbox: row.mailbox,
msgType: row.type, msgType: row.type,
time: new Date(row.time), time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message), payload: parsePayload(row.message),
}; };
}; };
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => { export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const gameTime = await loadCurrentGameTime(db); const gameTime = await loadCurrentGameTime(db);
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
const rows = await db.$queryRaw<MessageRow[]>` const rows = await db.$queryRaw<MessageRow[]>`
SELECT id, mailbox, type, src, dest, time, valid_until, message SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
FROM message m.created_at_wall, m.message,
WHERE id = ${id} ma.status AS action_status, ma.expires_game_tick
AND ${messageValidityPredicate(gameTime)} FROM message m
JOIN message_action ma ON ma.message_id = m.id
JOIN world_state world ON TRUE
WHERE m.id = ${id}
AND ma.status = 'PENDING'
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
AND world.clock_phase IN ('RUNNING', 'MANUAL')
AND ma.clock_revision = world.clock_revision
AND ma.deadline_generation = world.deadline_generation
LIMIT 1 LIMIT 1
FOR UPDATE FOR UPDATE OF m, ma, world
`; `;
const row = rows[0]; const row = rows[0];
if (!row) return null; if (!row) return null;
@@ -193,7 +214,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
id: row.id, id: row.id,
mailbox: row.mailbox, mailbox: row.mailbox,
msgType: row.type, msgType: row.type,
time: new Date(row.time), time: new Date(row.created_at_wall ?? row.time),
payload: parsePayload(row.message), payload: parsePayload(row.message),
}; };
}; };
@@ -202,16 +223,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return; if (uniqueIds.length === 0) return;
const gameTime = await loadCurrentGameTime(db); const gameTime = await loadCurrentGameTime(db);
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
await db.messageAction.updateMany({
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
});
await db.message.updateMany({ await db.message.updateMany({
where: { id: { in: uniqueIds } }, where: { id: { in: uniqueIds } },
data: { data: {
validUntil: gameTime.now, validUntil: gameTime.now,
// A partially migrated profile can still carry a legacy logical validUntilTick: BigInt(gameTime.tick),
// sentinel even while no authoritative clock exists. Replace it
// with an already-expired logical tick when expiring by wall time;
// NULL would fall back to the wall timestamp after clock recovery
// and could make the handled message visible again.
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
}, },
}); });
}; };
@@ -233,8 +254,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
END END
) || jsonb_build_object('invalid', true), ) || jsonb_build_object('invalid', true),
true true
) ),
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id IN (${GamePrisma.join(uniqueIds)}) WHERE id IN (${GamePrisma.join(uniqueIds)})
` `
); );
}; };
export const tombstoneMessagesWithinDeleteWindow = async (
db: DatabaseClient,
authorityMessageId: number,
ids: number[]
): Promise<number[]> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return [];
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
WITH wall AS (
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
), authority AS (
SELECT m.id
FROM message m, wall
WHERE m.id = ${authorityMessageId}
AND m.tombstoned_at_wall IS NULL
AND m.delete_until_wall >= wall.now_wall
FOR UPDATE
)
UPDATE message m
SET message = jsonb_set(
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
'{option}',
(
CASE
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
ELSE '{}'::jsonb
END
) || jsonb_build_object('invalid', true),
true
),
tombstoned_at_wall = wall.now_wall
FROM wall
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
AND EXISTS (SELECT 1 FROM authority)
RETURNING m.id
`);
return rows.map(({ id }) => id).sort((left, right) => left - right);
};
+1 -4
View File
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
color: '#000000', color: '#000000',
}; };
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
export const resolveNationInfo = async ( export const resolveNationInfo = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number nationId: number
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => { export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
const nation = await resolveNationInfo(db, general.nationId); const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return { return {
generalId: general.id, generalId: general.id,
generalName: general.name, generalName: general.name,
nationId: general.nationId, nationId: general.nationId,
nationName: nation.name, nationName: nation.name,
color: nation.color, color: nation.color,
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`, icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
}; };
}; };
+5 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { import {
readModelOutboxPayloadToDiplomacyMailboxes,
readModelOutboxPayloadToChanges, readModelOutboxPayloadToChanges,
readModelOutboxPayloadToMessageMailboxes, readModelOutboxPayloadToMessageMailboxes,
type ReadModelDomain, type ReadModelDomain,
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
'access.general', 'access.general',
'dashboard.global', 'dashboard.global',
'messages.mailbox', 'messages.mailbox',
'messages.diplomacyMailbox',
'tournament', 'tournament',
'betting', 'betting',
]); ]);
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
this.db, this.db,
async (payload) => { async (payload) => {
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload); const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
if (mailboxes.length > 0) { const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes); if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
} }
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) { if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
const changes = readModelOutboxPayloadToChanges(payload); const changes = readModelOutboxPayloadToChanges(payload);
+16 -3
View File
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => { const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const seen = new Set<string>(); const seen = new Set<string>();
return identities.filter((identity) => { return identities.filter((identity) => {
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`; const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
const changes = eventChanges(event); const changes = eventChanges(event);
if (!changes) return false; if (!changes) return false;
const generalId = identity.generalId; const generalId = identity.generalId;
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
return [ return [
changes.generalIds, changes.generalIds,
changes.mapGeneralIds ?? changes.generalIds, changes.mapGeneralIds ?? changes.generalIds,
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
); );
if (event.type === 'messageCreated' || event.type === 'messagesChanged') { if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes; const mailboxes =
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
const diplomacyMailboxes =
event.type === 'messageCreated'
? event.msgType === 'diplomacy'
? [event.mailbox]
: []
: (event.diplomacyMailboxes ?? []);
return viewers.some(
(identity) =>
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
(identity.canReadDiplomacy &&
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
)
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() } ? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null; : null;
} }
+6 -2
View File
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
export const publishRealtimeMessageChanges = async ( export const publishRealtimeMessageChanges = async (
redis: RedisConnector['client'], redis: RedisConnector['client'],
profileName: string, profileName: string,
mailboxes: readonly number[] mailboxes: readonly number[],
diplomacyMailboxes: readonly number[] = []
): Promise<void> => { ): Promise<void> => {
if (mailboxes.length === 0) return; if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
await publishRealtimeEvent(redis, profileName, { await publishRealtimeEvent(redis, profileName, {
type: 'messagesChanged', type: 'messagesChanged',
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right), mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
...(diplomacyMailboxes.length > 0
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
: {}),
}); });
}; };
+4 -10
View File
@@ -60,10 +60,7 @@ export interface AuctionDetail {
export const hasAuctionClosePassed = ( export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null } time: { now: Date; tick: number | null }
): boolean => ): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
interface AuctionBidRow { interface AuctionBidRow {
id: number; id: number;
@@ -433,7 +430,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -448,7 +444,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]); ]);
return { ok: true }; return { ok: true };
@@ -511,7 +507,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -526,7 +521,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]); ]);
return { ok: true }; return { ok: true };
@@ -650,7 +645,6 @@ export const auctionRouter = router({
auctionId: auction.id, auctionId: auction.id,
generalId: general.id, generalId: general.id,
amount: input.amount, amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false, tryExtendCloseDate: input.tryExtendCloseDate ?? false,
}); });
throwIfCommandRejected(result); throwIfCommandRejected(result);
@@ -665,7 +659,7 @@ export const auctionRouter = router({
const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
const nextCloseAt = new Date(result.closeAt); const nextCloseAt = new Date(result.closeAt);
await ctx.redis.zAdd(timerKeys.timerKey, [ await ctx.redis.zAdd(timerKeys.timerKey, [
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) }, { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
]); ]);
return { ok: true }; return { ok: true };
+40 -5
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra'; import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { z } from 'zod'; import { z } from 'zod';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
return world; return world;
}; };
interface BettingClockFenceRow {
currentYear: number;
currentMonth: number;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
}
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
SELECT current_year AS "currentYear",
current_month AS "currentMonth",
clock_phase AS "clockPhase",
clock_revision AS "clockRevision",
deadline_generation AS "deadlineGeneration"
FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
const world = rows[0];
if (!world) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
}
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
});
}
return world;
};
export const bettingRouter = router({ export const bettingRouter = router({
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()) getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
.query(async ({ ctx, input }) => { async ({ ctx, input }) => {
requireUserId(ctx.auth); requireUserId(ctx.auth);
await getMyGeneral(ctx); await getMyGeneral(ctx);
const [world, rows] = await Promise.all([ const [world, rows] = await Promise.all([
@@ -66,7 +100,8 @@ export const bettingRouter = router({
year: world.currentYear, year: world.currentYear,
month: world.currentMonth, month: world.currentMonth,
}; };
}), }
),
getDetail: authedProcedure getDetail: authedProcedure
.input(z.object({ bettingId: z.number().int().positive() })) .input(z.object({ bettingId: z.number().int().positive() }))
@@ -141,7 +176,7 @@ export const bettingRouter = router({
if (betting.finished) { if (betting.finished) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
} }
const world = await loadWorldDate(ctx.db); const world = await lockBettingClockFence(ctx.db);
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth); const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
if (betting.closeYearMonth <= yearMonth) { if (betting.closeYearMonth <= yearMonth) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
+8 -10
View File
@@ -15,7 +15,7 @@ import {
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js'; import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
import { insertMessage } from '../../messages/store.js'; import { insertMessage } from '../../messages/store.js';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { readDatabaseWallTime } from '../../services/wallClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
await sendMessage(store, { ...draft, msgType: 'national' }); await sendMessage(store, { ...draft, msgType: 'national' });
} }
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId); const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId); options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
}; };
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => { const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
@@ -306,8 +307,6 @@ export const diplomacyRouter = router({
nationColor: destNation.color, nationColor: destNation.color,
}, },
}; };
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
const created = await ctx.db.diplomacyLetter.create({ const created = await ctx.db.diplomacyLetter.create({
data: { data: {
srcNationId: srcNation.id, srcNationId: srcNation.id,
@@ -316,7 +315,6 @@ export const diplomacyRouter = router({
state: 'PROPOSED', state: 'PROPOSED',
textBrief: purifyDiplomacyHtml(input.brief), textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail), textDetail: purifyDiplomacyHtml(input.detail),
date: letterDate,
srcSignerId: me.id, srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue, aux: aux as GamePrisma.InputJsonValue,
}, },
@@ -332,7 +330,7 @@ export const diplomacyRouter = router({
src: srcTarget, src: srcTarget,
dest: destTarget, dest: destTarget,
text, text,
time: letterDate, time: created.date,
}); });
return { id: created.id }; return { id: created.id };
@@ -371,7 +369,7 @@ export const diplomacyRouter = router({
); );
const messageSrc = buildActorTarget(me, destNation); const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation); const messageDest = buildNationTarget(srcNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux); const aux = asRecord(letter.aux);
let messageText: string; let messageText: string;
if (input.agree) { if (input.agree) {
@@ -458,7 +456,7 @@ export const diplomacyRouter = router({
); );
const messageSrc = buildActorTarget(me, srcNation); const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation); const messageDest = buildNationTarget(destNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
const aux = asRecord(letter.aux); const aux = asRecord(letter.aux);
aux.reason = { aux.reason = {
who: me.id, who: me.id,
@@ -519,7 +517,7 @@ export const diplomacyRouter = router({
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation; const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation); const messageSrc = buildActorTarget(me, actorNation);
const messageDest = buildNationTarget(otherNation); const messageDest = buildNationTarget(otherNation);
const messageTime = (await loadCurrentGameTime(ctx.db)).now; const messageTime = await readDatabaseWallTime(ctx.db);
let resultState: 'ACTIVATED' | 'CANCELLED'; let resultState: 'ACTIVATED' | 'CANCELLED';
let messageText: string; let messageText: string;
+8 -9
View File
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
return started && !ended; return started && !ended;
}; };
const zGeneralSettings = z.object({ const zGeneralSettings = z.strictObject({
tnmt: z.number().int().optional(), tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(), defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(), use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
use_auto_nation_war: z.number().int().min(0).max(1).optional(), use_auto_nation_war: z.number().int().min(0).max(1).optional(),
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(), use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
use_auto_nation_finance: z.number().int().min(0).max(1).optional(), use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
@@ -244,7 +243,6 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다. // Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다. // 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0), use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0), use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0), use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
troopLeaderFirstTurn, troopLeaderFirstTurn,
accessLog, accessLog,
rankRows, rankRows,
gameTime,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true }, select: { type: true, value: true },
}), }),
loadCurrentGameTime(ctx.db),
]); ]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
parsedLastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
loadCurrentGameTime(ctx.db, now), loadCurrentGameTime(ctx.db, now),
]); ]);
// vote_poll timestamps are logical game-wall values. During PREOPEN the // vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock is held at the future open anchor, so comparing them with // logical clock advances through negative ticks before the opening anchor,
// JavaScript wall time inside a timestamp-without-time-zone predicate can // so comparing them with JavaScript wall time inside a timestamp-without-time-zone
// hide an otherwise active poll. Resolve the same tick-first clock contract // predicate can hide an otherwise active poll. Resolve the same tick-first
// used by voting instead; hasVoted only affects the notice, not activity. // clock contract used by voting; hasVoted only affects the notice, not activity.
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null; const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId); const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
+9 -1
View File
@@ -38,6 +38,8 @@ const BUFF_KEYS: InheritBuffType[] = [
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
const POSTGRES_INTEGER_MAX = 2_147_483_647; const POSTGRES_INTEGER_MAX = 2_147_483_647;
const MIN_INHERIT_BUFF_LEVEL = 1;
const MAX_INHERIT_BUFF_LEVEL = 5;
const parseBuffRecord = (raw: unknown): Record<string, number> => { const parseBuffRecord = (raw: unknown): Record<string, number> => {
if (typeof raw === 'string') { if (typeof raw === 'string') {
@@ -302,7 +304,7 @@ export const inheritRouter = router({
.input( .input(
z.object({ z.object({
type: z.enum(BUFF_KEYS), type: z.enum(BUFF_KEYS),
level: z.number().int().min(1).max(5), level: z.number().int(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -310,6 +312,12 @@ export const inheritRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
if (input.level < MIN_INHERIT_BUFF_LEVEL || input.level > MAX_INHERIT_BUFF_LEVEL) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
});
}
const result = await requestInheritanceAction(ctx, userId, { const result = await requestInheritanceAction(ctx, userId, {
action: 'buyHiddenBuff', action: 'buyHiddenBuff',
+48 -32
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import type { GameApiContext, WorldStateRow } from '../../context.js'; import type { GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { import {
isWarTraitKey, isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS, JOIN_PERSONALITY_TRAIT_KEYS,
@@ -302,6 +303,7 @@ export const joinRouter = router({
} }
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
const configConst = asRecord(config.const); const configConst = asRecord(config.const);
const availableSpecialWar = asStringArray(configConst.availableSpecialWar); const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
@@ -333,6 +335,11 @@ export const joinRouter = router({
}; };
}); });
const serverId = asRecord(worldState.meta).serverId;
const history =
typeof serverId === 'string'
? await ctx.db.gameHistory.findUnique({ where: { serverId }, select: { status: true } })
: null;
const inheritConst = resolveInheritConstants(worldState); const inheritConst = resolveInheritConstants(worldState);
const inheritTotalPoint = ctx.auth?.user.id const inheritTotalPoint = ctx.auth?.user.id
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous') ? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
@@ -348,7 +355,8 @@ export const joinRouter = router({
return { return {
rules: { rules: {
stat: resolveJoinStat(worldState), stat: resolveJoinStat(worldState),
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0, allowDirectCreation: (blockGeneralCreate & 1) === 0,
allowCustomName: (blockGeneralCreate & 2) === 0,
}, },
user: { user: {
id: ctx.auth?.user.id ?? '', id: ctx.auth?.user.id ?? '',
@@ -369,6 +377,7 @@ export const joinRouter = router({
npcGeneralCount, npcGeneralCount,
}, },
inherit: { inherit: {
enabled: history?.status !== 'COMPLETED',
totalPoint: inheritTotalPoint, totalPoint: inheritTotalPoint,
costs: { costs: {
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint, inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
@@ -391,15 +400,12 @@ export const joinRouter = router({
if (!userId) { if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const gameTime = await loadCurrentGameTime(ctx.db);
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId); const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve', type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
userId, userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId, seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
return resolveSelectionReservationCommandResult(result); return resolveSelectionReservationCommandResult(result);
}), }),
@@ -429,7 +435,6 @@ export const joinRouter = router({
}); });
} }
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create'); const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate', type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -438,8 +443,6 @@ export const joinRouter = router({
uniqueName: input.uniqueName, uniqueName: input.uniqueName,
personality: input.personality, personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon ...(selectedIcon
? { ? {
ownerPicture: selectedIcon.picture, ownerPicture: selectedIcon.picture,
@@ -469,15 +472,12 @@ export const joinRouter = router({
input.clientRequestId, input.clientRequestId,
'reselect' 'reselect'
); );
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect', type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}), ...(commandRequestId ? { requestId: commandRequestId } : {}),
userId, userId,
ownerDisplayName: auth.user.displayName, ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName, uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
return resolveSelectionCommandResult(result, 'selectPoolReselect'); return resolveSelectionCommandResult(result, 'selectPoolReselect');
}), }),
@@ -581,30 +581,46 @@ export const joinRouter = router({
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
}); });
} }
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
try { try {
const gameTime = await loadCurrentGameTime(ctx.db); return await ctx.db.$transaction!(async (transaction) => {
if (gameTime.tick === null) { await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
throw new TRPCError({ const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
code: 'PRECONDITION_FAILED', SELECT clock_phase AS "clockPhase"
message: 'Game clock is not initialized.', FROM world_state
ORDER BY id ASC
LIMIT 1
FOR UPDATE
`);
if (!clockRows[0]) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
if (!['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'COMPLETED'].includes(clockRows[0].clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
const gameTime = await loadCurrentGameTime(transaction);
if (!worldState || gameTime.tick === null) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Game clock is not initialized.',
});
}
return reserveNpcPossessionCandidates({
db: transaction,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
createdGameTick: gameTime.tick,
}); });
}
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
now: gameTime.now,
acceptedGameTick: gameTime.tick,
}); });
} catch (error) { } catch (error) {
if (error instanceof NpcPossessionError) { if (error instanceof NpcPossessionError) {
+2
View File
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
clockMode: gameTime.mode ?? 'realtime', clockMode: gameTime.mode ?? 'realtime',
clockRunning: gameTime.running, clockRunning: gameTime.running,
clockStartsAt: gameTime.startsAt?.toISOString() ?? null, clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
clockRecovery: gameTime.recovery ?? null,
turnEngineRunning, turnEngineRunning,
otherTextInfo: worldState.meta.otherTextInfo ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '',
npcMode: worldState.config.npcMode ?? 0, npcMode: worldState.config.npcMode ?? 0,
@@ -87,6 +88,7 @@ export const lobbyRouter = router({
: null, : null,
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
npcPossessionEnabled: worldState.config.npcMode === 1, npcPossessionEnabled: worldState.config.npcMode === 1,
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '', scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
myGeneral, myGeneral,
+111 -38
View File
@@ -1,11 +1,19 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord, ChangeJournal } from '@sammo-ts/common';
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import {
accessLimitAuthedInputProcedure,
accessWallAuthedInputProcedure,
authedProcedure,
engineAuthedProcedure,
router,
wallAuthedProcedure,
} from '../../trpc.js';
import { import {
MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC, MESSAGE_MAILBOX_PUBLIC,
@@ -20,13 +28,13 @@ import {
fetchOldMessagesFromMailbox, fetchOldMessagesFromMailbox,
fetchMessageById, fetchMessageById,
insertMessage, insertMessage,
tombstoneMessages, tombstoneMessagesWithinDeleteWindow,
type MessageView, type MessageView,
} from '../../messages/store.js'; } from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import { resolveNationPermission } from '../nation/shared.js'; import { resolveNationPermission } from '../nation/shared.js';
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { executeInputEvent } from '../../inputEventBoundary.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -41,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
return { return {
...message, ...message,
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { ...(message.option ?? {}), permissionRedacted: true },
}; };
}); });
}; };
@@ -68,9 +77,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
return value === true || value === 1 || value === '1'; return value === true || value === 1 || value === '1';
}; };
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => { const markMessageMailboxes = (
ctx: Pick<GameApiContext, 'changeJournal'>,
mailboxes: Iterable<number>,
msgType?: MessageType
): void => {
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
for (const mailbox of mailboxes) { for (const mailbox of mailboxes) {
ctx.changeJournal?.mark('messages.mailbox', mailbox); ctx.changeJournal?.mark(domain, mailbox);
} }
}; };
@@ -231,7 +245,7 @@ export const messagesRouter = router({
})), })),
}; };
}), }),
readLatest: authedProcedure readLatest: wallAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -264,7 +278,7 @@ export const messagesRouter = router({
`; `;
return { ok: true }; return { ok: true };
}), }),
delete: authedProcedure delete: wallAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -289,17 +303,16 @@ export const messagesRouter = router({
if (message.payload.option?.deletable === false) { if (message.payload.option?.deletable === false) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
} }
const { now } = await loadCurrentGameTime(ctx.db);
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMessageId = message.payload.option?.receiverMessageID; const receiverMessageId = message.payload.option?.receiverMessageID;
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national'; const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
const ids = [ const ids = [
message.id, message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
]; ];
await tombstoneMessages(ctx.db, ids); const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
if (!deletedIds.includes(message.id)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
}
const receiverMailbox = const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId ? message.payload.dest.generalId
@@ -308,10 +321,14 @@ export const messagesRouter = router({
message.msgType === 'national' message.msgType === 'national'
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId ? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null; : null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); markMessageMailboxes(
return { ok: true, deletedIds: ids }; ctx,
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
message.msgType
);
return { ok: true, deletedIds };
}), }),
respond: authedProcedure respond: engineAuthedProcedure
.input( .input(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
@@ -326,6 +343,9 @@ export const messagesRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
} }
const action = message.payload.option?.action; const action = message.payload.option?.action;
if (message.payload.option?.invalid === true) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
}
if (action === 'scout' || action === 'raiseInvader') { if (action === 'scout' || action === 'raiseInvader') {
if (!ctx.auth) { if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -342,29 +362,80 @@ export const messagesRouter = router({
} }
return { result: commandResult.ok, reason: commandResult.reason }; return { result: commandResult.ok, reason: commandResult.reason };
} }
const result = await respondToDiplomaticMessage({ const ownsChangeJournal = !ctx.changeJournal;
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
let journalPersisted = false;
const response = await executeInputEvent({
db: ctx.db, db: ctx.db,
actor: general, requestId: `messages.respond.diplomatic:${input.messageId}`,
messageId: input.messageId, eventType: 'messages.respond.diplomatic',
response: input.response, payload: input,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const transactionContext = { ...ctx, db: transaction, changeJournal };
const transactionGeneral = ownsChangeJournal
? await getOwnedGeneral(transactionContext, input.generalId)
: general;
const result = await respondToDiplomaticMessage({
db: transaction,
actor: transactionGeneral,
messageId: input.messageId,
response: input.response,
});
markMessageMailboxes(transactionContext, result.affectedMailboxes);
for (const generalId of result.affectedGeneralRecordIds) {
changeJournal.mark('records.general', generalId);
}
for (const nationId of result.affectedNationIds) {
changeJournal.mark('nation.content', nationId);
}
for (const cityId of result.affectedCityIds) {
changeJournal.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
changeJournal.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
changeJournal.mark('dashboard.global');
}
if (ownsChangeJournal) {
journalPersisted = Boolean(
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
);
}
return {
result: result.result,
reason: result.reason,
affectedNationIds: result.affectedNationIds,
affectedCityIds: result.affectedCityIds,
};
},
}); });
markMessageMailboxes(ctx, result.affectedMailboxes); if (journalPersisted) {
for (const generalId of result.affectedGeneralRecordIds) { ctx.readModelOutbox?.wake();
ctx.changeJournal?.mark('records.general', generalId);
} }
for (const nationId of result.affectedNationIds) { if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
ctx.changeJournal?.mark('nation.content', nationId); if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const synchronized = await ctx.turnDaemon.requestCommand({
type: 'syncDiplomaticResponse',
userId: ctx.auth.user.id,
generalId: general.id,
messageId: input.messageId,
nationIds: response.affectedNationIds,
cityIds: response.affectedCityIds,
});
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
const synchronizationReason =
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
});
}
} }
for (const cityId of result.affectedCityIds) { return { result: response.result, reason: response.reason };
ctx.changeJournal?.mark('city.content', cityId);
}
if (result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('map.world');
}
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
ctx.changeJournal?.mark('dashboard.global');
}
return { result: result.result, reason: result.reason };
}), }),
getOld: authedProcedure getOld: authedProcedure
.input( .input(
@@ -420,7 +491,7 @@ export const messagesRouter = router({
...messageBuckets, ...messageBuckets,
}; };
}), }),
send: accessAuthedInputProcedure( send: accessWallAuthedInputProcedure(
z.object({ z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
mailbox: z.number().int(), mailbox: z.number().int(),
@@ -436,7 +507,9 @@ export const messagesRouter = router({
} }
const src = await buildTargetFromGeneral(ctx.db, general); const src = await buildTargetFromGeneral(ctx.db, general);
const { now } = await loadCurrentGameTime(ctx.db); // Compatibility-only projection. persistMessageEnvelope records and
// displays the authoritative PostgreSQL wall instant.
const now = new Date();
const validUntil = new Date('9999-12-31T00:00:00Z'); const validUntil = new Date('9999-12-31T00:00:00Z');
let msgType: MessageType; let msgType: MessageType;
@@ -560,7 +633,7 @@ export const messagesRouter = router({
: msgType === 'private' : msgType === 'private'
? general.id ? general.id
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId; : MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]); markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
return { msgType, msgId: result.receiverId }; return { msgType, msgId: result.receiverId };
}), }),
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
name: general.name, name: general.name,
npcState: general.npcState, npcState: general.npcState,
injury: general.injury, injury: general.injury,
baseStats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
stats: { stats: {
leadership: woundedStat(general.leadership, general.injury), leadership: woundedStat(general.leadership, general.injury),
strength: woundedStat(general.strength, general.injury), strength: woundedStat(general.strength, general.injury),
+9 -3
View File
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
}; };
const resolveCommandEnv = ( const resolveCommandEnv = (
config: Record<string, unknown> config: Record<string, unknown>,
worldMeta: Record<string, unknown>
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => { ): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
const constValues = asRecord(config.const ?? config.consts); const constValues = asRecord(config.const ?? config.consts);
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
return { return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0), develCost: resolveNumberFromKeys(
worldMeta,
['develcost', 'develCost', 'develrate'],
configuredDevelCost
),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0), defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12), maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
}; };
@@ -409,7 +415,7 @@ export const npcRouter = router({
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const stat = resolveScenarioStat(config); const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config); const env = resolveCommandEnv(config, worldMeta);
const unitSetName = resolveUnitSetName(config, 'che'); const unitSetName = resolveUnitSetName(config, 'che');
const nationTech = readNumber(nation.tech, 0); const nationTech = readNumber(nation.tech, 0);
+109 -20
View File
@@ -5,12 +5,14 @@ import { asRecord } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic'; import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js'; import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js'; import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js'; import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime } from '../../services/gameClock.js';
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => { const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
@@ -36,13 +38,85 @@ const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured); return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
}; };
const adminProcedure = authedProcedure.use(({ ctx, next }) => { const adminProcedure = engineAuthedProcedure
const roles = ctx.auth?.user.roles ?? []; .use(({ ctx, next }) => {
if (!hasAdminRole(roles, ctx.profile.name)) { const roles = ctx.auth?.user.roles ?? [];
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' }); if (!hasAdminRole(roles, ctx.profile.name)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
}
return next();
})
.use(async ({ ctx, type, next }) => {
if (type !== 'mutation') return next({ ctx: { tournamentMutationLockHeld: false } });
// 참가·베팅은 Redis lock 안에서 ENGINE의 DB commit을 기다린다.
// 관리자도 Redis를 먼저 잡아 DB clock fence → Redis 역순 대기를 막는다.
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(() => next({ ctx: { tournamentMutationLockHeld: true } }));
})
.concat(procedure);
const withTournamentClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
profile: { name: string };
tournamentMutationLockHeld?: boolean;
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureActiveRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament mutation is disabled.',
});
} }
return next(); const clockContext: TournamentClockContext = {
}); phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
};
return store.withClockContext(clockContext, () =>
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
);
};
const withTournamentBetClockMutation = async <T>(
ctx: {
db: Parameters<typeof loadCurrentGameTime>[0];
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
profile: { name: string };
},
store: TournamentStore,
operation: () => Promise<T>
): Promise<T> => {
const gameTime = await loadCurrentGameTime(ctx.db);
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
if (!fence) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
});
}
return store.withClockContext(
{
phase: fence.phase,
revision: fence.revision,
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
},
() => store.withMutationLock(operation)
);
};
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
const tournamentJoinCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentJoin:${step}` : undefined;
const zTournamentState = z.object({ const zTournamentState = z.object({
stage: z.number().int().min(0), stage: z.number().int().min(0),
@@ -143,7 +217,10 @@ export const tournamentRouter = router({
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.getState(); return store.getState();
}), }),
getAdminStatus: adminProcedure.query(async () => ({ ok: true })), getAdminStatus: adminProcedure.query(async ({ ctx }) => ({
ok: true,
clock: await loadClockAdminStatus(ctx.db),
})),
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => { getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
@@ -271,7 +348,7 @@ export const tournamentRouter = router({
}), }),
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => { setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setState({ await store.setState({
...input, ...input,
type: input.type as TournamentType, type: input.type as TournamentType,
@@ -281,7 +358,7 @@ export const tournamentRouter = router({
}), }),
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => { patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
const current = await store.getState(); const current = await store.getState();
if (!current) { if (!current) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -297,21 +374,21 @@ export const tournamentRouter = router({
}), }),
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => { setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(input); await store.setParticipants(input);
return { ok: true, count: input.length }; return { ok: true, count: input.length };
}); });
}), }),
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => { setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setMatches(input); await store.setMatches(input);
return { ok: true, count: input.length }; return { ok: true, count: input.length };
}); });
}), }),
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => { setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setBettingEntries(input); await store.setBettingEntries(input);
return { ok: true, count: input.length }; return { ok: true, count: input.length };
}); });
@@ -347,7 +424,7 @@ export const tournamentRouter = router({
}; };
}); });
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
await store.setParticipants(participants); await store.setParticipants(participants);
return { ok: true, count: participants.length }; return { ok: true, count: participants.length };
}); });
@@ -391,10 +468,12 @@ export const tournamentRouter = router({
return { state, totals, myTotals, totalAmount, myAmount }; return { state, totals, myTotals, totalAmount, myAmount };
}), }),
join: authedProcedure.mutation(async ({ ctx }) => { // 참가비는 ENGINE transaction이 차감한다. API input-event transaction으로
// 감싸면 clock advisory lock을 쥔 채 child ENGINE event를 기다리게 된다.
join: engineAuthedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state || state.stage !== 1 || state.participantsLockedAt) { if (!state || state.stage !== 1 || state.participantsLockedAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
@@ -414,6 +493,7 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState); const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({ const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin', reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }], adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
}); });
@@ -449,6 +529,7 @@ export const tournamentRouter = router({
} catch (error) { } catch (error) {
await ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback', reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }], adjustments: [{ generalId: general.id, goldDelta: develCost }],
}); });
@@ -459,7 +540,7 @@ export const tournamentRouter = router({
}), }),
cancel: adminProcedure.mutation(async ({ ctx }) => { cancel: adminProcedure.mutation(async ({ ctx }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state) { if (!state) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
@@ -513,7 +594,10 @@ export const tournamentRouter = router({
return { ok: true }; return { ok: true };
}); });
}), }),
placeBet: authedProcedure // This route delegates its game mutations to durable ENGINE input events.
// Wrapping it in the API input-event transaction would hold the clock
// advisory lock while waiting for the daemon to claim the child event.
placeBet: engineAuthedProcedure
.input( .input(
z.object({ z.object({
targetId: z.number().int().positive(), targetId: z.number().int().positive(),
@@ -523,7 +607,7 @@ export const tournamentRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return store.withMutationLock(async () => { return withTournamentBetClockMutation(ctx, store, async () => {
const state = await store.getState(); const state = await store.getState();
if (!state || state.stage !== 6) { if (!state || state.stage !== 6) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
@@ -558,6 +642,7 @@ export const tournamentRouter = router({
const adjustResult = await ctx.turnDaemon.requestCommand({ const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
}); });
@@ -573,6 +658,7 @@ export const tournamentRouter = router({
const rankResult = await ctx.turnDaemon.requestCommand({ const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [ adjustments: [
{ {
@@ -584,6 +670,7 @@ export const tournamentRouter = router({
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) { if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
await ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}); });
@@ -600,11 +687,13 @@ export const tournamentRouter = router({
await Promise.all([ await Promise.all([
ctx.turnDaemon.requestCommand({ ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}), }),
ctx.turnDaemon.requestCommand({ ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [ adjustments: [
{ {
+8 -13
View File
@@ -23,6 +23,7 @@ import {
resolveRefreshScoreText, resolveRefreshScoreText,
resolveRemainingMinutes, resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js'; } from '../../services/generalBasicCardProjection.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -72,7 +73,7 @@ export const troopRouter = router({
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
} }
const [nation, troops, generals, cities, worldState] = await Promise.all([ const [nation, troops, generals, cities, worldState, gameTime] = await Promise.all([
ctx.db.nation.findUnique({ ctx.db.nation.findUnique({
where: { id: me.nationId }, where: { id: me.nationId },
select: { id: true, name: true, color: true, level: true, meta: true }, select: { id: true, name: true, color: true, level: true, meta: true },
@@ -121,6 +122,7 @@ export const troopRouter = router({
select: { id: true, name: true }, select: { id: true, name: true },
}), }),
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }), ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
loadCurrentGameTime(ctx.db),
]); ]);
if (!nation) { if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
@@ -214,15 +216,6 @@ export const troopRouter = router({
const traitName = (code: string, names: Map<string, { name: string }>): string => const traitName = (code: string, names: Map<string, { name: string }>): string =>
names.get(code)?.name ?? sanitizeInternalDisplayCode(code); names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code); const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
const worldMeta = asRecord(worldState?.meta);
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
const lastExecuted =
rawLastExecuted instanceof Date
? rawLastExecuted
: typeof rawLastExecuted === 'string'
? new Date(rawLastExecuted)
: null;
const mappedTroops = troops const mappedTroops = troops
.map((troop) => { .map((troop) => {
const leader = generalMap.get(troop.troopLeaderId); const leader = generalMap.get(troop.troopLeaderId);
@@ -231,7 +224,10 @@ export const troopRouter = router({
name: troop.name, name: troop.name,
nationId: troop.nationId, nationId: troop.nationId,
turnTime: leader?.turnTime.toISOString() ?? null, turnTime: leader?.turnTime.toISOString() ?? null,
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [], reservedCommands:
leader?.npcState === 5
? ['집합', '집합', '집합', '집합', '집합']
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
leader: leader leader: leader
? { ? {
id: leader.id, id: leader.id,
@@ -348,8 +344,7 @@ export const troopRouter = router({
killTurn: readNumber(meta.killturn ?? meta.killTurn), killTurn: readNumber(meta.killturn ?? meta.killTurn),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
lastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
troopId: general.troopId, troopId: general.troopId,
troop: { troop: {
+14
View File
@@ -17,6 +17,7 @@ import {
assertReservedTurnActionAvailable, assertReservedTurnActionAvailable,
assertReservedTurnArgsPassLegacyBasicValidation, assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions, buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
parseReservedTurnArgs, parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS, TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions, type TurnCommandInputOptions,
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
traits, traits,
moduleBundle, moduleBundle,
map, map,
itemOrder,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -310,6 +312,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
officerLevel: true, officerLevel: true,
gold: true, gold: true,
rice: true, rice: true,
leadership: true,
strength: true,
intel: true,
crew: true, crew: true,
train: true, train: true,
atmos: true, atmos: true,
@@ -327,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
loadBattleSimTraitOptions(), loadBattleSimTraitOptions(),
moduleBundlePromise, moduleBundlePromise,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)), loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
loadEquipmentTradeItemOrder(worldState.scenarioCode),
]); ]);
const nationById = new Map(nations.map((entry) => [entry.id, entry])); const nationById = new Map(nations.map((entry) => [entry.id, entry]));
@@ -380,10 +386,17 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])), troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
}); });
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
itemOrder,
configConst: asRecord(asRecord(worldState.config).const), configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules, itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0, currentSecurity: city?.security ?? 0,
generalGold: general.gold, generalGold: general.gold,
ownedItems: {
horse: general.horseCode,
weapon: general.weaponCode,
book: general.bookCode,
item: general.itemCode,
},
}); });
const inputOptions: TurnCommandInputOptions = { const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({ cities: cities.map((entry) => ({
@@ -446,6 +459,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
nationGenerals, nationGenerals,
realNationCount: nations.filter((entry) => entry.id > 0).length, realNationCount: nations.filter((entry) => entry.id > 0).length,
inputOptions, inputOptions,
generalActionModules: moduleBundle.general,
}); });
}; };
+16 -24
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -102,18 +102,13 @@ export const hasPollEnded = (
time: CurrentGameTime time: CurrentGameTime
): boolean => ): boolean =>
Boolean(poll.closed_at) || Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => { const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null; if (!date) return null;
try { const tick = time.dateToTick(date);
const tick = time.dateToTick(date); if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
return tick === null ? null : BigInt(tick); return BigInt(tick);
} catch {
return null;
}
}; };
type VoteListRow = { type VoteListRow = {
@@ -292,7 +287,9 @@ export const voteRouter = router({
userCnt, userCnt,
}; };
}), }),
submitVote: authedProcedure // 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
submitVote: engineAuthedProcedure
.input( .input(
z.object({ z.object({
voteId: z.number().int().positive(), voteId: z.number().int().positive(),
@@ -358,7 +355,6 @@ export const voteRouter = router({
voteId: input.voteId, voteId: input.voteId,
generalId: general.id, generalId: general.id,
selection: sortedSelection, selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
}); });
throwIfCommandRejected(rewardResult); throwIfCommandRejected(rewardResult);
@@ -369,7 +365,6 @@ export const voteRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
} }
ctx.changeJournal?.mark('front.general', general.id);
return { ok: true, wonLottery: rewardResult.awardedUnique }; return { ok: true, wonLottery: rewardResult.awardedUnique };
}), }),
addComment: authedProcedure addComment: authedProcedure
@@ -399,8 +394,6 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null; : null;
const nationName = nation?.name ?? '재야'; const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment ( INSERT INTO vote_comment (
vote_id, vote_id,
@@ -418,7 +411,7 @@ export const voteRouter = router({
${general.name}, ${general.name},
${nationName}, ${nationName},
${input.text}, ${input.text},
${createdAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -451,7 +444,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const operationalAt = new Date();
let multipleOptions = input.multipleOptions; let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) { if (multipleOptions < 0) {
@@ -464,7 +456,8 @@ export const voteRouter = router({
if (input.closePrevious) { if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt} SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE closed_at IS NULL WHERE closed_at IS NULL
`); `);
} }
@@ -497,8 +490,8 @@ export const voteRouter = router({
${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt}, ${endAt},
${toGameTickOrNull(gameTime, endAt)}, ${toGameTickOrNull(gameTime, endAt)},
${operationalAt}, CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
${operationalAt} CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
) )
`); `);
@@ -573,7 +566,6 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const updatedAt = new Date();
if ( if (
input.title === undefined && input.title === undefined &&
@@ -596,7 +588,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode), reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at}, end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = ${updatedAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId} WHERE id = ${input.voteId}
`); `);
@@ -609,10 +601,10 @@ export const voteRouter = router({
.input(z.object({ voteId: z.number().int().positive() })) .input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db); const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt} SET closed_at = ${gameTime.now},
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${input.voteId} WHERE id = ${input.voteId}
RETURNING id RETURNING id
`); `);
+87 -1
View File
@@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js';
const zDirectorySort = z.number().int().min(1).max(15).default(9); const zDirectorySort = z.number().int().min(1).max(15).default(9);
const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
const readNumber = (value: unknown, fallback = 0): number => const readNumber = (value: unknown, fallback = 0): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback; typeof value === 'number' && Number.isFinite(value) ? value : fallback;
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
return left < right ? -1 : 1; return left < right ? -1 : 1;
}; };
const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => {
if (leadership < 40) {
return strength + intel < 40 ? '무능' : '무지';
}
const maxStat = Math.max(leadership, strength, intel);
const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership);
if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) {
return '만능';
}
if (strength >= 60 && intel < strength * 0.8) {
return '무';
}
if (intel >= 60 && strength < intel * 0.8) {
return '지';
}
if (leadership >= 60 && strength + intel < leadership) {
return '통';
}
return '평범';
};
const resolveExperienceLevel = (experience: number, maxLevel: number): number => { const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)); const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
return Math.max(0, Math.min(level, maxLevel)); return Math.max(0, Math.min(level, maxLevel));
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
const [nations, generals, cities] = await Promise.all([ const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
ctx.db.nation.findMany({ ctx.db.nation.findMany({
select: { select: {
id: true, id: true,
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
npcState: true, npcState: true,
nationId: true, nationId: true,
cityId: true, cityId: true,
leadership: true,
strength: true,
intel: true,
dedication: true, dedication: true,
officerLevel: true, officerLevel: true,
meta: true, meta: true,
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
select: { id: true, name: true, nationId: true }, select: { id: true, name: true, nationId: true },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}), }),
ctx.db.generalAccessLog.findMany({
select: { generalId: true, refreshScoreTotal: true },
}),
ctx.db.worldState.findFirst({
select: { tickSeconds: true, meta: true },
}),
]); ]);
const directoryNations = nations.some((nation) => nation.id === 0) const directoryNations = nations.some((nation) => nation.id === 0)
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
} }
const citiesByNation = new Map<number, typeof cities>(); const citiesByNation = new Map<number, typeof cities>();
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const)); const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
const accessScoreByGeneralId = new Map(
accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const)
);
const worldMeta = asRecord(worldState?.meta);
const autorunUser = asRecord(worldMeta.autorun_user);
const worldKillturn = readNumber(worldMeta.killturn);
const turnMinutes = readNumber(worldState?.tickSeconds) / 60;
const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0;
const activeKillturnThreshold = worldKillturn - autorunLimitTurns;
for (const city of cities) { for (const city of cities) {
const list = citiesByNation.get(city.nationId) ?? []; const list = citiesByNation.get(city.nationId) ?? [];
list.push(city); list.push(city);
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
false false
), ),
})); }));
const analyzedGenerals = nationGenerals.map((general) => {
const killturn = readMetaNumber(general.meta, 'killturn');
const inactive = general.npcState < 2 && killturn < activeKillturnThreshold;
const accessScore = accessScoreByGeneralId.get(general.id) ?? 0;
return {
id: general.id,
name: general.name,
npcState: general.npcState,
leadership: general.leadership,
type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel),
inactive,
accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal',
killturn,
} as const;
});
const combatGenerals = analyzedGenerals.filter(
(general) => general.type !== '무능' && general.type !== '무지'
);
const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive);
const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5);
const generalGroups = directoryGeneralTypes
.map((type) => ({
type,
label: `${type}`,
generals: analyzedGenerals
.filter((general) => general.type === type)
.sort((left, right) => {
const leftScore = accessScoreByGeneralId.get(left.id) ?? 0;
const rightScore = accessScoreByGeneralId.get(right.id) ?? 0;
return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id;
})
.map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general),
}))
.filter((group) => group.generals.length > 0);
return { return {
id: nation.id, id: nation.id,
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null, rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length), generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
cityCount: nationCities.length, cityCount: nationCities.length,
forceEstimate: {
totalGeneralCount: analyzedGenerals.length,
userCombatGeneralCount: userCombatGenerals.length,
userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
npcCombatGeneralCount: npcCombatGenerals.length,
npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length,
},
generalGroups,
officers, officers,
ambassadorNames: secretPermissions ambassadorNames: secretPermissions
.filter(({ permission }) => permission === 4) .filter(({ permission }) => permission === 4)
+30 -10
View File
@@ -38,6 +38,7 @@ import {
shouldReloadRealtimeViewerIdentity, shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent, toPublicRealtimeEvent,
} from './realtime/publicEvent.js'; } from './realtime/publicEvent.js';
import { resolveNationPermission } from './router/nation/shared.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js'; import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
@@ -50,6 +51,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.js'; import { scopeHttpIdempotencyKey } from './requestId.js';
import { loadClockReadiness } from './services/clockReadiness.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => { const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) { if (!value) {
@@ -312,11 +314,24 @@ export const createGameApiServer = async () => {
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => { const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({ const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 }, where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true }, select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
}); });
return general if (!general) {
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId } return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
: { generalId: null, cityId: null, nationId: null }; }
const nation =
general.nationId > 0
? await postgres.prisma.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
return {
generalId: general.id,
cityId: general.cityId,
nationId: general.nationId,
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
};
}; };
let viewerIdentity = await loadViewerIdentity(); let viewerIdentity = await loadViewerIdentity();
@@ -420,12 +435,17 @@ export const createGameApiServer = async () => {
request.raw.on('aborted', close); request.raw.on('aborted', close);
}); });
app.get('/healthz', async () => ({ app.get('/healthz', async (_request, reply) => {
ok: true, const clock = await loadClockReadiness(postgres.prisma);
profile: config.profileName, if (!clock.reconciliationComplete) reply.code(503);
postgresPool: postgres.getPoolStats(), return {
accountIconReconciliation: accountIconResetReconciler.getHealth(), ok: clock.reconciliationComplete,
})); profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
clock,
};
});
try { try {
await realtimeHub.start(); await realtimeHub.start();
@@ -0,0 +1,92 @@
import { parseGameClockPhase } from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
const safeInteger = (value: bigint, label: string): number => {
const result = Number(value);
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`);
return result;
};
export const loadClockReadiness = async (db: DatabaseClient) => {
if (!db.clockProjectionOutbox) {
return {
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount: null,
};
}
const [world, incompleteOutboxCount] = await Promise.all([
db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
}),
db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }),
]);
if (!world) {
return {
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount,
};
}
const phase = parseGameClockPhase(world.clockPhase);
return {
reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
phase,
revision: safeInteger(world.clockRevision, 'clock revision'),
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
incompleteOutboxCount,
};
};
export const loadClockAdminStatus = async (db: DatabaseClient) => {
const readiness = await loadClockReadiness(db);
if (!db.clockSuspension) {
return { ...readiness, latestReconciliation: null };
}
const latest = await db.clockSuspension.findFirst({
orderBy: { createdAt: 'desc' },
include: {
participants: { orderBy: { participantKey: 'asc' } },
projectionOutbox: { orderBy: { id: 'asc' } },
},
});
if (!latest) return { ...readiness, latestReconciliation: null };
return {
...readiness,
latestReconciliation: {
id: latest.id,
source: latest.source,
policy: latest.policy,
status: latest.status,
sourceRevision: safeInteger(latest.sourceRevision, 'source revision'),
targetRevision: safeInteger(latest.targetRevision, 'target revision'),
cutTick: safeInteger(latest.cutTick, 'cut tick'),
alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'),
participantChecksumBefore: latest.participantChecksumBefore,
participantChecksumAfter: latest.participantChecksumAfter,
participants: latest.participants.map((participant) => ({
key: participant.participantKey,
policy: participant.policy,
beforeChecksum: participant.beforeChecksum,
afterChecksum: participant.afterChecksum,
affectedCount: participant.affectedCount,
})),
outbox: latest.projectionOutbox.map((entry) => ({
id: entry.id.toString(),
targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'),
status: entry.status,
attempts: entry.attempts,
lastError: entry.lastError,
})),
},
};
};
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
}; };
export const flushDeferredGeneralAccessBatch = async ( export const flushDeferredGeneralAccessBatch = async (
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>, db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
batchId: string, batchId: string,
entries: readonly DeferredGeneralAccessEntry[] entries: readonly DeferredGeneralAccessEntry[]
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => { ): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
await markBatchProcessed(db, batchId); await markBatchProcessed(db, batchId);
return { states: [], refreshLimit: 0 }; return { states: [], refreshLimit: 0 };
} }
if (!db.$transaction) {
throw new Error('Deferred general access persistence requires transaction support.');
}
const worldState = await db.worldState.findFirst({ const worldState = await db.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { select: {
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
})) }))
); );
const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1; const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1;
const states = await db.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql` const states = await db.$transaction(async (transaction) => {
// The turn flush and synchronous access path must enter through the same
// schema-scoped lock before either traffic or access rows are mutated.
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
return transaction.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
WITH inserted_batch AS ( WITH inserted_batch AS (
INSERT INTO "general_access_batch" ("id") INSERT INTO "general_access_batch" ("id")
VALUES (${batchId}) VALUES (${batchId})
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
END, END,
resolved."weight" resolved."weight"
FROM resolved FROM resolved
ORDER BY resolved."general_id"
ON CONFLICT ("general_id") DO UPDATE SET ON CONFLICT ("general_id") DO UPDATE SET
"user_id" = EXCLUDED."user_id", "user_id" = EXCLUDED."user_id",
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"), "last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
@@ -394,7 +402,8 @@ export const flushDeferredGeneralAccessBatch = async (
LEFT JOIN access_updates ON access_updates."general_id" = actor."id" LEFT JOIN access_updates ON access_updates."general_id" = actor."id"
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id" LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
ORDER BY actor."id" ORDER BY actor."id"
`); `);
});
return { return {
states, states,
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit), refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
+62 -4
View File
@@ -1,13 +1,26 @@
import { GameClock, type GameClockMode } from '@sammo-ts/common'; import {
GameClock,
readTurnRecovery,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
type GameClockPhase,
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { readTurnRuntimeReady } from '@sammo-ts/infra';
export interface CurrentGameTime { export interface CurrentGameTime {
now: Date; now: Date;
wallNow: Date; wallNow: Date;
tick: number | null; tick: number | null;
mode: GameClockMode | null; mode: GameClockMode | null;
phase?: GameClockPhase | null;
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean; running: boolean;
runtimeReady?: boolean;
recovery?: { startsAt: string; endsAt: string } | null;
startsAt: Date | null; startsAt: Date | null;
dateToTick(date: Date): number | null; dateToTick(date: Date): number | null;
} }
@@ -19,6 +32,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow, wallNow,
tick: null, tick: null,
mode: null, mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true, running: true,
startsAt: null, startsAt: null,
dateToTick: () => null, dateToTick: () => null,
@@ -31,7 +47,13 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockTick: true, clockTick: true,
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
tickSeconds: true, tickSeconds: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
}, },
}); });
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) { if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
@@ -40,32 +62,68 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow, wallNow,
tick: null, tick: null,
mode: null, mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true, running: true,
startsAt: null, startsAt: null,
dateToTick: () => null, dateToTick: () => null,
}; };
} }
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime'; const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
// During the dual-read migration an older profile (or a rolling-deploy
// fixture) can lack clock_phase. Preserve the existing future-anchor
// PREOPEN contract until every profile has the authoritative column.
const phase = state.clockPhase
? parseGameClockPhase(state.clockPhase)
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
? 'PREOPEN'
: inferClockPhase(mode);
const storedTick = Number(state.clockTick); const storedTick = Number(state.clockTick);
if (!Number.isSafeInteger(storedTick)) { if (!Number.isSafeInteger(storedTick)) {
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`); throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
} }
const revision = Number(state.clockRevision ?? 1n);
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
}
const clock = new GameClock({ const clock = new GameClock({
baseTime: state.clockBaseTime, baseTime: state.clockBaseTime,
tick: storedTick, tick: storedTick,
mode, mode,
wallAnchor: state.clockWallAnchor, wallAnchor: state.clockWallAnchor,
recovery: readTurnRecovery(state),
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase,
revision,
}); });
const tick = clock.nowTick(wallNow); const runtimeReady =
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime(); phase !== 'RUNNING' ||
mode !== 'realtime' ||
state.clockRecoveryStartTick === undefined ||
(await readTurnRuntimeReady(db, state.clockRevision));
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
return { return {
now: clock.tickToDate(tick), now: clock.tickToDate(tick),
wallNow, wallNow,
tick, tick,
mode, mode,
phase,
revision,
deadlineGeneration,
running, running,
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null, runtimeReady,
recovery:
clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow
? {
startsAt: clock.recovery.startWallAt.toISOString(),
endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(),
}
: null,
startsAt:
phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date), dateToTick: (date) => clock.dateToTick(date),
}; };
}; };
+2 -1
View File
@@ -1,5 +1,5 @@
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js'; import type { GameApiContext } from '../context.js';
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
throw new Error('Traffic access persistence requires transaction support.'); throw new Error('Traffic access persistence requires transaction support.');
} }
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
const periodKey = input.year * 12 + input.month - 1; const periodKey = input.year * 12 + input.month - 1;
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>( const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
GamePrisma.sql` GamePrisma.sql`
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
export const resolveRemainingMinutes = ( export const resolveRemainingMinutes = (
turnTime: Date, turnTime: Date,
lastExecuted: Date | null, currentGameTime: Date | null
turnTermSeconds: number
): number | null => { ): number | null => {
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null; if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
let nextTurnMillis = turnTime.getTime(); return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
if (nextTurnMillis < lastExecuted.getTime()) {
nextTurnMillis += turnTermSeconds * 1_000;
}
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
}; };
export interface NextTurnMonthOffsetInput { export interface NextTurnMonthOffsetInput {
@@ -0,0 +1,86 @@
import type { CurrentGameTime } from './gameClock.js';
import type { GameClockPhase } from '@sammo-ts/common';
interface ClockFenceRedis {
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
}
const BOOTSTRAP_CLOCK_FENCE_SCRIPT = `
local revision = redis.call('GET', KEYS[1])
local generation = redis.call('GET', KEYS[2])
local phase = redis.call('GET', KEYS[3])
if not revision and not generation and not phase then
redis.call('SET', KEYS[1], ARGV[1])
redis.call('SET', KEYS[2], ARGV[2])
redis.call('SET', KEYS[3], ARGV[3])
return 1
end
if revision == ARGV[1] and generation == ARGV[2] and phase == ARGV[3] then
return 2
end
return 0
`;
export interface ActiveRedisClockFence {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
generation: number;
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
}
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
const ensureRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime,
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => {
if (
gameTime.runtimeReady === false ||
!gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
!Number.isSafeInteger(gameTime.revision) ||
!Number.isSafeInteger(gameTime.deadlineGeneration)
) {
return null;
}
const phase: MutableProjectionPhase = gameTime.phase;
const fence: ActiveRedisClockFence = {
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
phaseKey: `sammo:${profileName}:clock:phase`,
revision: gameTime.revision!,
generation: gameTime.deadlineGeneration!,
phase,
};
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
arguments: [String(fence.revision), String(fence.generation), phase],
});
return Number(result) === 1 || Number(result) === 2 ? fence : null;
};
export const ensureActiveRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
if (!gameTime.running) return null;
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
/**
* User betting is allowed against a frozen tournament deadline while the game
* clock is suspended. Stage progression and settlement continue to use the
* RUNNING-only helper above.
*/
export const ensureBettingRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> =>
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
+18 -13
View File
@@ -1,32 +1,37 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities } from '@sammo-ts/common'; import { gatewayProfileCapabilities } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js'; import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
interface TurnDaemonLeaseSource { interface TurnDaemonLeaseSource {
turnDaemonLease: { $queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
findUnique(input: {
where: { profile: string };
select: { leaseUntil: true };
}): Promise<{ leaseUntil: Date } | null>;
};
} }
export const loadTurnEngineRunning = async ( export const loadTurnEngineRunning = async (
source: ProfileStatusSource | undefined, source: ProfileStatusSource | undefined,
db: TurnDaemonLeaseSource, db: TurnDaemonLeaseSource,
profileName: string, profileName: string,
now = new Date() now?: Date
): Promise<boolean | null> => { ): Promise<boolean | null> => {
if (!source) return null; if (!source) return null;
try { try {
const status = await source.get(profileName); const status = await source.get(profileName);
if (status === null) return null; if (status === null) return null;
if (!gatewayProfileCapabilities(status).turnsRunning) return false; if (!gatewayProfileCapabilities(status).turnsRunning) return false;
const lease = await db.turnDaemonLease.findUnique({ const wallNow = now
where: { profile: profileName }, ? GamePrisma.sql`${now}`
select: { leaseUntil: true }, : GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
}); const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
return lease !== null && lease.leaseUntil.getTime() > now.getTime(); SELECT EXISTS (
SELECT 1
FROM turn_daemon_lease
WHERE profile = ${profileName}
AND lease_until > ${wallNow}
) AS running
`);
return rows[0]?.running ?? false;
} catch { } catch {
return null; return null;
} }
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
private readonly db: TurnDaemonLeaseSource, private readonly db: TurnDaemonLeaseSource,
private readonly profileName: string, private readonly profileName: string,
private readonly cacheMs = 2_000, private readonly cacheMs = 2_000,
private readonly now = () => Date.now() private readonly now = () => performance.now()
) {} ) {}
get(): Promise<boolean | null> { get(): Promise<boolean | null> {
+13
View File
@@ -0,0 +1,13 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
`);
const wallNow = rows[0]?.wallNow;
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
return new Date(wallNow);
};
@@ -1,4 +1,5 @@
import { createHmac, randomUUID } from 'node:crypto'; import { createHmac, randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common'; import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
`); `);
if (rows.length === 0) return []; if (rows.length === 0) return [];
const ids = rows.map((row) => row.id); const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({ await tx.$executeRaw(GamePrisma.sql`
where: { id: { in: ids } }, UPDATE "web_push_outbox"
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } }, SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
}); "lock_owner" = ${this.owner},
"attempts" = "attempts" + 1
WHERE "id" IN (${GamePrisma.join(ids)})
`);
return tx.webPushOutbox.findMany({ return tx.webPushOutbox.findMany({
where: { id: { in: ids }, lockOwner: this.owner }, where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
}); });
for (const event of claimed) { for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) { const expired = await this.db.$executeRaw(GamePrisma.sql`
await this.db.webPushOutbox.updateMany({ UPDATE "web_push_outbox"
where: { id: event.id, lockOwner: this.owner }, SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, "locked_at" = NULL,
}); "lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id}
AND "lock_owner" = ${this.owner}
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
`);
if (expired > 0) {
continue; continue;
} }
try { try {
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}); });
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`); if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({ await this.db.$executeRaw(GamePrisma.sql`
where: { id: event.id, lockOwner: this.owner }, UPDATE "web_push_outbox"
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null }, SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
}); "locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
} catch (error) { } catch (error) {
const attempts = event.attempts; const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8)); const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({ const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
where: { id: event.id, lockOwner: this.owner }, await this.db.$executeRaw(GamePrisma.sql`
data: { UPDATE "web_push_outbox"
availableAt: new Date(Date.now() + delaySeconds * 1_000), SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
lockedAt: null, + ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
lockOwner: null, "locked_at" = NULL,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500), "lock_owner" = NULL,
}, "last_error" = ${errorText}
}); WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
this.onError(error); this.onError(error);
} }
} }
if (Date.now() >= this.nextPruneAt) { if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000; this.nextPruneAt = performance.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql` await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS ( WITH expired AS (
SELECT "id" SELECT "id"
+6
View File
@@ -8,6 +8,9 @@ export interface TournamentKeys {
sourceRevisionKey: string; sourceRevisionKey: string;
sourceRevisionChannel: string; sourceRevisionChannel: string;
realtimeEventChannel: string; realtimeEventChannel: string;
activeClockRevisionKey: string;
deadlineGenerationKey: string;
clockPhaseKey: string;
} }
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName), realtimeEventChannel: buildGameEventChannel(profileName),
activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
clockPhaseKey: `sammo:${profileName}:clock:phase`,
}); });
+64 -5
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common'; import { performance } from 'node:perf_hooks';
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import type { TournamentKeys } from './keys.js'; import type { TournamentKeys } from './keys.js';
@@ -37,8 +38,12 @@ const zTournamentState = z
openMonth: z.number().int(), openMonth: z.number().int(),
termSeconds: z.number(), termSeconds: z.number(),
nextAt: z.string(), nextAt: z.string(),
nextTick: z.number().int().safe().optional(),
clockRevision: z.number().int().positive().safe().optional(),
deadlineGeneration: z.number().int().positive().safe().optional(),
bettingId: z.number().int().optional(), bettingId: z.number().int().optional(),
bettingCloseAt: z.string().optional(), bettingCloseAt: z.string().optional(),
bettingCloseTick: z.number().int().safe().optional(),
winnerId: z.number().int().optional(), winnerId: z.number().int().optional(),
bettingSettled: z.boolean().optional(), bettingSettled: z.boolean().optional(),
rewardSettled: z.boolean().optional(), rewardSettled: z.boolean().optional(),
@@ -131,12 +136,62 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
return parsed.data; return parsed.data;
}; };
export interface TournamentClockContext {
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
revision: number;
deadlineGeneration: number;
dateToTick(date: Date): number | null;
}
const parseDeadlineTick = (value: string, context: TournamentClockContext, field: string): number => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(`Tournament ${field} is not a valid instant.`);
}
const tick = context.dateToTick(date);
if (tick === null || !Number.isSafeInteger(tick)) {
throw new Error(`Tournament ${field} cannot be represented in the active game clock.`);
}
return tick;
};
export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({
...state,
nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'),
clockRevision: context.revision,
deadlineGeneration: context.deadlineGeneration,
...(state.bettingCloseAt
? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') }
: { bettingCloseTick: undefined }),
});
const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({
activeRevisionKey: keys.activeClockRevisionKey,
deadlineGenerationKey: keys.deadlineGenerationKey,
phaseKey: keys.clockPhaseKey,
revision: context.revision,
deadlineGeneration: context.deadlineGeneration,
phase: context.phase,
});
export class TournamentStore { export class TournamentStore {
private clockContext: TournamentClockContext | null = null;
constructor( constructor(
private readonly redis: RedisClientLike, private readonly redis: RedisClientLike,
private readonly keys: TournamentKeys private readonly keys: TournamentKeys
) {} ) {}
async withClockContext<T>(context: TournamentClockContext, operation: () => Promise<T>): Promise<T> {
const previous = this.clockContext;
this.clockContext = context;
try {
return await operation();
} finally {
this.clockContext = previous;
}
}
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> { async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
if (!this.redis.del) { if (!this.redis.del) {
return operation(); return operation();
@@ -144,8 +199,8 @@ export class TournamentStore {
const lockKey = `${this.keys.stateKey}:mutation-lock`; const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID(); const token = randomUUID();
const deadline = Date.now() + timeoutMs; const deadline = performance.now() + timeoutMs;
while (Date.now() < deadline) { while (performance.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 }); const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) { if (acquired) {
try { try {
@@ -174,11 +229,15 @@ export class TournamentStore {
} }
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> { private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]); const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined;
return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence);
} }
async setState(state: TournamentState): Promise<string> { async setState(state: TournamentState): Promise<string> {
return this.writeWithSourceRevision(this.keys.stateKey, state); return this.writeWithSourceRevision(
this.keys.stateKey,
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
);
} }
async getParticipants(): Promise<TournamentParticipantEntry[]> { async getParticipants(): Promise<TournamentParticipantEntry[]> {
+4
View File
@@ -9,8 +9,12 @@ export interface TournamentState {
openMonth: number; openMonth: number;
termSeconds: number; termSeconds: number;
nextAt: string; nextAt: string;
nextTick?: number;
clockRevision?: number;
deadlineGeneration?: number;
bettingId?: number; bettingId?: number;
bettingCloseAt?: string; bettingCloseAt?: string;
bettingCloseTick?: number;
winnerId?: number; winnerId?: number;
bettingSettled?: boolean; bettingSettled?: boolean;
rewardSettled?: boolean; rewardSettled?: boolean;
+106 -34
View File
@@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js'; import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js'; import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
import type { TurnDaemonTransport } from '../daemon/transport.js'; import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js'; import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js'; import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js';
import type { TournamentMatchEntry, TournamentState } from './types.js'; import type { TournamentMatchEntry, TournamentState } from './types.js';
import { import {
applyGroupMatch, applyGroupMatch,
@@ -595,41 +596,63 @@ export const processTournamentTick = async (options: {
prisma: GamePrismaClient; prisma: GamePrismaClient;
daemonTransport: TurnDaemonTransport; daemonTransport: TurnDaemonTransport;
now?: () => number; now?: () => number;
clockContext?: TournamentClockContext;
}): Promise<TournamentState | null> => { }): Promise<TournamentState | null> => {
const { store, prisma, daemonTransport } = options; const { store, prisma, daemonTransport } = options;
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
let processedState: TournamentState | null = null; let processedState: TournamentState | null = null;
await store.withMutationLock(async () => { const processWithLock = async (): Promise<void> =>
const state = await store.getState(); store.withMutationLock(async () => {
if (!state || (!state.auto && !needsSettlement(state))) { const state = await store.getState();
return; if (!state || (!state.auto && !needsSettlement(state))) {
} return;
const nextAt = new Date(state.nextAt).getTime(); }
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { if (
return; options.clockContext &&
} (state.clockRevision !== options.clockContext.revision ||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
) {
throw new Error('Tournament state does not match the active clock revision.');
}
const nextAt = new Date(state.nextAt).getTime();
const notDue = options.clockContext
? !Number.isSafeInteger(state.nextTick) ||
state.nextTick! > options.clockContext.dateToTick(new Date(now()))!
: Number.isFinite(nextAt) && nextAt > now();
if (state.auto && notDue) {
if (options.clockContext && !Number.isSafeInteger(state.nextTick)) {
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
}
return;
}
if (needsSettlement(state)) { if (needsSettlement(state)) {
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
return; return;
} }
const worldState = await prisma.worldState.findFirst(); const worldState = await prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament'; const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state; let nextState = state;
if (isBattleStage(state.stage)) { if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) { } else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now); nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
} }
processedState = processedState =
(await settleTournamentOutcome({ (await settleTournamentOutcome({
store, store,
daemonTransport, daemonTransport,
state: nextState, state: nextState,
})) ?? nextState; })) ?? nextState;
}); });
if (options.clockContext) {
await store.withClockContext(options.clockContext, processWithLock);
} else {
await processWithLock();
}
return processedState; return processedState;
}; };
@@ -656,16 +679,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
try { try {
while (!control.signal.aborted) { while (!control.signal.aborted) {
const state = await store.getState(); let state = await store.getState();
if (!state || (!state.auto && !needsSettlement(state))) { if (!state || (!state.auto && !needsSettlement(state))) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs); await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue; continue;
} }
const gameTime = await loadCurrentGameTime(postgres.prisma);
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const clockFence = gameTime.phase
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
: null;
if (gameTime.phase && !clockFence) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const clockContext: TournamentClockContext | undefined = clockFence
? {
phase: 'RUNNING',
revision: clockFence.revision,
deadlineGeneration: clockFence.generation,
dateToTick: gameTime.dateToTick,
}
: undefined;
if (
clockContext &&
(!Number.isSafeInteger(state.nextTick) ||
state.clockRevision === undefined ||
state.deadlineGeneration === undefined)
) {
state = stampTournamentClock(state, clockContext);
await store.withClockContext(clockContext, () => store.setState(state!));
}
if (
clockContext &&
(state.clockRevision !== clockContext.revision ||
state.deadlineGeneration !== clockContext.deadlineGeneration)
) {
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
continue;
}
const nextAt = new Date(state.nextAt).getTime(); const nextAt = new Date(state.nextAt).getTime();
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime(); const gameNow = gameTime.now.getTime();
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) { const notDue = clockContext
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow)); ? state.nextTick! > gameTime.tick!
: Number.isFinite(nextAt) && nextAt > gameNow;
if (state.auto && notDue) {
await waitForWorkerPoll(
control.signal,
Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow))
);
continue; continue;
} }
@@ -675,6 +742,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
prisma: postgres.prisma, prisma: postgres.prisma,
daemonTransport, daemonTransport,
now: () => gameNow, now: () => gameNow,
clockContext,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
@@ -700,7 +768,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
lastError: message, lastError: message,
lastErrorAt: failedAt, lastErrorAt: failedAt,
}; };
await store.setState(nextState); if (clockContext) {
await store.withClockContext(clockContext, () => store.setState(nextState));
} else {
await store.setState(nextState);
}
} }
await waitForWorkerPoll(control.signal, config.tournamentPollMs); await waitForWorkerPoll(control.signal, config.tournamentPollMs);
+4 -1
View File
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
const resolveScheduledBaseMs = (state: TournamentState): number => { const resolveScheduledBaseMs = (state: TournamentState): number => {
const scheduled = new Date(state.nextAt).getTime(); const scheduled = new Date(state.nextAt).getTime();
return Number.isFinite(scheduled) ? scheduled : Date.now(); if (!Number.isFinite(scheduled)) {
throw new Error('Tournament GAME_TIME schedule is invalid.');
}
return scheduled;
}; };
export const resolveNextAt = (state: TournamentState): string => export const resolveNextAt = (state: TournamentState): string =>
+71 -52
View File
@@ -62,61 +62,68 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string => export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`; `${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => { const createInputEventMiddleware = (acquireClockFence: boolean) =>
if (type !== 'mutation' || !ctx.db.$transaction) { t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
return next(); if (type !== 'mutation' || !ctx.db.$transaction) {
} return next();
}
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex); const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput(); const payload = await getRawInput();
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
let journalPersisted = false; let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined; let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try { try {
const response = await executeInputEvent({ const response = await executeInputEvent({
db: ctx.db, db: ctx.db,
requestId, requestId,
eventType: path, eventType: path,
payload, payload,
actorUserId: ctx.auth?.user.id, actorUserId: ctx.auth?.user.id,
execute: async (transaction) => { acquireClockFence,
const result = await next({ execute: async (transaction) => {
ctx: { const result = await next({
...ctx, ctx: {
db: transaction, ...ctx,
changeJournal, db: transaction,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId), changeJournal,
}, turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
}); },
if (!result.ok) { });
throw result.error; if (!result.ok) {
} throw result.error;
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); }
executedResult = result; journalPersisted = Boolean(
return result.data; await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
}, );
}); executedResult = result;
if (journalPersisted) { return result.data;
ctx.readModelOutbox?.wake(); },
}
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
}); });
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
code: 'CONFLICT',
message: error.message,
});
}
throw error;
} }
throw error; });
}
}); const inputEventMiddleware = createInputEventMiddleware(true);
const wallInputEventMiddleware = createInputEventMiddleware(false);
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => { const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다. // 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
@@ -180,10 +187,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router; export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware); export const procedure = t.procedure.use(inputEventMiddleware);
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
export const authedProcedure: typeof procedure = t.procedure export const authedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalActivityMiddleware) .use(generalActivityMiddleware)
.use(inputEventMiddleware); .use(inputEventMiddleware);
export const wallAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -234,6 +246,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
.use(generalAccessEndpointMiddleware) .use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware) .use(generalActivityMiddleware)
.use(inputEventMiddleware); .use(inputEventMiddleware);
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(wallInputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) => export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
+51 -9
View File
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod'; import { z } from 'zod';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -79,7 +80,9 @@ export interface TurnCommandInputField {
required: boolean; required: boolean;
min?: number; min?: number;
max?: number; max?: number;
legacyWidthMax?: number;
step?: number; step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue; constValue?: TurnCommandOptionValue;
options?: TurnCommandOption[]; options?: TurnCommandOption[];
optionSource?: TurnCommandOptionSource; optionSource?: TurnCommandOptionSource;
@@ -118,22 +121,60 @@ const plainLegacyInfo = (value: string): string =>
.replace(/\s+/gu, ' ') .replace(/\s+/gu, ' ')
.trim(); .trim();
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
return undefined;
}
try {
const scenario = await loadScenarioDefinitionById(Number(normalized));
const configConst = scenario.config.const;
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
} catch (error) {
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
if (isRecord(error) && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
};
export const buildEquipmentTradeItemOptions = (options: { export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>; configConst: Record<string, unknown>;
itemOrder?: readonly string[];
itemModules: readonly EquipmentTradeItemModule[]; itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number; currentSecurity: number;
generalGold: number; generalGold: number;
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
}): TurnCommandInputOptions['items'] => { }): TurnCommandInputOptions['items'] => {
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst); const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
const items: TurnCommandInputOptions['items'] = { const items: TurnCommandInputOptions['items'] = {};
horse: [{ value: 'None', label: '판매/해제' }], const catalog = new Map(options.itemModules.map((item) => [item.key, item]));
weapon: [{ value: 'None', label: '판매/해제' }], // Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다.
book: [{ value: 'None', label: '판매/해제' }], for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
item: [{ value: 'None', label: '판매/해제' }], const ownedCode = options.ownedItems[slot];
}; const ownedItem = ownedCode && ownedCode !== 'None' ? catalog.get(ownedCode) : undefined;
const slotName = STATIC_LABELS.itemType?.[slot] ?? slot;
items[slot] = [
{
value: 'None',
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
description: ownedItem
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}`
: ownedCode && ownedCode !== 'None'
? '보유 장비 정보를 확인할 수 없습니다.'
: '현재 보유한 장비가 없습니다.',
},
];
}
for (const item of options.itemModules) { // 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
if (!item.buyable || !purchasableItemKeys.has(item.key)) { const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
for (const key of orderedKeys) {
const item = catalog.get(key);
if (!item?.buyable || !purchasableItemKeys.has(key)) {
continue; continue;
} }
const cost = item.cost ?? 0; const cost = item.cost ?? 0;
@@ -327,6 +368,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
required, required,
min: typeof schema.minLength === 'number' ? schema.minLength : undefined, min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined, max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
legacyWidthMax: key === 'nationName' ? 18 : undefined,
}; };
} }
@@ -564,7 +606,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
getLegacyStringWidth(nationName) < 1 || getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18) getLegacyStringWidth(nationName) > 18)
) { ) {
throwLegacyBasicTurnArgError(); throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
} }
} }
}; };
+145 -11
View File
@@ -18,7 +18,7 @@ import type {
TriggerValue, TriggerValue,
UnitSetDefinition, UnitSetDefinition,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic'; import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
@@ -26,6 +26,7 @@ import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/log
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
import { import {
buildTurnCommandInputFields, buildTurnCommandInputFields,
loadTurnCommandSpecs, loadTurnCommandSpecs,
@@ -40,6 +41,7 @@ export interface TurnCommandAvailability {
key: string; key: string;
name: string; name: string;
turnDurationText?: string; turnDurationText?: string;
costText?: string;
reqArg: boolean; reqArg: boolean;
possible: boolean; possible: boolean;
status: AvailabilityStatus; status: AvailabilityStatus;
@@ -326,9 +328,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const constValues = asRecord(config.const); const constValues = asRecord(config.const);
const meta = asRecord(worldState.meta);
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
return { return {
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), // daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0), trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1), trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
@@ -554,7 +559,10 @@ export const buildRecruitmentCommandInfo = (options: {
avoid: crewType.avoid, avoid: crewType.avoid,
baseCost: displayCost.gold, baseCost: displayCost.gold,
baseRice: displayCost.rice, baseRice: displayCost.rice,
info: [...crewType.info], info: [
...crewType.info,
...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean),
],
}; };
}), }),
})); }));
@@ -646,6 +654,40 @@ const evaluateDefinition = (
return evaluateAvailability(constraints, ctx, view, reqArg); return evaluateAvailability(constraints, ctx, view, reqArg);
}; };
const readNextAvailableTurn = (meta: Readonly<Record<string, unknown>>, actionName: string): number | null => {
const raw = meta[`next_execute_${actionName}`];
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string') {
const parsed = Number(raw);
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
};
const evaluateCooldown = (
definition: GeneralActionDefinition,
scope: 'general' | 'nation',
ctx: ConstraintContext,
view: StateView,
currentYearMonth: number
): AvailabilityCore | null => {
const owner =
scope === 'general'
? (view.get({ kind: 'general', id: ctx.actorId }) as General | null)
: ctx.nationId === undefined
? null
: (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null);
if (!owner) return null;
const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name);
if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null;
return {
possible: false,
status: 'blocked',
reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`,
};
};
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore => const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs; AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
@@ -656,18 +698,33 @@ const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위
const buildEntries = ( const buildEntries = (
env: CommandEnv, env: CommandEnv,
specs: TurnCommandSpec[], specs: TurnCommandSpec[],
options: { foundingAvailable?: boolean } = {} options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
): CommandEntry[] => { ): CommandEntry[] => {
const entries: CommandEntry[] = []; const entries: CommandEntry[] = [];
for (const spec of specs) { for (const spec of specs) {
const definition = spec.createDefinition(env); const definition = spec.createDefinition(env);
const inputFields = buildTurnCommandInputFields(spec).map((field) => {
if (spec.key !== 'che_불가침제의') return field;
if (field.key === 'year' && options.currentYear !== undefined) {
return {
...field,
min: options.currentYear + 1,
max: resolveNonAggressionMaxEndYear(options.currentYear),
defaultValue: options.currentYear + 1,
};
}
if (field.key === 'month' && options.currentMonth !== undefined) {
return { ...field, defaultValue: options.currentMonth };
}
return field;
});
const entry: CommandEntry = { const entry: CommandEntry = {
category: spec.category, category: spec.category,
definition, definition,
reqArg: spec.reqArg, reqArg: spec.reqArg,
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {}, availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
inputFields: buildTurnCommandInputFields(spec), inputFields,
}; };
if (spec.key === 'che_포상') { if (spec.key === 'che_포상') {
@@ -711,23 +768,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined; return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined;
}; };
const formatCost = (gold: number | undefined, rice: number | undefined): string | undefined => {
const parts = [
gold && gold > 0 ? `${gold.toLocaleString('ko-KR')}` : null,
rice && rice > 0 ? `${rice.toLocaleString('ko-KR')}` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? parts.join(' · ') : undefined;
};
const getCommandCostText = (
entry: CommandEntry,
ctx: ConstraintContext,
view: StateView,
env: CommandEnv
): string | undefined => {
const hint = entry.definition.getCostHint?.(ctx, view);
if (hint?.formula) return hint.formula;
const hintedCost = hint ? formatCost(hint.gold, hint.rice) : undefined;
if (hintedCost) return hintedCost;
const develCost = env.develCost;
const general = view.get({ kind: 'general', id: ctx.actorId }) as General | null;
switch (entry.definition.key) {
case 'che_단련':
case 'che_숙련전환':
return formatCost(develCost, develCost);
case 'che_첩보':
return formatCost(develCost * 3, develCost * 3);
case 'che_이동':
case 'che_인재탐색':
return formatCost(develCost, 0);
case 'che_강행':
return formatCost(develCost * 5, 0);
case 'che_사기진작':
return formatCost(Math.round((general?.crew ?? 0) / 100), 0);
case 'che_출병':
return formatCost(0, Math.round((general?.crew ?? 0) / 100));
case 'che_천도':
return `금·쌀 ${(develCost * 5).toLocaleString('ko-KR')} × 2^거리`;
case 'cr_인구이동':
return `금·쌀 ${develCost.toLocaleString('ko-KR')} × 인구[만]`;
case 'che_증축': {
const cost = develCost * 500 + 60_000;
return formatCost(cost, cost);
}
case 'che_감축': {
const recovery = develCost * 500 + 30_000;
return `${recovery.toLocaleString('ko-KR')} · 쌀 ${recovery.toLocaleString('ko-KR')} 회수`;
}
default:
return undefined;
}
};
const buildGroups = ( const buildGroups = (
entries: CommandEntry[], entries: CommandEntry[],
ctx: ConstraintContext, ctx: ConstraintContext,
view: StateView, view: StateView,
includeTurnDuration = false includeTurnDuration = false,
env: CommandEnv,
scope: 'general' | 'nation',
currentYearMonth: number
): TurnCommandGroup[] => { ): TurnCommandGroup[] => {
const groups = new Map<string, TurnCommandAvailability[]>(); const groups = new Map<string, TurnCommandAvailability[]>();
for (const entry of entries) { for (const entry of entries) {
const availability = entry.evaluate const baseAvailability = entry.evaluate
? entry.evaluate(ctx, view) ? entry.evaluate(ctx, view)
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs); : evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
const availability =
baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown'
? baseAvailability
: (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability);
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined; const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
const costText = getCommandCostText(entry, ctx, view, env);
const value: TurnCommandAvailability = { const value: TurnCommandAvailability = {
key: entry.definition.key, key: entry.definition.key,
name: entry.definition.name, name: entry.definition.name,
...(turnDurationText ? { turnDurationText } : {}), ...(turnDurationText ? { turnDurationText } : {}),
...(costText ? { costText } : {}),
reqArg: entry.reqArg, reqArg: entry.reqArg,
inputFields: entry.inputFields, inputFields: entry.inputFields,
...availability, ...availability,
@@ -785,6 +904,7 @@ export const buildTurnCommandTable = async (options: {
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */ /** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
realNationCount?: number; realNationCount?: number;
inputOptions?: TurnCommandInputOptions; inputOptions?: TurnCommandInputOptions;
generalActionModules?: TurnCommandEnv['generalActionModules'];
}): Promise<TurnCommandTable> => { }): Promise<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
const general = mapGeneralRow(options.general); const general = mapGeneralRow(options.general);
@@ -803,7 +923,10 @@ export const buildTurnCommandTable = async (options: {
mode: 'precheck', mode: 'precheck',
}; };
const env = buildCommandEnv(options.worldState); const env = {
...buildCommandEnv(options.worldState),
...(options.generalActionModules ? { generalActionModules: options.generalActionModules } : {}),
};
const scenarioConst = asRecord(options.worldState.config).const; const scenarioConst = asRecord(options.worldState.config).const;
const { const {
general: generalSpecs, general: generalSpecs,
@@ -817,19 +940,30 @@ export const buildTurnCommandTable = async (options: {
? undefined ? undefined
: options.realNationCount < resolveMaxNation(options.worldState), : options.realNationCount < resolveMaxNation(options.worldState),
}); });
const nationEntries = buildEntries(env, nationSpecs); const nationEntries = buildEntries(env, nationSpecs, {
currentYear: options.worldState.currentYear,
currentMonth: options.worldState.currentMonth,
});
const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1;
return { return {
general: buildGroups( general: buildGroups(
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS), projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
ctx, ctx,
view view,
false,
env,
'general',
currentYearMonth
), ),
nation: buildGroups( nation: buildGroups(
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
ctx, ctx,
view, view,
true true,
env,
'nation',
currentYearMonth
), ),
inputOptions: options.inputOptions ?? { inputOptions: options.inputOptions ?? {
cities: [], cities: [],
+49 -7
View File
@@ -11,6 +11,9 @@ export interface GeneralTargetSource {
officerLevel: number; officerLevel: number;
gold?: number; gold?: number;
rice?: number; rice?: number;
leadership?: number;
strength?: number;
intel?: number;
crew?: number; crew?: number;
train?: number; train?: number;
atmos?: number; atmos?: number;
@@ -56,28 +59,67 @@ export const buildRefGeneralTargetOptions = (options: {
}): RefGeneralTargetOptions => { }): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => { const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined; const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
const cityName = options.cityNames.get(entry.cityId) ?? '재야';
const troopLabel = entry.troopId
? `${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
: '부대 없음';
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id); const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
const isTroopExit = action === 'che_부대탈퇴지시'; const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => {
if (action === 'che_발령') return `${entry.name} (${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})();
const details = [ const details = [
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`, entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`, entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
entry.troopId action === 'che_발령' || action === 'che_포상' || action === 'che_몰수'
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}` ? null
: '탑승 부대 없음', : entry.troopId
? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value)); ].filter((value): value is string => Boolean(value));
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
const assignmentDetails =
action === 'che_발령'
? [
entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음',
[
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
]
.filter(Boolean)
.join('\n')
: undefined;
if (isTroopExit) { if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가'); details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
} }
return { return {
value: entry.id, value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${ label,
options.cityNames.get(entry.cityId) ?? '재야' description: assignmentDetails ?? details.join(' · '),
})`,
description: details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }), ...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }), ...(entry.gold === undefined ? {} : { gold: entry.gold }),
...(entry.rice === undefined ? {} : { rice: entry.rice }), ...(entry.rice === undefined ? {} : { rice: entry.rice }),
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'), lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T10:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'MANUAL',
clockRevision: 1,
deadlineGeneration: 1,
meta: { killturn: 24, scenarioMeta }, meta: { killturn: 24, scenarioMeta },
}; };
const lifecycleGeneral: TurnGeneral = { const lifecycleGeneral: TurnGeneral = {
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
currentYear: lifecycleState.currentYear, currentYear: lifecycleState.currentYear,
currentMonth: lifecycleState.currentMonth, currentMonth: lifecycleState.currentMonth,
tickSeconds: lifecycleState.tickSeconds, tickSeconds: lifecycleState.tickSeconds,
clockBaseTime: lifecycleState.clockBaseTime,
clockTick: BigInt(lifecycleState.clockTick ?? 0),
clockMode: lifecycleState.clockMode ?? 'manual',
clockWallAnchor: lifecycleState.clockWallAnchor,
lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0),
clockPhase: lifecycleState.clockPhase ?? 'MANUAL',
clockRevision: BigInt(lifecycleState.clockRevision ?? 1),
deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: lifecycleState.meta as GamePrisma.InputJsonValue, meta: lifecycleState.meta as GamePrisma.InputJsonValue,
}, },
+8 -11
View File
@@ -96,6 +96,7 @@ const buildContext = (options: {
ok: true as const, ok: true as const,
auctionId: 91, auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z', closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
}; };
} }
return { return {
@@ -103,6 +104,7 @@ const buildContext = (options: {
ok: true as const, ok: true as const,
auctionId: 91, auctionId: 91,
closeAt: '2026-07-27T00:00:00.000Z', closeAt: '2026-07-27T00:00:00.000Z',
closeTick: 200,
}; };
}); });
const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
@@ -112,14 +114,10 @@ const buildContext = (options: {
currentYear: 200, currentYear: 200,
currentMonth: 1, currentMonth: 1,
tickSeconds: 3600, tickSeconds: 3600,
...(options.clockTick === undefined clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
? {} clockTick: BigInt(options.clockTick ?? 100),
: { clockMode: 'manual',
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'), clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
config: { config: {
const: { const: {
auctionName: ['청룡', '백호', '주작', '현무'], auctionName: ['청룡', '백호', '주작', '현무'],
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
tick: 72_000_001, tick: 72_000_001,
}) })
).toBe(true); ).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false); expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
}); });
it('rejects unauthenticated auction reads', async () => { it('rejects unauthenticated auction reads', async () => {
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31, auctionId: 31,
generalId: 7, generalId: 7,
amount: 110, amount: 110,
acceptedGameTick: 100,
tryExtendCloseDate: false, tryExtendCloseDate: false,
}); });
}); });
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false }, detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN', status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000), closeAt: new Date(Date.now() + 60 * 60_000),
closeTick: 200n,
}, },
]; ];
} }
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31, auctionId: 31,
generalId: 7, generalId: 7,
amount: 500, amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true, tryExtendCloseDate: true,
}); });
}); });
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { amount: 100 }, detail: { amount: 100 },
status, status,
closeAt, closeAt,
closeTick: 0n,
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}), ...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
}, },
}); });
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
return auction; return auction;
}; };
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
buildAuctionFinalizeRequestId(auction.id, { if (auction.closeTick === null || auction.closeTick === undefined) {
throw new Error(`auction ${auction.id} fixture requires closeTick`);
}
return buildAuctionFinalizeRequestId(auction.id, {
closeAt: auction.closeAt, closeAt: auction.closeAt,
closeTick: auction.closeTick ?? null, closeTick: auction.closeTick,
}); });
};
const memoryRedis = () => ({ const memoryRedis = () => ({
zRangeByScore: vi.fn(async () => []), zRangeByScore: vi.fn(async () => []),
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`); ).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id }, payload: {
type: 'auctionFinalize',
requestId,
auctionId: auction.id,
expectedCloseAt: auction.closeAt.toISOString(),
expectedCloseTick: Number(auction.closeTick),
},
status: 'FAILED', status: 'FAILED',
attempts: 3, attempts: 3,
error: 'simulated terminal failure', error: 'simulated terminal failure',
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
await expect( await expect(
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`); ).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
}); });
it('creates a new generation after an earlier close was extended', async () => { it('creates a new generation after an earlier close was extended', async () => {
const auction = await createAuction('OPEN'); const auction = await createAuction('OPEN');
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`; const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
await connector.prisma.inputEvent.create({ await connector.prisma.inputEvent.create({
data: { data: {
requestId: priorRequestId, requestId: priorRequestId,
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'), lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T11:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'RUNNING',
clockRevision: 1,
deadlineGeneration: 1,
meta: { killturn: 24, scenarioMeta }, meta: { killturn: 24, scenarioMeta },
}; };
const buildGeneral = (options: { const buildGeneral = (options: {
@@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => {
currentYear: state.currentYear, currentYear: state.currentYear,
currentMonth: state.currentMonth, currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds, tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'realtime',
clockWallAnchor: state.clockWallAnchor,
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockPhase: state.clockPhase ?? 'RUNNING',
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
@@ -415,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 200, amount: 200,
eventId: `auction-durable-bid:${auction.id}`, eventId: `auction-durable-bid:${auction.id}`,
eventAt: new Date(), eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
}, },
}); });
const requestId = requestIdFor(auction); const requestId = requestIdFor(auction);
@@ -425,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(auction.id), id: String(auction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}); });
const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const world = new InMemoryTurnWorld(state, snapshot, { schedule });
@@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
detail: { remainCloseDateExtensionCnt: 1 }, detail: { remainCloseDateExtensionCnt: 1 },
status: 'OPEN', status: 'OPEN',
closeAt: logicalPastCloseAt, closeAt: logicalPastCloseAt,
closeTick: 0n,
}, },
}); });
extensionAuctionId = extensionAuction.id; extensionAuctionId = extensionAuction.id;
@@ -505,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
amount: 50, amount: 50,
eventId: `auction-extension-bid:${extensionAuction.id}`, eventId: `auction-extension-bid:${extensionAuction.id}`,
eventAt: new Date(), eventAt: new Date(),
occurredGameTick: 0n,
requestedAtWall: new Date(),
meta: { tryExtendCloseDate: true }, meta: { tryExtendCloseDate: true },
}, },
}); });
@@ -516,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
historyKey: 'history', historyKey: 'history',
id: String(extensionAuction.id), id: String(extensionAuction.id),
nowMs: Date.now(), nowMs: Date.now(),
nowTick: 0,
}); });
let reopened: { status: string; closeAt: Date } | null = null; let reopened: { status: string; closeAt: Date } | null = null;
+63 -14
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra'; import type { GamePrismaClient } from '@sammo-ts/infra';
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js'; import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js'; import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
const buildRedis = () => ({ const buildRedis = () => ({
@@ -33,7 +33,7 @@ const buildDb = (options: {
$executeRaw: vi.fn(async () => options.updated), $executeRaw: vi.fn(async () => options.updated),
auction: { auction: {
findUnique: vi.fn(async () => findUnique: vi.fn(async () =>
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
), ),
}, },
inputEvent: { inputEvent: {
@@ -55,6 +55,38 @@ const buildDb = (options: {
}; };
describe('auction worker clock-shift race', () => { describe('auction worker clock-shift race', () => {
it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => {
const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) };
await expect(
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
activeRevisionKey: 'clock-revision',
deadlineGenerationKey: 'deadline-generation',
phaseKey: 'clock-phase',
revision: 4,
generation: 8,
})
).resolves.toEqual(['7', '9']);
expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), {
keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'],
arguments: ['4', '8', '123456', '100'],
});
expect(redis.zRangeByScore).not.toHaveBeenCalled();
expect(redis.zRem).not.toHaveBeenCalled();
});
it('returns no due member when the atomic Redis clock fence rejects the pop', async () => {
const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) };
await expect(
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
activeRevisionKey: 'clock-revision',
deadlineGenerationKey: 'deadline-generation',
phaseKey: 'clock-phase',
revision: 4,
generation: 8,
})
).resolves.toEqual([]);
});
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => { it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
const now = new Date('2026-07-30T12:00:00.000Z'); const now = new Date('2026-07-30T12:00:00.000Z');
const time = { const time = {
@@ -201,11 +233,12 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 36_000_000,
}) })
).resolves.toBe('RESCHEDULED'); ).resolves.toBe('RESCHEDULED');
expect(redis.zAdd).toHaveBeenCalledTimes(1); expect(redis.zAdd).toHaveBeenCalledTimes(1);
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]); expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
expect(transaction.inputEvent.create).not.toHaveBeenCalled(); expect(transaction.inputEvent.create).not.toHaveBeenCalled();
}); });
@@ -235,7 +268,7 @@ describe('auction worker clock-shift race', () => {
it('leaves OPEN untouched and creates one durable command before recording history', async () => { it('leaves OPEN untouched and creates one durable command before recording history', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } }); const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime(); const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
@@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs, nowMs,
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -318,6 +353,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -330,7 +366,7 @@ describe('auction worker clock-shift race', () => {
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => { it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
auction: { status: 'OPEN', closeAt }, auction: { status: 'OPEN', closeAt },
@@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
status: 'PENDING', status: 'PENDING',
result: null, result: null,
@@ -359,6 +396,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled(); expect(transaction.inputEvent.create).not.toHaveBeenCalled();
@@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: logicalNowMs, nowMs: logicalNowMs,
nowTick: 72_000_000,
historyNowMs: operationalNowMs, historyNowMs: operationalNowMs,
}); });
@@ -388,12 +427,12 @@ describe('auction worker clock-shift race', () => {
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => { it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const existingEvent = { const existingEvent = {
requestId, requestId,
target: 'ENGINE' as const, target: 'ENGINE' as const,
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'PENDING' as const, status: 'PENDING' as const,
result: null, result: null,
}; };
@@ -411,6 +450,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -423,7 +463,7 @@ describe('auction worker clock-shift race', () => {
it('creates one bounded successor after a terminal event failure', async () => { it('creates one bounded successor after a terminal event failure', async () => {
const redis = buildRedis(); const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z'); const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const retryRequestId = `${requestId}:retry:1`; const retryRequestId = `${requestId}:retry:1`;
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
@@ -433,7 +473,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 }, payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
status: 'FAILED', status: 'FAILED',
result: null, result: null,
}, },
@@ -448,6 +488,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => {
requestId: retryRequestId, requestId: retryRequestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -468,19 +510,23 @@ describe('auction worker clock-shift race', () => {
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => { it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
const redis = buildRedis(); const redis = buildRedis();
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
const closeAt = new Date('2026-07-30T11:30:00.000Z'); const closeAt = new Date('2026-07-30T11:30:00.000Z');
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`; const previousRequestId = 'auction:finalize:7:tick:36000000';
const requestId = `auction:finalize:7:${closeAt.getTime()}`; const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({ const { db, transaction } = buildDb({
updated: 0, updated: 0,
auction: { status: 'OPEN', closeAt }, auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
existingEvents: [ existingEvents: [
{ {
requestId: previousRequestId, requestId: previousRequestId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'auctionFinalize', eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 }, payload: {
type: 'auctionFinalize',
requestId: previousRequestId,
auctionId: 7,
expectedCloseTick: 36_000_000,
},
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { type: 'auctionFinalize', ok: false, auctionId: 7 }, result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
}, },
@@ -495,6 +541,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).resolves.toBe('PENDING'); ).resolves.toBe('PENDING');
@@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => {
requestId, requestId,
auctionId: 7, auctionId: 7,
expectedCloseAt: closeAt.toISOString(), expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
}, },
}, },
}); });
@@ -527,6 +575,7 @@ describe('auction worker clock-shift race', () => {
historyKey: 'history', historyKey: 'history',
id: '7', id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(), nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
nowTick: 72_000_000,
}) })
).rejects.toThrow('event insert failed'); ).rejects.toThrow('event insert failed');
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
describe('clock reconciliation readiness', () => {
it('fails closed when the reconciliation schema is not available', async () => {
const db = {} as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toEqual({
reconciliationComplete: false,
gameplayEnabled: false,
phase: null,
revision: null,
deadlineGeneration: null,
incompleteOutboxCount: null,
});
});
it('blocks readiness for RECONCILING or incomplete outbox state', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockPhase: 'RECONCILING',
clockRevision: 9n,
deadlineGeneration: 4n,
})),
},
clockProjectionOutbox: { count: vi.fn(async () => 1) },
} as unknown as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toMatchObject({
reconciliationComplete: false,
gameplayEnabled: false,
phase: 'RECONCILING',
revision: 9,
deadlineGeneration: 4,
incompleteOutboxCount: 1,
});
});
it('exposes participant checksums and incomplete outbox detail to admins', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockPhase: 'RUNNING',
clockRevision: 3n,
deadlineGeneration: 2n,
})),
},
clockProjectionOutbox: { count: vi.fn(async () => 0) },
clockSuspension: {
findFirst: vi.fn(async () => ({
id: 'maintenance-1',
source: 'MAINTENANCE',
policy: 'EXACT',
status: 'APPLIED',
sourceRevision: 2n,
targetRevision: 3n,
cutTick: 100n,
alignedTick: 130n,
participantChecksumBefore: 'before-all',
participantChecksumAfter: 'after-all',
participants: [
{
participantKey: 'general-turn',
policy: 'SHIFT',
beforeChecksum: 'before',
afterChecksum: 'after',
affectedCount: 2,
},
],
projectionOutbox: [{ id: 8n, targetRevision: 3n, status: 'APPLIED', attempts: 1, lastError: null }],
})),
},
} as unknown as DatabaseClient;
await expect(loadClockAdminStatus(db)).resolves.toMatchObject({
reconciliationComplete: true,
latestReconciliation: {
id: 'maintenance-1',
participantChecksumBefore: 'before-all',
participantChecksumAfter: 'after-all',
participants: [{ key: 'general-turn', policy: 'SHIFT', affectedCount: 2 }],
outbox: [{ id: '8', status: 'APPLIED' }],
},
});
});
});
+102 -2
View File
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
import { import {
assertReservedTurnArgsPassLegacyBasicValidation, assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions, buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
buildTurnCommandInputFields, buildTurnCommandInputFields,
parseReservedTurnArgs, parseReservedTurnArgs,
sanitizeReservedTurnArgs, sanitizeReservedTurnArgs,
@@ -112,12 +113,22 @@ describe('turn command argument input', () => {
).toThrow('턴이 입력되지 않았습니다.'); ).toThrow('턴이 입력되지 않았습니다.');
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow(); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow(); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
);
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow( expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
'턴이 입력되지 않았습니다.' '턴이 입력되지 않았습니다.'
); );
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined(); await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
}); });
it('exposes the Ref fullwidth nation-name limit to command input clients', async () => {
const specs = await loadGeneralTurnCommandSpecs(['che_건국']);
expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual(
expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 })
);
});
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => { it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
expect( expect(
sanitizeReservedTurnArgs({ sanitizeReservedTurnArgs({
@@ -212,20 +223,109 @@ describe('turn command argument input', () => {
); );
}); });
it('preserves scenario order while filtering unavailable catalog entries in every slot', () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: { [slot]: { z_last_code: 0, missing: 0, unique: 1, blocked: 0, a_first_code: -1 } },
},
itemModules: [
{ ...buildShopItem('a_first_code', '첫 코드'), slot },
{ ...buildShopItem('z_last_code', '마지막 코드'), slot },
{ ...buildShopItem('unique', '유니크'), slot },
{ ...buildShopItem('blocked', '비매품'), slot, buyable: false },
],
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items[slot].map((item) => item.value)).toEqual(['None', 'z_last_code', 'a_first_code']);
}
});
it('restores original scenario order after DB key reordering without restoring removed purchase permissions', async () => {
const scenario = await loadScenarioDefinitionById(2701);
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
const originalKeys = Object.keys(pool.item!);
const buyableKeys = originalKeys.filter((key) => pool.item![key]! <= 0);
const removedKey = buyableKeys[1]!;
const reorderedPool = Object.fromEntries(Object.entries(pool.item!).reverse());
reorderedPool[removedKey] = 1;
reorderedPool.runtime_added = 0;
const items = buildEquipmentTradeItemOptions({
configConst: { allItems: { item: reorderedPool } },
itemOrder: await loadEquipmentTradeItemOrder('scenario_2701.json'),
itemModules: [...buyableKeys]
.reverse()
.concat('runtime_added')
.map((key) => buildShopItem(key, key)),
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items.item.map((item) => item.value)).toEqual([
'None',
...buyableKeys.filter((key) => key !== removedKey),
'runtime_added',
]);
expect((await loadEquipmentTradeItemOrder('1'))?.filter((key) => key.startsWith('che_치료'))).toEqual([
'che_치료_환약',
]);
expect(await loadEquipmentTradeItemOrder('unknown')).toBeUndefined();
expect(await loadEquipmentTradeItemOrder('999999')).toBeUndefined();
});
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
const items = buildEquipmentTradeItemOptions({
configConst: {},
itemModules: [owned],
currentSecurity: 0,
generalGold: 0,
ownedItems: { horse: null, weapon: null, book: null, item: null, [slot]: owned.key },
});
expect(items[slot]).toEqual([
{ value: 'None', label: '보유 유니크 판매', description: '소유 물품 판매 · 판매가 5,000금' },
]);
await expect(
parseReservedTurnArgs('general', 'che_장비매매', { itemType: slot, itemCode: 'None' })
).resolves.toEqual({ itemType: slot, itemCode: 'None' });
}
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => { it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: {}, configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')], itemModules: [
buildShopItem('event_전투특기_격노', '격노의 비급'),
buildShopItem('che_계략_향낭', '향낭'),
buildShopItem('che_계략_이추', '이추'),
buildShopItem('che_훈련_청주', '청주'),
buildShopItem('che_사기_탁주', '탁주'),
buildShopItem('che_저격_수극', '수극'),
buildShopItem('che_치료_환약', '환약'),
],
currentSecurity: 5000, currentSecurity: 5000,
generalGold: 1000, generalGold: 1000,
}); });
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']); expect(items.item.map((item) => item.value)).toEqual([
'None',
'che_치료_환약',
'che_저격_수극',
'che_사기_탁주',
'che_훈련_청주',
'che_계략_이추',
'che_계략_향낭',
]);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명'); expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
}); });
it('shows only zero-count buyable items selected by an explicit scenario pool', () => { it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
const items = buildEquipmentTradeItemOptions({ const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: { configConst: {
allItems: { allItems: {
item: { item: {
+172 -10
View File
@@ -102,6 +102,9 @@ const buildNation = (): NationRow =>
}) as unknown as NationRow; }) as unknown as NationRow;
describe('buildTurnCommandTable', () => { describe('buildTurnCommandTable', () => {
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
it('projects the general and chief reserved-turn categories and command order from Ref', async () => { it('projects the general and chief reserved-turn categories and command order from Ref', async () => {
const table = await buildTurnCommandTable({ const table = await buildTurnCommandTable({
worldState: buildWorldState(), worldState: buildWorldState(),
@@ -211,6 +214,29 @@ describe('buildTurnCommandTable', () => {
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual( expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
expect.objectContaining({ turnDurationText: expect.any(String) }) expect.objectContaining({ turnDurationText: expect.any(String) })
); );
const generalCosts = Object.fromEntries(
table.general.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
);
expect(generalCosts).toMatchObject({
che_농지개간: '금 100',
che_기술연구: '금 100',
che_정착장려: '쌀 200',
che_주민선정: '쌀 200',
che_화계: '금 500 · 쌀 500',
che_첩보: '금 300 · 쌀 300',
che_사기진작: '금 1',
che_출병: '쌀 1',
che_이동: '금 100',
che_강행: '금 500',
});
const nationCosts = Object.fromEntries(
table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
);
expect(nationCosts).toMatchObject({
che_천도: '금·쌀 500 × 2^거리',
che_증축: '금 110,000 · 쌀 110,000',
che_감축: '금 80,000 · 쌀 80,000 회수',
});
}); });
it('projects scenario-specific command categories instead of the default profile', async () => { it('projects scenario-specific command categories instead of the default profile', async () => {
@@ -239,6 +265,13 @@ describe('buildTurnCommandTable', () => {
'che_물자조달', 'che_물자조달',
'cr_맹훈련', 'cr_맹훈련',
]); ]);
expect(
Object.fromEntries(table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText])))
).toMatchObject({
cr_인구이동: '금·쌀 100 × 인구[만]',
event_대검병연구: '금 50,000 · 쌀 50,000',
event_화륜차연구: '금 100,000 · 쌀 100,000',
});
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']); expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([ expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
'휴식', '휴식',
@@ -256,6 +289,103 @@ describe('buildTurnCommandTable', () => {
}); });
}); });
it('blocks both speciality resets while their cooldown remains and opens them at the boundary month', async () => {
const cooldownGeneral = {
...buildGeneral(),
specialCode: 'che_상재',
special2Code: 'che_신산',
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 36,
'next_execute_전투 특기 초기화': '37',
},
} as GeneralRow;
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: cooldownGeneral,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
expect(findCommand(table, 'che_내정특기초기화')).toMatchObject({
possible: true,
status: 'available',
});
expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: {
...buildGeneral(),
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 37,
'next_execute_전투 특기 초기화': 37,
},
} as GeneralRow,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) {
expect(findCommand(table, key)).toMatchObject({
possible: false,
status: 'blocked',
reason: '특기가 없습니다.',
});
}
});
it('projects a multi-turn nation command cooldown before target input', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: {
...buildNation(),
meta: { next_execute_피장파장: 37 },
} as NationRow,
nationGenerals: null,
});
expect(findCommand(table, 'che_피장파장')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('projects costs from the current world develcost used by command execution', async () => {
const worldState = buildWorldState();
(worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120;
const table = await buildTurnCommandTable({
worldState,
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const costs = Object.fromEntries(
[...table.general, ...table.nation].flatMap(({ values }) =>
values.map(({ key, costText }) => [key, costText])
)
);
expect(costs).toMatchObject({
che_농지개간: '금 120',
che_화계: '금 600 · 쌀 600',
che_천도: '금·쌀 600 × 2^거리',
che_증축: '금 120,000 · 쌀 120,000',
});
});
it('projects the real 904/905/910/912 world command profiles into the API table', async () => { it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
const buildScenarioTable = async (scenarioId: number) => { const buildScenarioTable = async (scenarioId: number) => {
const scenario = await loadScenarioDefinitionById(scenarioId); const scenario = await loadScenarioDefinitionById(scenarioId);
@@ -337,6 +467,27 @@ describe('buildTurnCommandTable', () => {
} }
}); });
it('projects the Ref twenty-year non-aggression end-year window', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const proposal = table.nation
.flatMap((group) => group.values)
.find((command) => command.key === 'che_불가침제의');
expect(proposal?.inputFields.find((field) => field.key === 'year')).toMatchObject({
kind: 'number',
min: 4,
max: 23,
defaultValue: 4,
});
expect(proposal?.inputFields.find((field) => field.key === 'month')).toMatchObject({ defaultValue: 1 });
});
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
buildTurnCommandTable({ buildTurnCommandTable({
@@ -444,15 +595,17 @@ describe('buildTurnCommandTable', () => {
nationGenerals: null, nationGenerals: null,
}); });
const appointment = table.general for (const commandKey of ['che_임관', 'che_등용']) {
.flatMap((group) => group.values) const command = table.general
.find((command) => command.key === 'che_임관'); .flatMap((group) => group.values)
.find((candidate) => candidate.key === commandKey);
expect(appointment).toMatchObject({ expect(command).toMatchObject({
possible: false, possible: false,
status: 'blocked', status: 'blocked',
reason: '랜덤 임관만 가능합니다', reason: '랜덤 임관만 가능합니다',
}); });
}
}); });
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => { it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
@@ -525,7 +678,11 @@ describe('buildTurnCommandTable', () => {
magicCoef: 0, magicCoef: 0,
cost: 12, cost: 12,
rice: 10, rice: 10,
requirements: [{ type: 'ReqTech', tech: 2000 }], requirements: [
{ type: 'ReqTech', tech: 2000 },
{ type: 'ReqRegions', regions: ['중원'] },
{ type: 'ReqCities', cities: ['TestCity'] },
],
attackCoef: {}, attackCoef: {},
defenceCoef: {}, defenceCoef: {},
info: ['강력하지만 기술이 필요합니다.'], info: ['강력하지만 기술이 필요합니다.'],
@@ -583,7 +740,12 @@ describe('buildTurnCommandTable', () => {
special: true, special: true,
attack: 175, attack: 175,
defence: 225, defence: 225,
info: ['강력하지만 기술이 필요합니다.'], info: [
'강력하지만 기술이 필요합니다.',
'기술력 2000 이상 필요',
'중원 지역 소유시 가능',
'TestCity 소유시 가능',
],
}); });
}); });
}); });
+23 -1
View File
@@ -67,6 +67,9 @@ describe('Ref command general targets', () => {
gold: 100, gold: 100,
rice: 200, rice: 200,
crew: 900, crew: 900,
leadership: 100,
strength: 95,
intel: 0,
train: 80, train: 80,
atmos: 70, atmos: 70,
troopId: 3, troopId: 3,
@@ -87,7 +90,26 @@ describe('Ref command general targets', () => {
troopId: 3, troopId: 3,
description: expect.stringContaining('탑승 부대 청룡대'), description: expect.stringContaining('탑승 부대 청룡대'),
}); });
expect(detailed.generalTargets.che_포상?.[0]?.description).toContain('금 5,000 · 쌀 4,000 · 병력 1,000'); expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_발령?.[1]?.description).toBe(
'탑승 부대 청룡대\n통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200'
);
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe(
'탑승 부대 없음\n병력 1,000\n금 5,000 · 쌀 4,000'
);
expect(detailed.generalTargets.che_발령?.[2]?.description).toContain('청룡대 (부대장)');
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
}); });
}); });
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
any: { any: {
ban: { expire: 4_102_444_800, value: 1 }, ban: { expire: 4_102_444_800, value: 1 },
expired: { expire: 1, value: 9 }, expired: { expire: 1, value: 9 },
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
}, },
hwe: { hwe: {
ban: { expire: 4_102_444_800, value: 2 }, ban: { expire: 4_102_444_800, value: 2 },
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 2, scenarioId: 2,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'), now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: { installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5, turnTermMinutes: 5,
npcMode: 0, npcMode: 0,
showImgLevel: 3, showImgLevel: 3,
@@ -227,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => {
create: { userId, key, value }, create: { userId, key, value },
}); });
} }
// 이 fixture는 특기 없는 일반 생성/reload를 검증한다. 1% 천재 추첨은 별도 계약이다.
const seededWorld = await db.worldState.findFirstOrThrow();
await db.worldState.update({
where: { id: seededWorld.id },
data: {
meta: { ...(seededWorld.meta as Record<string, GamePrisma.InputJsonValue>), genius: 0 },
},
});
await startRuntime('create-general-integration-daemon'); await startRuntime('create-general-integration-daemon');
}, 60_000); }, 60_000);
@@ -238,6 +248,7 @@ integration('generic general creation through the durable turn daemon', () => {
it('commits one complete ref-shaped general and survives a daemon reload', async () => { it('commits one complete ref-shaped general and survives a daemon reload', async () => {
const auth = buildAuth(userId, '생성사용자', 4242); const auth = buildAuth(userId, '생성사용자', 4242);
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig(); const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
expect(config.rules.allowDirectCreation).toBe(true);
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔'); expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999'); expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
@@ -288,6 +299,7 @@ integration('generic general creation through the durable turn daemon', () => {
}, },
}); });
expect(created.affinity).toBeGreaterThanOrEqual(1); expect(created.affinity).toBeGreaterThanOrEqual(1);
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
expect(created.affinity).toBeLessThanOrEqual(150); expect(created.affinity).toBeLessThanOrEqual(150);
expect(created.meta).toMatchObject({ expect(created.meta).toMatchObject({
createdBy: 'join', createdBy: 'join',
@@ -304,6 +316,9 @@ integration('generic general creation through the durable turn daemon', () => {
throw new Error('created general must have an initial access timestamp'); throw new Error('created general must have an initial access timestamp');
} }
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
expect( expect(
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() - new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
acceptedEvent.createdAt.getTime() acceptedEvent.createdAt.getTime()
@@ -552,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => {
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })]) expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
); );
}, 10_000); }, 10_000);
it.each([
{ phase: 'SUSPENDED', united: 0, finalized: false },
{ phase: 'SUSPENDED', united: 2, finalized: true },
{ phase: 'COMPLETED', united: 3, finalized: true },
])(
'creates a visitor in $phase/$united without reopening season records',
async ({ phase, united, finalized }) => {
await stopRuntime('prepare frozen participation');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `visitor-${phase}-${united}`;
const auth = buildAuth(visitorId, '방문자', 7000 + united);
await db.inheritancePoint.create({ data: { userId: visitorId, key: 'previous', value: 5000 } });
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: {
...(original.meta as Record<string, GamePrisma.InputJsonValue>),
isUnited: united,
isunited: united,
},
},
});
await db.gameHistory.update({
where: { serverId: profile },
data: { status: finalized ? 'COMPLETED' : 'OPEN' },
});
const readRecords = async () => ({
points: await db.inheritancePoint.findMany({ where: { userId: visitorId }, orderBy: { id: 'asc' } }),
logs: await db.inheritanceLog.count({ where: { userId: visitorId } }),
baseline: await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }),
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
oldGenerals: await db.oldGeneral.count({ where: { serverId: profile } }),
});
const before = await readRecords();
try {
await startRuntime(`visitor-${phase}-${united}`);
const caller = appRouter.createCaller(buildContext(`visitor-${phase}-${united}`, auth));
expect((await caller.join.getConfig()).inherit.enabled).toBe(!finalized);
const input = {
name: `방문${united}`,
pic: false,
leadership: 55,
strength: 55,
intel: 55,
character: 'che_안전' as const,
};
if (finalized) {
await expect(
appRouter
.createCaller(buildContext(`visitor-paid-${united}`, auth))
.join.createGeneral({ ...input, inheritTurntimeZone: 7 })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(await readRecords()).toEqual(before);
}
const result = await caller.join.createGeneral(input);
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
npcState: 0,
});
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
if (finalized) expect(await readRecords()).toEqual(before);
else
expect(
await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } })
).toBe(1);
await stopRuntime('verify visitor persisted');
await startRuntime(`visitor-reload-${phase}-${united}`);
expect(runtime?.world.getGeneralById(result.generalId)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore participation fixture');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('participation-fixture-restored');
}
},
30_000
);
}); });
@@ -114,6 +114,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
}, },
}), }),
}, },
$transaction: db.$transaction.bind(db),
$queryRaw: db.$queryRaw.bind(db), $queryRaw: db.$queryRaw.bind(db),
$executeRaw: db.$executeRaw.bind(db), $executeRaw: db.$executeRaw.bind(db),
}; };
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1), $executeRaw: vi.fn(async () => 1),
}; };
Object.assign(db, {
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
});
await expect( await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [ flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] }); ).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"'); expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset'); expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"'); expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"'); expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
expect(statement.sql).toContain('INSERT INTO "general_access_log"'); expect(statement.sql).toContain('INSERT INTO "general_access_log"');
expect(statement.sql).toContain('ORDER BY resolved."general_id"');
expect(statement.sql).not.toContain('read_model_revision'); expect(statement.sql).not.toContain('read_model_revision');
expect(statement.sql).not.toContain('read_model_outbox'); expect(statement.sql).not.toContain('read_model_outbox');
}); });
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some( changes.some(
(change) => (change) =>
Array.isArray(change) && Array.isArray(change) &&
change[0] === 'messages.mailbox' && (change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
) )
); );
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await deleteFixtureOutboxes(); await deleteFixtureOutboxes();
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
}); });
}; };
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0); await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
await expect( await expect(
db.readModelRevision.count({ db.readModelRevision.count({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
}) })
).resolves.toBe(0); ).resolves.toBe(0);
await expect( await expect(
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some( changes.some(
(change) => (change) =>
Array.isArray(change) && Array.isArray(change) &&
change[0] === 'messages.mailbox' && (change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
) )
); );
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
}); });
await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
}); });
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id); const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
+1 -10
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra'; import type { RedisConnector } from '@sammo-ts/infra';
@@ -88,7 +87,7 @@ const storedLetter = {
}; };
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => { const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
const create = vi.fn(async () => ({ id: 9 })); const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
let messageId = 100; let messageId = 100;
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]); const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
const db = { const db = {
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>', textBrief: '<p><strong>공개</strong></p>',
textDetail: textDetail:
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>', '<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
date: new Date('0185-01-01T00:00:00.000Z'),
}), }),
}); });
expect(fixture.queryRaw).toHaveBeenCalledTimes(2); expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
);
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
}); });
it('purifies legacy stored rows on every read while preserving secret redaction', async () => { it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
+94 -1
View File
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
leadership: 70, leadership: 70,
strength: 60, strength: 60,
intel: 50, intel: 50,
crew: 100,
experience: 1_000, experience: 1_000,
meta: { killturn: 3 }, meta: { killturn: 3 },
penalty: {}, penalty: {},
@@ -124,6 +125,9 @@ const globalGenerals = [
name: '군주', name: '군주',
dedication: 900, dedication: 900,
officerLevel: 12, officerLevel: 12,
leadership: 80,
strength: 80,
intel: 80,
meta: { killturn: 4, owner_name: '통일유저' }, meta: { killturn: 4, owner_name: '통일유저' },
experience: 10_000, experience: 10_000,
}), }),
@@ -132,6 +136,9 @@ const globalGenerals = [
name: '외교관', name: '외교관',
dedication: 800, dedication: 800,
officerLevel: 1, officerLevel: 1,
leadership: 70,
strength: 80,
intel: 40,
meta: { killturn: 2, permission: 'ambassador' }, meta: { killturn: 2, permission: 'ambassador' },
experience: 5_000, experience: 5_000,
}), }),
@@ -140,6 +147,9 @@ const globalGenerals = [
name: '제재외교관', name: '제재외교관',
dedication: 700, dedication: 700,
officerLevel: 1, officerLevel: 1,
leadership: 70,
strength: 40,
intel: 80,
meta: { killturn: 1, permission: 'ambassador' }, meta: { killturn: 1, permission: 'ambassador' },
penalty: { noTopSecret: true }, penalty: { noTopSecret: true },
experience: 2_000, experience: 2_000,
@@ -149,6 +159,9 @@ const globalGenerals = [
name: '조언자', name: '조언자',
dedication: 600, dedication: 600,
officerLevel: 1, officerLevel: 1,
leadership: 30,
strength: 70,
intel: 70,
meta: { killturn: 5, permission: 'auditor' }, meta: { killturn: 5, permission: 'auditor' },
experience: 1_000, experience: 1_000,
}), }),
@@ -161,6 +174,8 @@ const globalGenerals = [
dedication: 500, dedication: 500,
officerLevel: 12, officerLevel: 12,
leadership: 80, leadership: 80,
strength: 70,
intel: 65,
meta: { killturn: 0 }, meta: { killturn: 0 },
experience: 20_000, experience: 20_000,
}), }),
@@ -221,7 +236,12 @@ const createContext = (
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
config: { const: { maxLevel: 100, maxDedLevel: 30 } }, config: { const: { maxLevel: 100, maxDedLevel: 30 } },
meta: { isUnited: options.isUnited ?? 0 }, meta: {
isUnited: options.isUnited ?? 0,
killturn: 4,
autorun_user: { limit_minutes: 60 },
},
tickSeconds: 3_600,
})), })),
}, },
}; };
@@ -279,6 +299,20 @@ describe('legacy global nation/general directories', () => {
); );
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
for (const result of results) { for (const result of results) {
const serializedNationRows = JSON.stringify(result.nations);
for (const privateField of [
'totalCrew',
'crew',
'leadership',
'strength',
'intel',
'killturn',
'refreshScoreTotal',
'meta',
'penalty',
]) {
expect(serializedNationRows).not.toContain(`"${privateField}"`);
}
const serializedGeneralRows = JSON.stringify(result.generals); const serializedGeneralRows = JSON.stringify(result.generals);
for (const privateField of [ for (const privateField of [
'userId', 'userId',
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
id: 1, id: 1,
generalCount: 4, generalCount: 4,
cityCount: 1, cityCount: 1,
forceEstimate: {
totalGeneralCount: 4,
userCombatGeneralCount: 1,
userTroops: 8_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 2,
},
ambassadorNames: ['군주', '외교관'], ambassadorNames: ['군주', '외교관'],
auditorCount: 1, auditorCount: 1,
}); });
expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([
['만능장', ['군주']],
['무장', ['외교관']],
['지장', ['제재외교관']],
['무지장', ['조언자']],
]);
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']); expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
expect(result[1]?.officers[0]).toMatchObject({ expect(result[1]?.officers[0]).toMatchObject({
officerLevel: 12, officerLevel: 12,
general: { id: 10, name: '군주' }, general: { id: 10, name: '군주' },
}); });
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 }); expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
expect(JSON.stringify(result)).not.toContain('totalCrew');
});
it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => {
const categoryStats = [
['만능장', 80, 80, 80],
['통장', 70, 20, 20],
['무장', 70, 80, 40],
['지장', 70, 40, 80],
['평범장', 60, 50, 50],
['무지장', 30, 50, 50],
['무능장', 30, 10, 10],
] as const;
const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) =>
directoryGeneral({
id: 101 + index,
name,
nationId: 1,
leadership,
strength,
intel,
meta: { killturn: 4 },
})
);
const [nation] = await appRouter
.createCaller(
createContext({
directory: {
nations: [nationRows[0]!],
generals: categoryGenerals,
cities: [{ id: 1, name: '허창', nationId: 1 }],
},
}).context
)
.world.getNationDirectory();
expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name));
expect(nation?.forceEstimate).toEqual({
totalGeneralCount: 7,
userCombatGeneralCount: 5,
userTroops: 35_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 0,
});
}); });
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => { it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
+97 -11
View File
@@ -3,37 +3,123 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { loadCurrentGameTime } from '../src/services/gameClock.js'; import { loadCurrentGameTime } from '../src/services/gameClock.js';
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient => const buildDatabase = (
mode: 'realtime' | 'manual' = 'realtime',
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
): DatabaseClient =>
({ ({
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'), clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
clockTick: 36_000_000n, clockTick: 0n,
clockMode: mode, clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600, tickSeconds: 600,
clockPhase: phase,
clockRevision: 1n,
deadlineGeneration: 1n,
})), })),
}, },
}) as unknown as DatabaseClient; }) as unknown as DatabaseClient;
describe('current game time projection', () => { describe('current game time projection', () => {
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => { it('keeps independent workers frozen until this clock revision has a ready daemon', async () => {
const row = {
clockBaseTime: new Date('2026-09-06T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-06T00:00:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: 288_000_000n,
clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'),
};
let ready = false;
const db = {
worldState: { findFirst: vi.fn(async () => row) },
$queryRaw: vi.fn(async () => [{ ready }]),
} as unknown as DatabaseClient;
const now = new Date('2026-09-06T05:00:00Z');
expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false });
ready = true;
expect(await loadCurrentGameTime(db, now)).toMatchObject({
tick: 72_000_000,
running: true,
runtimeReady: true,
recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' },
});
});
it('exposes waiting, 2x and normal speed boundaries from a reloaded recovery', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-09-07T00:00:00Z'),
clockTick: 6000000n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-07T00:35:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 6000000n,
clockRecoveryEndTick: 36000000n,
clockRecoveryStartWallAt: new Date('2026-09-07T00:35:00Z'),
})),
},
$queryRaw: vi.fn(async () => [{ ready: true }]),
} as unknown as DatabaseClient;
for (const now of ['00:24:00', '00:34:59.999']) {
expect(await loadCurrentGameTime(db, new Date(`2026-09-07T${now}Z`))).toMatchObject({
tick: 6000000,
running: false,
startsAt: new Date('2026-09-07T00:35:00Z'),
recovery: { startsAt: '2026-09-07T00:35:00.000Z', endsAt: '2026-09-07T01:00:00.000Z' },
});
}
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:35:00.001Z'))).toMatchObject({
tick: 6000020,
running: true,
startsAt: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:59:59.999Z'))).toMatchObject({ tick: 35999980 });
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00Z'))).toMatchObject({
tick: 36000000,
running: true,
recovery: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00.001Z'))).toMatchObject({ tick: 36000010 });
});
it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') });
});
it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase(); const db = buildDatabase();
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z')); const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
expect(preopen).toMatchObject({ expect(preopen).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'), now: new Date('2026-08-21T10:30:00.000Z'),
wallNow: new Date('2026-08-21T10:30:00.000Z'), wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: 36_000_000, tick: -108_000_000,
mode: 'realtime', mode: 'realtime',
phase: 'PREOPEN',
running: false, running: false,
startsAt: new Date('2026-08-21T11:00:00.000Z'), startsAt: new Date('2026-08-21T11:00:00.000Z'),
}); });
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z')); const opened = await loadCurrentGameTime(
buildDatabase('realtime', 'RUNNING'),
new Date('2026-08-21T11:00:05.000Z')
);
expect(opened).toMatchObject({ expect(opened).toMatchObject({
now: new Date('2026-08-21T10:00:05.000Z'), now: new Date('2026-08-21T11:00:05.000Z'),
tick: 36_300_000, tick: 300_000,
running: true, running: true,
startsAt: null, startsAt: null,
}); });
@@ -43,8 +129,8 @@ describe('current game time projection', () => {
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z')); const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
expect(result).toMatchObject({ expect(result).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'), now: new Date('2026-08-21T11:00:00.000Z'),
tick: 36_000_000, tick: 0,
running: false, running: false,
startsAt: null, startsAt: null,
}); });
+12 -12
View File
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
}); });
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(executeRaw).toHaveBeenCalledTimes(2); expect(executeRaw).toHaveBeenCalledTimes(3);
const lockStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(lockStatement.sql).toContain('pg_advisory_xact_lock');
expect(lockStatement.values).toContain('general-access:persistence');
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(periodStatement.sql).toContain('INSERT INTO traffic_period'); expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
expect(periodStatement.values).toContain(now); expect(periodStatement.values).toContain(now);
expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const memberStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general'); expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING'); expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
expect(memberStatement.sql).toContain('SET online = traffic_period.online'); expect(memberStatement.sql).toContain('SET online = traffic_period.online');
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] }; const accessStatement = executeRaw.mock.calls[2]![0] as { sql: string; values: unknown[] };
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
expect(accessStatement.values).toContain(7); expect(accessStatement.values).toContain(7);
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true); await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0); expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now); expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(now);
}); });
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => { it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
@@ -322,18 +326,14 @@ describe('general access tracking', () => {
: [{ id: 41 }]; : [{ id: 41 }];
}), }),
$executeRaw: vi.fn(async (query: unknown) => { $executeRaw: vi.fn(async (query: unknown) => {
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) { const sql = (query as { sql?: string }).sql ?? '';
if (sql.includes('INSERT INTO input_event')) {
events.push('input-event-create'); events.push('input-event-create');
} }
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
return 1; return 1;
}), }),
$executeRawUnsafe: vi.fn(async () => 0), $executeRawUnsafe: vi.fn(async () => 0),
inputEvent: {
update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'FAILED') events.push('input-event-failed');
return {};
}),
},
}; };
const db = { const db = {
general: { general: {
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
expect(resolveRefreshScoreText(12_800)).toBe('헐...'); expect(resolveRefreshScoreText(12_800)).toBe('헐...');
}); });
it('matches the Ref remaining-minute calculation and one-turn rollover', () => { it('matches the Ref remaining-minute calculation against the current game clock', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z'); const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7); expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); expect(resolveRemainingMinutes(new Date('2026-08-13T00:19:00.000Z'), currentGameTime)).toBe(0);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:20:00.000Z'), null)).toBeNull();
}); });
it('moves the first reserved month only after the general turn bucket has passed', () => { it('moves the first reserved month only after the general turn bucket has passed', () => {
+11 -7
View File
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
} }
}); });
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => { it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
const persistedPayload = { const persistedPayload = {
type: 'voteReward' as const, type: 'voteReward' as const,
requestId: 'vote-reward', requestId: 'vote-reward',
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
selection: [0], selection: [0],
acceptedGameTick: 100, acceptedGameTick: 100,
}; };
const currentCommand = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
};
const create = async () => { const create = async () => {
throw Object.assign(new Error('duplicate'), { code: 'P2002' }); throw Object.assign(new Error('duplicate'), { code: 'P2002' });
}; };
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
); );
await expect( await expect(
transport.sendCommand({ transport.sendCommand(currentCommand)
...persistedPayload,
acceptedGameTick: 101,
})
).resolves.toBe('vote-reward'); ).resolves.toBe('vote-reward');
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) { for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
await expect( await expect(
transport.sendCommand({ transport.sendCommand({
...persistedPayload, ...currentCommand,
...changedIdentity, ...changedIdentity,
acceptedGameTick: 101,
}) })
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError); ).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
} }
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
gameClockNow?: Date;
rankRows?: Array<{ generalId?: number; type: string; value: number }>; rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
@@ -164,6 +165,10 @@ const createContext = (options: {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
config: { const: { upgradeLimit: 20 } }, config: { const: { upgradeLimit: 20 } },
clockBaseTime: options.gameClockNow ?? null,
clockTick: options.gameClockNow ? 0n : null,
clockMode: 'manual',
clockWallAnchor: options.gameClockNow ?? null,
})), })),
}, },
logEntry: { logEntry: {
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
troopLeaderAction: '휴식', troopLeaderAction: '휴식',
refreshScore: 3, refreshScore: 3,
refreshScoreTotal: 1_141, refreshScoreTotal: 1_141,
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
}); });
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
retirementYear: 70, retirementYear: 70,
defenceTrain: 80, defenceTrain: 80,
killTurn: 6, killTurn: 6,
remainingMinutes: null, remainingMinutes: 7,
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' }, troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
refreshScore: { current: 3, total: 1_141, text: '열심' }, refreshScore: { current: 3, total: 1_141, text: '열심' },
}, },
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
defence_train: 80, defence_train: 80,
use_treatment: 21, use_treatment: 21,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0, use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
expect(requestCommand).not.toHaveBeenCalled(); expect(requestCommand).not.toHaveBeenCalled();
}); });
it('rejects the removed automatic diplomacy setting before dispatching it to ENGINE', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
await expect(
appRouter.createCaller(fixture.context).general.setMySetting({
use_auto_nation_diplomacy: 1,
} as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(requestCommand).not.toHaveBeenCalled();
});
it('sends settings directly to ENGINE without creating an API input event', async () => { it('sends settings directly to ENGINE without creating an API input event', async () => {
const transaction = vi.fn(async () => { const transaction = vi.fn(async () => {
throw new Error('API transaction must not run'); throw new Error('API transaction must not run');
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
currentMonth: 4, currentMonth: 4,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'), lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
clockBaseTime: new Date('2026-08-19T00:00:00.000Z'),
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'),
lastTurnTick: 0,
clockPhase: 'MANUAL',
clockRevision: 1,
deadlineGeneration: 1,
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta }, meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
}; };
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
currentYear: 200, currentYear: 200,
currentMonth: 4, currentMonth: 4,
tickSeconds: 600, tickSeconds: 600,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor,
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockPhase: state.clockPhase ?? 'MANUAL',
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
+15
View File
@@ -285,6 +285,21 @@ describe('inherit router actor and permission boundaries', () => {
}); });
}); });
it('rejects an out-of-range inheritance buff level with a user-facing message', async () => {
const fixture = buildContext({});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'warAvoidRatio',
level: 6,
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => { it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
const fixture = buildContext({ const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' }, configMap: { targetGeneralPool: 'SPoolUnderU100' },
@@ -18,6 +18,7 @@ import {
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const journalGeneralIds = [9_980_081, 9_980_082] as const; const journalGeneralIds = [9_980_081, 9_980_082] as const;
const clockScenarioCode = 'input-event-boundary';
const journalBoundaryRouter = router({ const journalBoundaryRouter = router({
mutate: procedure mutate: procedure
@@ -56,6 +57,21 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } }, where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
}); });
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
const base = new Date('2099-09-03T00:00:00.000Z');
await db.worldState.create({
data: {
scenarioCode: clockScenarioCode,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
clockBaseTime: base,
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: base,
clockPhase: 'RUNNING',
},
});
}); });
afterAll(async () => { afterAll(async () => {
@@ -68,6 +84,7 @@ integration('API input event boundary', () => {
await db.readModelRevision.deleteMany({ await db.readModelRevision.deleteMany({
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } }, where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
}); });
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
await close?.(); await close?.();
}); });
@@ -518,6 +535,10 @@ integration('API input event boundary', () => {
expect(event.actorUserId).toBe('user-7'); expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
expect(event.acceptedGameTick).toBeNull();
expect(event.acceptedClockRevision).toBeNull();
expect(event.acceptedDeadlineGeneration).toBeNull();
expect(event.processingGameTick).toBeNull();
await expect( await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }) transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId); ).resolves.toBe(requestId);
+47 -3
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js'; import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js'; import { procedure, router, wallProcedure } from '../src/trpc.js';
const testRouter = router({ const testRouter = router({
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => { mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
@@ -14,6 +14,14 @@ const testRouter = router({
}), }),
}); });
const wallTestRouter = router({
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
return { ok: true };
}),
});
const createContext = (payload: unknown = {}) => { const createContext = (payload: unknown = {}) => {
const order: string[] = []; const order: string[] = [];
const queryRaw = vi.fn(async (query: { sql?: string }) => { const queryRaw = vi.fn(async (query: { sql?: string }) => {
@@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => {
status: 'PENDING', status: 'PENDING',
result: null, result: null,
attempts: 0, attempts: 0,
acceptedGameTick: 100n,
acceptedClockRevision: 3n,
acceptedDeadlineGeneration: 2n,
}, },
]; ];
} }
@@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => {
}); });
const transaction = { const transaction = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async () => { $executeRaw: vi.fn(async (query: { sql?: string }) => {
order.push('accepted'); const sql = query.sql ?? '';
order.push(
sql.includes('pg_advisory_xact_lock')
? 'clock-fence'
: sql.includes("status = 'PROCESSING'")
? 'processing'
: sql.includes("status = 'SUCCEEDED'")
? 'succeeded'
: sql.includes("status = 'FAILED'")
? 'failed'
: 'accepted'
);
return 1; return 1;
}), }),
$executeRawUnsafe: vi.fn(async (statement: string) => { $executeRawUnsafe: vi.fn(async (statement: string) => {
@@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([ expect(fixture.order).toEqual([
'transaction-begin', 'transaction-begin',
'clock-fence',
'accepted', 'accepted',
'locked', 'locked',
'processing', 'processing',
@@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => {
expect(fixture.order).toEqual([ expect(fixture.order).toEqual([
'transaction-begin', 'transaction-begin',
'clock-fence',
'accepted', 'accepted',
'locked', 'locked',
'processing', 'processing',
@@ -126,4 +150,24 @@ describe('API input-event change journal boundary', () => {
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled(); expect(fixture.wake).not.toHaveBeenCalled();
}); });
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
const fixture = createContext();
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'journal',
'succeeded',
'savepoint-release',
'commit',
'wake',
]);
});
}); });
+22 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DatabaseClient, GameApiContext } from '../src/context.js'; import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
@@ -42,11 +42,16 @@ const buildContext = (
turnDaemonLease: { turnDaemonLease: {
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })), findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
}, },
$queryRaw: vi.fn(async () => [{ running: true }]),
} as unknown as DatabaseClient, } as unknown as DatabaseClient,
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) }, profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
}) as unknown as GameApiContext; }) as unknown as GameApiContext;
describe('lobby season state', () => { describe('lobby season state', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
const result = await appRouter const result = await appRouter
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited })) .createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
@@ -96,15 +101,18 @@ describe('lobby season state', () => {
expect(result.turnEngineRunning).toBe(false); expect(result.turnEngineRunning).toBe(false);
}); });
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => { it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
const wallAnchor = new Date('2099-08-21T11:00:00.000Z'); const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
const wallNow = new Date('2099-08-21T10:30:00.000Z');
vi.useFakeTimers();
vi.setSystemTime(wallNow);
const result = await appRouter const result = await appRouter
.createCaller( .createCaller(
buildContext( buildContext(
{}, {},
{ {
baseTime: new Date('2026-08-21T09:00:00.000Z'), baseTime: wallAnchor,
tick: 36_000_000n, tick: 0n,
mode: 'realtime', mode: 'realtime',
wallAnchor, wallAnchor,
} }
@@ -112,13 +120,22 @@ describe('lobby season state', () => {
) )
.lobby.info(); .lobby.info();
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z'); expect(result.serverTime).toBe(wallNow.toISOString());
expect(result.clockMode).toBe('realtime'); expect(result.clockMode).toBe('realtime');
expect(result.clockRunning).toBe(false); expect(result.clockRunning).toBe(false);
expect(result.clockStartsAt).toBe(wallAnchor.toISOString()); expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime()); expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
}); });
it('reports direct creation and possession as independent acquisition modes', async () => {
const result = await appRouter
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
.lobby.info();
expect(result.directGeneralCreationEnabled).toBe(false);
expect(result.npcPossessionEnabled).toBe(true);
});
it('preserves zero as the first official game index', async () => { it('preserves zero as the first official game index', async () => {
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info(); const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
@@ -44,8 +44,8 @@ const buildContext = (
tickSeconds: 3600, tickSeconds: 3600,
...(options.preopenClock ...(options.preopenClock
? { ? {
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'), clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
clockTick: -180_000_000n, clockTick: 0n,
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
} }
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
}); });
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => { it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z')); vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
const context = buildContext({ hasVoted: true, preopenClock: true }); const context = buildContext({ hasVoted: true, preopenClock: true });
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({ await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
@@ -0,0 +1,82 @@
import { afterAll, describe, expect, it, vi } from 'vitest';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox } from '../src/messages/store.js';
vi.mock('../src/services/gameClock.js', () => ({
loadCurrentGameTime: vi.fn(async () => ({ tick: 100 })),
}));
// SQL is mocked; this client is never connected. Keep the real delegate types.
const connector = createGamePostgresConnector({ url: 'postgresql://localhost:1/message_lifecycle_unit' });
const db = connector.prisma;
const queryRaw = vi.spyOn(db, '$queryRaw');
afterAll(() => connector.disconnect());
const target = { generalId: 1, generalName: '장수', nationId: 1, nationName: '국가', color: '#000', icon: '' };
for (const older of [false, true]) {
describe(older ? 'older message lifecycle' : 'recent message lifecycle', () => {
it.each([
['PENDING', 101n, 'pending', false],
['PENDING', 100n, 'expired', true],
['PENDING', 99n, 'expired', true],
['PENDING', null, 'pending', false],
['RESOLVED', 101n, 'resolved', true],
['RESOLVED', 99n, 'resolved', true],
['CANCELLED', 101n, 'unavailable', true],
['UNKNOWN', 101n, 'unavailable', true],
[null, null, undefined, undefined],
])('preserves body for status %s and deadline %s', async (status, deadline, state, used) => {
const row = {
id: 10,
mailbox: 1,
type: 'diplomacy',
src: 2,
dest: 1,
time: new Date('0200-01-01T00:00:00Z'),
created_at_wall: new Date('2026-09-10T00:00:00Z'),
action_status: status,
expires_game_tick: deadline,
message: {
src: target,
dest: target,
text: '210년 1월까지 불가침 제의',
option: { action: 'noAggression' },
},
};
queryRaw.mockResolvedValue([row]);
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
const result = older
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
expect(result[0]?.text).toBe(row.message.text);
expect(result[0]?.option?.invalid).toBeUndefined();
expect(result[0]?.option?.actionState).toBe(state);
expect(result[0]?.option?.used).toBe(used);
});
it('keeps actual deletion authoritative even when an action remains', async () => {
queryRaw.mockResolvedValue([
{
id: 10,
type: 'diplomacy',
time: new Date(),
created_at_wall: new Date(),
action_status: 'RESOLVED',
expires_game_tick: 99n,
message: {
src: target,
dest: target,
text: '삭제된 메시지입니다.',
option: { action: 'noAggression', invalid: true },
},
},
]);
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
const result = older
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
expect(result[0]).toMatchObject({ text: '삭제된 메시지입니다.', option: { invalid: true, used: true } });
});
});
}
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra';
import { tombstoneMessages } from '../src/messages/store.js'; import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
@@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => {
afterAll(async () => close?.()); afterAll(async () => close?.());
it('persists an ordinary wall-time envelope without creating a game action', async () => {
const rollback = new Error('rollback ordinary message envelope fixture');
await expect(
db.$transaction(async (transaction) => {
const target = {
generalId: 7,
generalName: '보낸이',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '',
};
const id = await persistMessageEnvelope(
transaction,
{
mailbox: 9999,
msgType: 'public',
srcId: target.generalId,
destId: 9999,
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
payload: {
src: target,
dest: target,
text: '일반 메시지는 WALL_TIME envelope만 저장한다.',
option: {},
},
},
null
);
const message = await transaction.message.findUniqueOrThrow({
where: { id },
include: { action: true },
});
expect(message.createdAtWall).toBeInstanceOf(Date);
expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000);
expect(message.occurredGameTick).toBeNull();
expect(message.action).toBeNull();
throw rollback;
})
).rejects.toBe(rollback);
});
it('keeps sender and receiver rows readable while replacing their bodies', async () => { it('keeps sender and receiver rows readable while replacing their bodies', async () => {
const rollback = new Error('rollback message tombstone fixture'); const rollback = new Error('rollback message tombstone fixture');
await expect( await expect(
@@ -70,6 +115,7 @@ integration('message deletion tombstone persistence', () => {
expect(rows).toHaveLength(2); expect(rows).toHaveLength(2);
for (const row of rows) { for (const row of rows) {
expect(row.validUntil).toEqual(validUntil); expect(row.validUntil).toEqual(validUntil);
expect(row.tombstonedAtWall).not.toBeNull();
expect(row.message).toMatchObject({ expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.', text: '삭제된 메시지입니다.',
option: { invalid: true }, option: { invalid: true },
@@ -81,4 +127,43 @@ integration('message deletion tombstone persistence', () => {
}) })
).rejects.toBe(rollback); ).rejects.toBe(rollback);
}); });
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
const rollback = new Error('rollback wall deletion fixture');
await expect(
db.$transaction(async (transaction) => {
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
`;
const draft = (text: string) => ({
mailbox: 7,
type: 'private' as const,
src: 7,
dest: 8,
time: new Date('0200-01-01T00:00:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
createdAtWall: nowWall,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text,
option: {},
},
});
const deletable = await transaction.message.create({
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
});
const expired = await transaction.message.create({
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
});
expect(
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
).toEqual([deletable.id]);
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
throw rollback;
})
).rejects.toBe(rollback);
});
}); });
+265 -123
View File
@@ -128,64 +128,69 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(false); expect(result.canRespondDiplomacy).toBe(false);
}); });
it('redacts recent and old diplomacy content below secret permission 3', async () => { it.each([null, 'RESOLVED'])(
const diplomacyRow = { 'redacts recent and old diplomacy content below secret permission 3 (%s)',
id: 19, async (actionStatus) => {
mailbox: 9001, const diplomacyRow = {
type: 'diplomacy', id: 19,
src: 9002, action_status: actionStatus,
dest: 9001, expires_game_tick: null,
time: new Date(), mailbox: 9001,
valid_until: new Date('9999-12-31T00:00:00Z'), type: 'diplomacy',
message: { src: 9002,
src: { dest: 9001,
generalId: 8, time: new Date(),
generalName: '외교관', valid_until: new Date('9999-12-31T00:00:00Z'),
nationId: 2, message: {
nationName: '촉', src: {
color: '#000000', generalId: 8,
icon: '', generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
}, },
dest: { };
generalId: 0, const queryRaw = vi.fn(async () => [diplomacyRow]);
generalName: '', const { caller } = buildContext({
nationId: 1, $queryRaw: queryRaw,
nationName: '위', nation: {
color: '#ffffff', findMany: vi.fn(async () => []),
icon: '', findUnique: vi.fn(async () => ({ meta: {} })),
}, },
text: '보이면 안 되는 외교 본문', });
option: { action: 'noAggression' },
},
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const recent = await caller.messages.getRecent({ generalId: general.id }); const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({ const old = await caller.messages.getOld({
generalId: general.id, generalId: general.id,
type: 'diplomacy', type: 'diplomacy',
to: 20, to: 20,
}); });
expect(recent.permission).toBe(2); expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({ expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' }, option: { action: 'noAggression', permissionRedacted: true },
}); });
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({ expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' }, option: { action: 'noAggression', permissionRedacted: true },
}); });
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
}); }
);
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]); const queryRaw = vi.fn(async () => [{ id: 51 }]);
@@ -210,7 +215,7 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('national'); expect(result.msgType).toBe('national');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); expect(queryRaw).toHaveBeenCalledOnce();
}); });
it('journals committed message mailbox copies instead of publishing before commit', async () => { it('journals committed message mailbox copies instead of publishing before commit', async () => {
@@ -228,6 +233,84 @@ describe('messages router missing-flow compatibility', () => {
expect(redis.publish).not.toHaveBeenCalled(); expect(redis.publish).not.toHaveBeenCalled();
}); });
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
'keeps ordinary public messages available while the game clock is %s',
async (clockPhase) => {
const queryRaw = vi.fn(async () => [{ id: 52 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ clockPhase })) },
});
await expect(
caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` })
).resolves.toMatchObject({ msgType: 'public' });
expect(queryRaw).toHaveBeenCalledOnce();
}
);
it.each(['SUSPENDED', 'RECONCILING'] as const)(
'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s',
async (clockPhase) => {
const scoutRow = {
id: 54,
mailbox: general.id,
type: 'private',
src: 8,
dest: general.id,
time: new Date('0200-01-01T00:00:00.000Z'),
created_at_wall: new Date('2026-09-03T15:00:00.000Z'),
action_status: 'PENDING',
expires_game_tick: 200n,
message: {
src: {
generalId: 8,
generalName: '등용권유자',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
dest: {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: '위',
color: '#fff',
icon: '',
},
text: '등용 권유 서신',
option: { action: 'scout' },
},
};
const { caller } = buildContext({
$queryRaw: vi.fn(async () => [scoutRow]),
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
clockTick: 100n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
tickSeconds: 600,
clockPhase,
clockRevision: 9n,
deadlineGeneration: 4n,
})),
},
});
const result = await caller.messages.getRecent({ generalId: general.id });
expect(result.private[0]).toMatchObject({
id: scoutRow.id,
text: '등용 권유 서신',
option: { action: 'scout' },
time: '2026-09-03 15:00:00',
});
expect(result.private[0]?.option).not.toMatchObject({ invalid: true });
}
);
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = { const ambassador = {
...general, ...general,
@@ -259,7 +342,7 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('diplomacy'); expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); expect(queryRaw).toHaveBeenCalledTimes(2);
}); });
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => { it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
@@ -288,11 +371,10 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result.msgType).toBe('diplomacy'); expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 }, { domain: 'messages.diplomacyMailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 }, { domain: 'messages.diplomacyMailbox', entityId: 9001 },
]); ]);
}); });
@@ -314,7 +396,6 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('national'); expect(result.msgType).toBe('national');
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
}); });
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => { it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
@@ -555,44 +636,50 @@ describe('messages router missing-flow compatibility', () => {
}); });
it('invalidates a recent owned message and its receiver copy', async () => { it('invalidates a recent owned message and its receiver copy', async () => {
const queryRaw = vi.fn(async () => [ let rawCall = 0;
{ const queryRaw = vi.fn(async () => {
id: 21, rawCall += 1;
mailbox: general.id, if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
type: 'private', return [
src: general.id, {
dest: 8, id: 21,
time: new Date(), mailbox: general.id,
valid_until: new Date('9999-12-31T00:00:00Z'), type: 'private',
message: { src: general.id,
src: { dest: 8,
generalId: general.id, time: new Date(),
generalName: general.name, valid_until: new Date('9999-12-31T00:00:00Z'),
nationId: 1, message: {
nationName: '위', src: {
color: '#fff', generalId: general.id,
icon: '', generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
}, },
dest: {
generalId: 8,
generalName: '받는이',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '삭제할 메시지',
option: { receiverMessageID: 22 },
}, },
}, ];
]); });
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]); expect(result.deletedIds).toEqual([21, 22]);
expect(executeRaw).toHaveBeenCalledOnce(); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 }, { domain: 'messages.mailbox', entityId: 7 },
@@ -601,43 +688,49 @@ describe('messages router missing-flow compatibility', () => {
}); });
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [ let rawCall = 0;
{ const queryRaw = vi.fn(async () => {
id: 25, rawCall += 1;
mailbox: 9001, if (rawCall > 1) return [{ id: 25 }];
type: 'diplomacy', return [
src: 9001, {
dest: 9002, id: 25,
time: new Date(), mailbox: 9001,
valid_until: new Date('9999-12-31T00:00:00Z'), type: 'diplomacy',
message: { src: 9001,
src: { dest: 9002,
generalId: general.id, time: new Date(),
generalName: general.name, valid_until: new Date('9999-12-31T00:00:00Z'),
nationId: 1, message: {
nationName: '위', src: {
color: '#fff', generalId: general.id,
icon: '', generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
}, },
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
}, },
}, ];
]); });
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }); const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]); expect(result.deletedIds).toEqual([25]);
expect(executeRaw).toHaveBeenCalledOnce(); expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled(); expect(updateMany).not.toHaveBeenCalled();
}); });
@@ -713,8 +806,11 @@ describe('messages router missing-flow compatibility', () => {
action, action,
reason: 'success', reason: 'success',
})); }));
const transaction = vi.fn(async () => {
throw new Error('ENGINE message response must not enter the API input-event transaction.');
});
const { caller } = buildContext( const { caller } = buildContext(
{ $queryRaw: vi.fn(async () => [messageRow]) }, { $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
{ turnDaemon: { requestCommand } } { turnDaemon: { requestCommand } }
); );
@@ -734,6 +830,7 @@ describe('messages router missing-flow compatibility', () => {
response: true, response: true,
}) })
); );
expect(transaction).not.toHaveBeenCalled();
} }
); );
@@ -745,6 +842,7 @@ describe('messages router missing-flow compatibility', () => {
proposerCurrentNationId?: number; proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>; proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number; diplomacyState?: number;
invalid?: boolean;
response?: boolean; response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>; cities?: Array<{ id: number; nationId: number; frontState: number }>;
}) => { }) => {
@@ -799,6 +897,7 @@ describe('messages router missing-flow compatibility', () => {
text: '외교 제안', text: '외교 제안',
option: { option: {
action, action,
...(options?.invalid ? { invalid: true } : {}),
...(action === 'noAggression' ? { year: 201, month: 2 } : {}), ...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
}, },
}, },
@@ -864,8 +963,18 @@ describe('messages router missing-flow compatibility', () => {
const nationUpdate = vi.fn(async () => ({})); const nationUpdate = vi.fn(async () => ({}));
const logCreateMany = vi.fn(async () => ({ count: 1 })); const logCreateMany = vi.fn(async () => ({ count: 1 }));
const messageUpdateMany = vi.fn(async () => ({ count: 1 })); const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
const cityUpdate = vi.fn(async () => ({})); const cityUpdate = vi.fn(async () => ({}));
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({
type: 'syncDiplomaticResponse' as const,
ok: true,
generalId: command.generalId,
messageId: command.messageId,
nations: 2,
diplomacy: 2,
cities: 0,
}));
const { caller } = buildContext( const { caller } = buildContext(
{ {
general: { general: {
@@ -941,13 +1050,22 @@ describe('messages router missing-flow compatibility', () => {
currentYear: 200, currentYear: 200,
currentMonth: 3, currentMonth: 3,
config: { environment: { mapName: 'che' } }, config: { environment: { mapName: 'che' } },
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
clockTick: 1_000n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'),
tickSeconds: 600,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
})), })),
}, },
logEntry: { createMany: logCreateMany }, logEntry: { createMany: logCreateMany },
message: { updateMany: messageUpdateMany }, message: { updateMany: messageUpdateMany },
messageAction: { updateMany: messageActionUpdateMany },
$queryRaw: queryRaw, $queryRaw: queryRaw,
}, },
{ changeJournal } { changeJournal, turnDaemon: { requestCommand } }
); );
return { return {
caller, caller,
@@ -960,9 +1078,25 @@ describe('messages router missing-flow compatibility', () => {
messageUpdateMany, messageUpdateMany,
cityUpdate, cityUpdate,
changeJournal, changeJournal,
requestCommand,
}; };
}; };
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
const setup = buildDiplomaticContext({ invalid: true });
await expect(
setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: true,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
expect(setup.requestCommand).not.toHaveBeenCalled();
});
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => { it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
const setup = buildDiplomaticContext(); const setup = buildDiplomaticContext();
@@ -973,6 +1107,14 @@ describe('messages router missing-flow compatibility', () => {
}); });
expect(result).toEqual({ result: true, reason: 'success' }); expect(result).toEqual({ result: true, reason: 'success' });
expect(setup.requestCommand).toHaveBeenCalledWith({
type: 'syncDiplomaticResponse',
userId: auth.user.id,
generalId: setup.actor.id,
messageId: 31,
nationIds: [1, 2],
cityIds: [],
});
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
expect(setup.nationUpdate).toHaveBeenCalledWith( expect(setup.nationUpdate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -987,7 +1129,7 @@ describe('messages router missing-flow compatibility', () => {
); );
expect(setup.messageUpdateMany).toHaveBeenCalledWith({ expect(setup.messageUpdateMany).toHaveBeenCalledWith({
where: { id: { in: [31] } }, where: { id: { in: [31] } },
data: { validUntil: expect.any(Date), validUntilTick: 0n }, data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
}); });
expect(setup.queryRaw).toHaveBeenCalledTimes(9); expect(setup.queryRaw).toHaveBeenCalledTimes(9);
}); });
@@ -13,13 +13,16 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071; const bettingId = 990_071;
const concurrentBettingId = 990_072; const concurrentBettingId = 990_072;
const phaseBettingId = 990_073;
const generalId = 9_971; const generalId = 9_971;
const otherGeneralId = 9_972; const otherGeneralId = 9_972;
const phaseGeneralId = 9_973;
const nationId = 990_071; const nationId = 990_071;
const otherNationId = 990_072; const otherNationId = 990_072;
const userId = 'nation-betting-router-user'; const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user'; const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user'; const noGeneralUserId = 'nation-betting-router-no-general-user';
const phaseUserId = 'nation-betting-router-phase-user';
const auth: GameSessionTokenPayload = { const auth: GameSessionTokenPayload = {
version: 1, version: 1,
@@ -58,6 +61,17 @@ const noGeneralAuth: GameSessionTokenPayload = {
}, },
}; };
const phaseAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-phase-session',
user: {
...auth.user,
id: phaseUserId,
username: 'phase-bettor',
displayName: 'Phase Bettor',
},
};
integration('nation betting router', () => { integration('nation betting router', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined; let closeDb: (() => Promise<void>) | undefined;
@@ -90,12 +104,14 @@ integration('nation betting router', () => {
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.inputEvent.deleteMany({
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.createMany({ await db.nation.createMany({
@@ -138,6 +154,17 @@ integration('nation betting router', () => {
turnTime: new Date('0200-01-01T00:00:00.000Z'), turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {}, meta: {},
}, },
{
id: phaseGeneralId,
userId: phaseUserId,
name: '정지중베팅장수',
nationId,
cityId: 1,
npcState: 0,
officerLevel: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
], ],
}); });
const world = await db.worldState.create({ const world = await db.worldState.create({
@@ -169,6 +196,17 @@ integration('nation betting router', () => {
], ],
}, },
}); });
await db.nationBetting.create({
data: {
id: phaseBettingId,
name: '정지 중 베팅',
selectCount: 1,
requiresInheritancePoint: true,
openYearMonth: 2_400,
closeYearMonth: 2_424,
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
},
});
await db.nationBetting.create({ await db.nationBetting.create({
data: { data: {
id: concurrentBettingId, id: concurrentBettingId,
@@ -184,17 +222,20 @@ integration('nation betting router', () => {
data: [ data: [
{ userId, key: 'previous', value: 1_000 }, { userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 }, { userId: otherUserId, key: 'previous', value: 500 },
{ userId: phaseUserId, key: 'previous', value: 500 },
], ],
}); });
}); });
afterAll(async () => { afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } }); await db.inputEvent.deleteMany({
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } }); });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } }); await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } }); await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } }); await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } }); await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.(); await closeDb?.();
@@ -290,6 +331,51 @@ integration('nation betting router', () => {
).toMatchObject({ value: 250 }); ).toMatchObject({ value: 250 });
}); });
it('accepts nation betting during suspension but rejects it during reconciliation', async () => {
const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const frozenTick = before.clockTick;
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } });
await expect(
appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({
bettingId: phaseBettingId,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 400 });
await expect(
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
).resolves.toMatchObject({ amount: 100 });
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({
clockPhase: 'SUSPENDED',
clockTick: frozenTick,
});
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } });
await expect(
appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({
bettingId: phaseBettingId,
bettingType: [0],
amount: 50,
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 400 });
await expect(
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
).resolves.toMatchObject({ amount: 100 });
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } });
});
it('requires authentication and an owned player general for every betting operation', async () => { it('requires authentication and an owned player general for every betting operation', async () => {
await expect( await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({ appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
@@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => {
const ally = general({ const ally = general({
id: 3, id: 3,
userId: 'u3', userId: 'u3',
injury: 25,
gold: 3000, gold: 3000,
crew: 200, crew: 200,
train: 80, train: 80,
@@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => {
expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]); expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200); expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 52, strength: 45, intelligence: 37 },
});
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 }); expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(result.generals[0]?.reservedCommands).toEqual([ expect(result.generals[0]?.reservedCommands).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } }, { action: 'che_징병', args: { crewType: 1, amount: 300 } },
+14
View File
@@ -161,6 +161,20 @@ describe('NPC policy router', () => {
}); });
}); });
it('uses the live world development cost instead of the scenario snapshot', async () => {
const fixture = createContext({
world: {
...baseWorld,
config: { ...baseWorld.config, const: { develCost: 100 } },
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
},
});
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
});
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => { it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 }; const reader = { ...baseGeneral, officerLevel: 2 };
const requestCommand = vi.fn(async () => ({ const requestCommand = vi.fn(async () => ({
@@ -1,13 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
buildNpcSelectionTokenSeed,
createTurnDaemonRuntime,
seedScenarioToDatabase,
type TurnDaemonRuntime,
} from '@sammo-ts/game-engine';
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector, createGamePostgresConnector,
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
const delayedUserId = 'npc-possession-integration-delayed'; const delayedUserId = 'npc-possession-integration-delayed';
const cleanupUserId = 'npc-possession-integration-cleanup'; const cleanupUserId = 'npc-possession-integration-cleanup';
const raceUserId = 'npc-possession-integration-race'; const raceUserId = 'npc-possession-integration-race';
const preopenUserId = 'npc-possession-integration-preopen';
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
const assertDedicatedDatabase = (rawUrl: string): void => { const assertDedicatedDatabase = (rawUrl: string): void => {
@@ -93,6 +88,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705); const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706); const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707); const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => { const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
const redisClient = { const redisClient = {
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.inputEvent.deleteMany(); await db.inputEvent.deleteMany();
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
await db.npcSelectionToken.deleteMany(); await db.npcSelectionToken.deleteMany();
await db.worldState.updateMany({
data: {
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date(),
},
});
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
await db.general.createMany({ await db.general.createMany({
data: Array.from({ length: 24 }, (_, index) => ({ data: Array.from({ length: 24 }, (_, index) => ({
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await closeDb?.(); await closeDb?.();
}, 30_000); }, 30_000);
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => { it('reserves at most five type-2 NPCs and preserves Ref refresh/keep timing', async () => {
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig(); const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
expect(config.npcPossession).toEqual({ enabled: true }); expect(config.npcPossession).toEqual({ enabled: true });
const worldState = await db.worldState.findFirstOrThrow();
const acceptedGameTick = Number(worldState.clockTick);
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
throw new Error('NPC possession integration hidden seed is missing');
}
const selectable = await db.general.findMany({
where: { userId: null, npcState: 2 },
orderBy: { id: 'asc' },
select: { id: true, leadership: true, strength: true, intel: true },
});
const weights = Object.fromEntries(
selectable.map((candidate) => [
String(candidate.id),
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
])
);
const rng = new RandUtil(
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
);
const expectedCandidateIds = new Set<number>();
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
}
const [first, concurrentSameOwner] = await Promise.all([ const [first, concurrentSameOwner] = await Promise.all([
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
@@ -240,9 +217,6 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(first.candidates.length).toBeGreaterThan(0); expect(first.candidates.length).toBeGreaterThan(0);
expect(first.candidates.length).toBeLessThanOrEqual(5); expect(first.candidates.length).toBeLessThanOrEqual(5);
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length); expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
[...expectedCandidateIds].sort((left, right) => left - right)
);
expect(first.pickMoreSeconds).toBe(0); expect(first.pickMoreSeconds).toBe(0);
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true); expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
const rows = await db.general.findMany({ const rows = await db.general.findMany({
@@ -281,6 +255,49 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false); expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
}, 30_000); }, 30_000);
it('refreshes possession candidates while the realtime game tick is negative before opening', async () => {
await stopRuntime('preopen candidate clock boundary');
const worldState = await db.worldState.findFirstOrThrow();
const originalClock = {
clockBaseTime: worldState.clockBaseTime,
clockTick: worldState.clockTick,
clockMode: worldState.clockMode,
clockWallAnchor: worldState.clockWallAnchor,
};
const openAt = new Date('2099-08-01T00:00:00.000Z');
try {
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
await db.worldState.update({
where: { id: worldState.id },
data: {
clockBaseTime: openAt,
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: openAt,
},
});
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-07-31T23:59:30.000Z'));
const first = await appRouter
.createCaller(buildContext('npc-possession-preopen-first', preopenAuth))
.join.listPossessCandidates({});
expect(first.pickMoreSeconds).toBe(0);
vi.advanceTimersByTime(30_000);
const refreshed = await appRouter
.createCaller(buildContext('npc-possession-preopen-refresh', preopenAuth))
.join.listPossessCandidates({ refresh: true, keepIds: [] });
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
await db.worldState.update({ where: { id: worldState.id }, data: originalClock });
await startRuntime('npc-possession-integration-daemon-resumed');
}
}, 30_000);
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => { it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
const reservation = await appRouter const reservation = await appRouter
.createCaller(buildContext('npc-possession-token-current')) .createCaller(buildContext('npc-possession-token-current'))
@@ -362,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
attempts: 1, attempts: 1,
actorUserId: userId, actorUserId: userId,
}); });
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const logs = await db.logEntry.findMany({ const logs = await db.logEntry.findMany({
where: { where: {
OR: [ OR: [
@@ -403,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
}); });
}, 45_000); }, 45_000);
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => { it('revalidates a queued token at the authoritative daemon processing tick', async () => {
const reservation = await appRouter const reservation = await appRouter
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth)) .createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
.join.listPossessCandidates({}); .join.listPossessCandidates({});
@@ -423,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
).rejects.toMatchObject({ code: 'TIMEOUT' }); ).rejects.toMatchObject({ code: 'TIMEOUT' });
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
const acceptedGameAt = new Date( expect(event.acceptedGameTick).toBeNull();
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time' expect(event.processingGameTick).toBeNull();
); expect(event.payload).not.toHaveProperty('acceptedGameAt');
expect(acceptedGameAt.toString()).not.toBe('Invalid Date'); const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
await db.npcSelectionToken.update({ await db.npcSelectionToken.update({
where: { ownerUserId: delayedUserId }, where: { ownerUserId: delayedUserId },
data: { validUntil: acceptedGameAt }, data: { validUntilTick: queuedAtTick },
}); });
await db.worldState.updateMany({ await db.worldState.updateMany({
data: { clockTick: { increment: 1 } }, data: { clockTick: { increment: 1 } },
@@ -453,12 +470,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await startRuntime('npc-possession-delayed-retry-daemon'); await startRuntime('npc-possession-delayed-retry-daemon');
await expect( await expect(
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input) appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
).resolves.toEqual({ ok: true, generalId: candidate.id }); ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' });
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
attempts: 1, attempts: 1,
processingGameTick: expect.anything(),
}); });
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1); expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0);
}, 45_000); }, 45_000);
it('serializes durable enqueue before a token refresh can replace its nonce', async () => { it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
@@ -653,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.npcSelectionToken.update({ await db.npcSelectionToken.update({
where: { ownerUserId: rejectedUserId }, where: { ownerUserId: rejectedUserId },
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') }, data: { validUntilTick: -1n },
}); });
await expect( await expect(
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({ appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
@@ -698,4 +716,59 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0); expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false); expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
}, 45_000); }, 45_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'reserves and possesses an NPC in %s without changing finalized records',
async (phase) => {
await stopRuntime('prepare frozen possession');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `npc-visitor-${phase}`;
const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802);
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: { ...(original.meta as Record<string, GamePrisma.InputJsonValue>), isUnited: 2, isunited: 2 },
},
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
const records = async () => ({
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await records();
try {
await startRuntime(`npc-visitor-${phase}`);
const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor));
const candidates = await caller.join.listPossessCandidates({});
const picked = candidates.candidates[0]!;
expect(picked).toBeDefined();
await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce });
expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({
userId: visitorId,
npcState: 1,
});
expect(await records()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
await stopRuntime('verify frozen possession reload');
await startRuntime(`npc-visitor-reload-${phase}`);
expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore frozen possession');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('frozen-possession-restored');
}
},
30_000
);
}); });
+64 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveNationPermission } from '../src/router/nation/shared.js';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
@@ -9,7 +10,7 @@ import {
toPublicRealtimeEvent as convertPublicRealtimeEvent, toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js'; } from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const; const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
const refreshGrant = 'opaque-grant'; const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) => const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant); convertPublicRealtimeEvent(event, identities, () => refreshGrant);
@@ -198,6 +199,37 @@ describe('public realtime event privacy boundary', () => {
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
}); });
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
const blockedViewer = { ...viewer, canReadDiplomacy: false };
expect(
toPublicRealtimeEvent(
{
type: 'messageCreated',
at: '2026-09-04T00:00:00Z',
mailbox,
msgType: 'diplomacy',
messageId: 1,
senderId: 2,
},
[blockedViewer]
)
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
});
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => { it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
const event: RealtimeEvent = { const event: RealtimeEvent = {
type: 'messagesChanged', type: 'messagesChanged',
@@ -225,6 +257,12 @@ describe('public realtime event privacy boundary', () => {
viewer viewer
) )
).toBe(false); ).toBe(false);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
viewer
)
).toBe(true);
}); });
it('merges previous and committed identities across an ownership transition', () => { it('merges previous and committed identities across an ownership transition', () => {
@@ -251,3 +289,28 @@ describe('public realtime event privacy boundary', () => {
}); });
}); });
}); });
describe('diplomatic notification recipient roles', () => {
it.each([
['군주', 12, 'normal', {}, true],
['외교권자', 1, 'ambassador', {}, true],
['조언자', 1, 'auditor', {}, true],
['일반 장수', 1, 'normal', {}, false],
['수뇌', 11, 'normal', {}, false],
['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false],
] as const)(
'%s receives only eligible nation diplomacy invalidations',
(_, officerLevel, permission, penalty, allowed) => {
const canReadDiplomacy =
resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3;
expect(canReadDiplomacy).toBe(allowed);
const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] };
expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual(
allowed ? { type: 'messagesInvalidated', refreshGrant } : null
);
expect(
toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }])
).toBeNull();
}
);
});
+38 -22
View File
@@ -12,6 +12,7 @@ const payload = (
| 'access.general' | 'access.general'
| 'dashboard.global' | 'dashboard.global'
| 'messages.mailbox' | 'messages.mailbox'
| 'messages.diplomacyMailbox'
| 'tournament' | 'tournament'
| 'betting' | 'betting'
) => ({ ) => ({
@@ -19,7 +20,11 @@ const payload = (
changes: [ changes: [
[ [
domain, domain,
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, domain === 'front.general' || domain === 'access.general'
? 7
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
? 9999
: 0,
'1', '1',
], ],
], ],
@@ -27,15 +32,16 @@ const payload = (
const createFixture = (rows: readonly object[]) => { const createFixture = (rows: readonly object[]) => {
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]); const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
const updateMany = vi.fn().mockResolvedValue({ count: 1 }); const executeRaw = vi.fn().mockResolvedValue(1);
const incr = vi.fn().mockResolvedValue(41); const incr = vi.fn().mockResolvedValue(41);
const publish = vi.fn().mockResolvedValue(1); const publish = vi.fn().mockResolvedValue(1);
const db = { const db = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
readModelOutbox: { updateMany }, $executeRaw: executeRaw,
readModelOutbox: {},
} as unknown as ReadModelOutboxDatabase; } as unknown as ReadModelOutboxDatabase;
const redis = { incr, publish } as unknown as RedisConnector['client']; const redis = { incr, publish } as unknown as RedisConnector['client'];
return { db, redis, queryRaw, updateMany, incr, publish }; return { db, redis, queryRaw, executeRaw, incr, publish };
}; };
describe('ReadModelOutboxWorker', () => { describe('ReadModelOutboxWorker', () => {
@@ -47,7 +53,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({ expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
@@ -64,7 +70,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision'); expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
@@ -74,9 +80,7 @@ describe('ReadModelOutboxWorker', () => {
revision: 41, revision: 41,
changes: { frontStatusActorIds: [7] }, changes: { frontStatusActorIds: [7] },
}); });
expect(fixture.updateMany).toHaveBeenCalledWith( expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
);
}); });
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)( it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
@@ -89,7 +93,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled(); expect(fixture.incr).not.toHaveBeenCalled();
@@ -105,7 +109,7 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled(); expect(fixture.incr).not.toHaveBeenCalled();
@@ -115,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
}); });
}); });
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
type: 'messagesChanged',
mailboxes: [],
diplomacyMailboxes: [9999],
});
});
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => { it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
let releaseFirst: (() => void) | undefined; let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => { const first = new Promise<readonly object[]>((resolve) => {
@@ -150,22 +173,15 @@ describe('ReadModelOutboxWorker', () => {
}); });
worker.start(); worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop(); await worker.stop();
expect(onError).toHaveBeenCalledWith( expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' }) expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
); );
expect(fixture.updateMany).toHaveBeenCalledWith( const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect.objectContaining({ expect(releaseQuery.sql).toContain('"available_at"');
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null }, expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
data: expect.objectContaining({
lockedAt: null,
lockOwner: null,
lastError: expect.stringContaining('redis unavailable'),
}),
})
);
}); });
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => { it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
+37 -3
View File
@@ -710,7 +710,7 @@ describe('appRouter', () => {
).rejects.toMatchObject({ code: 'FORBIDDEN' }); ).rejects.toMatchObject({ code: 'FORBIDDEN' });
}); });
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => { it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => {
const transport = new InMemoryTurnDaemonTransport(); const transport = new InMemoryTurnDaemonTransport();
const requestId = 'select-pool-reserve-http'; const requestId = 'select-pool-reserve-http';
const commandRequestId = `select-pool:user-1:${requestId}:reserve`; const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
@@ -741,8 +741,6 @@ describe('appRouter', () => {
requestId: commandRequestId, requestId: commandRequestId,
userId: 'user-1', userId: 'user-1',
seedOwnerIdentity: 'user-1', seedOwnerIdentity: 'user-1',
acceptedGameAt,
acceptedGameTick: 0,
}); });
}); });
@@ -1418,6 +1416,20 @@ describe('appRouter', () => {
message: expect.stringContaining('랜덤 임관만 가능합니다'), message: expect.stringContaining('랜덤 임관만 가능합니다'),
}); });
expect(randomOnlyWrites).toHaveLength(0); expect(randomOnlyWrites).toHaveLength(0);
await expect(
randomOnlyCaller.turns.reserved.setGeneral({
generalId: general.id,
turnIndex: 0,
action: 'che_등용',
args: { destGeneralId: 2 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('랜덤 임관만 가능합니다'),
});
expect(randomOnlyWrites).toHaveLength(0);
}); });
it('checks nation treaty-term reservation permission but not full diplomacy state', async () => { it('checks nation treaty-term reservation permission but not full diplomacy state', async () => {
@@ -1462,6 +1474,28 @@ describe('appRouter', () => {
}) })
).resolves.toMatchObject({ ok: true }); ).resolves.toMatchObject({ ok: true });
expect(validTermWrites).toHaveLength(1); expect(validTermWrites).toHaveLength(1);
const excessiveTermWrites: unknown[] = [];
const excessiveTermCaller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
general,
nationTurnWrites: excessiveTermWrites,
})
);
await expect(
excessiveTermCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_불가침제의',
args: { destNationId: 2, year: 211, month: 1 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('기한은 210년 이하'),
});
expect(excessiveTermWrites).toHaveLength(0);
}); });
it('validates every bulk reservation permission before writing any turn', async () => { it('validates every bulk reservation permission before writing any turn', async () => {
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await seedScenarioToDatabase({ await seedScenarioToDatabase({
scenarioId: 903, scenarioId: 903,
databaseUrl: databaseUrl!, databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'), now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: { installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5, turnTermMinutes: 5,
npcMode: 2, npcMode: 2,
showImgLevel: 3, showImgLevel: 3,
@@ -227,19 +228,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.listGeneralPoolCandidates(new Date(firstReservation.validUntil)) .listGeneralPoolCandidates(new Date(firstReservation.validUntil))
?.some((candidate) => reservedNames.has(candidate.uniqueName)) ?.some((candidate) => reservedNames.has(candidate.uniqueName))
).toBe(false); ).toBe(false);
await expect( const reserveEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({ where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` }, });
}) expect(reserveEvent).toMatchObject({
).resolves.toMatchObject({
eventType: 'selectPoolReserve', eventType: 'selectPoolReserve',
status: 'SUCCEEDED', status: 'SUCCEEDED',
actorUserId: userId, actorUserId: userId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick');
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const; const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
const attempts = await Promise.allSettled([ const attempts = await Promise.allSettled([
@@ -276,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id }, result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
}); });
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
if (!initialAccess.lastRefresh) { if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp'); throw new Error('selected general must have an initial access timestamp');
} }
@@ -357,6 +359,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' }); ).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
const cooledAt = '2026-07-29T00:00:00.000Z'; const cooledAt = '2026-07-29T00:00:00.000Z';
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await expect( await expect(
turnDaemon.requestCommand({ turnDaemon.requestCommand({
type: 'patchGeneral', type: 'patchGeneral',
@@ -366,6 +369,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: { meta: {
next_change: cooledAt, next_change: cooledAt,
nextChangeAt: cooledAt, nextChangeAt: cooledAt,
next_change_tick: cooledTick,
}, },
}, },
}) })
@@ -380,16 +384,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.createCaller(buildContext('select-pool-reselect')) .createCaller(buildContext('select-pool-reselect'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName }) .join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id }); ).resolves.toEqual({ ok: true, generalId: initial.id });
await expect( const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } }) where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
).resolves.toMatchObject({ });
expect(reselectionEvent).toMatchObject({
eventType: 'selectPoolReselect', eventType: 'selectPoolReselect',
actorUserId: userId, actorUserId: userId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } }); const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
expect(updated).toMatchObject({ expect(updated).toMatchObject({
@@ -455,6 +459,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue }, data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
}); });
const secondCooledAt = '2026-07-28T00:00:00.000Z'; const secondCooledAt = '2026-07-28T00:00:00.000Z';
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
await turnDaemon.requestCommand({ await turnDaemon.requestCommand({
type: 'patchGeneral', type: 'patchGeneral',
requestId: 'select-pool-full-cooldown-patch', requestId: 'select-pool-full-cooldown-patch',
@@ -463,6 +468,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
meta: { meta: {
next_change: secondCooledAt, next_change: secondCooledAt,
nextChangeAt: secondCooledAt, nextChangeAt: secondCooledAt,
next_change_tick: secondCooledTick,
}, },
}, },
}); });
@@ -571,21 +577,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.join.selectPoolGeneral(stableInput); .join.selectPoolGeneral(stableInput);
expect(retried).toEqual(first); expect(retried).toEqual(first);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
await expect( const stableEvent = await db.inputEvent.findUniqueOrThrow({
db.inputEvent.findUniqueOrThrow({ where: {
where: { requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, },
}, });
}) expect(stableEvent).toMatchObject({
).resolves.toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
attempts: 1, attempts: 1,
actorUserId: otherUserId, actorUserId: otherUserId,
payload: { processingGameTick: expect.anything(),
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
}); });
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
}, 30_000); }, 30_000);
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => { it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
@@ -660,4 +664,50 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
error: null, error: null,
}); });
}, 30_000); }, 30_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'creates from the selection pool while %s',
async (phase) => {
await runtime!.lifecycle.stop('prepare frozen pool creation');
await daemonLoop;
await runtime!.close();
const original = await db.worldState.findFirstOrThrow();
await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } });
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: `frozen-pool-${phase}`,
});
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start();
await turnDaemon.requestStatus(10_000);
const visitorId = `pool-visitor-${phase}`;
const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } };
const readRecords = async () => ({
hall: await db.hallOfFame.count({ where: { serverId: profile } }),
old: await db.oldGeneral.count({ where: { serverId: profile } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await readRecords();
const candidates = await appRouter
.createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth))
.join.getSelectionPool();
const result = await appRouter
.createCaller(buildContext(`pool-create-${phase}`, visitorAuth))
.join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' });
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
});
expect(await readRecords()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
},
30_000
);
}); });
@@ -0,0 +1,232 @@
import { it, expect } from 'vitest';
import {
createGamePostgresConnector,
createRedisConnector,
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
} from '@sammo-ts/infra';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import type { GameApiContext } from '../src/context.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
integration.each([
'setState',
'patchState',
'setParticipants',
'setMatches',
'setBettingEntries',
'seedParticipants',
'cancel',
])(
'serializes %s behind a joining user without holding the ENGINE clock lock',
async (adminAction) => {
const url = databaseUrl!;
const connector = createGamePostgresConnector({ url });
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
await connector.connect();
await redisConnector.connect();
const db = connector.prisma;
const redis = redisConnector.client;
const profileName = 'che:tournament-lock-integration';
const keys = buildTournamentKeys(profileName);
const redisKeys = [...Object.values(keys), `${keys.stateKey}:mutation-lock`];
const prefix = 'integration:tournament-lock:';
const nextAt = new Date().toISOString();
let notifyJoin: () => void;
const joinAtDaemon = new Promise<void>((resolve) => {
notifyJoin = resolve;
});
let notifyAdmin: () => void;
const adminAtRedis = new Promise<void>((resolve) => {
notifyAdmin = resolve;
});
const transport = new DatabaseTurnDaemonTransport(db, 4_000);
try {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await db.turnDaemonLease.create({
data: {
profile: profileName,
ownerId: 'audit',
leaseUntil: new Date(Date.now() + 60000),
clockReady: true,
},
});
await db.worldState.create({
data: {
id: -991900,
scenarioCode: 'audit',
currentYear: 200,
currentMonth: 1,
tickSeconds: 60,
clockBaseTime: new Date(),
clockTick: 0n,
clockWallAnchor: new Date(),
clockPhase: 'RUNNING',
clockRevision: 1n,
config: { const: { develCost: 10 } },
},
});
await db.general.create({
data: { id: 991900, userId: 'audit-user', name: 'audit', turnTime: new Date(), gold: 1000 },
});
await redis.set(
keys.stateKey,
JSON.stringify({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
})
);
const base: Partial<GameApiContext> = {
db,
redis,
profile: { id: 'che', scenario: 'tournament-lock-integration', name: profileName },
auth: {
version: 1,
profile: profileName,
issuedAt: new Date().toISOString(),
expiresAt: '2999-01-01T00:00:00Z',
sessionId: 'audit',
user: { id: 'audit-user', username: 'audit', displayName: 'audit', roles: [] },
sanctions: {},
},
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
notifyJoin!();
await adminAtRedis;
const requestId = await transport.sendCommand(command);
return db.$transaction(async (tx) => {
// 이전 순서에서는 관리자가 DB lock을 보유하여 별도 ENGINE 연결이 막힌다.
await tx.$executeRawUnsafe("SET LOCAL lock_timeout = '500ms'");
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (command.type !== 'adjustGeneralResources') throw new Error('unexpected command');
const result = {
type: 'adjustGeneralResources' as const,
ok: true as const,
processed: 1,
missing: 0,
totalGoldDelta: -10,
totalRiceDelta: 0,
};
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result } });
return result;
});
},
},
};
const joinContext = { ...base, requestId: `${prefix}join` } as GameApiContext;
const adminRedis = new Proxy(redis, {
get(target, property) {
if (property === 'set')
return async (...args: Parameters<typeof redis.set>) => {
if (args[0] === `${keys.stateKey}:mutation-lock`) notifyAdmin!();
return redis.set(...args);
};
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const adminContext = {
...base,
requestId: `${prefix}admin`,
redis: adminRedis,
auth: { ...base.auth!, user: { ...base.auth!.user, roles: ['admin'] } },
} as GameApiContext;
const callAdmin = async (context: GameApiContext) => {
const caller = appRouter.createCaller(context).tournament;
switch (adminAction) {
case 'setState':
return caller.setState({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
});
case 'patchState':
return caller.patchState({ auto: true });
case 'setParticipants':
return caller.setParticipants([]);
case 'setMatches':
return caller.setMatches([]);
case 'setBettingEntries':
return caller.setBettingEntries([]);
case 'seedParticipants':
return caller.seedParticipants({ generalIds: [991900] });
default:
return caller.cancel();
}
};
const join = appRouter.createCaller(joinContext).tournament.join();
const joinOutcome = join.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await joinAtDaemon;
const admin = callAdmin(adminContext);
const adminOutcome = admin.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await adminAtRedis;
const [joined, managed] = await Promise.all([joinOutcome, adminOutcome]);
expect(joined).toMatchObject({ ok: true, value: { ok: true, count: 1 } });
expect(managed).toMatchObject({ ok: true, value: { ok: true } });
const revision = await redis.get(keys.sourceRevisionKey);
const engineCount = await db.inputEvent.count({
where: { requestId: { startsWith: prefix }, target: 'ENGINE' },
});
// API 입력 원장은 유지한다. 동일 요청 재실행은 Redis/환불 command를 다시 쓰지 않는다.
await expect(callAdmin(adminContext)).resolves.toMatchObject({ ok: true });
expect(await redis.get(keys.sourceRevisionKey)).toBe(revision);
expect(await db.inputEvent.count({ where: { requestId: { startsWith: prefix }, target: 'ENGINE' } })).toBe(
engineCount
);
const inputEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}admin:tournament.${adminAction}` },
});
expect(inputEvent).toMatchObject({ target: 'API', status: 'SUCCEEDED', attempts: 1 });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
const retryContext = { ...adminContext, requestId: `${prefix}retry` };
const beforeFailure = await redis.get(keys.sourceRevisionKey);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'SUSPENDED' } });
await expect(callAdmin(retryContext)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
expect(await redis.get(keys.sourceRevisionKey)).toBe(beforeFailure);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'RUNNING' } });
await expect(callAdmin(retryContext)).resolves.toMatchObject({ ok: true });
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}retry:tournament.${adminAction}` },
})
).toMatchObject({ status: 'SUCCEEDED', attempts: 2 });
} finally {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await redisConnector.disconnect();
await connector.disconnect();
}
},
15000
);
+138 -11
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -30,11 +30,31 @@ class MemoryRedis {
} }
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> { async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
const [valueKey, revisionKey] = options.keys; if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) {
const [value] = options.arguments; const current = options.keys.map((key) => this.values.get(key));
if (current.every((value) => value === undefined)) {
options.keys.forEach((key, index) => this.values.set(key, options.arguments[index]!));
return '1';
}
return current.every((value, index) => value === options.arguments[index]) ? '2' : '0';
}
const fenced = options.keys.at(-1)?.endsWith(':clock:phase') === true;
const writeCount = options.keys.length - (fenced ? 4 : 1);
if (fenced) {
const clockKeys = options.keys.slice(-3);
const expected = options.arguments.slice(-3);
if (!clockKeys.every((key, index) => this.values.get(key) === expected[index])) {
return '__CLOCK_FENCE__';
}
}
const valueKey = options.keys[0];
const revisionKey = options.keys[writeCount];
const value = options.arguments[0];
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
const revision = Number(this.values.get(revisionKey) ?? '0') + 1; const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
this.values.set(valueKey, value); for (let index = 0; index < writeCount; index += 1) {
this.values.set(options.keys[index]!, options.arguments[index]!);
}
this.values.set(revisionKey, String(revision)); this.values.set(revisionKey, String(revision));
return String(revision); return String(revision);
} }
@@ -131,6 +151,8 @@ const buildContext = (options: {
develCost?: number; develCost?: number;
currentDevelCost?: number; currentDevelCost?: number;
rankRows?: Array<{ generalId: number; type: string; value: number }>; rankRows?: Array<{ generalId: number; type: string; value: number }>;
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
requestId?: string;
}): GameApiContext => { }): GameApiContext => {
const db = { const db = {
general: { general: {
@@ -144,12 +166,21 @@ const buildContext = (options: {
}, },
worldState: { worldState: {
findFirst: async () => ({ findFirst: async () => ({
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
clockPhase: options.clockPhase ?? 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
tickSeconds: 60,
config: { const: { develCost: options.develCost ?? 200 } }, config: { const: { develCost: options.develCost ?? 200 } },
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }), ...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
}), }),
}, },
} as unknown as DatabaseClient; } as unknown as DatabaseClient;
return { return {
requestId: options.requestId,
db, db,
redis: options.redis as unknown as RedisConnector['client'], redis: options.redis as unknown as RedisConnector['client'],
turnDaemon: options.transport, turnDaemon: options.transport,
@@ -246,15 +277,33 @@ describe('tournament router permissions and mutations', () => {
nextAt: '2026-07-26T01:00:00.000Z', nextAt: '2026-07-26T01:00:00.000Z',
}); });
await redis.set('sammo:che:default:tournament:participants', '[]'); await redis.set('sammo:che:default:tournament:participants', '[]');
const caller = appRouter.createCaller( const context = buildContext({
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 }) redis,
); transport,
generals: [general],
userId: 'user-1',
develCost: 200,
requestId: 'http:tournament-join',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800); expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0); expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
const snapshot = await caller.tournament.getSnapshot(); const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toHaveLength(1); expect(snapshot.participants).toHaveLength(1);
@@ -341,6 +390,83 @@ describe('tournament router permissions and mutations', () => {
expect(transport.gold.get(general.id)).toBe(2_400); expect(transport.gold.get(general.id)).toBe(2_400);
}); });
it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
const context = buildContext({
redis,
transport,
generals: [general],
userId: 'user-1',
clockPhase: 'SUSPENDED',
requestId: 'http:suspended-tournament-bet',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament bet must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true });
expect(transport.gold.get(general.id)).toBe(2_400);
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
reason: 'tournamentBet',
})
);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' })
);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
expect(transport.gold.get(general.id)).toBe(3_000);
expect(transport.commands).toHaveLength(0);
});
it('keeps another user from reading my bet identity and requires that user to own a general', async () => { it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
@@ -394,7 +520,10 @@ describe('tournament router permissions and mutations', () => {
roles: ['admin.tournament:che:default'], roles: ['admin.tournament:che:default'],
}) })
); );
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true }); await expect(adminCaller.tournament.getAdminStatus()).resolves.toMatchObject({
ok: true,
clock: { reconciliationComplete: false, latestReconciliation: null },
});
}); });
it('applies the admin role boundary to every tournament mutation', async () => { it('applies the admin role boundary to every tournament mutation', async () => {
@@ -479,9 +608,7 @@ describe('tournament router permissions and mutations', () => {
]) ])
).resolves.toEqual({ ok: true, count: 1 }); ).resolves.toEqual({ ok: true, count: 1 });
await expect( await expect(
caller.tournament.setBettingEntries([ caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }])
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
).resolves.toEqual({ ok: true, count: 1 }); ).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({ await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true, ok: true,
@@ -41,6 +41,9 @@ integration('TournamentStore Redis source revision', () => {
keys.matchesKey, keys.matchesKey,
keys.bettingKey, keys.bettingKey,
keys.sourceRevisionKey, keys.sourceRevisionKey,
keys.activeClockRevisionKey,
keys.deadlineGenerationKey,
keys.clockPhaseKey,
]); ]);
if (subscriber) { if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel); await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
@@ -124,4 +127,48 @@ integration('TournamentStore Redis source revision', () => {
}), }),
]); ]);
}); });
it('dual-writes deadline ticks and rejects a Redis clock revision race atomically', async () => {
const store = new TournamentStore(connector.client, keys);
await Promise.all([
connector.client.set(keys.activeClockRevisionKey, '7'),
connector.client.set(keys.deadlineGenerationKey, '3'),
connector.client.set(keys.clockPhaseKey, 'RUNNING'),
]);
const clockContext = {
phase: 'RUNNING' as const,
revision: 7,
deadlineGeneration: 3,
dateToTick: (date: Date) => Math.trunc(date.getTime() / 1_000),
};
const nextAt = '2026-09-03T10:00:00.000Z';
await store.withClockContext(clockContext, () =>
store.setState({
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 200,
openMonth: 1,
termSeconds: 10,
nextAt,
bettingCloseAt: '2026-09-03T09:59:50.000Z',
})
);
await expect(store.getState()).resolves.toMatchObject({
nextTick: Math.trunc(new Date(nextAt).getTime() / 1_000),
bettingCloseTick: Math.trunc(new Date('2026-09-03T09:59:50.000Z').getTime() / 1_000),
clockRevision: 7,
deadlineGeneration: 3,
});
const beforeRevision = await store.getSourceRevision();
await connector.client.set(keys.activeClockRevisionKey, '8');
await expect(
store.withClockContext(clockContext, () =>
store.setMatches([{ id: 99, stage: 7, roundIndex: 0, attackerId: 1, defenderId: 2 }])
)
).rejects.toThrow('clock revision fence failed');
await expect(store.getSourceRevision()).resolves.toBe(beforeRevision);
});
}); });
+12
View File
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
}); });
}); });
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
const fixture = buildContext({
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
result: null,
});
const result = await appRouter.createCaller(fixture.context).troop.getList();
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
});
it('creates a troop only for the general owned by the authenticated user', async () => { it('creates a troop only for the general owned by the authenticated user', async () => {
const { context, requestCommand } = buildContext({ const { context, requestCommand } = buildContext({
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
+7 -13
View File
@@ -5,10 +5,8 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
describe('turn engine status projection', () => { describe('turn engine status projection', () => {
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => { it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
const activeLease = { const activeLease = {
turnDaemonLease: { $queryRaw: vi.fn(async () => [{ running: true }]),
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })), } as any;
},
};
const now = new Date('2026-08-24T00:00:00.000Z'); const now = new Date('2026-08-24T00:00:00.000Z');
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe( await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
true true
@@ -36,7 +34,7 @@ describe('turn engine status projection', () => {
await expect( await expect(
loadTurnEngineRunning( loadTurnEngineRunning(
source, source,
{ turnDaemonLease: { findUnique: async () => null } }, { $queryRaw: async () => [{ running: false }] } as any,
'che:default', 'che:default',
now now
) )
@@ -44,11 +42,7 @@ describe('turn engine status projection', () => {
await expect( await expect(
loadTurnEngineRunning( loadTurnEngineRunning(
source, source,
{ { $queryRaw: async () => [{ running: false }] } as any,
turnDaemonLease: {
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
},
},
'che:default', 'che:default',
now now
) )
@@ -58,10 +52,10 @@ describe('turn engine status projection', () => {
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => { it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
let now = 1_000; let now = 1_000;
const get = vi.fn(async () => 'RUNNING' as const); const get = vi.fn(async () => 'RUNNING' as const);
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })); const queryRaw = vi.fn(async () => [{ running: true }]);
const cache = new CachedTurnEngineStatus( const cache = new CachedTurnEngineStatus(
{ get }, { get },
{ turnDaemonLease: { findUnique } }, { $queryRaw: queryRaw } as any,
'che:default', 'che:default',
2_000, 2_000,
() => now () => now
@@ -75,6 +69,6 @@ describe('turn engine status projection', () => {
now += 1; now += 1;
await expect(cache.get()).resolves.toBe(true); await expect(cache.get()).resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(2); expect(get).toHaveBeenCalledTimes(2);
expect(findUnique).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
}); });
}); });
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
tryGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
await closeDb?.(); await closeDb?.();
}); });
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
const requestId = 'integration:vote-comment-timestamp:submit';
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
const context: Partial<GameApiContext> = {
requestId,
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
const acceptedId = await transport.sendCommand(command);
expect(acceptedId).toBe(engineRequestId);
await db.$transaction(async (transaction) => {
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
true
);
const event = await transaction.inputEvent.findUniqueOrThrow({
where: { requestId: acceptedId },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'PENDING',
actorUserId: fixtureUserId,
});
await transaction.inputEvent.update({
where: { requestId: acceptedId },
data: {
status: 'SUCCEEDED',
result: {
type: 'voteReward',
ok: true,
voteId: fixtureId,
generalId: fixtureId,
awardedUnique: false,
},
},
});
});
return transport.requestCommand(command);
},
},
};
const caller = appRouter.createCaller(context as GameApiContext);
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
ok: true,
wonLottery: false,
});
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => { it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>` const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone" SELECT current_setting('TIMEZONE') AS "timeZone"
+43 -26
View File
@@ -216,6 +216,40 @@ const buildContext = (options: {
}; };
describe('vote router actor and permission boundaries', () => { describe('vote router actor and permission boundaries', () => {
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
const fixture = buildContext({ requestId: 'vote-boundary' });
const transaction = vi.fn(async () => {
throw new Error('API transaction blocks the vote ENGINE clock fence');
});
Object.assign(fixture.context.db, { $transaction: transaction });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
generalId: 7,
})
);
});
it.each([
{ auth: null, code: 'UNAUTHORIZED' },
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
])(
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
async ({ auth, code }) => {
const fixture = buildContext({ auth });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).rejects.toMatchObject({ code });
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
);
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => { it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z'); const now = new Date('2026-07-26T00:00:00Z');
const time = { const time = {
@@ -230,13 +264,7 @@ describe('vote router actor and permission boundaries', () => {
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false); expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
expect(
hasPollEnded(
{ closed_at: null, end_at: now, end_tick: null },
{ ...time, now: new Date(now.getTime() + 1), tick: null }
)
).toBe(true);
}); });
it('rejects unauthenticated survey access', async () => { it('rejects unauthenticated survey access', async () => {
@@ -261,12 +289,11 @@ describe('vote router actor and permission boundaries', () => {
voteId: 1, voteId: 1,
generalId: 7, generalId: 7,
selection: [0], selection: [0],
acceptedGameTick: 100,
}); });
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe( expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false false
); );
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]); expect(fixture.changeJournal.snapshot()).toEqual([]);
expect(fixture.redisIncr).not.toHaveBeenCalled(); expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
}); });
@@ -301,8 +328,6 @@ describe('vote router actor and permission boundaries', () => {
const auth = buildAuth(['admin.survey.open']); const auth = buildAuth(['admin.survey.open']);
const fixture = buildContext({ auth }); const fixture = buildContext({ auth });
const caller = appRouter.createCaller(fixture.context); const caller = appRouter.createCaller(fixture.context);
const windowStart = Date.now();
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true }); await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
await expect( await expect(
caller.vote.createPoll({ caller.vote.createPoll({
@@ -314,8 +339,6 @@ describe('vote router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true }); ).resolves.toEqual({ ok: true });
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true }); await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true }); await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
const windowEnd = Date.now();
const mutationQueries = fixture.queryRaw.mock.calls const mutationQueries = fixture.queryRaw.mock.calls
.map(([query]) => query) .map(([query]) => query)
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query))); .filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
@@ -325,30 +348,24 @@ describe('vote router actor and permission boundaries', () => {
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL')); const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE')); const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id')); const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => { const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => {
expect(query).toBeDefined(); expect(query).toBeDefined();
const value = query?.values.at(index); expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
expect(value).toBeInstanceOf(Date);
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
return value as Date;
}; };
expect(sqlText(commentInsert!)).toContain('created_at'); expect(sqlText(commentInsert!)).toContain('created_at');
expectCurrentDateAt(commentInsert, -1); expectDbWallClock(commentInsert);
expect(sqlText(pollInsert!)).toContain('created_at'); expect(sqlText(pollInsert!)).toContain('created_at');
expect(sqlText(pollInsert!)).toContain('updated_at'); expect(sqlText(pollInsert!)).toContain('updated_at');
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2); expectDbWallClock(pollInsert);
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
expect(pollUpdatedAt).toBe(pollCreatedAt);
expect(pollUpdates).toHaveLength(3); expect(pollUpdates).toHaveLength(3);
expect(sqlText(closePreviousUpdate!)).toContain('updated_at'); expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt); expectDbWallClock(closePreviousUpdate);
expect(sqlText(editPollUpdate!)).toContain('updated_at'); expect(sqlText(editPollUpdate!)).toContain('updated_at');
expectCurrentDateAt(editPollUpdate, -2); expectDbWallClock(editPollUpdate);
expect(sqlText(closePollUpdate!)).toContain('updated_at'); expect(sqlText(closePollUpdate!)).toContain('updated_at');
expectCurrentDateAt(closePollUpdate, -2); expectDbWallClock(closePollUpdate);
}); });
it('reports the current world develcost as the legacy five-times survey reward', async () => { it('reports the current world develcost as the legacy five-times survey reward', async () => {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js'; import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js';
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const revisionRow = (overrides: Record<string, unknown> = {}) => ({ const revisionRow = (overrides: Record<string, unknown> = {}) => ({
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
db: { $queryRaw: queryRaw }, db: { $queryRaw: queryRaw },
} as unknown as GameApiContext; } as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
'sammo:map:base:hwe:scenario_2400:pg12'
);
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
'sammo:map:public:hwe:scenario_2400:pg12' 'sammo:map:public:hwe:scenario_2400:pg12'
); );
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('read_model_revision_meta'); expect(statement.sql).toContain('read_model_revision_meta');
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'"); expect(statement.sql).toContain('revision."domain" = \'map.world\'');
expect(statement.values).toEqual([]); expect(statement.values).toEqual([]);
}); });
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true }); const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true }); const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } }); expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } }); expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
expect(redis.set).toHaveBeenCalledTimes(1); expect(redis.set).toHaveBeenCalledTimes(1);
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
expect(shared).not.toHaveProperty('shownByGeneralList'); expect(shared).not.toHaveProperty('shownByGeneralList');
expect(shared).not.toHaveProperty('myCity'); expect(shared).not.toHaveProperty('myCity');
expect(shared).not.toHaveProperty('myNation'); expect(shared).not.toHaveProperty('myNation');
// 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다.
delete shared.uniqueItemLimit;
cache.set(cache.keys().next().value!, JSON.stringify(shared));
const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true });
expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(redis.set).toHaveBeenCalledTimes(2);
}); });
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => { it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
redis, redis,
db: { db: {
$queryRaw: queryRaw, $queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) }, worldState: {
findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })),
},
}, },
} as unknown as GameApiContext; } as unknown as GameApiContext;
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
expect(redis.set).not.toHaveBeenCalled(); expect(redis.set).not.toHaveBeenCalled();
}); });
}); });
describe('map unique ownership limit', () => {
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
currentYear,
meta: { scenarioMeta: { startYear: 180 } },
config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } },
});
it.each([
[179, 1, 182],
[181, 1, 182],
[182, 1, 182],
[183, 2, 189],
[189, 2, 189],
[190, 3, 199],
[200, 4, null],
])('projects year %i and the inclusive last month', (year, count, untilYear) => {
expect(resolveMapUniqueItemLimit(state(year!))).toEqual({
count,
until: untilYear === null ? null : { year: untilYear, month: 12 },
});
});
it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => {
expect(
resolveMapUniqueItemLimit(
state(181, {
maxUniqueItemLimit: [
[-1, 1],
[2, 1],
[4, 3],
[8, 4],
],
})
)
).toEqual({ count: 1, until: { year: 183, month: 12 } });
expect(
resolveMapUniqueItemLimit(
state(183, {
allItems: { horse: {}, weapon: {} },
})
)
).toEqual({ count: 2, until: null });
expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null });
});
});
+1
View File
@@ -8,5 +8,6 @@ export default defineConfig({
environment: 'node', environment: 'node',
globals: true, globals: true,
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
reporters: [process.env.SAMMO_TEST_REPORTER === 'verbose' ? 'default' : 'minimal'],
}, },
}); });
+10 -2
View File
@@ -42,6 +42,14 @@
"types": "./dist/turn/databaseHooks.d.ts", "types": "./dist/turn/databaseHooks.d.ts",
"default": "./dist/turn/databaseHooks.js" "default": "./dist/turn/databaseHooks.js"
}, },
"./turn/clockReconciliation.js": {
"types": "./dist/turn/clockReconciliation.d.ts",
"default": "./dist/turn/clockReconciliation.js"
},
"./turn/clockProjectionOutbox.js": {
"types": "./dist/turn/clockProjectionOutbox.d.ts",
"default": "./dist/turn/clockProjectionOutbox.js"
},
"./turn/inMemoryWorld.js": { "./turn/inMemoryWorld.js": {
"types": "./dist/turn/inMemoryWorld.d.ts", "types": "./dist/turn/inMemoryWorld.d.ts",
"default": "./dist/turn/inMemoryWorld.js" "default": "./dist/turn/inMemoryWorld.js"
@@ -103,8 +111,8 @@
"build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .", "build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .",
"dev": "tsdown -c ./tsdown.config.ts --watch", "dev": "tsdown -c ./tsdown.config.ts --watch",
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js", "start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-engine",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-engine",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs", "profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs",
"profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs", "profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs",
+48 -23
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -60,27 +61,27 @@ export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
now: Date, now: Date,
nowTick: number | null nowTick: number | null
): boolean => ): boolean => {
auction.closeTick !== null && nowTick !== null void now;
? auction.closeTick < BigInt(nowTick) return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
: auction.closeAt.getTime() < now.getTime(); };
export const resolveAuctionBidTiming = ( export const resolveAuctionBidTiming = (
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>, world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingNow: Date, processingGameTick: number
acceptedGameTick?: number ): { bidAt: Date; bidTick: number } => {
): { bidAt: Date; bidTick: number } => if (!Number.isSafeInteger(processingGameTick)) {
acceptedGameTick === undefined throw new Error('Auction bid requires an authoritative daemon processing game tick.');
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) } }
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick }; return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
};
export const hasAuctionBidClosePassed = ( export const hasAuctionBidClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null }, auction: { closeAt: Date; closeTick: bigint | null },
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>, world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
processingNow: Date, processingGameTick: number
acceptedGameTick?: number
): boolean => { ): boolean => {
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick); const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
return hasAuctionClosePassed(auction, bidAt, bidTick); return hasAuctionClosePassed(auction, bidAt, bidTick);
}; };
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`, text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
@@ -268,8 +269,15 @@ export const createAuctionBidder = async (options: {
reason: '경매가 종료되었습니다.', reason: '경매가 종료되었습니다.',
}; };
} }
const processingNow = world.getGameNow(new Date()); const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick); if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionBid requires its durable input-event wall occurrence.');
}
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
if (hasAuctionClosePassed(auction, bidAt, bidTick)) { if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
return { return {
type: 'auctionBid', type: 'auctionBid',
@@ -503,13 +511,24 @@ export const createAuctionBidder = async (options: {
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => { const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta) INSERT INTO auction_bid (
auction_id,
general_id,
amount,
event_id,
event_at,
occurred_game_tick,
requested_at_wall,
meta
)
VALUES ( VALUES (
${command.auctionId}, ${command.auctionId},
${command.generalId}, ${command.generalId},
${command.amount}, ${command.amount},
${eventId}, ${eventId},
${eventAt}, ${eventAt},
${BigInt(bidTick)},
${requestedAtWall},
${JSON.stringify({ ${JSON.stringify({
tryExtendCloseDate: command.tryExtendCloseDate ?? true, tryExtendCloseDate: command.tryExtendCloseDate ?? true,
...(auction.type === 'UNIQUE_ITEM' ...(auction.type === 'UNIQUE_ITEM'
@@ -529,7 +548,7 @@ export const createAuctionBidder = async (options: {
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
latest_event_id = ${eventId}, latest_event_id = ${eventId},
latest_event_at = ${eventAt}, latest_event_at = ${eventAt},
updated_at = ${eventAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${command.auctionId} WHERE id = ${command.auctionId}
AND status = 'OPEN' AND status = 'OPEN'
AND latest_event_id = ${auction.latestEventId} AND latest_event_id = ${auction.latestEventId}
@@ -549,7 +568,7 @@ export const createAuctionBidder = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE inheritance_point UPDATE inheritance_point
SET value = value - ${morePoint}, SET value = value - ${morePoint},
updated_at = ${eventAt} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE user_id = ${userId} WHERE user_id = ${userId}
AND key = 'previous' AND key = 'previous'
AND value >= ${morePoint} AND value >= ${morePoint}
@@ -580,11 +599,16 @@ export const createAuctionBidder = async (options: {
await tx.$executeRaw( await tx.$executeRaw(
GamePrisma.sql` GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at) INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt}) VALUES (
${prevUserId},
'previous',
${highestBid.amount},
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (user_id, key) ON CONFLICT (user_id, key)
DO UPDATE SET DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value, value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
` `
); );
await tx.$executeRaw( await tx.$executeRaw(
@@ -704,6 +728,7 @@ export const createAuctionBidder = async (options: {
ok: true, ok: true,
auctionId: command.auctionId, auctionId: command.auctionId,
closeAt: nextCloseAt.toISOString(), closeAt: nextCloseAt.toISOString(),
closeTick: world.dateToGameTick(nextCloseAt),
}; };
}, },
close: async (): Promise<void> => { close: async (): Promise<void> => {
+17 -17
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic'; import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
@@ -91,21 +92,17 @@ export const isAuctionFinalizeGenerationCurrent = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>, auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'> command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
): boolean => { ): boolean => {
if (command.expectedCloseTick !== undefined) { return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
}
if (command.expectedCloseAt !== undefined) {
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
}
return true;
}; };
export const hasAuctionFinalizeDeadlineArrived = ( export const hasAuctionFinalizeDeadlineArrived = (
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>, auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
now: Date, now: Date,
nowTick: number nowTick: number
): boolean => ): boolean => {
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick); void now;
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
};
export const buildAuctionBidderSystemMessage = (options: { export const buildAuctionBidderSystemMessage = (options: {
bidder: TurnGeneral; bidder: TurnGeneral;
@@ -128,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: options.text, text: options.text,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
@@ -293,7 +290,11 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId }; return { type: 'auctionFinalize', ok: true, auctionId };
} }
const now = world.getGameNow(new Date()); const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
if (auction.status === 'OPEN') { if (auction.status === 'OPEN') {
if (!isAuctionFinalizeGenerationCurrent(auction, command)) { if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
return { return {
@@ -303,8 +304,7 @@ export const createAuctionFinalizer = async (options: {
reason: '경매 마감 세대가 변경되었습니다.', reason: '경매 마감 세대가 변경되었습니다.',
}; };
} }
const nowTick = world.dateToGameTick(now); if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
return { return {
type: 'auctionFinalize', type: 'auctionFinalize',
ok: false, ok: false,
@@ -316,8 +316,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = 'FINALIZING', SET status = 'FINALIZING',
finalizing_at = ${now}, finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = ${now} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId} WHERE id = ${auctionId}
AND status = 'OPEN' AND status = 'OPEN'
` `
@@ -364,8 +364,8 @@ export const createAuctionFinalizer = async (options: {
GamePrisma.sql` GamePrisma.sql`
UPDATE auction UPDATE auction
SET status = ${status}, SET status = ${status},
finished_at = ${now}, finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
updated_at = ${now} updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE id = ${auctionId} WHERE id = ${auctionId}
` `
); );
+20 -4
View File
@@ -94,7 +94,11 @@ const openResourceAuction = async (
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`); return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
} }
const now = world.getGameNow(new Date()); const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const now = world.gameTickToDate(processingGameTick);
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60)); const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000); const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({ const auction = await db.auction.create({
@@ -113,7 +117,7 @@ const openResourceAuction = async (
}, },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
openTick: BigInt(world.dateToGameTick(now)), openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)), closeTick: BigInt(world.dateToGameTick(closeAt)),
}, },
}); });
@@ -125,6 +129,7 @@ const openResourceAuction = async (
ok: true, ok: true,
auctionId: auction.id, auctionId: auction.id,
closeAt: closeAt.toISOString(), closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
}; };
}; };
@@ -220,8 +225,16 @@ const openUniqueAuction = async (
} }
const state = world.getState(); const state = world.getState();
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
}
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
}
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60)); const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
const now = world.getGameNow(new Date()); const now = world.gameTickToDate(processingGameTick);
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES); const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000); const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max( const extensionLimitMinutes = Math.max(
@@ -253,7 +266,7 @@ const openUniqueAuction = async (
}, },
status: 'OPEN', status: 'OPEN',
closeAt, closeAt,
openTick: BigInt(world.dateToGameTick(now)), openTick: BigInt(processingGameTick),
closeTick: BigInt(world.dateToGameTick(closeAt)), closeTick: BigInt(world.dateToGameTick(closeAt)),
latestEventId: eventId, latestEventId: eventId,
latestEventAt: now, latestEventAt: now,
@@ -263,6 +276,8 @@ const openUniqueAuction = async (
amount: command.amount, amount: command.amount,
eventId, eventId,
eventAt: now, eventAt: now,
occurredGameTick: BigInt(processingGameTick),
requestedAtWall,
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount), meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
}, },
}, },
@@ -308,6 +323,7 @@ const openUniqueAuction = async (
ok: true, ok: true,
auctionId: auction.id, auctionId: auction.id,
closeAt: closeAt.toISOString(), closeAt: closeAt.toISOString(),
closeTick: world.dateToGameTick(closeAt),
}; };
}; };

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