Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9cde3592 | ||
|
|
99a831b251 | ||
|
|
dee72bdf7b | ||
|
|
b2b6723c4d | ||
|
|
16b320dabc | ||
|
|
f5264352f9 | ||
|
|
908ae72cba | ||
|
|
e34c24c74d | ||
|
|
e542a9beea | ||
|
|
d89bdc439f | ||
|
|
581ecd6a7b | ||
|
|
70b80e7afa | ||
|
|
21c68323fa | ||
|
|
c81a9eaf42 | ||
|
|
292ca7662d | ||
|
|
c9fe204c94 | ||
|
|
ef1ba055b5 | ||
|
|
718cc05d15 | ||
|
|
f80a239024 | ||
|
|
028f985e7a | ||
|
|
eb680121c3 | ||
|
|
b470e32e81 | ||
|
|
22cf566c6b | ||
|
|
63907d95cc | ||
|
|
9f31b28297 | ||
|
|
2f5036ea6b | ||
|
|
52cea882ab | ||
|
|
ae0dfd503f | ||
|
|
c719cecc80 | ||
|
|
02ee706af7 | ||
|
|
ff9bb8a428 | ||
|
|
0691c502fa | ||
|
|
248d457dd5 | ||
|
|
bfe9ef479c | ||
|
|
b9d9b63b14 | ||
|
|
8faab62866 | ||
|
|
3d60e6122d | ||
|
|
d549cbf887 | ||
|
|
10f3f0ebc6 | ||
|
|
9e14306fe4 | ||
|
|
31c5927449 | ||
|
|
69a61b317c | ||
|
|
e1a05e27e7 | ||
|
|
b12a899526 | ||
|
|
8f3852662e | ||
|
|
7d5ee1348d | ||
|
|
ed2597aef3 | ||
|
|
35155e6b59 | ||
|
|
4de3175279 | ||
|
|
5ac961dfd1 | ||
|
|
f4aabec1fa | ||
|
|
a51574d20c | ||
|
|
47ee6491da | ||
|
|
2eef008213 | ||
|
|
ab5a1d240c | ||
|
|
4fb1343943 | ||
|
|
14b893d8a3 | ||
|
|
e094246963 | ||
|
|
7dc1670935 | ||
|
|
380020ab2f | ||
|
|
854a91c397 | ||
|
|
62f4ca3058 | ||
|
|
e1096d7cd1 | ||
|
|
0c3c32b60e | ||
|
|
3e25c5c385 | ||
|
|
c9d3763b28 | ||
|
|
7522f5100e | ||
|
|
8760eab1a9 | ||
|
|
d50cff8640 | ||
|
|
1b07c5ffe9 | ||
|
|
23f1573bfc | ||
|
|
35f587768e | ||
|
|
0cd2b7770c | ||
|
|
047d86cace | ||
|
|
8fa89d499e | ||
|
|
4dac08b916 | ||
|
|
3ad230beb3 | ||
|
|
e55c653e40 | ||
|
|
21d10d5dfc | ||
|
|
5041fc365e | ||
|
|
a31c2711c7 | ||
|
|
957a599608 | ||
|
|
73e1743a3d | ||
|
|
3a5d4e7833 | ||
|
|
3f4e5095b5 | ||
|
|
a00f46dfc8 | ||
|
|
9c73085353 | ||
|
|
604b951eff | ||
|
|
09f4e60df4 | ||
|
|
d191960ea1 | ||
|
|
c1fd79da19 | ||
|
|
d8c9a63915 | ||
|
|
c39527bdd7 | ||
|
|
d57fd3e553 | ||
|
|
bdb4e22028 | ||
|
|
b2be86a9a2 | ||
|
|
69f461612d | ||
|
|
9d5447dd3f | ||
|
|
27049dae1f | ||
|
|
09dfe96ff1 | ||
|
|
b14d8d52f6 | ||
|
|
4d64978cb6 | ||
|
|
d3443ec7f5 | ||
|
|
63da0921cb | ||
|
|
6dcc914f94 | ||
|
|
d3b2bcd155 | ||
|
|
ce7d15eba9 | ||
|
|
8488bf1aae | ||
|
|
71d7abcda4 | ||
|
|
332bd9a3dd | ||
|
|
3437cd99f4 | ||
|
|
1d53e6f226 | ||
|
|
0157ff6a6b | ||
|
|
5d220de739 | ||
|
|
07897d9765 | ||
|
|
01fdb51b6e | ||
|
|
746bbe7e7d | ||
|
|
f009c50df5 | ||
|
|
38e516de6d | ||
|
|
ffbd51a2ef | ||
|
|
b77276f202 | ||
|
|
424f0b66ec | ||
|
|
51f8be492f | ||
|
|
156f4e68b8 | ||
|
|
a2b0b997a2 | ||
|
|
62006fb2d4 | ||
|
|
03e47feef1 | ||
|
|
1cb66f76fb | ||
|
|
b250e908f4 | ||
|
|
0aa549a6c5 | ||
|
|
000e7e8fa5 | ||
|
|
abceee8315 | ||
|
|
10cddbb565 | ||
|
|
24a63d338c | ||
|
|
c10a71fbcc | ||
|
|
69ac6028df | ||
|
|
a9f23703d9 | ||
|
|
a3e2bf90ae | ||
|
|
ae7d55ef47 | ||
|
|
b91dcbcaaa | ||
|
|
1a2c7ba775 | ||
|
|
97a8b1daef | ||
|
|
b1984669d4 | ||
|
|
77299ffe41 | ||
|
|
89a33a8349 | ||
|
|
76da41a00b | ||
|
|
63e29743d9 | ||
|
|
426e1127ef | ||
|
|
9c82eb0cf5 | ||
|
|
dd84e6d6b3 | ||
|
|
8e0e7291de | ||
|
|
50a443d94b | ||
|
|
bf13dabc01 | ||
|
|
7080ae5504 | ||
|
|
96f6d85cf4 | ||
|
|
3ef86448d7 | ||
|
|
bd826b1ff8 | ||
|
|
0bc4be9a13 | ||
|
|
898d756036 | ||
|
|
29014e9c2d | ||
|
|
99a477b47e | ||
|
|
8d88603305 | ||
|
|
d4e83661ca | ||
|
|
70a4980a31 | ||
|
|
f3eaab1fa9 | ||
|
|
71b9449903 | ||
|
|
6162e56583 | ||
|
|
fa7898b6a2 | ||
|
|
c769d915a6 | ||
|
|
4e4dff55a1 | ||
|
|
dd66da14f4 | ||
|
|
c56610b179 | ||
|
|
24e57ee94c | ||
|
|
d68209879b | ||
|
|
7377aa62ed | ||
|
|
11fca1ed8b | ||
|
|
254d321e8b | ||
|
|
52a75ca9b7 | ||
|
|
23678df9de | ||
|
|
95d5246d4b | ||
|
|
342994f77b | ||
|
|
71ee529d35 | ||
|
|
20a2812d65 | ||
|
|
26952fcdc7 | ||
|
|
fa9fe71539 | ||
|
|
e6873f813a | ||
|
|
b251120e45 | ||
|
|
eb755757ec | ||
|
|
ddbfa0afa9 | ||
|
|
b45fe5ba39 | ||
|
|
59f0a051d8 | ||
|
|
8ad10e6b2c | ||
|
|
c741ef4be5 | ||
|
|
f43a1db136 | ||
|
|
ab0f1b9cd5 | ||
|
|
ca3e028603 | ||
|
|
212c13bf16 | ||
|
|
4b06330753 | ||
|
|
6a60882005 | ||
|
|
9e5e580134 | ||
|
|
68870be5e5 | ||
|
|
7c07742690 |
@@ -99,3 +99,7 @@ VITE_BOARD_TIP_URL=
|
||||
VITE_BOARD_PATCH_URL=
|
||||
VITE_OFFICIAL_CHAT_URL=
|
||||
VITE_CASUAL_CHAT_URL=
|
||||
|
||||
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
|
||||
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
|
||||
TURN_BUILD_COMMIT_SHA=
|
||||
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
@@ -1,39 +1,29 @@
|
||||
# core2026 작업 지침
|
||||
|
||||
## 적용 범위와 목표
|
||||
## 적용 범위와 문서 역할
|
||||
|
||||
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운
|
||||
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
|
||||
지침 순으로 적용합니다.
|
||||
이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
|
||||
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,
|
||||
공통 package, Prisma와 검증 도구가 실제로 존재합니다.
|
||||
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`입니다.
|
||||
`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 또는 삭제를 하지 말아 주세요.
|
||||
PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
|
||||
매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
|
||||
각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
|
||||
|
||||
## 완료 상태를 해석하는 법
|
||||
|
||||
@@ -41,7 +31,9 @@ commit 또는 삭제를 하지 말아 주세요.
|
||||
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
|
||||
판정하지 말아 주세요.
|
||||
|
||||
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요.
|
||||
- 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
|
||||
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
|
||||
확정하지 않습니다.
|
||||
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
|
||||
운영 장애 복구를 자동으로 증명하지 않습니다.
|
||||
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
|
||||
@@ -52,71 +44,19 @@ commit 또는 삭제를 하지 말아 주세요.
|
||||
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
|
||||
코드와 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,
|
||||
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
|
||||
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`의
|
||||
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
|
||||
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언해
|
||||
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md`에
|
||||
있습니다.
|
||||
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
|
||||
상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
|
||||
|
||||
## 레거시 매핑과 비교
|
||||
|
||||
기능을 변경하기 전에 다음을 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에
|
||||
남겨 주세요.
|
||||
차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
|
||||
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
|
||||
되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
|
||||
따릅니다.
|
||||
|
||||
## 인증과 권한
|
||||
|
||||
@@ -163,18 +103,18 @@ API request
|
||||
|
||||
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
|
||||
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
|
||||
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가
|
||||
추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
|
||||
소비하는 RNG call을 생략하지 말아 주세요.
|
||||
|
||||
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
|
||||
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기
|
||||
side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
|
||||
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
|
||||
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
|
||||
|
||||
## 프론트엔드와 룩앤필
|
||||
|
||||
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는
|
||||
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
||||
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
|
||||
UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
|
||||
따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
|
||||
|
||||
- gateway `/gateway/`, game `/che/`와 `/hwe/` prefix에서 direct navigation,
|
||||
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
|
||||
@@ -191,20 +131,10 @@ layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
||||
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
||||
일괄 disable하지 말아 주세요.
|
||||
|
||||
UI를 변경하면 실제 Chromium을 사용해 주세요.
|
||||
|
||||
- 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에 넣지 말아 주세요.
|
||||
|
||||
Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
|
||||
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
|
||||
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
|
||||
`docs/frontend-legacy-parity.md`를 따라 주세요.
|
||||
`docs/frontend-legacy-parity.md`를 따릅니다.
|
||||
|
||||
## DB와 레거시 데이터 이관
|
||||
|
||||
@@ -244,89 +174,29 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
|
||||
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
|
||||
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
|
||||
|
||||
현재 외부 호스트 계약은 `0.0.0.0` bind와 `/gateway/`, `/che/`, `/hwe/`
|
||||
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지
|
||||
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요.
|
||||
|
||||
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다.
|
||||
|
||||
```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`에서 확인하고 현재 인프라와 대조해 주세요.
|
||||
컨테이너 서비스는 필요한 listener를 `0.0.0.0`에 bind합니다. 로컬 E2E의
|
||||
`/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
|
||||
대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
|
||||
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
|
||||
HTTPS 검증과 구분합니다.
|
||||
|
||||
## 개발과 검증 절차
|
||||
|
||||
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요.
|
||||
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요.
|
||||
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다.
|
||||
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요.
|
||||
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
|
||||
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
|
||||
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
|
||||
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
|
||||
상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
|
||||
검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
|
||||
명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
|
||||
[테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
|
||||
|
||||
기본 정적 검사는 실제 루트 script를 사용해 주세요.
|
||||
|
||||
```sh
|
||||
CI=1 pnpm typecheck
|
||||
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 뒤에 불필요한 `--`를 넣지 말아 주세요.
|
||||
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
|
||||
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
|
||||
`packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
|
||||
- Vitest filter에 불필요한 `--`를 넣지 않습니다.
|
||||
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
|
||||
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요.
|
||||
해당 Playwright script 또는 legacy parity suite를 사용해 주세요.
|
||||
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use`
|
||||
(nvm 환경은 `nvm use`) 후
|
||||
`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나 제안 문서만 보고
|
||||
실행하지 말아 주세요.
|
||||
- frontend package의 `test` placeholder는 UI 검증이 아닙니다. 해당 Playwright
|
||||
script 또는 legacy parity suite를 실행합니다.
|
||||
- ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
|
||||
오래된 report만 보고 명령을 가정하지 않습니다.
|
||||
- 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
|
||||
|
||||
## 코드 스타일
|
||||
|
||||
@@ -350,42 +220,22 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
|
||||
|
||||
## 문서와 보고서
|
||||
|
||||
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요.
|
||||
변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
|
||||
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
|
||||
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
|
||||
|
||||
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계
|
||||
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약
|
||||
- `docs/*`: core2026 내부 구현·운영 상세
|
||||
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
|
||||
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
|
||||
|
||||
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
|
||||
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
|
||||
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
|
||||
보고서의 필수 내용과 저장소별 commit 기록은
|
||||
[상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
|
||||
|
||||
## Git, worktree와 commit
|
||||
|
||||
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다.
|
||||
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에
|
||||
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요.
|
||||
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요.
|
||||
- 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하지 말아 주세요.
|
||||
[상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
|
||||
[기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
|
||||
충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
|
||||
않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
|
||||
|
||||
## Qwen 보조 분석
|
||||
|
||||
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만
|
||||
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을
|
||||
보내지 말아 주세요.
|
||||
|
||||
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
|
||||
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
|
||||
- 응답 budget은 128–4096 token 범위로 제한해 주세요.
|
||||
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
|
||||
로컬 분석을 계속해 주세요.
|
||||
Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
|
||||
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
|
||||
worktree 정리가 요청 범위라면 clean status와
|
||||
`git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
|
||||
이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 삼국지 모의전투 HiDCHe core2026
|
||||
|
||||
`core2026`은 `../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm
|
||||
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작은 ref 구현과 실제 실행
|
||||
결과를 기준으로 검증합니다.
|
||||
`core2026`은 PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
|
||||
발전하는 TypeScript pnpm 모노레포입니다. 계승 영역은 Ref의 동작·상태·권한과
|
||||
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
||||
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
||||
|
||||
## 저장소 구성
|
||||
|
||||
@@ -79,7 +80,7 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는
|
||||
|
||||
## 도구 체인
|
||||
|
||||
- pnpm `11.21.0`, Turbo
|
||||
- Node.js `24.21.0`, pnpm `12.5.1`, Turbo
|
||||
- TypeScript `6.0.3` compiler API와 TypeScript `7.0.2` native `tsc`
|
||||
- Fastify, tRPC, zod
|
||||
- Vue 3, Pinia, Vue Router, Vite
|
||||
@@ -175,6 +176,17 @@ pnpm build
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
`pnpm lint`는 ESLint의 일반 TypeScript/Vue 검사, Oxlint의 type-aware TypeScript
|
||||
Promise 검사, ESLint의 type-aware Vue Promise 검사를 메모리 경계에 맞게 나누어
|
||||
실행합니다. cache를 제외한 전체 검증은 `pnpm lint:uncached`, package 하나만
|
||||
검사할 때는 기존처럼 `pnpm --filter <package-name> lint`를 사용합니다.
|
||||
|
||||
루트 `test`와 `build`는 Codex와 일반 개발 검증에서 성공 로그를 줄이는 간단 출력이
|
||||
기본입니다. 테스트별 진행과 전체 Turbo task 로그가 필요한 실패 진단에는
|
||||
`pnpm test-verbose`, `pnpm build-verbose`를 사용합니다. Gateway 서버 빌드 워커는
|
||||
관리자 operation 화면에 진행 상황을 제공하기 위해 별도의 streaming 상세 출력
|
||||
정책을 계속 사용합니다.
|
||||
|
||||
PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다.
|
||||
|
||||
```sh
|
||||
@@ -241,9 +253,11 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
|
||||
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
|
||||
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
|
||||
|
||||
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한
|
||||
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE,
|
||||
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다.
|
||||
활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
|
||||
`/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
|
||||
[환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
|
||||
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
|
||||
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
|
||||
|
||||
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
|
||||
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||
"worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js",
|
||||
"worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "pnpm -w lint:target app/game-api",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-api",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"tsdown": "^0.22.14",
|
||||
"vitest": "^4.1.10"
|
||||
"tsdown": "^0.23.0",
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
|
||||
generalId: number,
|
||||
input: OpenAuctionInput,
|
||||
requestId?: string
|
||||
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionOpen',
|
||||
...(requestId ? { requestId } : {}),
|
||||
@@ -46,10 +46,11 @@ export const openAuctionWithDaemon = async (
|
||||
const closeAt = new Date(result.closeAt);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
|
||||
]);
|
||||
return {
|
||||
auctionId: result.auctionId,
|
||||
closeAt: result.closeAt,
|
||||
closeTick: result.closeTick,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,18 +10,19 @@ interface RedisSortedSetClient {
|
||||
}
|
||||
|
||||
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
||||
if (closeTick !== null && closeTick !== undefined) {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
}
|
||||
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
||||
void time;
|
||||
void closeAt;
|
||||
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
||||
if (row.status === 'FINALIZING') {
|
||||
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
||||
return time.tick ?? time.now.getTime();
|
||||
if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
|
||||
return time.tick;
|
||||
}
|
||||
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
type GamePrismaClient,
|
||||
@@ -10,6 +13,7 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
|
||||
|
||||
@@ -24,13 +28,27 @@ interface RedisTimerClient {
|
||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||
zRem(key: string, values: string | string[]): Promise<number>;
|
||||
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
|
||||
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
const POP_DUE_AUCTIONS_SCRIPT = `
|
||||
if redis.call('GET', KEYS[2]) ~= ARGV[1]
|
||||
or redis.call('GET', KEYS[3]) ~= ARGV[2]
|
||||
or redis.call('GET', KEYS[4]) ~= 'RUNNING' then
|
||||
return { '__CLOCK_FENCE__' }
|
||||
end
|
||||
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4])
|
||||
if #ids > 0 then
|
||||
redis.call('ZREM', KEYS[1], unpack(ids))
|
||||
end
|
||||
return ids
|
||||
`;
|
||||
|
||||
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
||||
|
||||
interface AuctionFinalizeDeadline {
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
closeTick: bigint;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeCommand {
|
||||
@@ -38,19 +56,10 @@ interface AuctionFinalizeCommand {
|
||||
requestId: string;
|
||||
auctionId: number;
|
||||
expectedCloseAt: string;
|
||||
expectedCloseTick?: number;
|
||||
expectedCloseTick: number;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeEventRecord {
|
||||
target: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
status: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
|
||||
if (closeTick === null) return undefined;
|
||||
const readSafeCloseTick = (closeTick: bigint): number => {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
@@ -63,14 +72,7 @@ export const buildAuctionFinalizeRequestId = (
|
||||
deadline: AuctionFinalizeDeadline,
|
||||
retry = 0
|
||||
): string => {
|
||||
const generation =
|
||||
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
|
||||
const base = `auction:finalize:${auctionId}:${generation}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
|
||||
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
|
||||
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
@@ -83,7 +85,7 @@ const buildAuctionFinalizeCommand = (
|
||||
requestId,
|
||||
auctionId,
|
||||
expectedCloseAt: deadline.closeAt.toISOString(),
|
||||
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
|
||||
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
|
||||
});
|
||||
|
||||
const isMatchingAuctionFinalizeEvent = (
|
||||
@@ -95,10 +97,7 @@ const isMatchingAuctionFinalizeEvent = (
|
||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const expectedGenerationMatches =
|
||||
payloadRecord?.expectedCloseTick !== undefined
|
||||
? payloadRecord.expectedCloseTick === command.expectedCloseTick
|
||||
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
|
||||
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
|
||||
return (
|
||||
event.target === 'ENGINE' &&
|
||||
event.eventType === command.type &&
|
||||
@@ -115,12 +114,29 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
|
||||
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
|
||||
};
|
||||
|
||||
const popDueAuctionIds = async (
|
||||
export const popDueAuctionIds = async (
|
||||
redis: RedisTimerClient,
|
||||
timerKey: string,
|
||||
nowMs: number,
|
||||
batchSize: number
|
||||
batchSize: number,
|
||||
clockFence?: {
|
||||
activeRevisionKey: string;
|
||||
deadlineGenerationKey: string;
|
||||
phaseKey: string;
|
||||
revision: number;
|
||||
generation: number;
|
||||
}
|
||||
): Promise<string[]> => {
|
||||
if (clockFence) {
|
||||
if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.');
|
||||
const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, {
|
||||
keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey],
|
||||
arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)],
|
||||
});
|
||||
if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.');
|
||||
if (result[0] === '__CLOCK_FENCE__') return [];
|
||||
return result.map(String);
|
||||
}
|
||||
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
|
||||
if (ids.length > 0) {
|
||||
await redis.zRem(timerKey, ids);
|
||||
@@ -157,13 +173,12 @@ export const reconcilePendingAuctionTimers = async (options: {
|
||||
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
||||
continue;
|
||||
}
|
||||
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
|
||||
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
||||
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
||||
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
|
||||
const bases = [...new Set([canonicalBase, legacyBase])];
|
||||
const events = await options.db.inputEvent.findMany({
|
||||
where: {
|
||||
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
|
||||
OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
|
||||
},
|
||||
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
||||
orderBy: { sequence: 'desc' },
|
||||
@@ -182,7 +197,10 @@ export const reconcilePendingAuctionTimers = async (options: {
|
||||
timers.push({
|
||||
score:
|
||||
row.status === 'FINALIZING'
|
||||
? (options.gameTime.tick ?? options.gameTime.now.getTime())
|
||||
? (() => {
|
||||
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
|
||||
return options.gameTime.tick;
|
||||
})()
|
||||
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
||||
value: String(row.id),
|
||||
});
|
||||
@@ -205,6 +223,8 @@ export const processDueAuctionId = async (options: {
|
||||
nowMs: number;
|
||||
nowTick?: number | null;
|
||||
historyNowMs?: number;
|
||||
expectedClockRevision?: number;
|
||||
expectedDeadlineGeneration?: number;
|
||||
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
|
||||
const auctionId = Number(id);
|
||||
@@ -213,6 +233,29 @@ export const processDueAuctionId = async (options: {
|
||||
}
|
||||
const now = new Date(nowMs);
|
||||
const outcome = await db.$transaction(async (transaction) => {
|
||||
if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const [world] = await transaction.$queryRaw<
|
||||
Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }>
|
||||
>(GamePrisma.sql`
|
||||
SELECT
|
||||
clock_phase AS "clockPhase",
|
||||
clock_revision AS "clockRevision",
|
||||
deadline_generation AS "deadlineGeneration"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (
|
||||
!world ||
|
||||
world.clockPhase !== 'RUNNING' ||
|
||||
world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) ||
|
||||
world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1)
|
||||
) {
|
||||
return { status: 'RESCHEDULED' as const, clockFenceFailed: true };
|
||||
}
|
||||
}
|
||||
const current = await transaction.auction.findUnique({
|
||||
where: { id: auctionId },
|
||||
select: { status: true, closeAt: true, closeTick: true },
|
||||
@@ -221,10 +264,10 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
if (current.status === 'OPEN') {
|
||||
const isDue =
|
||||
current.closeTick !== null && nowTick !== null
|
||||
? current.closeTick <= BigInt(nowTick)
|
||||
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
|
||||
if (current.closeTick === null || nowTick === null) {
|
||||
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
|
||||
}
|
||||
const isDue = current.closeTick <= BigInt(nowTick);
|
||||
if (!isDue) {
|
||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
}
|
||||
@@ -233,24 +276,15 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
|
||||
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
|
||||
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
||||
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
||||
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
||||
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
|
||||
let existing: AuctionFinalizeEventRecord | null = null;
|
||||
let existingRequestId = requestId;
|
||||
for (const candidateRequestId of candidateRequestIds) {
|
||||
existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId: candidateRequestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
if (existing) {
|
||||
existingRequestId = candidateRequestId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
|
||||
const existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
if (!existing) {
|
||||
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
await transaction.inputEvent.create({
|
||||
@@ -264,14 +298,14 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
||||
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
|
||||
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
}
|
||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
||||
throw new Error(
|
||||
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
|
||||
`Auction remained ${current.status} after successful durable event: ${requestId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +318,10 @@ export const processDueAuctionId = async (options: {
|
||||
return 'PENDING';
|
||||
}
|
||||
if (outcome.status === 'RESCHEDULED') {
|
||||
if ('clockFenceFailed' in outcome) {
|
||||
await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]);
|
||||
return 'RESCHEDULED';
|
||||
}
|
||||
const gameTime = await loadCurrentGameTime(db, now);
|
||||
await redis.zAdd(timerKey, [
|
||||
{
|
||||
@@ -315,18 +353,30 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
|
||||
]);
|
||||
|
||||
let nextResyncAt = Date.now();
|
||||
let nextResyncAt = performance.now();
|
||||
const pendingFinalizationIds = new Set<number>();
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
const operationalNowMs = Date.now();
|
||||
const operationalElapsedMs = performance.now();
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||
const gameNowMs = gameTime.now.getTime();
|
||||
const dueScore = gameTime.tick ?? gameNowMs;
|
||||
if (operationalNowMs >= nextResyncAt) {
|
||||
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
|
||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
||||
continue;
|
||||
}
|
||||
const clockFence = gameTime.phase
|
||||
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
|
||||
: null;
|
||||
if (gameTime.phase && !clockFence) {
|
||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
||||
continue;
|
||||
}
|
||||
if (operationalElapsedMs >= nextResyncAt) {
|
||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
||||
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
|
||||
}
|
||||
if (pendingFinalizationIds.size > 0) {
|
||||
const reconciliation = await reconcilePendingAuctionTimers({
|
||||
@@ -345,7 +395,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
if (historyTrimBefore > 0) {
|
||||
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
||||
}
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined);
|
||||
if (dueIds.length > 0) {
|
||||
for (const id of dueIds) {
|
||||
try {
|
||||
@@ -358,6 +408,12 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
nowMs: gameNowMs,
|
||||
nowTick: gameTime.tick,
|
||||
historyNowMs: operationalNowMs,
|
||||
...(clockFence
|
||||
? {
|
||||
expectedClockRevision: clockFence.revision,
|
||||
expectedDeadlineGeneration: clockFence.generation,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (outcome === 'PENDING') {
|
||||
pendingFinalizationIds.add(Number(id));
|
||||
@@ -395,3 +451,4 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
await closeResources();
|
||||
}
|
||||
};
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
@@ -124,27 +124,25 @@ export const loadBattleSimTraitOptions = async (): Promise<{
|
||||
return cachedTraitOptions;
|
||||
};
|
||||
|
||||
let cachedItemOptions: Promise<BattleSimItemOptions> | null = null;
|
||||
let cachedItemModules: Promise<ItemModule[]> | null = null;
|
||||
|
||||
const toItemOption = (module: ItemModule): BattleSimItemOption => ({
|
||||
key: module.key,
|
||||
name: module.name,
|
||||
});
|
||||
|
||||
export const loadBattleSimItemOptions = async (): Promise<BattleSimItemOptions> => {
|
||||
if (!cachedItemOptions) {
|
||||
cachedItemOptions = loadItemModules([...ITEM_KEYS]).then((modules) => {
|
||||
const items: BattleSimItemOptions = {
|
||||
horse: [],
|
||||
weapon: [],
|
||||
book: [],
|
||||
item: [],
|
||||
};
|
||||
for (const module of modules) {
|
||||
items[module.slot].push(toItemOption(module));
|
||||
}
|
||||
return items;
|
||||
});
|
||||
export const loadBattleSimItemOptions = async (
|
||||
allowedKeys: ReadonlySet<string>,
|
||||
itemOrder: readonly string[] | undefined
|
||||
): Promise<BattleSimItemOptions> => {
|
||||
cachedItemModules ??= loadItemModules([...ITEM_KEYS]);
|
||||
const modules = await cachedItemModules;
|
||||
const catalog = new Map(modules.map((module) => [module.key, module]));
|
||||
const items: BattleSimItemOptions = { horse: [], weapon: [], book: [], item: [] };
|
||||
for (const key of new Set([...(itemOrder ?? []), ...ITEM_KEYS])) {
|
||||
if (!allowedKeys.has(key)) continue;
|
||||
const module = catalog.get(key);
|
||||
if (module) items[module.slot].push(toItemOption(module));
|
||||
}
|
||||
return cachedItemOptions;
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { ApiInputExecutionContext } from './inputEventBoundary.js';
|
||||
import type { ChangeJournal } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
@@ -65,7 +66,18 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
||||
|
||||
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
||||
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
|
||||
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
|
||||
type WorldClockFields =
|
||||
| 'clockBaseTime'
|
||||
| 'clockTick'
|
||||
| 'clockMode'
|
||||
| 'clockWallAnchor'
|
||||
| 'clockRecoveryStartTick'
|
||||
| 'clockRecoveryEndTick'
|
||||
| 'clockRecoveryStartWallAt'
|
||||
| 'lastTurnTick'
|
||||
| 'clockPhase'
|
||||
| 'clockRevision'
|
||||
| 'deadlineGeneration';
|
||||
type GeneralClockFields = 'turnTick' | 'recentWarTick';
|
||||
|
||||
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
|
||||
@@ -88,6 +100,8 @@ export type InputJsonValue = GamePrisma.InputJsonValue;
|
||||
export type DatabaseClient = InfraDatabaseClient;
|
||||
|
||||
export interface GameApiContext {
|
||||
/** 현재 API 업무 transaction이 잠근 입력 원장의 인증된 식별자다. */
|
||||
auditInput?: ApiInputExecutionContext;
|
||||
requestId?: string;
|
||||
generalAccessTracking?: boolean;
|
||||
/** Validated server-issued proof for one realtime refresh burst. */
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
readInputEventClockCoordinate,
|
||||
type DatabaseClient,
|
||||
type GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import type { TurnDaemonTransport } from './transport.js';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
@@ -82,9 +87,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||
if (durableCommand.type === 'npcPossessGeneral') {
|
||||
delete durableCommand.acceptedGameAt;
|
||||
}
|
||||
// Rolling-upgrade compatibility: older API versions supplied game
|
||||
// coordinates. They are deliberately not persisted as command facts;
|
||||
// the daemon assigns the authoritative processing coordinate while
|
||||
// claiming the input event under the clock fence.
|
||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
|
||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
|
||||
if (command.type === 'npcPossessGeneral') {
|
||||
const existing = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
@@ -103,15 +111,14 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
try {
|
||||
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
|
||||
const rejectionReason = await this.db.$transaction(async (transaction) => {
|
||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
||||
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
||||
const token = await transaction.npcSelectionToken.findFirst({
|
||||
where: {
|
||||
ownerUserId: command.userId,
|
||||
nonce: command.tokenNonce,
|
||||
validUntil: { gte: acceptedGameAt },
|
||||
validUntilTick: { gte: coordinate.gameTick },
|
||||
},
|
||||
select: { pickResult: true },
|
||||
});
|
||||
@@ -126,18 +133,20 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
) {
|
||||
return '선택한 장수가 목록에 없습니다.';
|
||||
}
|
||||
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
|
||||
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||
acceptedGameAt: acceptedGameAt.toISOString(),
|
||||
};
|
||||
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
|
||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||
return null;
|
||||
});
|
||||
if (rejectionReason) {
|
||||
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
|
||||
}
|
||||
} else {
|
||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||
if (this.db.$transaction) {
|
||||
await this.db.$transaction(async (transaction) => {
|
||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
||||
});
|
||||
} else {
|
||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RejectedNpcPossessionCommandError) {
|
||||
@@ -165,8 +174,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
private async createInputEvent(
|
||||
db: DatabaseClient,
|
||||
command: TurnDaemonCommand,
|
||||
requestId: string,
|
||||
createdAt?: Date
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
@@ -175,7 +183,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
eventType: command.type,
|
||||
payload: asJson(command),
|
||||
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
// PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
|
||||
// authoritative game coordinate when the daemon claims it.
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -192,8 +201,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
}
|
||||
|
||||
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
||||
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||
while (performance.now() < deadline) {
|
||||
const event = await this.db.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
select: { status: true, result: true, error: true },
|
||||
@@ -204,7 +213,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
||||
if (event?.status === 'FAILED') {
|
||||
throw new FailedTurnDaemonCommandError(requestId, event.error);
|
||||
}
|
||||
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
|
||||
await delay(Math.min(50, Math.max(1, deadline - performance.now())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
|
||||
import {
|
||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
type DatabaseClient as InfraDatabaseClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from './context.js';
|
||||
|
||||
@@ -13,6 +18,7 @@ export interface ApiInputPayloadIdentity {
|
||||
}
|
||||
|
||||
interface LockedInputEvent {
|
||||
sequence: bigint;
|
||||
target: 'API' | 'ENGINE';
|
||||
eventType: string;
|
||||
payload: GamePrisma.JsonValue;
|
||||
@@ -22,6 +28,14 @@ interface LockedInputEvent {
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface ApiInputExecutionContext {
|
||||
requestId: string;
|
||||
sequence: bigint;
|
||||
actorUserId: string | null;
|
||||
eventType: string;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
type InputEventOutcome<T> =
|
||||
{ kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown };
|
||||
|
||||
@@ -29,8 +43,6 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
|
||||
$executeRawUnsafe(query: string): Promise<number>;
|
||||
};
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
const canonicalJson = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, entry: unknown) => {
|
||||
if (typeof entry === 'bigint') {
|
||||
@@ -85,6 +97,9 @@ const insertPendingIfAbsent = async (
|
||||
actor_user_id,
|
||||
status,
|
||||
attempts,
|
||||
accepted_game_tick,
|
||||
accepted_clock_revision,
|
||||
accepted_deadline_generation,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@@ -95,6 +110,9 @@ const insertPendingIfAbsent = async (
|
||||
${options.actorUserId},
|
||||
'PENDING'::"InputEventStatus",
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
ON CONFLICT (request_id) DO NOTHING
|
||||
@@ -106,6 +124,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<Lo
|
||||
const rows = await db.$queryRaw<LockedInputEvent[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT
|
||||
sequence,
|
||||
target,
|
||||
event_type AS "eventType",
|
||||
payload,
|
||||
@@ -153,20 +172,22 @@ const claimInputEvent = async (
|
||||
requestId: string,
|
||||
payloadIdentity: ApiInputPayloadIdentity
|
||||
): Promise<void> => {
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
payload: asJson(payloadIdentity),
|
||||
status: 'PROCESSING',
|
||||
result: GamePrisma.DbNull,
|
||||
error: null,
|
||||
attempts: { increment: 1 },
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: new Date(),
|
||||
completedAt: null,
|
||||
},
|
||||
});
|
||||
await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
|
||||
status = 'PROCESSING'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = NULL,
|
||||
attempts = attempts + 1,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
processing_game_tick = NULL,
|
||||
processing_clock_revision = NULL,
|
||||
processing_deadline_generation = NULL,
|
||||
completed_at = NULL
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
};
|
||||
|
||||
const markUnexpectedFailure = async (
|
||||
@@ -177,6 +198,7 @@ const markUnexpectedFailure = async (
|
||||
actorUserId: string | null;
|
||||
payloadIdentity: ApiInputPayloadIdentity;
|
||||
error: unknown;
|
||||
acquireClockFence: boolean;
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (!db.$transaction) return;
|
||||
@@ -184,6 +206,9 @@ const markUnexpectedFailure = async (
|
||||
|
||||
try {
|
||||
await db.$transaction(async (transaction) => {
|
||||
if (options.acquireClockFence) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
}
|
||||
await insertPendingIfAbsent(transaction, options);
|
||||
const row = await lockInputEvent(transaction, options.requestId);
|
||||
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
||||
@@ -192,20 +217,19 @@ const markUnexpectedFailure = async (
|
||||
// late failure recorder must never replace its durable success.
|
||||
return;
|
||||
}
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId: options.requestId },
|
||||
data: {
|
||||
payload: asJson(options.payloadIdentity),
|
||||
status: 'FAILED',
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
attempts: { increment: 1 },
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
processingAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
|
||||
status = 'FAILED'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = ${message},
|
||||
attempts = attempts + 1,
|
||||
locked_by = NULL,
|
||||
lease_until = NULL,
|
||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${options.requestId}
|
||||
`);
|
||||
});
|
||||
} catch {
|
||||
// Preserve the transaction failure that the caller actually observed. If
|
||||
@@ -220,10 +244,12 @@ export const executeInputEvent = async <T>(options: {
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
actorUserId?: string | null;
|
||||
execute(db: DatabaseClient): Promise<T>;
|
||||
acquireClockFence?: boolean;
|
||||
execute(db: DatabaseClient, context?: ApiInputExecutionContext): Promise<T>;
|
||||
}): Promise<T> => {
|
||||
const { db, requestId, eventType, payload, execute } = options;
|
||||
const actorUserId = options.actorUserId ?? null;
|
||||
const acquireClockFence = options.acquireClockFence !== false;
|
||||
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
||||
if (!db.$transaction) {
|
||||
return execute(db);
|
||||
@@ -233,6 +259,9 @@ export const executeInputEvent = async <T>(options: {
|
||||
let outcome: InputEventOutcome<T>;
|
||||
try {
|
||||
outcome = await db.$transaction(async (transaction) => {
|
||||
if (acquireClockFence) {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
}
|
||||
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
||||
const row = await lockInputEvent(transaction, requestId);
|
||||
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
||||
@@ -256,38 +285,49 @@ export const executeInputEvent = async <T>(options: {
|
||||
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
businessStarted = true;
|
||||
try {
|
||||
const value = await execute(transaction);
|
||||
const durableResult = canonicalJsonValue(value);
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(durableResult),
|
||||
error: null,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
const value = await execute(transaction, {
|
||||
requestId,
|
||||
sequence: row.sequence,
|
||||
actorUserId,
|
||||
eventType,
|
||||
attempt: row.attempts + 1,
|
||||
});
|
||||
const durableResult = canonicalJsonValue(value);
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
||||
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
|
||||
error = NULL,
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
return { kind: 'executed', value };
|
||||
} catch (error) {
|
||||
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
result: GamePrisma.DbNull,
|
||||
error: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await transaction.$executeRaw(GamePrisma.sql`
|
||||
UPDATE input_event
|
||||
SET status = 'FAILED'::"InputEventStatus",
|
||||
result = NULL,
|
||||
error = ${message},
|
||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE request_id = ${requestId}
|
||||
`);
|
||||
return { kind: 'failed', error };
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
||||
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
|
||||
await markUnexpectedFailure(db, {
|
||||
requestId,
|
||||
eventType,
|
||||
actorUserId,
|
||||
payloadIdentity,
|
||||
error,
|
||||
acquireClockFence,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GameApiContext, WorldStateRow } from '../context.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
||||
|
||||
export type MapCityCompact = [number, number, number, number, number, number];
|
||||
@@ -16,6 +17,7 @@ export type BaseMapResult = {
|
||||
initialLevel: number;
|
||||
increaseYears: number;
|
||||
};
|
||||
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
|
||||
cityList: MapCityCompact[];
|
||||
nationList: MapNationCompact[];
|
||||
};
|
||||
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
|
||||
const BASE_MAP_TTL_SECONDS = 30;
|
||||
const PUBLIC_MAP_TTL_SECONDS = 600;
|
||||
|
||||
const resolveStartYear = (worldState: WorldStateRow): number => {
|
||||
const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||
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> => {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
@@ -154,7 +178,10 @@ const loadBaseMap = async (
|
||||
}
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as BaseMapResult;
|
||||
const parsed = JSON.parse(cached) as BaseMapResult;
|
||||
if (parsed.uniqueItemLimit) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Ignore cache parse errors.
|
||||
}
|
||||
@@ -207,6 +234,7 @@ const loadBaseMap = async (
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
techLevelLimit: resolveTechLevelLimit(worldState),
|
||||
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
|
||||
cityList,
|
||||
nationList,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra';
|
||||
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
||||
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
@@ -59,7 +61,8 @@ const persistLogs = async (
|
||||
logs: LogEntryDraft[],
|
||||
year: number,
|
||||
month: number,
|
||||
at: Date
|
||||
at: Date,
|
||||
serverId: string | null
|
||||
): Promise<void> => {
|
||||
const data = logs.flatMap((entry) => {
|
||||
const record = finalizeLogEntry(entry, { year, month, at });
|
||||
@@ -68,6 +71,7 @@ const persistLogs = async (
|
||||
}
|
||||
return [
|
||||
{
|
||||
serverId,
|
||||
scope: record.scope,
|
||||
category: record.category,
|
||||
subType: record.subType ?? null,
|
||||
@@ -92,12 +96,26 @@ const persistEffects = async (
|
||||
effects: GeneralActionEffect[],
|
||||
year: number,
|
||||
month: number,
|
||||
at: Date
|
||||
at: Date,
|
||||
serverId: string | null,
|
||||
audit?: {
|
||||
before: GamePrisma.DiplomacyGetPayload<Record<string, never>>[];
|
||||
base: Omit<AuditDiplomacyEventDraft, 'srcNationId' | 'destNationId' | 'ordinal' | 'before' | 'after'>;
|
||||
}
|
||||
): Promise<void> => {
|
||||
const changes: AuditDiplomacyEventDraft[] = [];
|
||||
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
||||
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
||||
state: row.stateCode,
|
||||
term: row.term,
|
||||
isDead: row.isDead,
|
||||
isShowing: row.isShowing,
|
||||
dead: typeof asRecord(row.meta).dead === 'number' ? asRecord(row.meta).dead : 0,
|
||||
});
|
||||
const logs: LogEntryDraft[] = [];
|
||||
for (const effect of effects) {
|
||||
if (effect.type === 'diplomacy:patch') {
|
||||
await db.diplomacy.update({
|
||||
const updated = await db.diplomacy.update({
|
||||
where: {
|
||||
srcNationId_destNationId: {
|
||||
srcNationId: effect.srcNationId,
|
||||
@@ -110,6 +128,24 @@ const persistEffects = async (
|
||||
...(effect.patch.meta !== undefined ? { meta: effect.patch.meta as InputJsonValue } : {}),
|
||||
},
|
||||
});
|
||||
if (audit) {
|
||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||
const previous = states.get(key);
|
||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||
const before = project(previous);
|
||||
const after = project(updated);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
changes.push({
|
||||
...audit.base,
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
ordinal: changes.length + 1,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
states.set(key, updated);
|
||||
}
|
||||
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
||||
const patch = effect.patch;
|
||||
await db.nation.update({
|
||||
@@ -122,7 +158,8 @@ const persistEffects = async (
|
||||
logs.push(effect.entry);
|
||||
}
|
||||
}
|
||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at);
|
||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
||||
await persistAuditDiplomacyEvents(db, changes);
|
||||
};
|
||||
|
||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||
@@ -179,6 +216,7 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
actor: GeneralRow;
|
||||
messageId: number;
|
||||
response: boolean;
|
||||
auditInput?: ApiInputExecutionContext;
|
||||
}): Promise<DiplomaticMessageResponseResult> => {
|
||||
const { db, actor, messageId, response } = options;
|
||||
const message = await fetchMessageByIdForUpdate(db, messageId);
|
||||
@@ -187,12 +225,15 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
}
|
||||
|
||||
const world = await db.worldState.findFirst({
|
||||
select: { currentYear: true, currentMonth: true, config: true },
|
||||
select: { currentYear: true, currentMonth: true, config: true, meta: true },
|
||||
});
|
||||
if (!world) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' });
|
||||
}
|
||||
const now = (await loadCurrentGameTime(db)).now;
|
||||
const serverIdValue = asRecord(world.meta).serverId;
|
||||
const serverId = typeof serverIdValue === 'string' && serverIdValue.trim() ? serverIdValue : null;
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const now = gameTime.now;
|
||||
const action = parseAction(message.payload.option?.action);
|
||||
if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' });
|
||||
@@ -204,7 +245,8 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
buildFailureLog(actor.id, reason, actionName, response),
|
||||
world.currentYear,
|
||||
world.currentMonth,
|
||||
now
|
||||
now,
|
||||
serverId
|
||||
);
|
||||
return {
|
||||
result: false,
|
||||
@@ -302,7 +344,8 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
[...actorLogger.flush(), ...proposerLogger.flush()],
|
||||
world.currentYear,
|
||||
world.currentMonth,
|
||||
now
|
||||
now,
|
||||
serverId
|
||||
);
|
||||
await invalidateMessages(db, [message.id]);
|
||||
return {
|
||||
@@ -414,7 +457,49 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}),
|
||||
}
|
||||
);
|
||||
await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now);
|
||||
const auditInput = options.auditInput;
|
||||
if (auditInput && auditInput.actorUserId !== actor.userId) throw new Error('Diplomacy audit actor mismatch');
|
||||
await persistEffects(
|
||||
db,
|
||||
resolution.effects,
|
||||
world.currentYear,
|
||||
world.currentMonth,
|
||||
now,
|
||||
serverId,
|
||||
auditInput && serverId
|
||||
? {
|
||||
before: [actorDiplomacy, reverseDiplomacy],
|
||||
base: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
category: 'RELATION',
|
||||
source: 'API',
|
||||
eventType: `MESSAGE_ACCEPTED_${action}`,
|
||||
documentId: null,
|
||||
documentHash: null,
|
||||
previousDocumentId: null,
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
tick: gameTime.tick === null ? null : BigInt(gameTime.tick),
|
||||
clockRevision: gameTime.revision == null ? null : BigInt(gameTime.revision),
|
||||
executionId: `api:${auditInput.requestId}`,
|
||||
requestId: auditInput.requestId,
|
||||
inputSequence: auditInput.sequence,
|
||||
actor: {
|
||||
userId: actor.userId,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission: resolveNationPermission(actor, actorNation.meta, false),
|
||||
messageId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
let affectedCityIds: number[] = [];
|
||||
if (resolution.refreshFront) {
|
||||
const worldConfig = asRecord(world.config);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
enqueuePrivateMessageWebPush,
|
||||
GamePrisma,
|
||||
persistMessageEnvelope,
|
||||
type MessageGameContext,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -22,7 +26,9 @@ interface MessageRow {
|
||||
src: number;
|
||||
dest: number;
|
||||
time: Date;
|
||||
valid_until: Date;
|
||||
created_at_wall: Date;
|
||||
action_status: string | null;
|
||||
expires_game_tick: bigint | null;
|
||||
message: unknown;
|
||||
}
|
||||
|
||||
@@ -48,70 +54,73 @@ const formatMessageTime = (value: Date): string => {
|
||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
};
|
||||
|
||||
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
|
||||
if (gameTime.tick === null) {
|
||||
// A legacy or partially migrated profile has no authoritative logical
|
||||
// tick. Rows that already carry a tick still need the wall-time
|
||||
// fallback used by the clock migration.
|
||||
return GamePrisma.sql`valid_until > ${gameTime.now}`;
|
||||
}
|
||||
return GamePrisma.sql`(
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)`;
|
||||
};
|
||||
|
||||
const toMessageView = (row: MessageRow): MessageView => {
|
||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||
const payload = parsePayload(row.message);
|
||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
||||
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
|
||||
const 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 {
|
||||
id: row.id,
|
||||
msgType: row.type,
|
||||
src: payload.src,
|
||||
dest: row.type === 'public' ? null : payload.dest,
|
||||
text: payload.text,
|
||||
option: payload.option ?? null,
|
||||
time: formatMessageTime(new Date(row.time)),
|
||||
option:
|
||||
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> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const toTickOrNull = (date: Date): bigint | null => {
|
||||
// Ref represents its unlimited 9999-12-31 message lifetime with the
|
||||
// largest safe game tick instead of falling back to a wall-clock-only row.
|
||||
if (date.getUTCFullYear() >= 9000) {
|
||||
return BigInt(MAX_SAFE_GAME_TICK);
|
||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
||||
let gameContext: MessageGameContext | null = null;
|
||||
if (typeof action === 'string' && action !== '') {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (
|
||||
gameTime.tick === null ||
|
||||
gameTime.revision === null ||
|
||||
gameTime.revision === undefined ||
|
||||
gameTime.deadlineGeneration === null ||
|
||||
gameTime.deadlineGeneration === undefined
|
||||
) {
|
||||
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
|
||||
}
|
||||
try {
|
||||
const tick = gameTime.dateToTick(date);
|
||||
return tick === null ? null : BigInt(tick);
|
||||
} catch {
|
||||
return null;
|
||||
let expiresGameTick: bigint | null = null;
|
||||
if (draft.validUntil.getUTCFullYear() < 9000) {
|
||||
const expires = gameTime.dateToTick(draft.validUntil);
|
||||
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
|
||||
expiresGameTick = BigInt(expires);
|
||||
}
|
||||
};
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>`
|
||||
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
|
||||
VALUES (
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${toTickOrNull(draft.time)},
|
||||
${draft.validUntil},
|
||||
${toTickOrNull(draft.validUntil)},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error('Failed to insert message row.');
|
||||
gameContext = {
|
||||
occurredGameTick: BigInt(gameTime.tick),
|
||||
clockRevision: BigInt(gameTime.revision),
|
||||
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
|
||||
expiresGameTick,
|
||||
};
|
||||
}
|
||||
const id = await persistMessageEnvelope(db, draft, gameContext);
|
||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
|
||||
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
|
||||
return rows.map((row) => toMessageView(row, currentGameTick));
|
||||
};
|
||||
|
||||
export const fetchMessagesFromMailbox = async (params: {
|
||||
db: DatabaseClient;
|
||||
mailbox: number;
|
||||
@@ -120,19 +129,20 @@ export const fetchMessagesFromMailbox = async (params: {
|
||||
fromSeq: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const fromSeq = Math.max(params.fromSeq, 0);
|
||||
const gameTime = await loadCurrentGameTime(params.db);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id >= ${fromSeq}
|
||||
ORDER BY id DESC
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.mailbox = ${params.mailbox}
|
||||
AND m.type = ${params.msgType}
|
||||
AND m.id >= ${fromSeq}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
return loadMessageViews(params.db, rows);
|
||||
};
|
||||
|
||||
export const fetchOldMessagesFromMailbox = async (params: {
|
||||
@@ -142,28 +152,30 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
||||
toSeq: number;
|
||||
limit: number;
|
||||
}): Promise<MessageView[]> => {
|
||||
const gameTime = await loadCurrentGameTime(params.db);
|
||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id < ${params.toSeq}
|
||||
ORDER BY id DESC
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.mailbox = ${params.mailbox}
|
||||
AND m.type = ${params.msgType}
|
||||
AND m.id < ${params.toSeq}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ${params.limit}
|
||||
`;
|
||||
|
||||
return rows.map(toMessageView);
|
||||
return loadMessageViews(params.db, rows);
|
||||
};
|
||||
|
||||
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const rows = await db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
||||
WHERE m.id = ${id}
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
@@ -172,20 +184,29 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
|
||||
id: row.id,
|
||||
mailbox: row.mailbox,
|
||||
msgType: row.type,
|
||||
time: new Date(row.time),
|
||||
time: new Date(row.created_at_wall ?? row.time),
|
||||
payload: parsePayload(row.message),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
|
||||
const rows = await db.$queryRaw<MessageRow[]>`
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
||||
m.created_at_wall, m.message,
|
||||
ma.status AS action_status, ma.expires_game_tick
|
||||
FROM message m
|
||||
JOIN message_action ma ON ma.message_id = m.id
|
||||
JOIN world_state world ON TRUE
|
||||
WHERE m.id = ${id}
|
||||
AND ma.status = 'PENDING'
|
||||
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
|
||||
AND world.clock_phase IN ('RUNNING', 'MANUAL')
|
||||
AND ma.clock_revision = world.clock_revision
|
||||
AND ma.deadline_generation = world.deadline_generation
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
FOR UPDATE OF m, ma, world
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
@@ -193,7 +214,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
|
||||
id: row.id,
|
||||
mailbox: row.mailbox,
|
||||
msgType: row.type,
|
||||
time: new Date(row.time),
|
||||
time: new Date(row.created_at_wall ?? row.time),
|
||||
payload: parsePayload(row.message),
|
||||
};
|
||||
};
|
||||
@@ -202,16 +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)));
|
||||
if (uniqueIds.length === 0) return;
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
|
||||
await db.messageAction.updateMany({
|
||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
|
||||
});
|
||||
await db.message.updateMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
data: {
|
||||
validUntil: gameTime.now,
|
||||
// A partially migrated profile can still carry a legacy logical
|
||||
// sentinel even while no authoritative clock exists. Replace it
|
||||
// with an already-expired logical tick when expiring by wall time;
|
||||
// NULL would fall back to the wall timestamp after clock recovery
|
||||
// and could make the handled message visible again.
|
||||
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
|
||||
validUntilTick: BigInt(gameTime.tick),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -233,8 +254,48 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
)
|
||||
),
|
||||
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const tombstoneMessagesWithinDeleteWindow = async (
|
||||
db: DatabaseClient,
|
||||
authorityMessageId: number,
|
||||
ids: number[]
|
||||
): Promise<number[]> => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
WITH wall AS (
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
), authority AS (
|
||||
SELECT m.id
|
||||
FROM message m, wall
|
||||
WHERE m.id = ${authorityMessageId}
|
||||
AND m.tombstoned_at_wall IS NULL
|
||||
AND m.delete_until_wall >= wall.now_wall
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE message m
|
||||
SET message = jsonb_set(
|
||||
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
|
||||
'{option}',
|
||||
(
|
||||
CASE
|
||||
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
),
|
||||
tombstoned_at_wall = wall.now_wall
|
||||
FROM wall
|
||||
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
|
||||
AND EXISTS (SELECT 1 FROM authority)
|
||||
RETURNING m.id
|
||||
`);
|
||||
return rows.map(({ id }) => id).sort((left, right) => left - right);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
|
||||
color: '#000000',
|
||||
};
|
||||
|
||||
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
|
||||
|
||||
export const resolveNationInfo = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number
|
||||
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
|
||||
|
||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`,
|
||||
icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
readModelOutboxPayloadToDiplomacyMailboxes,
|
||||
readModelOutboxPayloadToChanges,
|
||||
readModelOutboxPayloadToMessageMailboxes,
|
||||
type ReadModelDomain,
|
||||
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
||||
'access.general',
|
||||
'dashboard.global',
|
||||
'messages.mailbox',
|
||||
'messages.diplomacyMailbox',
|
||||
'tournament',
|
||||
'betting',
|
||||
]);
|
||||
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
||||
this.db,
|
||||
async (payload) => {
|
||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||
if (mailboxes.length > 0) {
|
||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
||||
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
|
||||
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))) {
|
||||
const changes = readModelOutboxPayloadToChanges(payload);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
|
||||
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
||||
const seen = new Set<string>();
|
||||
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;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
|
||||
const changes = eventChanges(event);
|
||||
if (!changes) return false;
|
||||
const generalId = identity.generalId;
|
||||
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
|
||||
return [
|
||||
changes.generalIds,
|
||||
changes.mapGeneralIds ?? changes.generalIds,
|
||||
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
|
||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||
);
|
||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
||||
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||
const mailboxes =
|
||||
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() }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
|
||||
export const publishRealtimeMessageChanges = async (
|
||||
redis: RedisConnector['client'],
|
||||
profileName: string,
|
||||
mailboxes: readonly number[]
|
||||
mailboxes: readonly number[],
|
||||
diplomacyMailboxes: readonly number[] = []
|
||||
): Promise<void> => {
|
||||
if (mailboxes.length === 0) return;
|
||||
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
|
||||
await publishRealtimeEvent(redis, profileName, {
|
||||
type: 'messagesChanged',
|
||||
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||
...(diplomacyMailboxes.length > 0
|
||||
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { playAuditRouter } from './router/playAudit/index.js';
|
||||
import { router } from './trpc.js';
|
||||
|
||||
import { battleRouter } from './router/battle/index.js';
|
||||
@@ -28,6 +29,7 @@ import { archiveRouter } from './router/archive/index.js';
|
||||
import { dashboardRouter } from './router/dashboard/index.js';
|
||||
|
||||
export const appRouter = router({
|
||||
playAudit: playAuditRouter,
|
||||
health: healthRouter,
|
||||
auth: authRouter,
|
||||
lobby: lobbyRouter,
|
||||
|
||||
@@ -60,10 +60,7 @@ export interface AuctionDetail {
|
||||
export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
time: { now: Date; tick: number | null }
|
||||
): boolean =>
|
||||
auction.closeTick !== null && time.tick !== null
|
||||
? auction.closeTick < BigInt(time.tick)
|
||||
: auction.closeAt.getTime() < time.now.getTime();
|
||||
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
|
||||
|
||||
interface AuctionBidRow {
|
||||
id: number;
|
||||
@@ -433,7 +430,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -448,7 +444,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
@@ -511,7 +507,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -526,7 +521,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
@@ -650,7 +645,6 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
throwIfCommandRejected(result);
|
||||
@@ -665,7 +659,7 @@ export const auctionRouter = router({
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const nextCloseAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
|
||||
@@ -3,6 +3,10 @@ import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { getDexLevel } from '@sammo-ts/logic';
|
||||
import {
|
||||
resolveLegacyCompatibleUniqueConfig,
|
||||
resolveLegacyPurchasableItemKeys,
|
||||
} from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
|
||||
import {
|
||||
accessAuthedInputProcedure,
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
loadBattleSimTraitOptions,
|
||||
} from '../../battleSim/simulatorOptions.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadScenarioItemOrder } from '../../services/scenarioItemOrder.js';
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -98,7 +103,17 @@ export const battleRouter = router({
|
||||
}
|
||||
|
||||
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||
const [traits, items] = await Promise.all([loadBattleSimTraitOptions(), loadBattleSimItemOptions()]);
|
||||
const configConst = asRecord(asRecord(worldState.config).const);
|
||||
const [traits, itemOrder, uniqueConfig] = await Promise.all([
|
||||
loadBattleSimTraitOptions(),
|
||||
loadScenarioItemOrder(worldState.scenarioCode),
|
||||
resolveLegacyCompatibleUniqueConfig(configConst),
|
||||
]);
|
||||
const allowedItems = new Set([
|
||||
...resolveLegacyPurchasableItemKeys(configConst),
|
||||
...Object.values(uniqueConfig.allItems).flatMap((entries) => Object.keys(entries ?? {})),
|
||||
]);
|
||||
const items = await loadBattleSimItemOptions(allowedItems, itemOrder);
|
||||
|
||||
return {
|
||||
world: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
@@ -29,9 +29,43 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
||||
return world;
|
||||
};
|
||||
|
||||
interface BettingClockFenceRow {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
}
|
||||
|
||||
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
|
||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
|
||||
SELECT current_year AS "currentYear",
|
||||
current_month AS "currentMonth",
|
||||
clock_phase AS "clockPhase",
|
||||
clock_revision AS "clockRevision",
|
||||
deadline_generation AS "deadlineGeneration"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
const world = rows[0];
|
||||
if (!world) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
|
||||
}
|
||||
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
|
||||
});
|
||||
}
|
||||
return world;
|
||||
};
|
||||
|
||||
export const bettingRouter = router({
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
|
||||
async ({ ctx, input }) => {
|
||||
requireUserId(ctx.auth);
|
||||
await getMyGeneral(ctx);
|
||||
const [world, rows] = await Promise.all([
|
||||
@@ -66,7 +100,8 @@ export const bettingRouter = router({
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
};
|
||||
}),
|
||||
}
|
||||
),
|
||||
|
||||
getDetail: authedProcedure
|
||||
.input(z.object({ bettingId: z.number().int().positive() }))
|
||||
@@ -141,7 +176,7 @@ export const bettingRouter = router({
|
||||
if (betting.finished) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
||||
}
|
||||
const world = await loadWorldDate(ctx.db);
|
||||
const world = await lockBettingClockFence(ctx.db);
|
||||
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
||||
if (betting.closeYearMonth <= yearMonth) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||
import { insertMessage } from '../../messages/store.js';
|
||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { createDiplomacyDocumentAudit, projectAuditDocumentState } from '../../services/playAuditDiplomacy.js';
|
||||
import { readDatabaseWallTime } from '../../services/wallClock.js';
|
||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
@@ -86,8 +87,9 @@ const sendDocumentNotice = async (options: {
|
||||
await sendMessage(store, { ...draft, msgType: 'national' });
|
||||
}
|
||||
|
||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
|
||||
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) => {
|
||||
@@ -215,6 +217,7 @@ export const diplomacyRouter = router({
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
@@ -261,20 +264,21 @@ export const diplomacyRouter = router({
|
||||
}
|
||||
|
||||
if (prevLetter.state === 'PROPOSED') {
|
||||
const before = projectAuditDocumentState(prevLetter);
|
||||
const aux = asRecord(prevLetter.aux);
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
action: 'new_letter',
|
||||
reason: 'new_letter',
|
||||
};
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: prevId },
|
||||
data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
audit.record(updated, before, 'LETTER_REPLACED');
|
||||
}
|
||||
|
||||
destNationId =
|
||||
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
}
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
@@ -306,8 +310,6 @@ export const diplomacyRouter = router({
|
||||
nationColor: destNation.color,
|
||||
},
|
||||
};
|
||||
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
|
||||
|
||||
const created = await ctx.db.diplomacyLetter.create({
|
||||
data: {
|
||||
srcNationId: srcNation.id,
|
||||
@@ -316,12 +318,12 @@ export const diplomacyRouter = router({
|
||||
state: 'PROPOSED',
|
||||
textBrief: purifyDiplomacyHtml(input.brief),
|
||||
textDetail: purifyDiplomacyHtml(input.detail),
|
||||
date: letterDate,
|
||||
srcSignerId: me.id,
|
||||
aux: aux as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
audit.record(created, null, 'LETTER_PROPOSED');
|
||||
const letterIdText = String(created.id);
|
||||
const josaYi = JosaUtil.pick(letterIdText, '이');
|
||||
const text = prevId
|
||||
@@ -332,9 +334,10 @@ export const diplomacyRouter = router({
|
||||
src: srcTarget,
|
||||
dest: destTarget,
|
||||
text,
|
||||
time: letterDate,
|
||||
time: created.date,
|
||||
});
|
||||
|
||||
await audit.flush();
|
||||
return { id: created.id };
|
||||
}),
|
||||
respondLetter: accessAuthedInputProcedure(
|
||||
@@ -349,6 +352,7 @@ export const diplomacyRouter = router({
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
@@ -364,14 +368,11 @@ export const diplomacyRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||
}
|
||||
|
||||
const { srcNation, destNation } = await loadLetterNations(
|
||||
ctx,
|
||||
letter.srcNationId,
|
||||
letter.destNationId
|
||||
);
|
||||
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
|
||||
const messageSrc = buildActorTarget(me, destNation);
|
||||
const messageDest = buildNationTarget(srcNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
const before = projectAuditDocumentState(letter);
|
||||
const aux = asRecord(letter.aux);
|
||||
let messageText: string;
|
||||
if (input.agree) {
|
||||
@@ -381,7 +382,7 @@ export const diplomacyRouter = router({
|
||||
dest.generalIcon = messageSrc.icon;
|
||||
aux.dest = dest;
|
||||
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: {
|
||||
state: 'ACTIVATED',
|
||||
@@ -390,16 +391,20 @@ export const diplomacyRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
audit.record(updated, before, 'LETTER_ACCEPTED');
|
||||
let prevId = letter.prevId;
|
||||
while (prevId) {
|
||||
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
|
||||
if (!prevLetter) {
|
||||
break;
|
||||
}
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: prevId },
|
||||
data: { state: 'REPLACED' },
|
||||
});
|
||||
if (prevLetter.state !== 'REPLACED') {
|
||||
audit.record(updated, projectAuditDocumentState(prevLetter), 'LETTER_REPLACED');
|
||||
}
|
||||
prevId = prevLetter.prevId;
|
||||
}
|
||||
messageText = `외교 서신( #${letter.id})이 승인되었습니다.`;
|
||||
@@ -409,10 +414,11 @@ export const diplomacyRouter = router({
|
||||
action: 'disagree',
|
||||
reason: input.reason ?? '',
|
||||
};
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
audit.record(updated, before, 'LETTER_REJECTED');
|
||||
messageText = `외교 서신(#${letter.id})이 거부되었습니다.`;
|
||||
if (input.reason && input.reason !== '0') {
|
||||
messageText += ` 이유 : ${input.reason}`;
|
||||
@@ -428,14 +434,16 @@ export const diplomacyRouter = router({
|
||||
includeNational: true,
|
||||
});
|
||||
|
||||
await audit.flush();
|
||||
return { ok: true };
|
||||
}),
|
||||
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation(
|
||||
async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
@@ -451,14 +459,11 @@ export const diplomacyRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||
}
|
||||
|
||||
const { srcNation, destNation } = await loadLetterNations(
|
||||
ctx,
|
||||
letter.srcNationId,
|
||||
letter.destNationId
|
||||
);
|
||||
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
|
||||
const messageSrc = buildActorTarget(me, srcNation);
|
||||
const messageDest = buildNationTarget(destNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
const before = projectAuditDocumentState(letter);
|
||||
const aux = asRecord(letter.aux);
|
||||
aux.reason = {
|
||||
who: me.id,
|
||||
@@ -466,11 +471,12 @@ export const diplomacyRouter = router({
|
||||
reason: '회수',
|
||||
};
|
||||
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
|
||||
audit.record(updated, before, 'LETTER_WITHDRAWN');
|
||||
await sendDocumentNotice({
|
||||
ctx,
|
||||
src: messageSrc,
|
||||
@@ -479,14 +485,17 @@ export const diplomacyRouter = router({
|
||||
time: messageTime,
|
||||
});
|
||||
|
||||
await audit.flush();
|
||||
return { ok: true };
|
||||
}),
|
||||
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
}
|
||||
),
|
||||
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation(
|
||||
async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const permission = await resolvePermissionLevel(ctx, me.nationId);
|
||||
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
|
||||
if (permission < 4) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
@@ -502,6 +511,7 @@ export const diplomacyRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||
}
|
||||
|
||||
const before = projectAuditDocumentState(letter);
|
||||
const aux = asRecord(letter.aux);
|
||||
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
|
||||
const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest';
|
||||
@@ -510,32 +520,30 @@ export const diplomacyRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
|
||||
}
|
||||
|
||||
const { srcNation, destNation } = await loadLetterNations(
|
||||
ctx,
|
||||
letter.srcNationId,
|
||||
letter.destNationId
|
||||
);
|
||||
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
|
||||
const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation;
|
||||
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||
const messageSrc = buildActorTarget(me, actorNation);
|
||||
const messageDest = buildNationTarget(otherNation);
|
||||
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
||||
let resultState: 'ACTIVATED' | 'CANCELLED';
|
||||
let messageText: string;
|
||||
|
||||
if (stateOpt && stateOpt !== myStateOpt) {
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
audit.record(updated, before, 'LETTER_DESTROYED');
|
||||
resultState = 'CANCELLED';
|
||||
messageText = `외교 서신(#${letter.id})을 파기했습니다.`;
|
||||
} else {
|
||||
aux.state_opt = myStateOpt;
|
||||
await ctx.db.diplomacyLetter.update({
|
||||
const updated = await ctx.db.diplomacyLetter.update({
|
||||
where: { id: letter.id },
|
||||
data: { aux: aux as GamePrisma.InputJsonValue },
|
||||
});
|
||||
audit.record(updated, before, 'LETTER_DESTROY_REQUESTED');
|
||||
resultState = 'ACTIVATED';
|
||||
messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`;
|
||||
}
|
||||
@@ -547,6 +555,8 @@ export const diplomacyRouter = router({
|
||||
text: messageText,
|
||||
time: messageTime,
|
||||
});
|
||||
await audit.flush();
|
||||
return { state: resultState };
|
||||
}),
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
|
||||
return started && !ended;
|
||||
};
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
const zGeneralSettings = z.strictObject({
|
||||
tnmt: z.number().int().optional(),
|
||||
defence_train: z.number().int().optional(),
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_war: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
|
||||
@@ -244,7 +243,6 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
|
||||
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
|
||||
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
|
||||
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
|
||||
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
|
||||
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
|
||||
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
|
||||
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
troopLeaderFirstTurn,
|
||||
accessLog,
|
||||
rankRows,
|
||||
gameTime,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
parsedLastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
gameTime.now
|
||||
),
|
||||
crewTypeId: general.crewTypeId,
|
||||
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
|
||||
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock is held at the future open anchor, so comparing them with
|
||||
// JavaScript wall time inside a timestamp-without-time-zone predicate can
|
||||
// hide an otherwise active poll. Resolve the same tick-first clock contract
|
||||
// used by voting instead; hasVoted only affects the notice, not activity.
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
|
||||
// predicate can hide an otherwise active poll. Resolve the same tick-first
|
||||
// clock contract used by voting; hasVoted only affects the notice, not activity.
|
||||
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
||||
|
||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../services/inheritance.js';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||
import { loadScenarioItemOrder, orderScenarioItemEntries } from '../../services/scenarioItemOrder.js';
|
||||
|
||||
const BUFF_KEYS: InheritBuffType[] = [
|
||||
'warAvoidRatio',
|
||||
@@ -38,6 +39,8 @@ const BUFF_KEYS: InheritBuffType[] = [
|
||||
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||
|
||||
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
||||
const MIN_INHERIT_BUFF_LEVEL = 1;
|
||||
const MAX_INHERIT_BUFF_LEVEL = 5;
|
||||
|
||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
if (typeof raw === 'string') {
|
||||
@@ -63,10 +66,11 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
||||
const configConst = asRecord(asRecord(worldState.config).const);
|
||||
const loader = new ItemLoader();
|
||||
const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader);
|
||||
const itemOrder = await loadScenarioItemOrder(worldState.scenarioCode);
|
||||
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
||||
for (const slot of UNIQUE_ITEM_SLOT_ORDER) {
|
||||
const entries = allItems[slot] ?? {};
|
||||
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
||||
for (const [key, amount] of orderScenarioItemEntries(asRecord(entries), itemOrder)) {
|
||||
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
||||
enabledKeys.push(key);
|
||||
}
|
||||
@@ -137,9 +141,9 @@ const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
||||
|
||||
const formatTurnTimeBaseLabel = (value: number): string => {
|
||||
const wholeSeconds = Math.trunc(value);
|
||||
const hours = String(Math.trunc(wholeSeconds / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.trunc((wholeSeconds % 3600) / 60)).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
const minutes = String(Math.trunc(wholeSeconds / 60)).padStart(2, '0');
|
||||
const seconds = String(wholeSeconds % 60).padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
export const resolveResetTurnTimeBase = (options: {
|
||||
@@ -302,7 +306,7 @@ export const inheritRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
type: z.enum(BUFF_KEYS),
|
||||
level: z.number().int().min(1).max(5),
|
||||
level: z.number().int(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -310,6 +314,12 @@ export const inheritRouter = router({
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (input.level < MIN_INHERIT_BUFF_LEVEL || input.level > MAX_INHERIT_BUFF_LEVEL) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await requestInheritanceAction(ctx, userId, {
|
||||
action: 'buyHiddenBuff',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import {
|
||||
isWarTraitKey,
|
||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||
@@ -302,6 +303,7 @@ export const joinRouter = router({
|
||||
}
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
const configConst = asRecord(config.const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
@@ -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 inheritTotalPoint = ctx.auth?.user.id
|
||||
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
||||
@@ -348,7 +355,8 @@ export const joinRouter = router({
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||
allowDirectCreation: (blockGeneralCreate & 1) === 0,
|
||||
allowCustomName: (blockGeneralCreate & 2) === 0,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
@@ -369,6 +377,7 @@ export const joinRouter = router({
|
||||
npcGeneralCount,
|
||||
},
|
||||
inherit: {
|
||||
enabled: history?.status !== 'COMPLETED',
|
||||
totalPoint: inheritTotalPoint,
|
||||
costs: {
|
||||
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
||||
@@ -391,15 +400,12 @@ export const joinRouter = router({
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReserve',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionReservationCommandResult(result);
|
||||
}),
|
||||
@@ -429,7 +435,6 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolCreate',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
@@ -438,8 +443,6 @@ export const joinRouter = router({
|
||||
uniqueName: input.uniqueName,
|
||||
personality: input.personality,
|
||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
...(selectedIcon
|
||||
? {
|
||||
ownerPicture: selectedIcon.picture,
|
||||
@@ -469,15 +472,12 @@ export const joinRouter = router({
|
||||
input.clientRequestId,
|
||||
'reselect'
|
||||
);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReselect',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
ownerDisplayName: auth.user.displayName,
|
||||
uniqueName: input.uniqueName,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||
}),
|
||||
@@ -581,30 +581,46 @@ export const joinRouter = router({
|
||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
if (gameTime.tick === null) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Game clock is not initialized.',
|
||||
return await ctx.db.$transaction!(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
|
||||
SELECT clock_phase AS "clockPhase"
|
||||
FROM world_state
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (!clockRows[0]) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
if (!['PREOPEN', 'RUNNING', 'MANUAL', '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) {
|
||||
if (error instanceof NpcPossessionError) {
|
||||
|
||||
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
|
||||
clockMode: gameTime.mode ?? 'realtime',
|
||||
clockRunning: gameTime.running,
|
||||
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||
clockRecovery: gameTime.recovery ?? null,
|
||||
turnEngineRunning,
|
||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||
npcMode: worldState.config.npcMode ?? 0,
|
||||
@@ -87,6 +88,7 @@ export const lobbyRouter = router({
|
||||
: null,
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
|
||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
||||
myGeneral,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, ChangeJournal } from '@sammo-ts/common';
|
||||
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
|
||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
accessLimitAuthedInputProcedure,
|
||||
accessWallAuthedInputProcedure,
|
||||
authedProcedure,
|
||||
engineAuthedProcedure,
|
||||
router,
|
||||
wallAuthedProcedure,
|
||||
} from '../../trpc.js';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
MESSAGE_MAILBOX_PUBLIC,
|
||||
@@ -20,13 +28,13 @@ import {
|
||||
fetchOldMessagesFromMailbox,
|
||||
fetchMessageById,
|
||||
insertMessage,
|
||||
tombstoneMessages,
|
||||
tombstoneMessagesWithinDeleteWindow,
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
import { resolveNationPermission } from '../nation/shared.js';
|
||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { executeInputEvent } from '../../inputEventBoundary.js';
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
@@ -41,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
|
||||
return {
|
||||
...message,
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { ...(message.option ?? {}), permissionRedacted: true },
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -68,9 +77,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||
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) {
|
||||
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
||||
ctx.changeJournal?.mark(domain, mailbox);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,7 +245,7 @@ export const messagesRouter = router({
|
||||
})),
|
||||
};
|
||||
}),
|
||||
readLatest: authedProcedure
|
||||
readLatest: wallAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -264,7 +278,7 @@ export const messagesRouter = router({
|
||||
`;
|
||||
return { ok: true };
|
||||
}),
|
||||
delete: authedProcedure
|
||||
delete: wallAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -289,17 +303,16 @@ export const messagesRouter = router({
|
||||
if (message.payload.option?.deletable === false) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||
}
|
||||
const { now } = await loadCurrentGameTime(ctx.db);
|
||||
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||
const ids = [
|
||||
message.id,
|
||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||
];
|
||||
await tombstoneMessages(ctx.db, ids);
|
||||
const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
|
||||
if (!deletedIds.includes(message.id)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMailbox =
|
||||
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||
? message.payload.dest.generalId
|
||||
@@ -308,10 +321,14 @@ export const messagesRouter = router({
|
||||
message.msgType === 'national'
|
||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||
: null;
|
||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||
return { ok: true, deletedIds: ids };
|
||||
markMessageMailboxes(
|
||||
ctx,
|
||||
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
|
||||
message.msgType
|
||||
);
|
||||
return { ok: true, deletedIds };
|
||||
}),
|
||||
respond: authedProcedure
|
||||
respond: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
@@ -326,6 +343,9 @@ export const messagesRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||
}
|
||||
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 (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
@@ -342,29 +362,81 @@ export const messagesRouter = router({
|
||||
}
|
||||
return { result: commandResult.ok, reason: commandResult.reason };
|
||||
}
|
||||
const result = await respondToDiplomaticMessage({
|
||||
const ownsChangeJournal = !ctx.changeJournal;
|
||||
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
actor: general,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
requestId: `messages.respond.diplomatic:${input.messageId}`,
|
||||
eventType: 'messages.respond.diplomatic',
|
||||
payload: input,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
execute: async (transaction, auditInput) => {
|
||||
const transactionContext = { ...ctx, db: transaction, changeJournal };
|
||||
const transactionGeneral = ownsChangeJournal
|
||||
? await getOwnedGeneral(transactionContext, input.generalId)
|
||||
: general;
|
||||
const result = await respondToDiplomaticMessage({
|
||||
db: transaction,
|
||||
actor: transactionGeneral,
|
||||
auditInput,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
});
|
||||
markMessageMailboxes(transactionContext, result.affectedMailboxes);
|
||||
for (const generalId of result.affectedGeneralRecordIds) {
|
||||
changeJournal.mark('records.general', generalId);
|
||||
}
|
||||
for (const nationId of result.affectedNationIds) {
|
||||
changeJournal.mark('nation.content', nationId);
|
||||
}
|
||||
for (const cityId of result.affectedCityIds) {
|
||||
changeJournal.mark('city.content', cityId);
|
||||
}
|
||||
if (result.affectedCityIds.length > 0) {
|
||||
changeJournal.mark('map.world');
|
||||
}
|
||||
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
||||
changeJournal.mark('dashboard.global');
|
||||
}
|
||||
if (ownsChangeJournal) {
|
||||
journalPersisted = Boolean(
|
||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||
);
|
||||
}
|
||||
return {
|
||||
result: result.result,
|
||||
reason: result.reason,
|
||||
affectedNationIds: result.affectedNationIds,
|
||||
affectedCityIds: result.affectedCityIds,
|
||||
};
|
||||
},
|
||||
});
|
||||
markMessageMailboxes(ctx, result.affectedMailboxes);
|
||||
for (const generalId of result.affectedGeneralRecordIds) {
|
||||
ctx.changeJournal?.mark('records.general', generalId);
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
for (const nationId of result.affectedNationIds) {
|
||||
ctx.changeJournal?.mark('nation.content', nationId);
|
||||
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const synchronized = await ctx.turnDaemon.requestCommand({
|
||||
type: 'syncDiplomaticResponse',
|
||||
userId: ctx.auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: input.messageId,
|
||||
nationIds: response.affectedNationIds,
|
||||
cityIds: response.affectedCityIds,
|
||||
});
|
||||
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
|
||||
const synchronizationReason =
|
||||
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const cityId of result.affectedCityIds) {
|
||||
ctx.changeJournal?.mark('city.content', cityId);
|
||||
}
|
||||
if (result.affectedCityIds.length > 0) {
|
||||
ctx.changeJournal?.mark('map.world');
|
||||
}
|
||||
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
||||
ctx.changeJournal?.mark('dashboard.global');
|
||||
}
|
||||
return { result: result.result, reason: result.reason };
|
||||
return { result: response.result, reason: response.reason };
|
||||
}),
|
||||
getOld: authedProcedure
|
||||
.input(
|
||||
@@ -420,7 +492,7 @@ export const messagesRouter = router({
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
send: accessAuthedInputProcedure(
|
||||
send: accessWallAuthedInputProcedure(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
mailbox: z.number().int(),
|
||||
@@ -436,7 +508,9 @@ export const messagesRouter = router({
|
||||
}
|
||||
|
||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||
const { now } = await loadCurrentGameTime(ctx.db);
|
||||
// Compatibility-only projection. persistMessageEnvelope records and
|
||||
// displays the authoritative PostgreSQL wall instant.
|
||||
const now = new Date();
|
||||
const validUntil = new Date('9999-12-31T00:00:00Z');
|
||||
|
||||
let msgType: MessageType;
|
||||
@@ -560,7 +634,7 @@ export const messagesRouter = router({
|
||||
: msgType === 'private'
|
||||
? general.id
|
||||
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
|
||||
|
||||
return { msgType, msgId: result.receiverId };
|
||||
}),
|
||||
|
||||
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
injury: general.injury,
|
||||
baseStats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
stats: {
|
||||
leadership: woundedStat(general.leadership, general.injury),
|
||||
strength: woundedStat(general.strength, general.injury),
|
||||
|
||||
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
|
||||
};
|
||||
|
||||
const resolveCommandEnv = (
|
||||
config: Record<string, unknown>
|
||||
config: Record<string, unknown>,
|
||||
worldMeta: Record<string, unknown>
|
||||
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
||||
const constValues = asRecord(config.const ?? config.consts);
|
||||
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
return {
|
||||
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
develCost: resolveNumberFromKeys(
|
||||
worldMeta,
|
||||
['develcost', 'develCost', 'develrate'],
|
||||
configuredDevelCost
|
||||
),
|
||||
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
||||
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
||||
};
|
||||
@@ -409,7 +415,7 @@ export const npcRouter = router({
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const stat = resolveScenarioStat(config);
|
||||
const env = resolveCommandEnv(config);
|
||||
const env = resolveCommandEnv(config, worldMeta);
|
||||
const unitSetName = resolveUnitSetName(config, 'che');
|
||||
const nationTech = readNumber(nation.tech, 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
|
||||
const zEffectivePolicy = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
general: z.object({ priority: z.array(z.string()), flags: z.record(z.string(), z.boolean()) }),
|
||||
nation: z.object({
|
||||
priority: z.array(z.string()),
|
||||
flags: z.record(z.string(), z.boolean()),
|
||||
values: z.object({
|
||||
reqNationGold: z.number(),
|
||||
reqNationRice: z.number(),
|
||||
reqHumanWarUrgentGold: z.number(),
|
||||
reqHumanWarUrgentRice: z.number(),
|
||||
reqHumanWarRecommandGold: z.number(),
|
||||
reqHumanWarRecommandRice: z.number(),
|
||||
reqHumanDevelGold: z.number(),
|
||||
reqHumanDevelRice: z.number(),
|
||||
reqNpcWarGold: z.number(),
|
||||
reqNpcWarRice: z.number(),
|
||||
reqNpcDevelGold: z.number(),
|
||||
reqNpcDevelRice: z.number(),
|
||||
minimumResourceActionAmount: z.number(),
|
||||
maximumResourceActionAmount: z.number(),
|
||||
minNpcWarLeadership: z.number(),
|
||||
minWarCrew: z.number(),
|
||||
minNpcRecruitCityPopulation: z.number(),
|
||||
safeRecruitCityPopulationRatio: z.number(),
|
||||
properWarTrainAtmos: z.number(),
|
||||
cureThreshold: z.number(),
|
||||
}),
|
||||
combatForce: z.record(z.string(), z.array(z.number()).length(2)),
|
||||
supportForce: z.array(z.number()),
|
||||
developForce: z.array(z.number()),
|
||||
}),
|
||||
});
|
||||
const zId = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const zTick = z
|
||||
.string()
|
||||
.regex(/^\d{1,16}$/)
|
||||
.refine((value) => BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER));
|
||||
const zSummary = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
coverage: z.literal('PROCEDURES'),
|
||||
executionCoverage: z.literal('ATTEMPTS').optional(),
|
||||
executionStatus: z.enum(['PREPARING', 'BLOCKED', 'RESOLVED']).optional(),
|
||||
clockRevision: z.number().int(),
|
||||
codeVersion: z.string().nullable(),
|
||||
policyRefs: z.object({
|
||||
NPC_VALUES: zId.optional(),
|
||||
NPC_NATION_PRIORITY: zId.optional(),
|
||||
NPC_GENERAL_PRIORITY: zId.optional(),
|
||||
DEFENCE: zId.optional(),
|
||||
}),
|
||||
requestedAction: z.string(),
|
||||
selectedAction: z.string().nullable(),
|
||||
selectedReason: z.string().nullable(),
|
||||
executedAction: z.string(),
|
||||
completed: z.boolean().nullable(),
|
||||
usedFallback: z.boolean(),
|
||||
blockedReason: z.string().nullable(),
|
||||
});
|
||||
const zValue = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.object({ entityId: z.number() }),
|
||||
z.object({ unprojected: z.literal(true) }),
|
||||
]);
|
||||
const zStep = z.intersection(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
phase: z.enum(['general', 'nation']),
|
||||
generalId: z.number().int(),
|
||||
nationId: z.number().int(),
|
||||
cityId: z.number().int(),
|
||||
npcState: z.number().int(),
|
||||
year: z.number().int(),
|
||||
month: z.number().int(),
|
||||
tick: z.number().nullable(),
|
||||
}),
|
||||
z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('EXECUTION_ATTEMPT'),
|
||||
attempt: z.number().int().min(0).max(5),
|
||||
requestedAction: z.string(),
|
||||
resolvedAction: z.string(),
|
||||
executedAction: z.string().nullable(),
|
||||
completed: z.boolean(),
|
||||
usedFallback: z.boolean(),
|
||||
alternativeAction: z.string().nullable(),
|
||||
preparation: z.object({ term: z.number().int().positive(), total: z.number().int().positive() }).nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
stage: z.enum(['ARGS', 'CONSTRAINT', 'COOLDOWN', 'CONTEXT', 'BLOCK']),
|
||||
action: z.string(),
|
||||
result: z.enum(['allow', 'deny', 'unknown']),
|
||||
reason: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.max(5),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('DECISION_START'),
|
||||
reservedAction: z.string(),
|
||||
effectivePolicy: zEffectivePolicy.optional(),
|
||||
}),
|
||||
z.object({ kind: z.literal('DECISION_END'), action: z.string().nullable(), reason: z.string().nullable() }),
|
||||
z.object({ kind: z.literal('DECISION_ERROR') }),
|
||||
z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }),
|
||||
z.object({
|
||||
kind: z.literal('PROCEDURE_END'),
|
||||
procedure: z.string(),
|
||||
action: z.string().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('PROCEDURE_SKIP'),
|
||||
procedure: z.string(),
|
||||
reason: z.enum(['POLICY', 'AUTOMATION', 'NO_HANDLER']),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('CANDIDATE'),
|
||||
action: z.string(),
|
||||
result: z.enum(['INVALID_ARGS', 'allow', 'deny', 'unknown']),
|
||||
constraint: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('RNG'),
|
||||
method: z.string(),
|
||||
parameters: z.array(z.number()).nullable(),
|
||||
result: z.union([zValue, z.array(zValue)]),
|
||||
}),
|
||||
])
|
||||
);
|
||||
const select = {
|
||||
id: true,
|
||||
executionId: true,
|
||||
phase: true,
|
||||
generalId: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
npcState: true,
|
||||
year: true,
|
||||
month: true,
|
||||
tick: true,
|
||||
stepCount: true,
|
||||
summary: true,
|
||||
createdAt: true,
|
||||
} satisfies GamePrisma.PlayAuditDecisionSelect;
|
||||
const project = (row: GamePrisma.PlayAuditDecisionGetPayload<{ select: typeof select }>) => ({
|
||||
...row,
|
||||
phase: z.enum(['general', 'nation']).parse(row.phase),
|
||||
tick: row.tick.toString(),
|
||||
summary: zSummary.parse(row.summary),
|
||||
});
|
||||
export const decisionHistory = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
generalId: z.number().int().positive().max(2147483647),
|
||||
month: zAuditMonth.omit({ kind: true }).optional(),
|
||||
phase: z.enum(['general', 'nation']).optional(),
|
||||
cursor: z.object({ tick: zTick, id: zId }).strict().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const month = input.month ?? { year: world.year, month: world.month };
|
||||
const ordinal = monthOrdinal(month.year, month.month);
|
||||
if (
|
||||
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
|
||||
ordinal > monthOrdinal(world.year, world.month)
|
||||
)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 결정 조회 월을 선택해 주세요.' });
|
||||
const rows = world.serverId
|
||||
? await tx.playAuditDecision.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
generalId: input.generalId,
|
||||
year: month.year,
|
||||
month: month.month,
|
||||
phase: input.phase,
|
||||
...(input.cursor
|
||||
? {
|
||||
OR: [
|
||||
{ tick: { lt: BigInt(input.cursor.tick) } },
|
||||
{ tick: BigInt(input.cursor.tick), id: { lt: input.cursor.id } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ tick: 'desc' }, { id: 'desc' }],
|
||||
take: input.limit + 1,
|
||||
select,
|
||||
})
|
||||
: [];
|
||||
const last = rows[input.limit - 1];
|
||||
return {
|
||||
...world,
|
||||
month,
|
||||
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
items: rows.slice(0, input.limit).map(project),
|
||||
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
export const decisionDetail = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: zId,
|
||||
generalId: z.number().int().positive().max(2147483647),
|
||||
cursor: z.number().int().nonnegative().max(2147483647).optional(),
|
||||
limit: z.number().int().min(1).max(4).default(1),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const row = world.serverId
|
||||
? await tx.playAuditDecision.findFirst({
|
||||
where: { id: input.id, generalId: input.generalId, serverId: world.serverId },
|
||||
select,
|
||||
})
|
||||
: null;
|
||||
if (!row)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수에서 해당 결정 기록을 찾을 수 없습니다.' });
|
||||
const chunks = await tx.playAuditDecisionChunk.findMany({
|
||||
where: { decisionId: row.id, ordinal: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
||||
orderBy: { ordinal: 'asc' },
|
||||
take: input.limit,
|
||||
select: { ordinal: true, steps: true },
|
||||
});
|
||||
return {
|
||||
...world,
|
||||
decision: project(row),
|
||||
chunks: chunks.slice(0, input.limit).map((chunk) => ({
|
||||
ordinal: chunk.ordinal,
|
||||
steps: z.array(zStep).max(128).parse(chunk.steps),
|
||||
})),
|
||||
nextCursor:
|
||||
chunks.length && chunks.at(-1)!.ordinal + 1 < Math.ceil(row.stepCount / 128)
|
||||
? chunks.at(-1)!.ordinal
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { z } from 'zod';
|
||||
import { auditProcedure, findAuditMonth, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
import {
|
||||
citySelect,
|
||||
generalSelect,
|
||||
projectCurrentCity,
|
||||
projectCurrentGeneral,
|
||||
zAuditCityData,
|
||||
zAuditGeneralData,
|
||||
} from './projection.js';
|
||||
|
||||
const identity = z.object({ id: z.number(), name: z.string() });
|
||||
const zDetail = z.object({ id: z.number().int().nonnegative(), at: zAuditMonth.optional() }).strict();
|
||||
|
||||
export const generalDetail = auditProcedure.input(zDetail).query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const row = sample
|
||||
? await tx.playAuditGeneral.findUnique({
|
||||
where: { sampleId_generalId: { sampleId: sample.id, generalId: input.id } },
|
||||
select: { data: true },
|
||||
})
|
||||
: null;
|
||||
const general = row ? zAuditGeneralData.parse(row.data) : null;
|
||||
const nation =
|
||||
general && sample
|
||||
? await tx.playAuditNation.findUnique({
|
||||
where: { sampleId_nationId: { sampleId: sample.id, nationId: general.nationId } },
|
||||
select: { data: true },
|
||||
})
|
||||
: null;
|
||||
const city =
|
||||
general && sample
|
||||
? await tx.playAuditCity.findUnique({
|
||||
where: { sampleId_cityId: { sampleId: sample.id, cityId: general.cityId } },
|
||||
select: { data: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
general,
|
||||
nation: nation ? identity.parse(nation.data) : null,
|
||||
city: city ? identity.parse(city.data) : null,
|
||||
};
|
||||
}
|
||||
const row = await tx.general.findUnique({ where: { id: input.id }, select: generalSelect });
|
||||
const general = row ? projectCurrentGeneral(row) : null;
|
||||
const nation = general
|
||||
? await tx.nation.findUnique({ where: { id: general.nationId }, select: { id: true, name: true } })
|
||||
: null;
|
||||
const city = general
|
||||
? await tx.city.findUnique({ where: { id: general.cityId }, select: { id: true, name: true } })
|
||||
: null;
|
||||
return { ...world, sample: null, collected: true, general, nation, city };
|
||||
})
|
||||
);
|
||||
|
||||
export const cityDetail = auditProcedure.input(zDetail).query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const row = sample
|
||||
? await tx.playAuditCity.findUnique({
|
||||
where: { sampleId_cityId: { sampleId: sample.id, cityId: input.id } },
|
||||
select: { data: true },
|
||||
})
|
||||
: null;
|
||||
const city = row ? zAuditCityData.parse(row.data) : null;
|
||||
const nation =
|
||||
city && sample
|
||||
? await tx.playAuditNation.findUnique({
|
||||
where: { sampleId_nationId: { sampleId: sample.id, nationId: city.nationId } },
|
||||
select: { data: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
city,
|
||||
nation: nation ? identity.parse(nation.data) : null,
|
||||
};
|
||||
}
|
||||
const row = await tx.city.findUnique({ where: { id: input.id }, select: citySelect });
|
||||
const city = row ? projectCurrentCity(row) : null;
|
||||
const nation = city
|
||||
? await tx.nation.findUnique({ where: { id: city.nationId }, select: { id: true, name: true } })
|
||||
: null;
|
||||
return { ...world, sample: null, collected: true, city, nation };
|
||||
})
|
||||
);
|
||||
|
||||
// 현재 예약은 과거 표본과 분리한다. 잘못된 slot도 숨기지 않고 페이지로 조회한다.
|
||||
export const generalTurns = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
generalId: z.number().int().nonnegative(),
|
||||
cursor: z.number().int().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const general = await tx.general.findUnique({ where: { id: input.generalId }, select: { id: true } });
|
||||
const rows = general
|
||||
? await tx.generalTurn.findMany({
|
||||
where: {
|
||||
generalId: input.generalId,
|
||||
turnIdx: input.cursor === undefined ? undefined : { gt: input.cursor },
|
||||
},
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: { turnIdx: true, actionCode: true, arg: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
currentOnly: true,
|
||||
generalExists: Boolean(general),
|
||||
items: rows
|
||||
.slice(0, input.limit)
|
||||
.map((row) => ({
|
||||
turnIdx: row.turnIdx,
|
||||
actionCode: row.actionCode,
|
||||
argumentJson: JSON.stringify(row.arg),
|
||||
})),
|
||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.turnIdx : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,216 @@
|
||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { hashAuditDiplomacyDocument, type GamePrisma } from '@sammo-ts/infra';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
|
||||
const zActor = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
name: z.string(),
|
||||
nationId: z.number().int(),
|
||||
officerLevel: z.number().int(),
|
||||
npcState: z.number().int(),
|
||||
actionKey: z.string().optional(),
|
||||
kind: z.enum(['nation', 'general']).optional(),
|
||||
actionOrdinal: z.number().int().positive().optional(),
|
||||
messageId: z.number().int().positive().optional(),
|
||||
})
|
||||
.nullable();
|
||||
const zState = z
|
||||
.object({
|
||||
state: z.union([z.number().int(), z.enum(['PROPOSED', 'ACTIVATED', 'REPLACED', 'CANCELLED'])]),
|
||||
term: z.number().int().optional(),
|
||||
dead: z.number().optional(),
|
||||
isDead: z.boolean().optional(),
|
||||
isShowing: z.boolean().optional(),
|
||||
srcSignerId: z.number().int().nullable().optional(),
|
||||
destSignerId: z.number().int().nullable().optional(),
|
||||
srcNationName: z.string().nullable().optional(),
|
||||
destNationName: z.string().nullable().optional(),
|
||||
srcSignerName: z.string().nullable().optional(),
|
||||
destSignerName: z.string().nullable().optional(),
|
||||
stateOption: z.string().nullable().optional(),
|
||||
reason: z.string().nullable().optional(),
|
||||
reasonAction: z.string().nullable().optional(),
|
||||
reasonActorId: z.number().int().nullable().optional(),
|
||||
})
|
||||
.nullable();
|
||||
const summarySelect = {
|
||||
id: true,
|
||||
sequence: true,
|
||||
schemaVersion: true,
|
||||
srcNationId: true,
|
||||
destNationId: true,
|
||||
category: true,
|
||||
source: true,
|
||||
eventType: true,
|
||||
documentId: true,
|
||||
previousDocumentId: true,
|
||||
year: true,
|
||||
month: true,
|
||||
actor: true,
|
||||
createdAt: true,
|
||||
} satisfies GamePrisma.PlayAuditDiplomacyEventSelect;
|
||||
type Summary = GamePrisma.PlayAuditDiplomacyEventGetPayload<{ select: typeof summarySelect }>;
|
||||
export const projectDiplomacySummary = (row: Summary) => {
|
||||
if (row.schemaVersion !== 1)
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 외교 기록 버전입니다.' });
|
||||
return {
|
||||
id: row.id,
|
||||
sequence: row.sequence.toString(),
|
||||
srcNationId: row.srcNationId,
|
||||
destNationId: row.destNationId,
|
||||
category: z.enum(['DOCUMENT', 'RELATION']).parse(row.category),
|
||||
source: z.enum(['API', 'ENGINE', 'BASELINE']).parse(row.source),
|
||||
eventType: row.eventType,
|
||||
documentId: row.documentId,
|
||||
previousDocumentId: row.previousDocumentId,
|
||||
year: row.year,
|
||||
month: row.month,
|
||||
actor: zActor.parse(row.actor),
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
};
|
||||
const zSequence = z
|
||||
.string()
|
||||
.regex(/^[1-9][0-9]{0,18}$/)
|
||||
.refine((value) => /^[1-9][0-9]{0,18}$/.test(value) && BigInt(value) <= 9223372036854775807n);
|
||||
const zId = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
|
||||
export const diplomacyHistory = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
nationId: z.number().int().positive(),
|
||||
otherNationId: z.number().int().positive(),
|
||||
from: zAuditMonth.omit({ kind: true }),
|
||||
to: zAuditMonth.omit({ kind: true }),
|
||||
category: z.enum(['DOCUMENT', 'RELATION']).optional(),
|
||||
cursor: zSequence.optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
.refine((input) => input.nationId !== input.otherNationId, '서로 다른 국가를 선택해 주세요.')
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const from = monthOrdinal(input.from.year, input.from.month);
|
||||
const to = monthOrdinal(input.to.year, input.to.month);
|
||||
if (
|
||||
from > to ||
|
||||
from < monthOrdinal(world.startYear, world.startMonth) ||
|
||||
to > monthOrdinal(world.year, world.month)
|
||||
)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 외교 조회 기간을 선택해 주세요.' });
|
||||
const rows = world.serverId
|
||||
? await tx.playAuditDiplomacyEvent.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
nationA: Math.min(input.nationId, input.otherNationId),
|
||||
nationB: Math.max(input.nationId, input.otherNationId),
|
||||
category: input.category,
|
||||
sequence: input.cursor === undefined ? undefined : { lt: BigInt(input.cursor) },
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ year: { gt: input.from.year } },
|
||||
{ year: input.from.year, month: { gte: input.from.month } },
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{ year: { lt: input.to.year } },
|
||||
{ year: input.to.year, month: { lte: input.to.month } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: summarySelect,
|
||||
orderBy: { sequence: 'desc' },
|
||||
take: input.limit + 1,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
coverage: world.serverId ? ('RECORDED_EVENTS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
items: rows.slice(0, input.limit).map(projectDiplomacySummary),
|
||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.sequence.toString() : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const diplomacyEvent = auditProcedure.input(z.object({ id: zId }).strict()).query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const row = world.serverId
|
||||
? await tx.playAuditDiplomacyEvent.findFirst({
|
||||
where: { id: input.id, serverId: world.serverId },
|
||||
select: {
|
||||
...summarySelect,
|
||||
before: true,
|
||||
after: true,
|
||||
tick: true,
|
||||
clockRevision: true,
|
||||
executionId: true,
|
||||
ordinal: true,
|
||||
requestId: true,
|
||||
inputSequence: true,
|
||||
documentHash: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 외교 기록을 찾을 수 없습니다.' });
|
||||
const document =
|
||||
row.documentId === null
|
||||
? null
|
||||
: await tx.diplomacyLetter.findUnique({
|
||||
where: { id: row.documentId },
|
||||
select: {
|
||||
id: true,
|
||||
srcNationId: true,
|
||||
destNationId: true,
|
||||
prevId: true,
|
||||
textBrief: true,
|
||||
textDetail: true,
|
||||
srcSignerId: true,
|
||||
date: true,
|
||||
},
|
||||
});
|
||||
const documentStatus =
|
||||
row.documentId === null
|
||||
? ('NOT_APPLICABLE' as const)
|
||||
: !document
|
||||
? ('MISSING_REFERENCE' as const)
|
||||
: hashAuditDiplomacyDocument(document) !== row.documentHash
|
||||
? ('HASH_MISMATCH' as const)
|
||||
: ('AVAILABLE' as const);
|
||||
return {
|
||||
...world,
|
||||
event: {
|
||||
...projectDiplomacySummary(row),
|
||||
before: zState.parse(row.before),
|
||||
after: zState.parse(row.after),
|
||||
tick: row.tick?.toString() ?? null,
|
||||
clockRevision: row.clockRevision?.toString() ?? null,
|
||||
executionId: row.executionId,
|
||||
ordinal: row.ordinal,
|
||||
requestId: row.requestId,
|
||||
inputSequence: row.inputSequence?.toString() ?? null,
|
||||
documentStatus,
|
||||
document:
|
||||
documentStatus === 'AVAILABLE' && document
|
||||
? {
|
||||
id: document.id,
|
||||
brief: document.textBrief,
|
||||
detail: document.textDetail,
|
||||
briefHtml: purifyDiplomacyHtml(document.textBrief),
|
||||
detailHtml: purifyDiplomacyHtml(document.textDetail),
|
||||
writtenAt: document.date,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,268 @@
|
||||
import { requestState } from './requests.js';
|
||||
import { decisionHistory, decisionDetail } from './decisions.js';
|
||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||
import { nationSeries, zAuditNation } from './nationSeries.js';
|
||||
import { cityDetail, generalDetail, generalTurns } from './details.js';
|
||||
import { generalLogs } from './logs.js';
|
||||
import { policyHistory, policyVersion } from './policies.js';
|
||||
import { z } from 'zod';
|
||||
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
|
||||
import { router } from '../../trpc.js';
|
||||
import {
|
||||
auditProcedure,
|
||||
findAuditMonth,
|
||||
pageResult,
|
||||
readAudit,
|
||||
readAuditWorld,
|
||||
zAuditPage,
|
||||
zAuditMonth,
|
||||
} from './shared.js';
|
||||
import {
|
||||
citySelect,
|
||||
generalSelect,
|
||||
projectCurrentCity,
|
||||
projectCurrentGeneral,
|
||||
zAuditCityData,
|
||||
zAuditGeneralData,
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
requestState,
|
||||
decisionHistory,
|
||||
decisionDetail,
|
||||
diplomacyHistory,
|
||||
diplomacyEvent,
|
||||
policyHistory,
|
||||
policyVersion,
|
||||
generalLogs,
|
||||
cityDetail,
|
||||
generalDetail,
|
||||
generalTurns,
|
||||
nationSeries,
|
||||
nationSnapshot: auditProcedure
|
||||
.input(z.object({ nationId: z.number().int().nonnegative(), at: zAuditMonth }).strict())
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const row = sample
|
||||
? await tx.playAuditNation.findUnique({
|
||||
where: { sampleId_nationId: { sampleId: sample.id, nationId: input.nationId } },
|
||||
select: { data: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
nation: row ? zAuditNation.parse(row.data) : null,
|
||||
};
|
||||
})
|
||||
),
|
||||
nations: auditProcedure.input(zAuditPage.omit({ nationId: true })).query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const identity = z.object({ id: z.number(), name: z.string(), color: z.string() });
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const rows = sample
|
||||
? await tx.playAuditNation.findMany({
|
||||
where: {
|
||||
sampleId: sample.id,
|
||||
nationId: input.cursor === undefined ? undefined : { gt: input.cursor },
|
||||
},
|
||||
orderBy: { nationId: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: { data: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
collected: Boolean(sample),
|
||||
...pageResult(
|
||||
rows.map((row) => identity.parse(row.data)),
|
||||
input.limit,
|
||||
(row) => row.id
|
||||
),
|
||||
};
|
||||
}
|
||||
const rows = await tx.nation.findMany({
|
||||
where: { id: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
||||
orderBy: { id: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: { id: true, name: true, color: true },
|
||||
});
|
||||
return { ...world, collected: true, ...pageResult(rows, input.limit, (row) => row.id) };
|
||||
})
|
||||
),
|
||||
capabilities: auditProcedure.query(({ ctx }) => ({
|
||||
profileName: ctx.profile.name,
|
||||
read: true,
|
||||
accounts: canReadPlayAuditAccounts(ctx.auth!.user.roles, ctx.profile.name),
|
||||
})),
|
||||
coverage: auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({ cursor: zAuditMonth.optional(), limit: z.number().int().min(1).max(200).default(50) })
|
||||
.strict()
|
||||
.default({ limit: 50 })
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const samples = world.serverId
|
||||
? await tx.playAuditMonth.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
...(input.cursor
|
||||
? {
|
||||
OR: [
|
||||
{ year: { gt: input.cursor.year } },
|
||||
{ year: input.cursor.year, month: { gt: input.cursor.month } },
|
||||
{
|
||||
year: input.cursor.year,
|
||||
month: input.cursor.month,
|
||||
kind: { gt: input.cursor.kind },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
take: input.limit + 1,
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }, { kind: 'asc' }],
|
||||
select: { year: true, month: true, kind: true, settlementsComplete: true, createdAt: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
status: !world.serverId
|
||||
? ('IDENTITY_MISSING' as const)
|
||||
: samples.length
|
||||
? ('COLLECTED' as const)
|
||||
: input.cursor
|
||||
? ('PAGE_EMPTY' as const)
|
||||
: ('NOT_COLLECTED' as const),
|
||||
samples: samples.slice(0, input.limit),
|
||||
nextCursor:
|
||||
samples.length > input.limit
|
||||
? {
|
||||
year: samples[input.limit - 1]!.year,
|
||||
month: samples[input.limit - 1]!.month,
|
||||
kind: z.enum(['MONTH_END', 'FINAL', 'INITIAL']).parse(samples[input.limit - 1]!.kind),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
),
|
||||
generals: auditProcedure
|
||||
.input(
|
||||
zAuditPage.extend({
|
||||
cityId: z.number().int().nonnegative().optional(),
|
||||
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
|
||||
name: z.string().trim().max(64).optional(),
|
||||
order: z.enum(['asc', 'desc']).default('asc'),
|
||||
})
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const npcState =
|
||||
input.population === 'human'
|
||||
? { lt: 2 }
|
||||
: input.population === 'npc'
|
||||
? { gte: 2, not: 5 }
|
||||
: input.population === 'troopNpc'
|
||||
? 5
|
||||
: undefined;
|
||||
const filter = { nationId: input.nationId, cityId: input.cityId, npcState };
|
||||
// LIKE wildcard도 이름의 문자로 취급한다. 과거 검색은 선택한 표본 안에서만 수행한다.
|
||||
const name = input.name?.replace(/[\\%_]/g, '\\$&');
|
||||
const idRange =
|
||||
input.cursor === undefined
|
||||
? undefined
|
||||
: input.order === 'desc'
|
||||
? { lt: input.cursor }
|
||||
: { gt: input.cursor };
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const rows = sample
|
||||
? await tx.playAuditGeneral.findMany({
|
||||
where: {
|
||||
sampleId: sample.id,
|
||||
...filter,
|
||||
generalId: idRange,
|
||||
data: name ? { path: ['name'], string_contains: name } : undefined,
|
||||
},
|
||||
orderBy: { generalId: input.order },
|
||||
take: input.limit + 1,
|
||||
select: { data: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
...pageResult(
|
||||
rows.map((row) => zAuditGeneralData.parse(row.data)),
|
||||
input.limit,
|
||||
(row) => row.id
|
||||
),
|
||||
};
|
||||
}
|
||||
const rows = await tx.general.findMany({
|
||||
where: { ...filter, id: idRange, name: name ? { contains: name } : undefined },
|
||||
orderBy: { id: input.order },
|
||||
take: input.limit + 1,
|
||||
select: generalSelect,
|
||||
});
|
||||
return {
|
||||
...world,
|
||||
sample: null,
|
||||
collected: true,
|
||||
...pageResult(rows.map(projectCurrentGeneral), input.limit, (row) => row.id),
|
||||
};
|
||||
})
|
||||
),
|
||||
cities: auditProcedure.input(zAuditPage).query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const rows = sample
|
||||
? await tx.playAuditCity.findMany({
|
||||
where: {
|
||||
sampleId: sample.id,
|
||||
nationId: input.nationId,
|
||||
cityId: input.cursor === undefined ? undefined : { gt: input.cursor },
|
||||
},
|
||||
orderBy: { cityId: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: { data: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
...pageResult(
|
||||
rows.map((row) => zAuditCityData.parse(row.data)),
|
||||
input.limit,
|
||||
(row) => row.id
|
||||
),
|
||||
};
|
||||
}
|
||||
const rows = await tx.city.findMany({
|
||||
where: { nationId: input.nationId, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
||||
orderBy: { id: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: citySelect,
|
||||
});
|
||||
return {
|
||||
...world,
|
||||
sample: null,
|
||||
collected: true,
|
||||
...pageResult(rows.map(projectCurrentCity), input.limit, (row) => row.id),
|
||||
};
|
||||
})
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld } from './shared.js';
|
||||
|
||||
const categoryByType = {
|
||||
generalHistory: 'HISTORY',
|
||||
generalAction: 'ACTION',
|
||||
battleResult: 'BATTLE_BRIEF',
|
||||
battleDetail: 'BATTLE_DETAIL',
|
||||
} as const;
|
||||
export const generalLogs = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
generalId: z.number().int().nonnegative(),
|
||||
type: z.enum(['generalHistory', 'generalAction', 'battleResult', 'battleDetail']),
|
||||
month: z
|
||||
.object({ year: z.number().int().min(0).max(9999), month: z.number().int().min(1).max(12) })
|
||||
.strict()
|
||||
.optional(),
|
||||
cursor: z.number().int().positive().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
if (
|
||||
input.month &&
|
||||
(monthOrdinal(input.month.year, input.month.month) < monthOrdinal(world.startYear, world.startMonth) ||
|
||||
monthOrdinal(input.month.year, input.month.month) > monthOrdinal(world.year, world.month))
|
||||
) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 로그 월을 선택해 주세요.' });
|
||||
}
|
||||
const rows = world.serverId
|
||||
? await tx.logEntry.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
generalId: input.generalId,
|
||||
scope: 'GENERAL',
|
||||
category: categoryByType[input.type],
|
||||
id: input.cursor === undefined ? undefined : { lt: input.cursor },
|
||||
year: input.month?.year,
|
||||
month: input.month?.month,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: input.limit + 1,
|
||||
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
type: input.type,
|
||||
coverage: world.serverId ? ('IDENTIFIED_LOGS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
items: rows.slice(0, input.limit),
|
||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.id : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,171 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
|
||||
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
|
||||
const zPopulation = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
gold: z.number(),
|
||||
rice: z.number(),
|
||||
dex: zDex,
|
||||
averageGold: z.number().nullable(),
|
||||
averageRice: z.number().nullable(),
|
||||
averageDex: z.object({
|
||||
dex1: z.number().nullable(),
|
||||
dex2: z.number().nullable(),
|
||||
dex3: z.number().nullable(),
|
||||
dex4: z.number().nullable(),
|
||||
dex5: z.number().nullable(),
|
||||
}),
|
||||
});
|
||||
export const zAuditNation = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
gold: z.number(),
|
||||
rice: z.number(),
|
||||
tech: z.number(),
|
||||
appliedRate: z.number(),
|
||||
incomeGold: z.number().nullable(),
|
||||
incomeRice: z.number().nullable(),
|
||||
paidGold: z.number().nullable(),
|
||||
paidRice: z.number().nullable(),
|
||||
populations: z.object({ human: zPopulation, npc: zPopulation, troopNpc: zPopulation }),
|
||||
});
|
||||
export type AuditNationData = z.infer<typeof zAuditNation>;
|
||||
export interface NationMonthPoint {
|
||||
ordinal: number;
|
||||
collected: boolean;
|
||||
settlementsComplete: boolean;
|
||||
data: AuditNationData | null;
|
||||
}
|
||||
const dateOf = (ordinal: number): { year: number; month: number } => ({
|
||||
year: Math.floor(ordinal / 12),
|
||||
month: (ordinal % 12) + 1,
|
||||
});
|
||||
const flowKeys = ['incomeGold', 'incomeRice', 'paidGold', 'paidRice'] as const;
|
||||
|
||||
export const summarizeNationPeriod = (start: number, width: number, months: NationMonthPoint[]) => {
|
||||
let latest: NationMonthPoint | undefined;
|
||||
for (const point of months) if (point.data !== null) latest = point;
|
||||
const complete = months.length === width && months.every((point) => point.collected && point.data !== null);
|
||||
const flows: Record<(typeof flowKeys)[number], number | null> = {
|
||||
incomeGold: null,
|
||||
incomeRice: null,
|
||||
paidGold: null,
|
||||
paidRice: null,
|
||||
};
|
||||
for (const key of flowKeys) {
|
||||
if (
|
||||
months.length > 0 &&
|
||||
months.every((point) => point.collected && point.settlementsComplete && point.data?.[key] != null)
|
||||
) {
|
||||
flows[key] = months.reduce((sum, point) => sum + point.data![key]!, 0);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...dateOf(start),
|
||||
periodMonths: width,
|
||||
complete,
|
||||
from: months.length ? dateOf(months[0]!.ordinal) : null,
|
||||
to: months.length ? dateOf(months[months.length - 1]!.ordinal) : null,
|
||||
stockAsOf: latest ? dateOf(latest.ordinal) : null,
|
||||
stock: latest?.data
|
||||
? {
|
||||
id: latest.data.id,
|
||||
name: latest.data.name,
|
||||
color: latest.data.color,
|
||||
gold: latest.data.gold,
|
||||
rice: latest.data.rice,
|
||||
tech: latest.data.tech,
|
||||
appliedRate: latest.data.appliedRate,
|
||||
populations: latest.data.populations,
|
||||
}
|
||||
: null,
|
||||
flows,
|
||||
months: months.map((point) => ({
|
||||
...dateOf(point.ordinal),
|
||||
collected: point.collected,
|
||||
nationPresent: point.data !== null,
|
||||
settlementsComplete: point.settlementsComplete,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const zCalendarMonth = zAuditMonth.omit({ kind: true });
|
||||
export const nationSeries = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
nationId: z.number().int().nonnegative(),
|
||||
from: zCalendarMonth.optional(),
|
||||
to: zCalendarMonth.optional(),
|
||||
resolution: z.enum(['month', 'halfYear']).default('halfYear'),
|
||||
cursor: zCalendarMonth.optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const current = monthOrdinal(world.year, world.month);
|
||||
const from = input.from
|
||||
? monthOrdinal(input.from.year, input.from.month)
|
||||
: Math.max(monthOrdinal(world.startYear, world.startMonth), current - 5);
|
||||
const to = input.to ? monthOrdinal(input.to.year, input.to.month) : current;
|
||||
if (from < monthOrdinal(world.startYear, world.startMonth) || to > current || from > to) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안에서 시작·종료 월을 선택해 주세요.' });
|
||||
}
|
||||
const width = input.resolution === 'month' ? 1 : 6;
|
||||
const first = Math.floor(from / width) * width;
|
||||
const start = input.cursor ? monthOrdinal(input.cursor.year, input.cursor.month) : first;
|
||||
if (start < first || start > to || start % width !== 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '조회 기간에 맞는 다음 페이지를 선택해 주세요.' });
|
||||
}
|
||||
const end = Math.min(to, start + input.limit * width - 1);
|
||||
const low = dateOf(Math.max(from, start));
|
||||
const high = dateOf(end);
|
||||
const samples = world.serverId
|
||||
? await tx.playAuditMonth.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
kind: 'MONTH_END',
|
||||
AND: [
|
||||
{ OR: [{ year: { gt: low.year } }, { year: low.year, month: { gte: low.month } }] },
|
||||
{ OR: [{ year: { lt: high.year } }, { year: high.year, month: { lte: high.month } }] },
|
||||
],
|
||||
},
|
||||
select: { id: true, year: true, month: true, settlementsComplete: true },
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }],
|
||||
take: input.limit * width,
|
||||
})
|
||||
: [];
|
||||
const nations = samples.length
|
||||
? await tx.playAuditNation.findMany({
|
||||
where: {
|
||||
sampleId: { in: samples.map((sample) => sample.id) },
|
||||
nationId: input.nationId,
|
||||
},
|
||||
select: { sampleId: true, data: true },
|
||||
})
|
||||
: [];
|
||||
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
|
||||
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
|
||||
const items: ReturnType<typeof summarizeNationPeriod>[] = [];
|
||||
for (let period = start; period <= end; period += width) {
|
||||
const months: NationMonthPoint[] = [];
|
||||
for (let ordinal = Math.max(from, period); ordinal <= Math.min(end, period + width - 1); ordinal++) {
|
||||
const sample = sampleByMonth.get(ordinal);
|
||||
months.push({
|
||||
ordinal,
|
||||
collected: Boolean(sample),
|
||||
settlementsComplete: sample?.settlementsComplete ?? false,
|
||||
data: sample ? (dataBySample.get(sample.id) ?? null) : null,
|
||||
});
|
||||
}
|
||||
items.push(summarizeNationPeriod(period, width, months));
|
||||
}
|
||||
return { ...world, items, nextCursor: end < to ? dateOf(start + input.limit * width) : null };
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||
|
||||
const zArea = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE']);
|
||||
const zMonth = zAuditMonth.omit({ kind: true });
|
||||
const zActor = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
name: z.string(),
|
||||
nationId: z.number().int(),
|
||||
officerLevel: z.number().int(),
|
||||
npcState: z.number().int(),
|
||||
})
|
||||
.nullable();
|
||||
const summarySelect = {
|
||||
id: true,
|
||||
schemaVersion: true,
|
||||
nationId: true,
|
||||
area: true,
|
||||
revision: true,
|
||||
previousId: true,
|
||||
source: true,
|
||||
year: true,
|
||||
month: true,
|
||||
actor: true,
|
||||
createdAt: true,
|
||||
} satisfies GamePrisma.PlayAuditPolicySelect;
|
||||
type Summary = GamePrisma.PlayAuditPolicyGetPayload<{ select: typeof summarySelect }>;
|
||||
|
||||
/** 향후 자국 권한 API의 공개 정보 경계. 실제 자국 인가는 호출부에서 별도로 수행한다. */
|
||||
export const projectPolicySummary = (row: Summary) => {
|
||||
if (row.schemaVersion !== 1)
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 정책 기록 버전입니다.' });
|
||||
return {
|
||||
id: row.id,
|
||||
nationId: row.nationId,
|
||||
area: zArea.parse(row.area),
|
||||
revision: row.revision,
|
||||
previousId: row.previousId,
|
||||
source: z.enum(['BASELINE', 'CHANGE', 'OBSERVED_GAP']).parse(row.source),
|
||||
year: row.year,
|
||||
month: row.month,
|
||||
actor: zActor.parse(row.actor),
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
};
|
||||
|
||||
/** 계정·요청·내부 진단 값을 제외한 정책 전후 projection이다. */
|
||||
export const projectPolicyConfiguration = (
|
||||
row: Summary & { before: GamePrisma.JsonValue; after: GamePrisma.JsonValue }
|
||||
) => {
|
||||
const before = row.before === null ? null : asRecord(row.before);
|
||||
const after = asRecord(row.after);
|
||||
return {
|
||||
...projectPolicySummary(row),
|
||||
fields: [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])].sort().map((key) => ({
|
||||
key,
|
||||
beforeJson: before === null ? null : JSON.stringify(before[key] ?? null),
|
||||
afterJson: JSON.stringify(after[key] ?? null),
|
||||
changed: before !== null && JSON.stringify(before[key] ?? null) !== JSON.stringify(after[key] ?? null),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const policyHistory = auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
nationId: z.number().int().nonnegative(),
|
||||
area: zArea,
|
||||
from: zMonth,
|
||||
to: zMonth,
|
||||
cursor: z.number().int().positive().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const from = monthOrdinal(input.from.year, input.from.month);
|
||||
const to = monthOrdinal(input.to.year, input.to.month);
|
||||
if (
|
||||
from > to ||
|
||||
from < monthOrdinal(world.startYear, world.startMonth) ||
|
||||
to > monthOrdinal(world.year, world.month)
|
||||
)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 정책 조회 기간을 선택해 주세요.' });
|
||||
const rows = world.serverId
|
||||
? await tx.playAuditPolicy.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
nationId: input.nationId,
|
||||
area: input.area,
|
||||
revision: input.cursor === undefined ? undefined : { lt: input.cursor },
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ year: { gt: input.from.year } },
|
||||
{ year: input.from.year, month: { gte: input.from.month } },
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{ year: { lt: input.to.year } },
|
||||
{ year: input.to.year, month: { lte: input.to.month } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { revision: 'desc' },
|
||||
take: input.limit + 1,
|
||||
select: summarySelect,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||
items: rows.slice(0, input.limit).map(projectPolicySummary),
|
||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const policyVersion = auditProcedure
|
||||
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const row = world.serverId
|
||||
? await tx.playAuditPolicy.findFirst({
|
||||
where: { id: input.id, serverId: world.serverId },
|
||||
select: {
|
||||
...summarySelect,
|
||||
tick: true,
|
||||
ordinal: true,
|
||||
before: true,
|
||||
after: true,
|
||||
requestId: true,
|
||||
inputSequence: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 정책 버전을 찾을 수 없습니다.' });
|
||||
return {
|
||||
...world,
|
||||
version: {
|
||||
...projectPolicyConfiguration(row),
|
||||
tick: row.tick?.toString() ?? null,
|
||||
ordinal: row.ordinal,
|
||||
requestId: row.requestId,
|
||||
inputSequence: row.inputSequence?.toString() ?? null,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
import { z } from 'zod';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
const dex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
|
||||
export const zAuditGeneralData = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
userId: z.string().nullable(),
|
||||
nationId: z.number(),
|
||||
cityId: z.number(),
|
||||
troopId: z.number(),
|
||||
npcState: z.number(),
|
||||
gold: z.number(),
|
||||
rice: z.number(),
|
||||
stats: z.object({ leadership: z.number(), strength: z.number(), intelligence: z.number() }),
|
||||
experience: z.number(),
|
||||
dedication: z.number(),
|
||||
officerLevel: z.number(),
|
||||
injury: z.number(),
|
||||
age: z.number(),
|
||||
crew: z.number(),
|
||||
crewTypeId: z.number(),
|
||||
train: z.number(),
|
||||
atmos: z.number(),
|
||||
dex,
|
||||
role: z.object({
|
||||
personality: z.string().nullable(),
|
||||
specialDomestic: z.string().nullable(),
|
||||
specialWar: z.string().nullable(),
|
||||
items: z.object({
|
||||
horse: z.string().nullable(),
|
||||
weapon: z.string().nullable(),
|
||||
book: z.string().nullable(),
|
||||
item: z.string().nullable(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export const zAuditCityData = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
nationId: z.number(),
|
||||
level: z.number(),
|
||||
state: z.number(),
|
||||
population: z.number(),
|
||||
populationMax: z.number(),
|
||||
agriculture: z.number(),
|
||||
agricultureMax: z.number(),
|
||||
commerce: z.number(),
|
||||
commerceMax: z.number(),
|
||||
security: z.number(),
|
||||
securityMax: z.number(),
|
||||
wall: z.number(),
|
||||
wallMax: z.number(),
|
||||
defence: z.number(),
|
||||
defenceMax: z.number(),
|
||||
supplyState: z.number(),
|
||||
frontState: z.number(),
|
||||
trust: z.number(),
|
||||
});
|
||||
|
||||
export const generalSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
npcState: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
injury: true,
|
||||
age: true,
|
||||
crew: true,
|
||||
crewTypeId: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
meta: true,
|
||||
} satisfies GamePrisma.GeneralSelect;
|
||||
export const citySelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
level: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
trust: true,
|
||||
meta: true,
|
||||
} satisfies GamePrisma.CitySelect;
|
||||
const code = (value: string): string | null => (value === 'None' ? null : value);
|
||||
export const projectCurrentGeneral = (
|
||||
row: GamePrisma.GeneralGetPayload<{ select: typeof generalSelect }>
|
||||
): z.infer<typeof zAuditGeneralData> => {
|
||||
const meta = asRecord(row.meta);
|
||||
return zAuditGeneralData.parse({
|
||||
...row,
|
||||
stats: { leadership: row.leadership, strength: row.strength, intelligence: row.intel },
|
||||
dex: Object.fromEntries(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'].map((key) => [key, asNumber(meta[key], 0)])),
|
||||
role: {
|
||||
personality: code(row.personalCode),
|
||||
specialDomestic: code(row.specialCode),
|
||||
specialWar: code(row.special2Code),
|
||||
items: {
|
||||
horse: code(row.horseCode),
|
||||
weapon: code(row.weaponCode),
|
||||
book: code(row.bookCode),
|
||||
item: code(row.itemCode),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
export const projectCurrentCity = (
|
||||
row: GamePrisma.CityGetPayload<{ select: typeof citySelect }>
|
||||
): z.infer<typeof zAuditCityData> =>
|
||||
zAuditCityData.parse({ ...row, state: Math.floor(asNumber(asRecord(row.meta).state, 0)) });
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { auditProcedure, readAudit, readAuditWorld } from './shared.js';
|
||||
|
||||
interface RequestStateRow {
|
||||
sequence: bigint;
|
||||
requestId: string;
|
||||
target: 'API' | 'ENGINE';
|
||||
eventType: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
|
||||
attempts: number;
|
||||
acceptedGameTick: bigint | null;
|
||||
processingGameTick: bigint | null;
|
||||
acceptedClockRevision: bigint | null;
|
||||
processingClockRevision: bigint | null;
|
||||
createdAt: Date;
|
||||
processingAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
resultRecorded: boolean;
|
||||
errorRecorded: boolean;
|
||||
}
|
||||
|
||||
/** 현재 기수의 불변 사건이 참조한 요청만 읽는다. 임의 요청 ID 검색이나 원문 조회는 제공하지 않는다. */
|
||||
export const requestState = auditProcedure
|
||||
.input(z.object({ kind: z.enum(['POLICY', 'DIPLOMACY']), id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const reference = world.serverId
|
||||
? await (input.kind === 'POLICY'
|
||||
? tx.playAuditPolicy.findFirst({
|
||||
where: { id: input.id, serverId: world.serverId },
|
||||
select: { requestId: true, inputSequence: true },
|
||||
})
|
||||
: tx.playAuditDiplomacyEvent.findFirst({
|
||||
where: { id: input.id, serverId: world.serverId },
|
||||
select: { requestId: true, inputSequence: true },
|
||||
}))
|
||||
: null;
|
||||
if (!reference)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 감사 기록을 찾을 수 없습니다.' });
|
||||
const base = { ...world, coverage: 'CURRENT_JOURNAL_STATE' as const };
|
||||
if (!reference.requestId) return { ...base, status: 'NOT_LINKED' as const, request: null };
|
||||
if (reference.inputSequence === null)
|
||||
return { ...base, status: 'INCOMPLETE_REFERENCE' as const, request: null };
|
||||
// unique request_id로 한 행만 읽고 원문 payload/result/error/계정/lease owner는 materialize하지 않는다.
|
||||
const rows = await tx.$queryRaw<RequestStateRow[]>`
|
||||
SELECT sequence, request_id AS "requestId", target::text AS target, event_type AS "eventType", status::text AS status, attempts,
|
||||
accepted_game_tick AS "acceptedGameTick", processing_game_tick AS "processingGameTick",
|
||||
accepted_clock_revision AS "acceptedClockRevision", processing_clock_revision AS "processingClockRevision",
|
||||
created_at AS "createdAt", processing_at AS "processingAt", completed_at AS "completedAt",
|
||||
result IS NOT NULL AS "resultRecorded", error IS NOT NULL AS "errorRecorded"
|
||||
FROM input_event WHERE request_id = ${reference.requestId} LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return { ...base, status: 'MISSING_REQUEST' as const, request: null };
|
||||
if (row.sequence !== reference.inputSequence)
|
||||
return { ...base, status: 'REFERENCE_MISMATCH' as const, request: null };
|
||||
return {
|
||||
...base,
|
||||
status: 'AVAILABLE' as const,
|
||||
request: {
|
||||
...row,
|
||||
sequence: row.sequence.toString(),
|
||||
acceptedGameTick: row.acceptedGameTick?.toString() ?? null,
|
||||
processingGameTick: row.processingGameTick?.toString() ?? null,
|
||||
acceptedClockRevision: row.acceptedClockRevision?.toString() ?? null,
|
||||
processingClockRevision: row.processingClockRevision?.toString() ?? null,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord, canReadPlayAudit } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { readOnlyAuthedProcedure } from '../../trpc.js';
|
||||
|
||||
export const auditProcedure = readOnlyAuthedProcedure.use(({ ctx, next }) => {
|
||||
if (
|
||||
!ctx.auth ||
|
||||
ctx.auth.profile !== ctx.profile.name ||
|
||||
!canReadPlayAudit(ctx.auth.user.roles, ctx.profile.name)
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이 프로필의 플레이 감사 권한이 필요합니다.' });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
export const zAuditMonth = z
|
||||
.object({
|
||||
year: z.number().int().min(0).max(9999),
|
||||
month: z.number().int().min(1).max(12),
|
||||
kind: z.enum(['MONTH_END', 'FINAL', 'INITIAL']).default('MONTH_END'),
|
||||
})
|
||||
.strict();
|
||||
export const zAuditPage = z
|
||||
.object({
|
||||
at: zAuditMonth.optional(),
|
||||
nationId: z.number().int().nonnegative().optional(),
|
||||
cursor: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict();
|
||||
export const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
export const readAudit = async <T>(
|
||||
ctx: GameApiContext,
|
||||
read: (tx: GamePrisma.TransactionClient) => Promise<T>
|
||||
): Promise<T> => {
|
||||
if (!ctx.db.$transaction)
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '감사 조회 transaction을 사용할 수 없습니다.' });
|
||||
try {
|
||||
return await ctx.db.$transaction(read, { isolationLevel: 'RepeatableRead', maxWait: 2000, timeout: 5000 });
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
if (error && typeof error === 'object' && 'code' in error && ['P2028', 'P2034'].includes(String(error.code))) {
|
||||
throw new TRPCError({ code: 'TIMEOUT', message: '조회가 지연되었습니다. 기간을 줄여 다시 조회해 주세요.' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
|
||||
const world = await tx.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
lastTurnTick: true,
|
||||
meta: true,
|
||||
config: true,
|
||||
},
|
||||
});
|
||||
if (!world) throw new TRPCError({ code: 'NOT_FOUND', message: '게임 상태가 없습니다.' });
|
||||
const meta = asRecord(world.meta);
|
||||
const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId : null;
|
||||
const scenario = asRecord(meta.scenarioMeta);
|
||||
const config = asRecord(world.config);
|
||||
const scenarioStartYear =
|
||||
typeof scenario.startYear === 'number'
|
||||
? scenario.startYear
|
||||
: typeof asRecord(config.scenarioMeta).startYear === 'number'
|
||||
? Number(asRecord(config.scenarioMeta).startYear)
|
||||
: world.currentYear;
|
||||
// 동기화 개방은 시나리오 시작 전년도에 시작할 수 있다. 저장된 실제 달력을
|
||||
// 조회 경계로 쓰며 gameplay 규칙인 scenario.startYear는 변경하지 않는다.
|
||||
const hasInitialCalendar =
|
||||
typeof meta.initYear === 'number' &&
|
||||
Number.isInteger(meta.initYear) &&
|
||||
meta.initYear >= 0 &&
|
||||
typeof meta.initMonth === 'number' &&
|
||||
Number.isInteger(meta.initMonth) &&
|
||||
meta.initMonth >= 1 &&
|
||||
meta.initMonth <= 12 &&
|
||||
monthOrdinal(meta.initYear, meta.initMonth) <= monthOrdinal(world.currentYear, world.currentMonth);
|
||||
const startYear = hasInitialCalendar
|
||||
? Number(meta.initYear)
|
||||
: Number.isInteger(scenarioStartYear) && scenarioStartYear >= 0
|
||||
? Math.min(scenarioStartYear, world.currentYear)
|
||||
: world.currentYear;
|
||||
const startMonth = hasInitialCalendar ? Number(meta.initMonth) : 1;
|
||||
const collection = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
serverId: z.string(),
|
||||
year: z.number().int().nonnegative(),
|
||||
month: z.number().int().min(1).max(12),
|
||||
tick: z.number().int().nonnegative(),
|
||||
observedAt: z.string().datetime(),
|
||||
})
|
||||
.safeParse(meta.playAuditCollection);
|
||||
const collectionStart =
|
||||
collection.success &&
|
||||
collection.data.serverId === serverId &&
|
||||
monthOrdinal(collection.data.year, collection.data.month) <= monthOrdinal(world.currentYear, world.currentMonth)
|
||||
? {
|
||||
year: collection.data.year,
|
||||
month: collection.data.month,
|
||||
tick: String(collection.data.tick),
|
||||
observedAt: collection.data.observedAt,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
serverId,
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
startYear,
|
||||
startMonth,
|
||||
collectionStart,
|
||||
tick: world.lastTurnTick?.toString() ?? null,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
export type AuditWorld = Awaited<ReturnType<typeof readAuditWorld>>;
|
||||
|
||||
export const findAuditMonth = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
world: AuditWorld,
|
||||
at: z.infer<typeof zAuditMonth>
|
||||
) => {
|
||||
const ordinal = monthOrdinal(at.year, at.month);
|
||||
if (ordinal < monthOrdinal(world.startYear, world.startMonth) || ordinal > monthOrdinal(world.year, world.month)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수의 게임 연월을 선택해 주세요.' });
|
||||
}
|
||||
if (!world.serverId) return null;
|
||||
const sample = await tx.playAuditMonth.findUnique({
|
||||
where: {
|
||||
serverId_year_month_kind: {
|
||||
serverId: world.serverId,
|
||||
year: at.year,
|
||||
month: at.month,
|
||||
kind: at.kind,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
year: true,
|
||||
month: true,
|
||||
kind: true,
|
||||
tick: true,
|
||||
settlementsComplete: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return sample ? { ...sample, tick: sample.tick?.toString() ?? null } : null;
|
||||
};
|
||||
|
||||
export const pageResult = <T>(
|
||||
rows: T[],
|
||||
limit: number,
|
||||
getId: (row: T) => number
|
||||
): { items: T[]; nextCursor: number | null } => {
|
||||
const items = rows.slice(0, limit);
|
||||
return { items, nextCursor: rows.length > limit ? getId(items[items.length - 1]!) : null };
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findLegacyHallRows,
|
||||
isLegacyArchiveProfile,
|
||||
} from '../../services/legacyArchiveStore.js';
|
||||
import { loadScenarioItemOrder, orderScenarioItemEntries } from '../../services/scenarioItemOrder.js';
|
||||
|
||||
const DEFAULT_BG_COLOR = '#330000';
|
||||
const DEFAULT_FG_COLOR = '#ffffff';
|
||||
@@ -98,7 +99,7 @@ export const rankingRouter = router({
|
||||
.optional()
|
||||
).query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
select: { meta: true, config: true },
|
||||
select: { meta: true, config: true, scenarioCode: true },
|
||||
});
|
||||
const meta = asRecord(worldState?.meta);
|
||||
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
||||
@@ -309,6 +310,7 @@ export const rankingRouter = router({
|
||||
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||
}
|
||||
const itemOrder = await loadScenarioItemOrder(worldState?.scenarioCode ?? '');
|
||||
const activeAuctions = await ctx.db.auction.findMany({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
@@ -330,7 +332,7 @@ export const rankingRouter = router({
|
||||
item: '도 구',
|
||||
} as const;
|
||||
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
||||
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
|
||||
const configuredItems = orderScenarioItemEntries(uniqueConfig.allItems[slot] ?? {}, itemOrder).reverse();
|
||||
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
|
||||
const item = itemRegistry.get(itemKey);
|
||||
if (!item || item.buyable) {
|
||||
|
||||
@@ -5,12 +5,18 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||
import {
|
||||
ensureActiveRedisClockFence,
|
||||
ensureBettingRedisClockFence,
|
||||
ensureTournamentParticipationRedisClockFence,
|
||||
} from '../../services/redisClockFence.js';
|
||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
@@ -36,13 +42,86 @@ const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown
|
||||
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
|
||||
};
|
||||
|
||||
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
||||
const adminProcedure = engineAuthedProcedure
|
||||
.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
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: (gameTime: CurrentGameTime) => Promise<T>,
|
||||
ensureFence = ensureActiveRedisClockFence
|
||||
): Promise<T> => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const fence = await ensureFence(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(gameTime) : store.withMutationLock(() => operation(gameTime))
|
||||
);
|
||||
};
|
||||
|
||||
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({
|
||||
stage: z.number().int().min(0),
|
||||
@@ -143,7 +222,10 @@ export const tournamentRouter = router({
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.getState();
|
||||
}),
|
||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||
getAdminStatus: adminProcedure.query(async ({ ctx }) => ({
|
||||
ok: true,
|
||||
clock: await loadClockAdminStatus(ctx.db),
|
||||
})),
|
||||
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
@@ -269,9 +351,32 @@ export const tournamentRouter = router({
|
||||
return { prefix, ...tournamentRankInfo[prefix], entries };
|
||||
});
|
||||
}),
|
||||
start: adminProcedure.input(z.void()).mutation(async ({ ctx }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return withTournamentClockMutation(ctx, store, async (gameTime) => {
|
||||
const [current, world] = await Promise.all([store.getState(), ctx.db.worldState.findFirst()]);
|
||||
if (!world) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
// 브라우저 WALL 시각을 GAME 일정으로 저장하지 않고 검증한 서버 시계로 시작한다.
|
||||
await store.setState({
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: world.currentYear,
|
||||
openMonth: world.currentMonth,
|
||||
termSeconds: current?.termSeconds ?? 60,
|
||||
nextAt: new Date(gameTime.now.getTime() + 60_000).toISOString(),
|
||||
bettingSettled: false,
|
||||
rewardSettled: false,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}),
|
||||
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setState({
|
||||
...input,
|
||||
type: input.type as TournamentType,
|
||||
@@ -281,7 +386,7 @@ export const tournamentRouter = router({
|
||||
}),
|
||||
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
const current = await store.getState();
|
||||
if (!current) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
@@ -297,21 +402,21 @@ export const tournamentRouter = router({
|
||||
}),
|
||||
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setParticipants(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setMatches(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setBettingEntries(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
@@ -347,7 +452,7 @@ export const tournamentRouter = router({
|
||||
};
|
||||
});
|
||||
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
await store.setParticipants(participants);
|
||||
return { ok: true, count: participants.length };
|
||||
});
|
||||
@@ -391,75 +496,84 @@ export const tournamentRouter = router({
|
||||
|
||||
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 store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
||||
}
|
||||
return withTournamentClockMutation(
|
||||
ctx,
|
||||
store,
|
||||
async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
||||
}
|
||||
|
||||
const [participants, worldState] = await Promise.all([
|
||||
store.getParticipants(),
|
||||
ctx.db.worldState.findFirst(),
|
||||
]);
|
||||
if (participants.some((entry) => entry.id === general.id)) {
|
||||
return { ok: true, count: participants.length };
|
||||
}
|
||||
if (participants.length >= 64) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
|
||||
}
|
||||
const [participants, worldState] = await Promise.all([
|
||||
store.getParticipants(),
|
||||
ctx.db.worldState.findFirst(),
|
||||
]);
|
||||
if (participants.some((entry) => entry.id === general.id)) {
|
||||
return { ok: true, count: participants.length };
|
||||
}
|
||||
if (participants.length >= 64) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
|
||||
}
|
||||
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const applicant = assignManualApplicantGroup({
|
||||
state,
|
||||
baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'),
|
||||
current: participants,
|
||||
applicant: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
},
|
||||
});
|
||||
const next = participants.concat(applicant);
|
||||
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const applicant = assignManualApplicantGroup({
|
||||
state,
|
||||
baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'),
|
||||
current: participants,
|
||||
applicant: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
},
|
||||
});
|
||||
const next = participants.concat(applicant);
|
||||
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
},
|
||||
ensureTournamentParticipationRedisClockFence
|
||||
);
|
||||
}),
|
||||
cancel: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentClockMutation(ctx, store, async () => {
|
||||
const state = await store.getState();
|
||||
if (!state) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
@@ -513,7 +627,10 @@ export const tournamentRouter = router({
|
||||
return { ok: true };
|
||||
});
|
||||
}),
|
||||
placeBet: authedProcedure
|
||||
// This route delegates its game mutations to durable ENGINE input events.
|
||||
// Wrapping it in the API input-event transaction would hold the clock
|
||||
// advisory lock while waiting for the daemon to claim the child event.
|
||||
placeBet: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
targetId: z.number().int().positive(),
|
||||
@@ -523,7 +640,7 @@ export const tournamentRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.withMutationLock(async () => {
|
||||
return withTournamentBetClockMutation(ctx, store, async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 6) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||
@@ -558,6 +675,7 @@ export const tournamentRouter = router({
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
});
|
||||
@@ -573,6 +691,7 @@ export const tournamentRouter = router({
|
||||
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
@@ -584,6 +703,7 @@ export const tournamentRouter = router({
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
@@ -600,11 +720,13 @@ export const tournamentRouter = router({
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveRefreshScoreText,
|
||||
resolveRemainingMinutes,
|
||||
} from '../../services/generalBasicCardProjection.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
@@ -72,7 +73,7 @@ export const troopRouter = router({
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
|
||||
}
|
||||
|
||||
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
||||
const [nation, troops, generals, cities, worldState, gameTime] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||
@@ -121,6 +122,7 @@ export const troopRouter = router({
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
@@ -214,15 +216,6 @@ export const troopRouter = router({
|
||||
const traitName = (code: string, names: Map<string, { name: string }>): string =>
|
||||
names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
|
||||
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||
const lastExecuted =
|
||||
rawLastExecuted instanceof Date
|
||||
? rawLastExecuted
|
||||
: typeof rawLastExecuted === 'string'
|
||||
? new Date(rawLastExecuted)
|
||||
: null;
|
||||
|
||||
const mappedTroops = troops
|
||||
.map((troop) => {
|
||||
const leader = generalMap.get(troop.troopLeaderId);
|
||||
@@ -231,7 +224,10 @@ export const troopRouter = router({
|
||||
name: troop.name,
|
||||
nationId: troop.nationId,
|
||||
turnTime: leader?.turnTime.toISOString() ?? null,
|
||||
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [],
|
||||
reservedCommands:
|
||||
leader?.npcState === 5
|
||||
? ['집합', '집합', '집합', '집합', '집합']
|
||||
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
|
||||
leader: leader
|
||||
? {
|
||||
id: leader.id,
|
||||
@@ -348,8 +344,7 @@ export const troopRouter = router({
|
||||
killTurn: readNumber(meta.killturn ?? meta.killTurn),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
lastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
gameTime.now
|
||||
),
|
||||
troopId: general.troopId,
|
||||
troop: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
assertReservedTurnActionAvailable,
|
||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||
buildEquipmentTradeItemOptions,
|
||||
loadEquipmentTradeItemOrder,
|
||||
parseReservedTurnArgs,
|
||||
TURN_COMMAND_NATION_COLORS,
|
||||
type TurnCommandInputOptions,
|
||||
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
traits,
|
||||
moduleBundle,
|
||||
map,
|
||||
itemOrder,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -310,6 +312,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
officerLevel: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
crew: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
@@ -327,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
loadBattleSimTraitOptions(),
|
||||
moduleBundlePromise,
|
||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||
loadEquipmentTradeItemOrder(worldState.scenarioCode),
|
||||
]);
|
||||
|
||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||
@@ -359,7 +365,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
capitalName: entry.capitalCityId ? (cityById.get(entry.capitalCityId)?.name ?? '-') : '-',
|
||||
capitalName: entry.capitalCityId ? cityById.get(entry.capitalCityId)?.name : undefined,
|
||||
level: entry.level,
|
||||
power: readGeneralMetaNumber(entry.meta, 'power') ?? 0,
|
||||
generalCount: generalCountByNation.get(entry.id) ?? 0,
|
||||
@@ -380,15 +386,23 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
||||
});
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
itemOrder,
|
||||
configConst: asRecord(asRecord(worldState.config).const),
|
||||
itemModules: moduleBundle.itemModules,
|
||||
currentSecurity: city?.security ?? 0,
|
||||
generalGold: general.gold,
|
||||
ownedItems: {
|
||||
horse: general.horseCode,
|
||||
weapon: general.weaponCode,
|
||||
book: general.bookCode,
|
||||
item: general.itemCode,
|
||||
},
|
||||
});
|
||||
const inputOptions: TurnCommandInputOptions = {
|
||||
cities: cities.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
||||
targetNames: { name: entry.name, nationName: nationById.get(entry.nationId)?.name ?? null },
|
||||
})),
|
||||
nations: nationTargetOptions.nations,
|
||||
nationTargets: nationTargetOptions.nationTargets,
|
||||
@@ -446,6 +460,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
nationGenerals,
|
||||
realNationCount: nations.filter((entry) => entry.id > 0).length,
|
||||
inputOptions,
|
||||
generalActionModules: moduleBundle.general,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
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 { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
@@ -102,18 +102,13 @@ export const hasPollEnded = (
|
||||
time: CurrentGameTime
|
||||
): boolean =>
|
||||
Boolean(poll.closed_at) ||
|
||||
(poll.end_tick !== null && time.tick !== null
|
||||
? poll.end_tick < BigInt(time.tick)
|
||||
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
|
||||
Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
|
||||
|
||||
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||
if (!date) return null;
|
||||
try {
|
||||
const tick = time.dateToTick(date);
|
||||
return tick === null ? null : BigInt(tick);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const tick = time.dateToTick(date);
|
||||
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
|
||||
return BigInt(tick);
|
||||
};
|
||||
|
||||
type VoteListRow = {
|
||||
@@ -292,7 +287,9 @@ export const voteRouter = router({
|
||||
userCnt,
|
||||
};
|
||||
}),
|
||||
submitVote: authedProcedure
|
||||
// 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
|
||||
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
|
||||
submitVote: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
voteId: z.number().int().positive(),
|
||||
@@ -358,7 +355,6 @@ export const voteRouter = router({
|
||||
voteId: input.voteId,
|
||||
generalId: general.id,
|
||||
selection: sortedSelection,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
throwIfCommandRejected(rewardResult);
|
||||
|
||||
@@ -369,7 +365,6 @@ export const voteRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
||||
}
|
||||
|
||||
ctx.changeJournal?.mark('front.general', general.id);
|
||||
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
||||
}),
|
||||
addComment: authedProcedure
|
||||
@@ -399,8 +394,6 @@ export const voteRouter = router({
|
||||
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
||||
: null;
|
||||
const nationName = nation?.name ?? '재야';
|
||||
const createdAt = new Date();
|
||||
|
||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||
INSERT INTO vote_comment (
|
||||
vote_id,
|
||||
@@ -418,7 +411,7 @@ export const voteRouter = router({
|
||||
${general.name},
|
||||
${nationName},
|
||||
${input.text},
|
||||
${createdAt}
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -451,7 +444,6 @@ export const voteRouter = router({
|
||||
if (endAt && endAt < gameTime.now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||
}
|
||||
const operationalAt = new Date();
|
||||
|
||||
let multipleOptions = input.multipleOptions;
|
||||
if (multipleOptions < 0) {
|
||||
@@ -464,7 +456,8 @@ export const voteRouter = router({
|
||||
if (input.closePrevious) {
|
||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
|
||||
SET closed_at = ${gameTime.now},
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE closed_at IS NULL
|
||||
`);
|
||||
}
|
||||
@@ -497,8 +490,8 @@ export const voteRouter = router({
|
||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||
${endAt},
|
||||
${toGameTickOrNull(gameTime, endAt)},
|
||||
${operationalAt},
|
||||
${operationalAt}
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -573,7 +566,6 @@ export const voteRouter = router({
|
||||
if (endAt && endAt < gameTime.now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||
}
|
||||
const updatedAt = new Date();
|
||||
|
||||
if (
|
||||
input.title === undefined &&
|
||||
@@ -596,7 +588,7 @@ export const voteRouter = router({
|
||||
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
||||
end_at = ${endAt ?? poll.end_at},
|
||||
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
||||
updated_at = ${updatedAt}
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${input.voteId}
|
||||
`);
|
||||
|
||||
@@ -609,10 +601,10 @@ export const voteRouter = router({
|
||||
.input(z.object({ voteId: z.number().int().positive() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const updatedAt = new Date();
|
||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
UPDATE vote_poll
|
||||
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
|
||||
SET closed_at = ${gameTime.now},
|
||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
WHERE id = ${input.voteId}
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
@@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
const zDirectorySort = z.number().int().min(1).max(15).default(9);
|
||||
|
||||
const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
|
||||
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
|
||||
return left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => {
|
||||
if (leadership < 40) {
|
||||
return strength + intel < 40 ? '무능' : '무지';
|
||||
}
|
||||
|
||||
const maxStat = Math.max(leadership, strength, intel);
|
||||
const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership);
|
||||
if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) {
|
||||
return '만능';
|
||||
}
|
||||
if (strength >= 60 && intel < strength * 0.8) {
|
||||
return '무';
|
||||
}
|
||||
if (intel >= 60 && strength < intel * 0.8) {
|
||||
return '지';
|
||||
}
|
||||
if (leadership >= 60 && strength + intel < leadership) {
|
||||
return '통';
|
||||
}
|
||||
return '평범';
|
||||
};
|
||||
|
||||
const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
|
||||
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
|
||||
return Math.max(0, Math.min(level, maxLevel));
|
||||
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
|
||||
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
|
||||
const [nations, generals, cities] = await Promise.all([
|
||||
const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
meta: true,
|
||||
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
select: { id: true, name: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
select: { generalId: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
select: { tickSeconds: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const directoryNations = nations.some((nation) => nation.id === 0)
|
||||
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
}
|
||||
const citiesByNation = new Map<number, typeof cities>();
|
||||
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
|
||||
const accessScoreByGeneralId = new Map(
|
||||
accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const)
|
||||
);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const autorunUser = asRecord(worldMeta.autorun_user);
|
||||
const worldKillturn = readNumber(worldMeta.killturn);
|
||||
const turnMinutes = readNumber(worldState?.tickSeconds) / 60;
|
||||
const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0;
|
||||
const activeKillturnThreshold = worldKillturn - autorunLimitTurns;
|
||||
for (const city of cities) {
|
||||
const list = citiesByNation.get(city.nationId) ?? [];
|
||||
list.push(city);
|
||||
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
false
|
||||
),
|
||||
}));
|
||||
const analyzedGenerals = nationGenerals.map((general) => {
|
||||
const killturn = readMetaNumber(general.meta, 'killturn');
|
||||
const inactive = general.npcState < 2 && killturn < activeKillturnThreshold;
|
||||
const accessScore = accessScoreByGeneralId.get(general.id) ?? 0;
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
leadership: general.leadership,
|
||||
type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel),
|
||||
inactive,
|
||||
accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal',
|
||||
killturn,
|
||||
} as const;
|
||||
});
|
||||
const combatGenerals = analyzedGenerals.filter(
|
||||
(general) => general.type !== '무능' && general.type !== '무지'
|
||||
);
|
||||
const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive);
|
||||
const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5);
|
||||
const generalGroups = directoryGeneralTypes
|
||||
.map((type) => ({
|
||||
type,
|
||||
label: `${type}장`,
|
||||
generals: analyzedGenerals
|
||||
.filter((general) => general.type === type)
|
||||
.sort((left, right) => {
|
||||
const leftScore = accessScoreByGeneralId.get(left.id) ?? 0;
|
||||
const rightScore = accessScoreByGeneralId.get(right.id) ?? 0;
|
||||
return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id;
|
||||
})
|
||||
.map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general),
|
||||
}))
|
||||
.filter((group) => group.generals.length > 0);
|
||||
|
||||
return {
|
||||
id: nation.id,
|
||||
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
|
||||
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
|
||||
cityCount: nationCities.length,
|
||||
forceEstimate: {
|
||||
totalGeneralCount: analyzedGenerals.length,
|
||||
userCombatGeneralCount: userCombatGenerals.length,
|
||||
userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
|
||||
npcCombatGeneralCount: npcCombatGenerals.length,
|
||||
npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
|
||||
inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length,
|
||||
},
|
||||
generalGroups,
|
||||
officers,
|
||||
ambassadorNames: secretPermissions
|
||||
.filter(({ permission }) => permission === 4)
|
||||
|
||||
+30
-10
@@ -38,6 +38,7 @@ import {
|
||||
shouldReloadRealtimeViewerIdentity,
|
||||
toPublicRealtimeEvent,
|
||||
} from './realtime/publicEvent.js';
|
||||
import { resolveNationPermission } from './router/nation/shared.js';
|
||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
||||
@@ -50,6 +51,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||
import { scopeHttpIdempotencyKey } from './requestId.js';
|
||||
import { loadClockReadiness } from './services/clockReadiness.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -312,11 +314,24 @@ export const createGameApiServer = async () => {
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
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
|
||||
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
|
||||
: { generalId: null, cityId: null, nationId: null };
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -420,12 +435,17 @@ export const createGameApiServer = async () => {
|
||||
request.raw.on('aborted', close);
|
||||
});
|
||||
|
||||
app.get('/healthz', async () => ({
|
||||
ok: true,
|
||||
profile: config.profileName,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
}));
|
||||
app.get('/healthz', async (_request, reply) => {
|
||||
const clock = await loadClockReadiness(postgres.prisma);
|
||||
if (!clock.reconciliationComplete) reply.code(503);
|
||||
return {
|
||||
ok: clock.reconciliationComplete,
|
||||
profile: config.profileName,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||
clock,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await realtimeHub.start();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
const safeInteger = (value: bigint, label: string): number => {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const loadClockReadiness = async (db: DatabaseClient) => {
|
||||
if (!db.clockProjectionOutbox) {
|
||||
return {
|
||||
reconciliationComplete: false,
|
||||
gameplayEnabled: false,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
incompleteOutboxCount: null,
|
||||
};
|
||||
}
|
||||
const [world, incompleteOutboxCount] = await Promise.all([
|
||||
db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
|
||||
}),
|
||||
db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }),
|
||||
]);
|
||||
if (!world) {
|
||||
return {
|
||||
reconciliationComplete: false,
|
||||
gameplayEnabled: false,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
incompleteOutboxCount,
|
||||
};
|
||||
}
|
||||
const phase = parseGameClockPhase(world.clockPhase);
|
||||
return {
|
||||
reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
|
||||
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
|
||||
phase,
|
||||
revision: safeInteger(world.clockRevision, 'clock revision'),
|
||||
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
|
||||
incompleteOutboxCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
||||
const readiness = await loadClockReadiness(db);
|
||||
if (!db.clockSuspension) {
|
||||
return { ...readiness, latestReconciliation: null };
|
||||
}
|
||||
const latest = await db.clockSuspension.findFirst({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
participants: { orderBy: { participantKey: 'asc' } },
|
||||
projectionOutbox: { orderBy: { id: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!latest) return { ...readiness, latestReconciliation: null };
|
||||
return {
|
||||
...readiness,
|
||||
latestReconciliation: {
|
||||
id: latest.id,
|
||||
source: latest.source,
|
||||
policy: latest.policy,
|
||||
status: latest.status,
|
||||
sourceRevision: safeInteger(latest.sourceRevision, 'source revision'),
|
||||
targetRevision: safeInteger(latest.targetRevision, 'target revision'),
|
||||
cutTick: safeInteger(latest.cutTick, 'cut tick'),
|
||||
alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'),
|
||||
participantChecksumBefore: latest.participantChecksumBefore,
|
||||
participantChecksumAfter: latest.participantChecksumAfter,
|
||||
participants: latest.participants.map((participant) => ({
|
||||
key: participant.participantKey,
|
||||
policy: participant.policy,
|
||||
beforeChecksum: participant.beforeChecksum,
|
||||
afterChecksum: participant.afterChecksum,
|
||||
affectedCount: participant.affectedCount,
|
||||
})),
|
||||
outbox: latest.projectionOutbox.map((entry) => ({
|
||||
id: entry.id.toString(),
|
||||
targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'),
|
||||
status: entry.status,
|
||||
attempts: entry.attempts,
|
||||
lastError: entry.lastError,
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
|
||||
};
|
||||
|
||||
export const flushDeferredGeneralAccessBatch = async (
|
||||
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>,
|
||||
db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
|
||||
batchId: string,
|
||||
entries: readonly DeferredGeneralAccessEntry[]
|
||||
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
|
||||
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
await markBatchProcessed(db, batchId);
|
||||
return { states: [], refreshLimit: 0 };
|
||||
}
|
||||
if (!db.$transaction) {
|
||||
throw new Error('Deferred general access persistence requires transaction support.');
|
||||
}
|
||||
const worldState = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
}))
|
||||
);
|
||||
const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1;
|
||||
const states = await db.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
|
||||
const states = await db.$transaction(async (transaction) => {
|
||||
// The turn flush and synchronous access path must enter through the same
|
||||
// schema-scoped lock before either traffic or access rows are mutated.
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
return transaction.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
|
||||
WITH inserted_batch AS (
|
||||
INSERT INTO "general_access_batch" ("id")
|
||||
VALUES (${batchId})
|
||||
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
END,
|
||||
resolved."weight"
|
||||
FROM resolved
|
||||
ORDER BY resolved."general_id"
|
||||
ON CONFLICT ("general_id") DO UPDATE SET
|
||||
"user_id" = EXCLUDED."user_id",
|
||||
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
|
||||
@@ -394,7 +402,8 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
LEFT JOIN access_updates ON access_updates."general_id" = actor."id"
|
||||
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
|
||||
ORDER BY actor."id"
|
||||
`);
|
||||
`);
|
||||
});
|
||||
return {
|
||||
states,
|
||||
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
|
||||
|
||||
@@ -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 { readTurnRuntimeReady } from '@sammo-ts/infra';
|
||||
|
||||
export interface CurrentGameTime {
|
||||
now: Date;
|
||||
wallNow: Date;
|
||||
tick: number | null;
|
||||
mode: GameClockMode | null;
|
||||
phase?: GameClockPhase | null;
|
||||
revision?: number | null;
|
||||
deadlineGeneration?: number | null;
|
||||
running: boolean;
|
||||
runtimeReady?: boolean;
|
||||
recovery?: { startsAt: string; endsAt: string } | null;
|
||||
startsAt: Date | null;
|
||||
dateToTick(date: Date): number | null;
|
||||
}
|
||||
@@ -19,6 +32,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
wallNow,
|
||||
tick: null,
|
||||
mode: null,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
dateToTick: () => null,
|
||||
@@ -31,7 +47,13 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
clockTick: true,
|
||||
clockMode: true,
|
||||
clockWallAnchor: true,
|
||||
clockRecoveryStartTick: true,
|
||||
clockRecoveryEndTick: true,
|
||||
clockRecoveryStartWallAt: true,
|
||||
tickSeconds: true,
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
},
|
||||
});
|
||||
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
||||
@@ -40,32 +62,68 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
wallNow,
|
||||
tick: null,
|
||||
mode: null,
|
||||
phase: null,
|
||||
revision: null,
|
||||
deadlineGeneration: null,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
dateToTick: () => null,
|
||||
};
|
||||
}
|
||||
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||
// During the dual-read migration an older profile (or a rolling-deploy
|
||||
// fixture) can lack clock_phase. Preserve the existing future-anchor
|
||||
// PREOPEN contract until every profile has the authoritative column.
|
||||
const phase = state.clockPhase
|
||||
? parseGameClockPhase(state.clockPhase)
|
||||
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
|
||||
? 'PREOPEN'
|
||||
: inferClockPhase(mode);
|
||||
const storedTick = Number(state.clockTick);
|
||||
if (!Number.isSafeInteger(storedTick)) {
|
||||
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
|
||||
}
|
||||
const revision = Number(state.clockRevision ?? 1n);
|
||||
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
|
||||
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
|
||||
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
|
||||
}
|
||||
const clock = new GameClock({
|
||||
baseTime: state.clockBaseTime,
|
||||
tick: storedTick,
|
||||
mode,
|
||||
wallAnchor: state.clockWallAnchor,
|
||||
recovery: readTurnRecovery(state),
|
||||
turnSeconds: state.tickSeconds,
|
||||
phase,
|
||||
revision,
|
||||
});
|
||||
const tick = clock.nowTick(wallNow);
|
||||
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
|
||||
const runtimeReady =
|
||||
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 {
|
||||
now: clock.tickToDate(tick),
|
||||
wallNow,
|
||||
tick,
|
||||
mode,
|
||||
phase,
|
||||
revision,
|
||||
deadlineGeneration,
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
|
||||
throw new Error('Traffic access persistence requires transaction support.');
|
||||
}
|
||||
await db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const periodKey = input.year * 12 + input.month - 1;
|
||||
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
|
||||
GamePrisma.sql`
|
||||
|
||||
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
|
||||
|
||||
export const resolveRemainingMinutes = (
|
||||
turnTime: Date,
|
||||
lastExecuted: Date | null,
|
||||
turnTermSeconds: number
|
||||
currentGameTime: Date | null
|
||||
): number | null => {
|
||||
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null;
|
||||
let nextTurnMillis = turnTime.getTime();
|
||||
if (nextTurnMillis < lastExecuted.getTime()) {
|
||||
nextTurnMillis += turnTermSeconds * 1_000;
|
||||
}
|
||||
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
|
||||
if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
|
||||
return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
|
||||
};
|
||||
|
||||
export interface NextTurnMonthOffsetInput {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common';
|
||||
import {
|
||||
GamePrisma,
|
||||
hashAuditDiplomacyDocument,
|
||||
persistAuditDiplomacyEvents,
|
||||
projectAuditDocumentState,
|
||||
readTurnRuntimeReady,
|
||||
type AuditDiplomacyEventDraft,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { GameApiContext, GeneralRow } from '../context.js';
|
||||
|
||||
type Letter = GamePrisma.DiplomacyLetterGetPayload<Record<string, never>>;
|
||||
type DocumentAction =
|
||||
| 'LETTER_PROPOSED'
|
||||
| 'LETTER_REPLACED'
|
||||
| 'LETTER_ACCEPTED'
|
||||
| 'LETTER_REJECTED'
|
||||
| 'LETTER_WITHDRAWN'
|
||||
| 'LETTER_DESTROY_REQUESTED'
|
||||
| 'LETTER_DESTROYED';
|
||||
|
||||
export { projectAuditDocumentState } from '@sammo-ts/infra';
|
||||
|
||||
interface AuditCoordinateRow {
|
||||
serverId: string | null;
|
||||
year: number;
|
||||
month: number;
|
||||
wallNow: Date;
|
||||
clockBaseTime: Date | null;
|
||||
clockTick: bigint | null;
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date | null;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
clockRecoveryStartTick: bigint | null;
|
||||
clockRecoveryEndTick: bigint | null;
|
||||
clockRecoveryStartWallAt: Date | null;
|
||||
tickSeconds: number;
|
||||
}
|
||||
|
||||
/** API 입력 transaction의 기존 clock fence 안에서 작은 시계/기수 투영만 읽는다. */
|
||||
const readCoordinate = async (ctx: GameApiContext) => {
|
||||
const [row] = await ctx.db.$queryRaw<AuditCoordinateRow[]>(GamePrisma.sql`
|
||||
SELECT meta->>'serverId' AS "serverId", current_year AS year, current_month AS month,
|
||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow",
|
||||
clock_base_time AS "clockBaseTime", clock_tick AS "clockTick", clock_mode AS "clockMode",
|
||||
clock_wall_anchor AS "clockWallAnchor", clock_phase AS "clockPhase", clock_revision AS "clockRevision",
|
||||
clock_recovery_start_tick AS "clockRecoveryStartTick", clock_recovery_end_tick AS "clockRecoveryEndTick",
|
||||
clock_recovery_start_wall_at AS "clockRecoveryStartWallAt", tick_seconds AS "tickSeconds"
|
||||
FROM world_state ORDER BY id LIMIT 1
|
||||
`);
|
||||
if (!row?.serverId?.trim()) return null;
|
||||
let tick: bigint | null = null;
|
||||
if (row.clockBaseTime && row.clockTick !== null && row.clockWallAnchor) {
|
||||
const clockTick = Number(row.clockTick);
|
||||
const revision = Number(row.clockRevision);
|
||||
if (!Number.isSafeInteger(clockTick) || !Number.isSafeInteger(revision))
|
||||
throw new Error('Play audit diplomacy clock outside safe integer range');
|
||||
const mode = row.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||
const phase = row.clockPhase ? parseGameClockPhase(row.clockPhase) : inferClockPhase(mode);
|
||||
const clock = new GameClock({
|
||||
baseTime: row.clockBaseTime,
|
||||
tick: clockTick,
|
||||
mode,
|
||||
wallAnchor: row.clockWallAnchor,
|
||||
recovery: readTurnRecovery(row),
|
||||
turnSeconds: row.tickSeconds,
|
||||
phase,
|
||||
revision,
|
||||
});
|
||||
const ready =
|
||||
phase !== 'RUNNING' || mode !== 'realtime' || (await readTurnRuntimeReady(ctx.db, row.clockRevision));
|
||||
tick = BigInt(ready ? clock.nowTick(row.wallNow) : clock.tick);
|
||||
}
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
year: row.year,
|
||||
month: row.month,
|
||||
tick,
|
||||
clockRevision: row.clockRevision,
|
||||
wallAt: row.wallNow,
|
||||
};
|
||||
};
|
||||
|
||||
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
||||
const changes: {
|
||||
letter: Letter;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown>;
|
||||
eventType: DocumentAction;
|
||||
}[] = [];
|
||||
return {
|
||||
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
||||
if (!ctx.auditInput) return;
|
||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||
},
|
||||
flush: async (): Promise<void> => {
|
||||
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
||||
const input = ctx.auditInput;
|
||||
if (!input || !changes.length) return;
|
||||
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
||||
throw new Error('Play audit diplomacy actor mismatch');
|
||||
const coordinate = await readCoordinate(ctx);
|
||||
if (!coordinate) return;
|
||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||
schemaVersion: 1,
|
||||
serverId: coordinate.serverId,
|
||||
srcNationId: change.letter.srcNationId,
|
||||
destNationId: change.letter.destNationId,
|
||||
category: 'DOCUMENT',
|
||||
source: 'API',
|
||||
eventType: change.eventType,
|
||||
documentId: change.letter.id,
|
||||
documentHash: hashAuditDiplomacyDocument(change.letter),
|
||||
previousDocumentId: change.letter.prevId,
|
||||
year: coordinate.year,
|
||||
month: coordinate.month,
|
||||
tick: coordinate.tick,
|
||||
clockRevision: coordinate.clockRevision,
|
||||
executionId: `api:${input.requestId}`,
|
||||
ordinal: index + 1,
|
||||
requestId: input.requestId,
|
||||
inputSequence: input.sequence,
|
||||
actor: {
|
||||
userId: actor.userId,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission,
|
||||
},
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
}));
|
||||
await persistAuditDiplomacyEvents(ctx.db, events);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
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']);
|
||||
};
|
||||
|
||||
/** 복구 대기 중에도 참가 신청은 가능하다. 자동 진행/정산에는 사용하지 않는다. */
|
||||
export const ensureTournamentParticipationRedisClockFence = async (
|
||||
redis: ClockFenceRedis,
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
): Promise<ActiveRedisClockFence | null> => 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']);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||
import {
|
||||
loadLegacyDefaultUniqueItemPool,
|
||||
resolveLegacyPurchasableItemKeys,
|
||||
} from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
|
||||
/** JSONB does not retain the item key order declared by the scenario resource. */
|
||||
export const loadScenarioItemOrder = 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)));
|
||||
if (keys.length > 0) return keys;
|
||||
|
||||
const defaultUniques = await loadLegacyDefaultUniqueItemPool();
|
||||
return [
|
||||
...resolveLegacyPurchasableItemKeys(configConst),
|
||||
...Object.values(defaultUniques).flatMap((entries) => Object.keys(entries ?? {})),
|
||||
];
|
||||
} catch (error) {
|
||||
// Old saved seasons can outlive their scenario resource. Keep the stored entries available.
|
||||
if (isRecord(error) && error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const orderScenarioItemEntries = <T>(
|
||||
entries: Record<string, T>,
|
||||
itemOrder: readonly string[] | undefined
|
||||
): [string, T][] => {
|
||||
const ordered: [string, T][] = [];
|
||||
const remaining = new Map(Object.entries(entries));
|
||||
for (const key of itemOrder ?? []) {
|
||||
if (!remaining.has(key)) continue;
|
||||
ordered.push([key, remaining.get(key)!]);
|
||||
remaining.delete(key);
|
||||
}
|
||||
return [...ordered, ...remaining];
|
||||
};
|
||||
@@ -1,32 +1,37 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
||||
|
||||
interface TurnDaemonLeaseSource {
|
||||
turnDaemonLease: {
|
||||
findUnique(input: {
|
||||
where: { profile: string };
|
||||
select: { leaseUntil: true };
|
||||
}): Promise<{ leaseUntil: Date } | null>;
|
||||
};
|
||||
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
|
||||
}
|
||||
|
||||
export const loadTurnEngineRunning = async (
|
||||
source: ProfileStatusSource | undefined,
|
||||
db: TurnDaemonLeaseSource,
|
||||
profileName: string,
|
||||
now = new Date()
|
||||
now?: Date
|
||||
): Promise<boolean | null> => {
|
||||
if (!source) return null;
|
||||
try {
|
||||
const status = await source.get(profileName);
|
||||
if (status === null) return null;
|
||||
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
||||
const lease = await db.turnDaemonLease.findUnique({
|
||||
where: { profile: profileName },
|
||||
select: { leaseUntil: true },
|
||||
});
|
||||
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
|
||||
const wallNow = now
|
||||
? GamePrisma.sql`${now}`
|
||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM turn_daemon_lease
|
||||
WHERE profile = ${profileName}
|
||||
AND lease_until > ${wallNow}
|
||||
) AS running
|
||||
`);
|
||||
return rows[0]?.running ?? false;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
|
||||
private readonly db: TurnDaemonLeaseSource,
|
||||
private readonly profileName: string,
|
||||
private readonly cacheMs = 2_000,
|
||||
private readonly now = () => Date.now()
|
||||
private readonly now = () => performance.now()
|
||||
) {}
|
||||
|
||||
get(): Promise<boolean | null> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
|
||||
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
|
||||
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
|
||||
`);
|
||||
const wallNow = rows[0]?.wallNow;
|
||||
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
|
||||
return new Date(wallNow);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
|
||||
`);
|
||||
if (rows.length === 0) return [];
|
||||
const ids = rows.map((row) => row.id);
|
||||
await tx.webPushOutbox.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||
});
|
||||
await tx.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"lock_owner" = ${this.owner},
|
||||
"attempts" = "attempts" + 1
|
||||
WHERE "id" IN (${GamePrisma.join(ids)})
|
||||
`);
|
||||
return tx.webPushOutbox.findMany({
|
||||
where: { id: { in: ids }, lockOwner: this.owner },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
|
||||
});
|
||||
|
||||
for (const event of claimed) {
|
||||
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
const expired = await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${event.id}
|
||||
AND "lock_owner" = ${this.owner}
|
||||
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
|
||||
`);
|
||||
if (expired > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||
});
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||
`);
|
||||
} catch (error) {
|
||||
const attempts = event.attempts;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
await this.db.webPushOutbox.updateMany({
|
||||
where: { id: event.id, lockOwner: this.owner },
|
||||
data: {
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
||||
},
|
||||
});
|
||||
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "web_push_outbox"
|
||||
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = ${errorText}
|
||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
||||
`);
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
if (Date.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = Date.now() + 60_000;
|
||||
if (performance.now() >= this.nextPruneAt) {
|
||||
this.nextPruneAt = performance.now() + 60_000;
|
||||
await this.db.$executeRaw(GamePrisma.sql`
|
||||
WITH expired AS (
|
||||
SELECT "id"
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface TournamentKeys {
|
||||
sourceRevisionKey: string;
|
||||
sourceRevisionChannel: string;
|
||||
realtimeEventChannel: string;
|
||||
activeClockRevisionKey: string;
|
||||
deadlineGenerationKey: string;
|
||||
clockPhaseKey: string;
|
||||
}
|
||||
|
||||
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
@@ -18,4 +21,7 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||
realtimeEventChannel: buildGameEventChannel(profileName),
|
||||
activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
||||
clockPhaseKey: `sammo:${profileName}:clock:phase`,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TournamentKeys } from './keys.js';
|
||||
@@ -27,6 +28,14 @@ export class CorruptTournamentProjectionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zTournamentState = z
|
||||
.object({
|
||||
stage: z.number().int(),
|
||||
@@ -37,10 +46,15 @@ const zTournamentState = z
|
||||
openMonth: z.number().int(),
|
||||
termSeconds: z.number(),
|
||||
nextAt: z.string(),
|
||||
nextTick: z.number().int().safe().optional(),
|
||||
clockRevision: z.number().int().positive().safe().optional(),
|
||||
deadlineGeneration: z.number().int().positive().safe().optional(),
|
||||
bettingId: z.number().int().optional(),
|
||||
bettingCloseAt: z.string().optional(),
|
||||
bettingCloseTick: z.number().int().safe().optional(),
|
||||
winnerId: z.number().int().optional(),
|
||||
bettingSettled: z.boolean().optional(),
|
||||
npcBettingPlan: z.array(zTournamentBet).optional(),
|
||||
rewardSettled: z.boolean().optional(),
|
||||
participantsLockedAt: z.string().optional(),
|
||||
lastError: z.string().optional(),
|
||||
@@ -106,14 +120,6 @@ const zTournamentMatch = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zTournamentBet = z
|
||||
.object({
|
||||
generalId: z.number().int(),
|
||||
targetId: z.number().int(),
|
||||
amount: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T>): T | null => {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
@@ -131,12 +137,62 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export interface TournamentClockContext {
|
||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
||||
revision: number;
|
||||
deadlineGeneration: number;
|
||||
dateToTick(date: Date): number | null;
|
||||
}
|
||||
|
||||
const parseDeadlineTick = (value: string, context: TournamentClockContext, field: string): number => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`Tournament ${field} is not a valid instant.`);
|
||||
}
|
||||
const tick = context.dateToTick(date);
|
||||
if (tick === null || !Number.isSafeInteger(tick)) {
|
||||
throw new Error(`Tournament ${field} cannot be represented in the active game clock.`);
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({
|
||||
...state,
|
||||
nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'),
|
||||
clockRevision: context.revision,
|
||||
deadlineGeneration: context.deadlineGeneration,
|
||||
...(state.bettingCloseAt
|
||||
? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') }
|
||||
: { bettingCloseTick: undefined }),
|
||||
});
|
||||
|
||||
const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({
|
||||
activeRevisionKey: keys.activeClockRevisionKey,
|
||||
deadlineGenerationKey: keys.deadlineGenerationKey,
|
||||
phaseKey: keys.clockPhaseKey,
|
||||
revision: context.revision,
|
||||
deadlineGeneration: context.deadlineGeneration,
|
||||
phase: context.phase,
|
||||
});
|
||||
|
||||
export class TournamentStore {
|
||||
private clockContext: TournamentClockContext | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisClientLike,
|
||||
private readonly keys: TournamentKeys
|
||||
) {}
|
||||
|
||||
async withClockContext<T>(context: TournamentClockContext, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.clockContext;
|
||||
this.clockContext = context;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.clockContext = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
|
||||
if (!this.redis.del) {
|
||||
return operation();
|
||||
@@ -144,8 +200,8 @@ export class TournamentStore {
|
||||
|
||||
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (performance.now() < deadline) {
|
||||
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
@@ -174,11 +230,15 @@ export class TournamentStore {
|
||||
}
|
||||
|
||||
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
|
||||
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]);
|
||||
const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined;
|
||||
return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence);
|
||||
}
|
||||
|
||||
async setState(state: TournamentState): Promise<string> {
|
||||
return this.writeWithSourceRevision(this.keys.stateKey, state);
|
||||
return this.writeWithSourceRevision(
|
||||
this.keys.stateKey,
|
||||
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
|
||||
);
|
||||
}
|
||||
|
||||
async getParticipants(): Promise<TournamentParticipantEntry[]> {
|
||||
|
||||
@@ -9,10 +9,15 @@ export interface TournamentState {
|
||||
openMonth: number;
|
||||
termSeconds: number;
|
||||
nextAt: string;
|
||||
nextTick?: number;
|
||||
clockRevision?: number;
|
||||
deadlineGeneration?: number;
|
||||
bettingId?: number;
|
||||
bettingCloseAt?: string;
|
||||
bettingCloseTick?: number;
|
||||
winnerId?: number;
|
||||
bettingSettled?: boolean;
|
||||
npcBettingPlan?: TournamentBetEntry[];
|
||||
rewardSettled?: boolean;
|
||||
participantsLockedAt?: string;
|
||||
lastError?: string;
|
||||
|
||||
@@ -13,9 +13,10 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
|
||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
import { buildTournamentKeys } from './keys.js';
|
||||
import { TournamentStore } from './store.js';
|
||||
import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js';
|
||||
import type { TournamentMatchEntry, TournamentState } from './types.js';
|
||||
import {
|
||||
applyGroupMatch,
|
||||
@@ -464,8 +465,12 @@ export const applyPreBattleStage = async (
|
||||
bettingCloseAt: resolveBettingCloseAt(state),
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
// Keep the opening identity across retries, but do not expose stage 6
|
||||
// until its initial NPC bets have been written successfully.
|
||||
await store.setState({ ...state, bettingId: nextState.bettingId });
|
||||
await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport });
|
||||
nextState.npcBettingPlan = undefined;
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
@@ -595,41 +600,63 @@ export const processTournamentTick = async (options: {
|
||||
prisma: GamePrismaClient;
|
||||
daemonTransport: TurnDaemonTransport;
|
||||
now?: () => number;
|
||||
clockContext?: TournamentClockContext;
|
||||
}): Promise<TournamentState | null> => {
|
||||
const { store, prisma, daemonTransport } = options;
|
||||
const now = options.now ?? Date.now;
|
||||
let processedState: TournamentState | null = null;
|
||||
|
||||
await store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
return;
|
||||
}
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
|
||||
return;
|
||||
}
|
||||
const processWithLock = async (): Promise<void> =>
|
||||
store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options.clockContext &&
|
||||
(state.clockRevision !== options.clockContext.revision ||
|
||||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
|
||||
) {
|
||||
throw new Error('Tournament state does not match the active clock revision.');
|
||||
}
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const notDue = options.clockContext
|
||||
? !Number.isSafeInteger(state.nextTick) ||
|
||||
state.nextTick! > options.clockContext.dateToTick(new Date(now()))!
|
||||
: Number.isFinite(nextAt) && nextAt > now();
|
||||
if (state.auto && notDue) {
|
||||
if (options.clockContext && !Number.isSafeInteger(state.nextTick)) {
|
||||
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsSettlement(state)) {
|
||||
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||
return;
|
||||
}
|
||||
if (needsSettlement(state)) {
|
||||
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||
return;
|
||||
}
|
||||
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = state;
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
|
||||
}
|
||||
processedState =
|
||||
(await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
})) ?? nextState;
|
||||
});
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = state;
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
|
||||
}
|
||||
processedState =
|
||||
(await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
})) ?? nextState;
|
||||
});
|
||||
|
||||
if (options.clockContext) {
|
||||
await store.withClockContext(options.clockContext, processWithLock);
|
||||
} else {
|
||||
await processWithLock();
|
||||
}
|
||||
|
||||
return processedState;
|
||||
};
|
||||
@@ -656,16 +683,60 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
const state = await store.getState();
|
||||
let state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma);
|
||||
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
const clockFence = gameTime.phase
|
||||
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
|
||||
: null;
|
||||
if (gameTime.phase && !clockFence) {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
const clockContext: TournamentClockContext | undefined = clockFence
|
||||
? {
|
||||
phase: 'RUNNING',
|
||||
revision: clockFence.revision,
|
||||
deadlineGeneration: clockFence.generation,
|
||||
dateToTick: gameTime.dateToTick,
|
||||
}
|
||||
: undefined;
|
||||
if (
|
||||
clockContext &&
|
||||
(!Number.isSafeInteger(state.nextTick) ||
|
||||
state.clockRevision === undefined ||
|
||||
state.deadlineGeneration === undefined)
|
||||
) {
|
||||
state = stampTournamentClock(state, clockContext);
|
||||
await store.withClockContext(clockContext, () => store.setState(state!));
|
||||
}
|
||||
if (
|
||||
clockContext &&
|
||||
(state.clockRevision !== clockContext.revision ||
|
||||
state.deadlineGeneration !== clockContext.deadlineGeneration)
|
||||
) {
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime();
|
||||
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) {
|
||||
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow));
|
||||
const gameNow = gameTime.now.getTime();
|
||||
const notDue = clockContext
|
||||
? state.nextTick! > gameTime.tick!
|
||||
: Number.isFinite(nextAt) && nextAt > gameNow;
|
||||
if (state.auto && notDue) {
|
||||
await waitForWorkerPoll(
|
||||
control.signal,
|
||||
Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -675,6 +746,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
||||
prisma: postgres.prisma,
|
||||
daemonTransport,
|
||||
now: () => gameNow,
|
||||
clockContext,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -700,7 +772,11 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
||||
lastError: message,
|
||||
lastErrorAt: failedAt,
|
||||
};
|
||||
await store.setState(nextState);
|
||||
if (clockContext) {
|
||||
await store.withClockContext(clockContext, () => store.setState(nextState));
|
||||
} else {
|
||||
await store.setState(nextState);
|
||||
}
|
||||
}
|
||||
|
||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||
|
||||
@@ -37,7 +37,10 @@ export const nextStage = (stage: number): number => {
|
||||
|
||||
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
||||
const scheduled = new Date(state.nextAt).getTime();
|
||||
return Number.isFinite(scheduled) ? scheduled : Date.now();
|
||||
if (!Number.isFinite(scheduled)) {
|
||||
throw new Error('Tournament GAME_TIME schedule is invalid.');
|
||||
}
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
export const resolveNextAt = (state: TournamentState): string =>
|
||||
@@ -693,31 +696,38 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const seedNpcBets = async (options: {
|
||||
const buildNpcBettingPlan = async (options: {
|
||||
prisma: TournamentPrismaClient;
|
||||
store: TournamentStore;
|
||||
state: TournamentState;
|
||||
baseSeed: string;
|
||||
daemonTransport: TurnDaemonTransport;
|
||||
}): Promise<void> => {
|
||||
const { prisma, store, state, baseSeed, daemonTransport } = options;
|
||||
}): Promise<TournamentBetEntry[]> => {
|
||||
const { prisma, store, state, baseSeed } = options;
|
||||
const existing = await store.getBettingEntries();
|
||||
if (existing.length > 0) {
|
||||
return;
|
||||
}
|
||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||
|
||||
const matches = await store.getMatches();
|
||||
const candidateIds = Array.from(
|
||||
new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]))
|
||||
new Set(
|
||||
matches
|
||||
.filter((match) => match.stage === 7)
|
||||
.flatMap((match) => [match.attackerId, match.defenderId])
|
||||
.filter((id) => id > 0)
|
||||
)
|
||||
);
|
||||
if (candidateIds.length === 0) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const ?? config);
|
||||
const startYear = resolveNumber(constValues, ['startYear', 'startyear'], state.openYear);
|
||||
const scenarioMeta = asRecord(asRecord(worldState?.meta).scenarioMeta);
|
||||
const startYear = resolveNumber(
|
||||
scenarioMeta,
|
||||
['startYear'],
|
||||
resolveNumber(constValues, ['startYear', 'startyear'], state.openYear)
|
||||
);
|
||||
const currentYear = worldState?.currentYear ?? state.openYear;
|
||||
const betGold = Math.max(10, Math.floor((3 + currentYear - startYear) * 0.334) * 10);
|
||||
|
||||
@@ -730,9 +740,10 @@ export const seedNpcBets = async (options: {
|
||||
});
|
||||
const npcBetList = npcList
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number');
|
||||
.filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number')
|
||||
.sort((left, right) => (left.id as number) - (right.id as number));
|
||||
if (npcBetList.length === 0) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const rng = createTournamentRng(baseSeed, {
|
||||
@@ -745,27 +756,52 @@ export const seedNpcBets = async (options: {
|
||||
extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`,
|
||||
});
|
||||
|
||||
const entries = [...existing];
|
||||
const entries: TournamentBetEntry[] = [];
|
||||
for (const npc of npcBetList) {
|
||||
const targetId = rng.choice(candidateIds);
|
||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||
if (!existingBettors.has(npc.id as number)) {
|
||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const seedNpcBets = async (options: {
|
||||
prisma: TournamentPrismaClient;
|
||||
store: TournamentStore;
|
||||
state: TournamentState;
|
||||
baseSeed: string;
|
||||
daemonTransport: TurnDaemonTransport;
|
||||
}): Promise<void> => {
|
||||
const { store, state, daemonTransport } = options;
|
||||
// Persist the selected pool, amounts and targets before any resource command.
|
||||
// A retry must not reselect after a debit changes an NPC's eligibility.
|
||||
const plan = state.npcBettingPlan ?? (await buildNpcBettingPlan(options));
|
||||
if (plan.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!state.npcBettingPlan) {
|
||||
const storedState = await store.getState();
|
||||
if (!storedState) {
|
||||
throw new Error('Tournament state missing while preparing NPC bets.');
|
||||
}
|
||||
await store.setState({ ...storedState, npcBettingPlan: plan });
|
||||
}
|
||||
|
||||
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: `${requestPrefix}:resources`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: npcBetList.map((npc) => ({
|
||||
generalId: npc.id as number,
|
||||
goldDelta: -betGold,
|
||||
})),
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
||||
});
|
||||
await daemonTransport.sendCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: `${requestPrefix}:meta`,
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: npcBetList.map((npc) => ({
|
||||
generalId: npc.id as number,
|
||||
metaDelta: { betgold: betGold },
|
||||
})),
|
||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
||||
});
|
||||
await store.setBettingEntries(entries);
|
||||
const existing = await store.getBettingEntries();
|
||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
|
||||
};
|
||||
|
||||
+72
-52
@@ -62,61 +62,69 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
||||
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
||||
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
||||
|
||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
}
|
||||
const createInputEventMiddleware = (acquireClockFence: boolean) =>
|
||||
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||
const payload = await getRawInput();
|
||||
const changeJournal = new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||
try {
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId,
|
||||
eventType: path,
|
||||
payload,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
execute: async (transaction) => {
|
||||
const result = await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
db: transaction,
|
||||
changeJournal,
|
||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
|
||||
executedResult = result;
|
||||
return result.data;
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
if (executedResult) {
|
||||
return executedResult;
|
||||
}
|
||||
return {
|
||||
marker: middlewareMarker,
|
||||
ok: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateInputEventError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: error.message,
|
||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||
const payload = await getRawInput();
|
||||
const changeJournal = new ChangeJournal();
|
||||
let journalPersisted = false;
|
||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||
try {
|
||||
const response = await executeInputEvent({
|
||||
db: ctx.db,
|
||||
requestId,
|
||||
eventType: path,
|
||||
payload,
|
||||
actorUserId: ctx.auth?.user.id,
|
||||
acquireClockFence,
|
||||
execute: async (transaction, auditInput) => {
|
||||
const result = await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
db: transaction,
|
||||
auditInput,
|
||||
changeJournal,
|
||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
journalPersisted = Boolean(
|
||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
||||
);
|
||||
executedResult = result;
|
||||
return result.data;
|
||||
},
|
||||
});
|
||||
if (journalPersisted) {
|
||||
ctx.readModelOutbox?.wake();
|
||||
}
|
||||
if (executedResult) {
|
||||
return executedResult;
|
||||
}
|
||||
return {
|
||||
marker: middlewareMarker,
|
||||
ok: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateInputEventError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const inputEventMiddleware = createInputEventMiddleware(true);
|
||||
const wallInputEventMiddleware = createInputEventMiddleware(false);
|
||||
|
||||
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
||||
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
||||
@@ -180,10 +188,15 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const wallAuthedProcedure: typeof procedure = t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(wallInputEventMiddleware);
|
||||
|
||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||
@@ -234,6 +247,13 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(inputEventMiddleware);
|
||||
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
.input(input)
|
||||
.use(generalAccessEndpointMiddleware)
|
||||
.use(generalActivityMiddleware)
|
||||
.use(wallInputEventMiddleware);
|
||||
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||
t.procedure
|
||||
.use(requireAuthMiddleware)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { z } from 'zod';
|
||||
import { loadScenarioItemOrder } from '../services/scenarioItemOrder.js';
|
||||
|
||||
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||
|
||||
@@ -19,6 +20,14 @@ export type TurnCommandOptionValue = string | number;
|
||||
export interface TurnCommandOption {
|
||||
value: TurnCommandOptionValue;
|
||||
label: string;
|
||||
/** 원본 이름. 없는 소속/부대/수도는 null이며 대체 문구는 화면에서 표시한다. */
|
||||
targetNames?: {
|
||||
name: string;
|
||||
nationName?: string | null;
|
||||
cityName?: string | null;
|
||||
troopName?: string | null;
|
||||
capitalName?: string | null;
|
||||
};
|
||||
color?: string;
|
||||
description?: string;
|
||||
availableNow?: boolean;
|
||||
@@ -79,7 +88,9 @@ export interface TurnCommandInputField {
|
||||
required: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
legacyWidthMax?: number;
|
||||
step?: number;
|
||||
defaultValue?: TurnCommandOptionValue | boolean;
|
||||
constValue?: TurnCommandOptionValue;
|
||||
options?: TurnCommandOption[];
|
||||
optionSource?: TurnCommandOptionSource;
|
||||
@@ -118,22 +129,42 @@ const plainLegacyInfo = (value: string): string =>
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
|
||||
export const loadEquipmentTradeItemOrder = loadScenarioItemOrder;
|
||||
|
||||
export const buildEquipmentTradeItemOptions = (options: {
|
||||
configConst: Record<string, unknown>;
|
||||
itemOrder?: readonly string[];
|
||||
itemModules: readonly EquipmentTradeItemModule[];
|
||||
currentSecurity: number;
|
||||
generalGold: number;
|
||||
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
|
||||
}): TurnCommandInputOptions['items'] => {
|
||||
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
|
||||
const items: TurnCommandInputOptions['items'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||
book: [{ value: 'None', label: '판매/해제' }],
|
||||
item: [{ value: 'None', label: '판매/해제' }],
|
||||
};
|
||||
const items: TurnCommandInputOptions['items'] = {};
|
||||
const catalog = new Map(options.itemModules.map((item) => [item.key, item]));
|
||||
// Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다.
|
||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||
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) {
|
||||
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
|
||||
// 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
|
||||
const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
|
||||
for (const key of orderedKeys) {
|
||||
const item = catalog.get(key);
|
||||
if (!item?.buyable || !purchasableItemKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const cost = item.cost ?? 0;
|
||||
@@ -327,6 +358,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
|
||||
required,
|
||||
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
|
||||
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
|
||||
legacyWidthMax: key === 'nationName' ? 18 : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -564,7 +596,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
|
||||
getLegacyStringWidth(nationName) < 1 ||
|
||||
getLegacyStringWidth(nationName) > 18)
|
||||
) {
|
||||
throwLegacyBasicTurnArgError();
|
||||
throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,7 +18,13 @@ import type {
|
||||
TriggerValue,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
||||
import {
|
||||
resolveScenarioStartYear,
|
||||
LEGACY_DEFAULT_OPENING_PART_YEAR,
|
||||
evaluateConstraints,
|
||||
LEGACY_DEFAULT_MAX_LEVEL,
|
||||
resolveNonAggressionMaxEndYear,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
@@ -26,6 +32,7 @@ import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/log
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
||||
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
|
||||
import {
|
||||
buildTurnCommandInputFields,
|
||||
loadTurnCommandSpecs,
|
||||
@@ -40,6 +47,7 @@ export interface TurnCommandAvailability {
|
||||
key: string;
|
||||
name: string;
|
||||
turnDurationText?: string;
|
||||
costText?: string;
|
||||
reqArg: boolean;
|
||||
possible: boolean;
|
||||
status: AvailabilityStatus;
|
||||
@@ -326,9 +334,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
|
||||
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
const config = asRecord(worldState.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const meta = asRecord(worldState.meta);
|
||||
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
|
||||
return {
|
||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
// daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
|
||||
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
|
||||
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
||||
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
||||
@@ -339,7 +350,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0),
|
||||
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
|
||||
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 0),
|
||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], LEGACY_DEFAULT_OPENING_PART_YEAR),
|
||||
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0),
|
||||
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
||||
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
||||
@@ -365,8 +376,8 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
||||
const constValues = asRecord(config.const);
|
||||
const meta = asRecord(worldState.meta);
|
||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
||||
const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined;
|
||||
const startYear = resolveScenarioStartYear(scenarioMeta.startYear);
|
||||
const relYear = worldState.currentYear - startYear;
|
||||
const joinModeRaw = config.join_mode ?? config.joinMode;
|
||||
const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full';
|
||||
|
||||
@@ -378,7 +389,7 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
||||
startYear,
|
||||
relYear,
|
||||
join_mode: joinMode,
|
||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], LEGACY_DEFAULT_OPENING_PART_YEAR),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
||||
};
|
||||
};
|
||||
@@ -554,7 +565,7 @@ export const buildRecruitmentCommandInfo = (options: {
|
||||
avoid: crewType.avoid,
|
||||
baseCost: displayCost.gold,
|
||||
baseRice: displayCost.rice,
|
||||
info: [...crewType.info],
|
||||
info: [...crewType.info, ...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean)],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -646,6 +657,40 @@ const evaluateDefinition = (
|
||||
return evaluateAvailability(constraints, ctx, view, reqArg);
|
||||
};
|
||||
|
||||
const readNextAvailableTurn = (meta: Readonly<Record<string, unknown>>, actionName: string): number | null => {
|
||||
const raw = meta[`next_execute_${actionName}`];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const evaluateCooldown = (
|
||||
definition: GeneralActionDefinition,
|
||||
scope: 'general' | 'nation',
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
currentYearMonth: number
|
||||
): AvailabilityCore | null => {
|
||||
const owner =
|
||||
scope === 'general'
|
||||
? (view.get({ kind: 'general', id: ctx.actorId }) as General | null)
|
||||
: ctx.nationId === undefined
|
||||
? null
|
||||
: (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null);
|
||||
if (!owner) return null;
|
||||
|
||||
const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name);
|
||||
if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null;
|
||||
return {
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`,
|
||||
};
|
||||
};
|
||||
|
||||
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
|
||||
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
|
||||
|
||||
@@ -656,18 +701,33 @@ const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위
|
||||
const buildEntries = (
|
||||
env: CommandEnv,
|
||||
specs: TurnCommandSpec[],
|
||||
options: { foundingAvailable?: boolean } = {}
|
||||
options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
|
||||
): CommandEntry[] => {
|
||||
const entries: CommandEntry[] = [];
|
||||
|
||||
for (const spec of specs) {
|
||||
const definition = spec.createDefinition(env);
|
||||
const inputFields = buildTurnCommandInputFields(spec).map((field) => {
|
||||
if (spec.key !== 'che_불가침제의') return field;
|
||||
if (field.key === 'year' && options.currentYear !== undefined) {
|
||||
return {
|
||||
...field,
|
||||
min: options.currentYear + 1,
|
||||
max: resolveNonAggressionMaxEndYear(options.currentYear),
|
||||
defaultValue: options.currentYear + 1,
|
||||
};
|
||||
}
|
||||
if (field.key === 'month' && options.currentMonth !== undefined) {
|
||||
return { ...field, defaultValue: options.currentMonth };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
const entry: CommandEntry = {
|
||||
category: spec.category,
|
||||
definition,
|
||||
reqArg: spec.reqArg,
|
||||
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
|
||||
inputFields: buildTurnCommandInputFields(spec),
|
||||
inputFields,
|
||||
};
|
||||
|
||||
if (spec.key === 'che_포상') {
|
||||
@@ -711,23 +771,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
|
||||
return preReqTurn > 0 ? `${preReqTurn + 1}턴` : undefined;
|
||||
};
|
||||
|
||||
const formatCost = (gold: number | undefined, rice: number | undefined): string | undefined => {
|
||||
const parts = [
|
||||
gold && gold > 0 ? `금 ${gold.toLocaleString('ko-KR')}` : null,
|
||||
rice && rice > 0 ? `쌀 ${rice.toLocaleString('ko-KR')}` : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
};
|
||||
|
||||
const getCommandCostText = (
|
||||
entry: CommandEntry,
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
env: CommandEnv
|
||||
): string | undefined => {
|
||||
const hint = entry.definition.getCostHint?.(ctx, view);
|
||||
if (hint?.formula) return hint.formula;
|
||||
const hintedCost = hint ? formatCost(hint.gold, hint.rice) : undefined;
|
||||
if (hintedCost) return hintedCost;
|
||||
|
||||
const develCost = env.develCost;
|
||||
const general = view.get({ kind: 'general', id: ctx.actorId }) as General | null;
|
||||
switch (entry.definition.key) {
|
||||
case 'che_단련':
|
||||
case 'che_숙련전환':
|
||||
return formatCost(develCost, develCost);
|
||||
case 'che_첩보':
|
||||
return formatCost(develCost * 3, develCost * 3);
|
||||
case 'che_이동':
|
||||
case 'che_인재탐색':
|
||||
return formatCost(develCost, 0);
|
||||
case 'che_강행':
|
||||
return formatCost(develCost * 5, 0);
|
||||
case 'che_사기진작':
|
||||
return formatCost(Math.round((general?.crew ?? 0) / 100), 0);
|
||||
case 'che_출병':
|
||||
return formatCost(0, Math.round((general?.crew ?? 0) / 100));
|
||||
case 'che_천도':
|
||||
return `금·쌀 ${(develCost * 5).toLocaleString('ko-KR')} × 2^거리`;
|
||||
case 'cr_인구이동':
|
||||
return `금·쌀 ${develCost.toLocaleString('ko-KR')} × 인구[만]`;
|
||||
case 'che_증축': {
|
||||
const cost = develCost * 500 + 60_000;
|
||||
return formatCost(cost, cost);
|
||||
}
|
||||
case 'che_감축': {
|
||||
const recovery = develCost * 500 + 30_000;
|
||||
return `금 ${recovery.toLocaleString('ko-KR')} · 쌀 ${recovery.toLocaleString('ko-KR')} 회수`;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const buildGroups = (
|
||||
entries: CommandEntry[],
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
includeTurnDuration = false
|
||||
env: CommandEnv,
|
||||
scope: 'general' | 'nation',
|
||||
currentYearMonth: number
|
||||
): TurnCommandGroup[] => {
|
||||
const groups = new Map<string, TurnCommandAvailability[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const availability = entry.evaluate
|
||||
const baseAvailability = entry.evaluate
|
||||
? entry.evaluate(ctx, view)
|
||||
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
|
||||
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
|
||||
const availability =
|
||||
baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown'
|
||||
? baseAvailability
|
||||
: (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability);
|
||||
// 개인·국가 모두 실행 정의의 준비 턴과 마지막 실행 턴을 표시한다.
|
||||
const turnDurationText = getTurnDurationText(entry.definition);
|
||||
const costText = getCommandCostText(entry, ctx, view, env);
|
||||
const value: TurnCommandAvailability = {
|
||||
key: entry.definition.key,
|
||||
name: entry.definition.name,
|
||||
...(turnDurationText ? { turnDurationText } : {}),
|
||||
...(costText ? { costText } : {}),
|
||||
reqArg: entry.reqArg,
|
||||
inputFields: entry.inputFields,
|
||||
...availability,
|
||||
@@ -785,6 +907,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
|
||||
realNationCount?: number;
|
||||
inputOptions?: TurnCommandInputOptions;
|
||||
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||
}): Promise<TurnCommandTable> => {
|
||||
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
||||
const general = mapGeneralRow(options.general);
|
||||
@@ -803,7 +926,10 @@ export const buildTurnCommandTable = async (options: {
|
||||
mode: 'precheck',
|
||||
};
|
||||
|
||||
const env = buildCommandEnv(options.worldState);
|
||||
const env = {
|
||||
...buildCommandEnv(options.worldState),
|
||||
...(options.generalActionModules ? { generalActionModules: options.generalActionModules } : {}),
|
||||
};
|
||||
const scenarioConst = asRecord(options.worldState.config).const;
|
||||
const {
|
||||
general: generalSpecs,
|
||||
@@ -817,19 +943,28 @@ export const buildTurnCommandTable = async (options: {
|
||||
? undefined
|
||||
: options.realNationCount < resolveMaxNation(options.worldState),
|
||||
});
|
||||
const nationEntries = buildEntries(env, nationSpecs);
|
||||
const nationEntries = buildEntries(env, nationSpecs, {
|
||||
currentYear: options.worldState.currentYear,
|
||||
currentMonth: options.worldState.currentMonth,
|
||||
});
|
||||
const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1;
|
||||
|
||||
return {
|
||||
general: buildGroups(
|
||||
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view
|
||||
view,
|
||||
env,
|
||||
'general',
|
||||
currentYearMonth
|
||||
),
|
||||
nation: buildGroups(
|
||||
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view,
|
||||
true
|
||||
env,
|
||||
'nation',
|
||||
currentYearMonth
|
||||
),
|
||||
inputOptions: options.inputOptions ?? {
|
||||
cities: [],
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface GeneralTargetSource {
|
||||
officerLevel: number;
|
||||
gold?: number;
|
||||
rice?: number;
|
||||
leadership?: number;
|
||||
strength?: number;
|
||||
intel?: number;
|
||||
crew?: number;
|
||||
train?: number;
|
||||
atmos?: number;
|
||||
@@ -21,7 +24,7 @@ export interface NationTargetSource {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
capitalName: string;
|
||||
capitalName?: string;
|
||||
level: number;
|
||||
power: number;
|
||||
generalCount: number;
|
||||
@@ -56,28 +59,64 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
}): RefGeneralTargetOptions => {
|
||||
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
|
||||
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
|
||||
const cityName = options.cityNames.get(entry.cityId) ?? '재야';
|
||||
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
|
||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||
const label = (() => {
|
||||
if (action === 'che_발령') return `${entry.name} (${cityName})`;
|
||||
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||
})();
|
||||
const details = [
|
||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||
entry.troopId
|
||||
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
|
||||
: '탑승 부대 없음',
|
||||
].filter((value): value is string => Boolean(value));
|
||||
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
||||
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
||||
const assignmentDetails =
|
||||
action === 'che_발령'
|
||||
? [
|
||||
[
|
||||
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) {
|
||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||
}
|
||||
return {
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
|
||||
options.cityNames.get(entry.cityId) ?? '재야'
|
||||
})`,
|
||||
description: details.join(' · '),
|
||||
label,
|
||||
targetNames: {
|
||||
name: entry.name,
|
||||
nationName: options.nationNames.get(entry.nationId) ?? null,
|
||||
cityName: options.cityNames.get(entry.cityId) ?? null,
|
||||
troopName: troopName ?? null,
|
||||
},
|
||||
description: assignmentDetails ?? details.join(' · '),
|
||||
...(availableNow === undefined ? {} : { availableNow }),
|
||||
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
||||
...(entry.rice === undefined ? {} : { rice: entry.rice }),
|
||||
@@ -170,8 +209,9 @@ export const buildRefNationTargetOptions = (options: {
|
||||
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
targetNames: { name: entry.name, capitalName: entry.capitalName ?? null },
|
||||
color: entry.color,
|
||||
description: `수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
|
||||
description: `수도 ${entry.capitalName ?? '-'} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
|
||||
}));
|
||||
const nationTargets: Record<string, TurnCommandOption[]> = {};
|
||||
for (const action of NATION_TARGET_COMMANDS) {
|
||||
@@ -183,9 +223,10 @@ export const buildRefNationTargetOptions = (options: {
|
||||
return {
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
targetNames: { name: entry.name, capitalName: entry.capitalName ?? null },
|
||||
color: entry.color,
|
||||
availableNow: availability.available,
|
||||
description: `${availability.reason} · ${relation}${term} · 수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
|
||||
description: `${availability.reason} · ${relation}${term} · 수도 ${entry.capitalName ?? '-'} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
|
||||
power: entry.power,
|
||||
} as TurnCommandOption & { power: number };
|
||||
})
|
||||
|
||||
@@ -56,6 +56,14 @@ const lifecycleState: TurnWorldState = {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-07-31T10:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'MANUAL',
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: { killturn: 24, scenarioMeta },
|
||||
};
|
||||
const lifecycleGeneral: TurnGeneral = {
|
||||
@@ -121,6 +129,14 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
|
||||
currentYear: lifecycleState.currentYear,
|
||||
currentMonth: lifecycleState.currentMonth,
|
||||
tickSeconds: lifecycleState.tickSeconds,
|
||||
clockBaseTime: lifecycleState.clockBaseTime,
|
||||
clockTick: BigInt(lifecycleState.clockTick ?? 0),
|
||||
clockMode: lifecycleState.clockMode ?? 'manual',
|
||||
clockWallAnchor: lifecycleState.clockWallAnchor,
|
||||
lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0),
|
||||
clockPhase: lifecycleState.clockPhase ?? 'MANUAL',
|
||||
clockRevision: BigInt(lifecycleState.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -96,6 +96,7 @@ const buildContext = (options: {
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
closeTick: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -103,6 +104,7 @@ const buildContext = (options: {
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
closeTick: 200,
|
||||
};
|
||||
});
|
||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||
@@ -112,14 +114,10 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick ?? 100),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
@@ -194,7 +192,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
tick: 72_000_001,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
@@ -423,7 +421,6 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
@@ -441,6 +438,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
closeTick: 200n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -501,7 +499,6 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
detail: { amount: 100 },
|
||||
status,
|
||||
closeAt,
|
||||
closeTick: 0n,
|
||||
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
||||
},
|
||||
});
|
||||
@@ -82,11 +83,15 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
return auction;
|
||||
};
|
||||
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
||||
buildAuctionFinalizeRequestId(auction.id, {
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
|
||||
if (auction.closeTick === null || auction.closeTick === undefined) {
|
||||
throw new Error(`auction ${auction.id} fixture requires closeTick`);
|
||||
}
|
||||
return buildAuctionFinalizeRequestId(auction.id, {
|
||||
closeAt: auction.closeAt,
|
||||
closeTick: auction.closeTick ?? null,
|
||||
closeTick: auction.closeTick,
|
||||
});
|
||||
};
|
||||
|
||||
const memoryRedis = () => ({
|
||||
zRangeByScore: vi.fn(async () => []),
|
||||
@@ -108,6 +113,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -118,6 +124,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -160,6 +167,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
|
||||
@@ -180,7 +188,13 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: auction.id,
|
||||
expectedCloseAt: auction.closeAt.toISOString(),
|
||||
expectedCloseTick: Number(auction.closeTick),
|
||||
},
|
||||
status: 'FAILED',
|
||||
attempts: 3,
|
||||
error: 'simulated terminal failure',
|
||||
@@ -196,6 +210,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -206,6 +221,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
@@ -230,13 +246,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
||||
});
|
||||
|
||||
it('creates a new generation after an earlier close was extended', async () => {
|
||||
const auction = await createAuction('OPEN');
|
||||
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`;
|
||||
const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
|
||||
await connector.prisma.inputEvent.create({
|
||||
data: {
|
||||
requestId: priorRequestId,
|
||||
@@ -263,6 +280,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -299,6 +317,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-07-31T11:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: { killturn: 24, scenarioMeta },
|
||||
};
|
||||
const buildGeneral = (options: {
|
||||
@@ -381,6 +407,14 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
currentYear: state.currentYear,
|
||||
currentMonth: state.currentMonth,
|
||||
tickSeconds: state.tickSeconds,
|
||||
clockBaseTime: state.clockBaseTime,
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
clockMode: state.clockMode ?? 'realtime',
|
||||
clockWallAnchor: state.clockWallAnchor,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
||||
clockPhase: state.clockPhase ?? 'RUNNING',
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
@@ -415,6 +449,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
amount: 200,
|
||||
eventId: `auction-durable-bid:${auction.id}`,
|
||||
eventAt: new Date(),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date(),
|
||||
},
|
||||
});
|
||||
const requestId = requestIdFor(auction);
|
||||
@@ -425,6 +461,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
});
|
||||
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
@@ -493,6 +530,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
detail: { remainCloseDateExtensionCnt: 1 },
|
||||
status: 'OPEN',
|
||||
closeAt: logicalPastCloseAt,
|
||||
closeTick: 0n,
|
||||
},
|
||||
});
|
||||
extensionAuctionId = extensionAuction.id;
|
||||
@@ -505,6 +543,8 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
amount: 50,
|
||||
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
||||
eventAt: new Date(),
|
||||
occurredGameTick: 0n,
|
||||
requestedAtWall: new Date(),
|
||||
meta: { tryExtendCloseDate: true },
|
||||
},
|
||||
});
|
||||
@@ -516,6 +556,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
historyKey: 'history',
|
||||
id: String(extensionAuction.id),
|
||||
nowMs: Date.now(),
|
||||
nowTick: 0,
|
||||
});
|
||||
|
||||
let reopened: { status: string; closeAt: Date } | null = null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||
import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
||||
|
||||
const buildRedis = () => ({
|
||||
@@ -33,7 +33,7 @@ const buildDb = (options: {
|
||||
$executeRaw: vi.fn(async () => options.updated),
|
||||
auction: {
|
||||
findUnique: vi.fn(async () =>
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
|
||||
),
|
||||
},
|
||||
inputEvent: {
|
||||
@@ -55,6 +55,38 @@ const buildDb = (options: {
|
||||
};
|
||||
|
||||
describe('auction worker clock-shift race', () => {
|
||||
it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => {
|
||||
const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) };
|
||||
await expect(
|
||||
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
|
||||
activeRevisionKey: 'clock-revision',
|
||||
deadlineGenerationKey: 'deadline-generation',
|
||||
phaseKey: 'clock-phase',
|
||||
revision: 4,
|
||||
generation: 8,
|
||||
})
|
||||
).resolves.toEqual(['7', '9']);
|
||||
expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), {
|
||||
keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'],
|
||||
arguments: ['4', '8', '123456', '100'],
|
||||
});
|
||||
expect(redis.zRangeByScore).not.toHaveBeenCalled();
|
||||
expect(redis.zRem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns no due member when the atomic Redis clock fence rejects the pop', async () => {
|
||||
const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) };
|
||||
await expect(
|
||||
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
|
||||
activeRevisionKey: 'clock-revision',
|
||||
deadlineGenerationKey: 'deadline-generation',
|
||||
phaseKey: 'clock-phase',
|
||||
revision: 4,
|
||||
generation: 8,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
const time = {
|
||||
@@ -201,11 +233,12 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 36_000_000,
|
||||
})
|
||||
).resolves.toBe('RESCHEDULED');
|
||||
|
||||
expect(redis.zAdd).toHaveBeenCalledTimes(1);
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]);
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -235,7 +268,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
@@ -247,6 +280,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs,
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -261,6 +295,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -318,6 +353,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -330,7 +366,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
@@ -344,6 +380,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
@@ -359,6 +396,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
@@ -379,6 +417,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: logicalNowMs,
|
||||
nowTick: 72_000_000,
|
||||
historyNowMs: operationalNowMs,
|
||||
});
|
||||
|
||||
@@ -388,12 +427,12 @@ describe('auction worker clock-shift race', () => {
|
||||
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const existingEvent = {
|
||||
requestId,
|
||||
target: 'ENGINE' as const,
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||
status: 'PENDING' as const,
|
||||
result: null,
|
||||
};
|
||||
@@ -411,6 +450,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -423,7 +463,7 @@ describe('auction worker clock-shift race', () => {
|
||||
it('creates one bounded successor after a terminal event failure', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const retryRequestId = `${requestId}:retry:1`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
@@ -433,7 +473,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
||||
status: 'FAILED',
|
||||
result: null,
|
||||
},
|
||||
@@ -448,6 +488,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -461,6 +502,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId: retryRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -468,19 +510,23 @@ describe('auction worker clock-shift race', () => {
|
||||
|
||||
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
|
||||
const redis = buildRedis();
|
||||
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
||||
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const previousRequestId = 'auction:finalize:7:tick:36000000';
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: previousRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseTick: 36_000_000,
|
||||
},
|
||||
status: 'SUCCEEDED',
|
||||
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
||||
},
|
||||
@@ -495,6 +541,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
@@ -508,6 +555,7 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -527,6 +575,7 @@ describe('auction worker clock-shift race', () => {
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).rejects.toThrow('event insert failed');
|
||||
|
||||
|
||||
@@ -353,6 +353,39 @@ describe('battle router orchestration', () => {
|
||||
expect(worldStateReads).toBe(1);
|
||||
});
|
||||
|
||||
it('offers simulator equipment and consumables in source order for the selected scenario', async () => {
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: '905',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {
|
||||
const: {
|
||||
allItems: {
|
||||
item: { che_필살_둔갑천서: 1, che_의술_정력견혈산: 1, che_치료_환약: 0 },
|
||||
horse: { che_명마_15_적토마: 1, che_명마_07_백마: 1, che_명마_01_노기: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
const caller = appRouter.createCaller(buildContext({ state, battleSim: new QueuedBattleSimTransport() }));
|
||||
|
||||
const context = await caller.battle.getSimulatorContext();
|
||||
expect(context.items.horse.map(({ key }) => key)).toEqual([
|
||||
'che_명마_01_노기',
|
||||
'che_명마_07_백마',
|
||||
'che_명마_15_적토마',
|
||||
]);
|
||||
expect(context.items.item.map(({ key }) => key)).toEqual([
|
||||
'che_치료_환약',
|
||||
'che_의술_정력견혈산',
|
||||
'che_필살_둔갑천서',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||
buildEquipmentTradeItemOptions,
|
||||
loadEquipmentTradeItemOrder,
|
||||
buildTurnCommandInputFields,
|
||||
parseReservedTurnArgs,
|
||||
sanitizeReservedTurnArgs,
|
||||
@@ -112,12 +113,22 @@ describe('turn command argument input', () => {
|
||||
).toThrow('턴이 입력되지 않았습니다.');
|
||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
|
||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
|
||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
|
||||
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
|
||||
);
|
||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
|
||||
'턴이 입력되지 않았습니다.'
|
||||
);
|
||||
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 () => {
|
||||
expect(
|
||||
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', () => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||
configConst: {},
|
||||
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
||||
itemModules: [
|
||||
buildShopItem('event_전투특기_격노', '격노의 비급'),
|
||||
buildShopItem('che_계략_향낭', '향낭'),
|
||||
buildShopItem('che_계략_이추', '이추'),
|
||||
buildShopItem('che_훈련_청주', '청주'),
|
||||
buildShopItem('che_사기_탁주', '탁주'),
|
||||
buildShopItem('che_저격_수극', '수극'),
|
||||
buildShopItem('che_치료_환약', '환약'),
|
||||
],
|
||||
currentSecurity: 5000,
|
||||
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 · 환약 · 설명');
|
||||
});
|
||||
|
||||
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||
configConst: {
|
||||
allItems: {
|
||||
item: {
|
||||
|
||||
@@ -102,6 +102,33 @@ const buildNation = (): NationRow =>
|
||||
}) as unknown as NationRow;
|
||||
|
||||
describe('buildTurnCommandTable', () => {
|
||||
it.each([null, 180, 189, 2025, 0])(
|
||||
'uses the resolved calendar for sortie reservation when startYear=%s',
|
||||
async (startYear) => {
|
||||
const base = startYear ?? 180;
|
||||
for (const offset of [0, 1, 2, 3]) {
|
||||
const worldState = buildWorldState('full');
|
||||
worldState.currentYear = base + offset;
|
||||
worldState.meta = { scenarioMeta: { startYear } };
|
||||
const general = buildGeneral();
|
||||
general.rice = 10000;
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState,
|
||||
general,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: [],
|
||||
});
|
||||
const sortie = table.general.flatMap(({ values }) => values).find(({ key }) => key === 'che_출병');
|
||||
expect(sortie).toBeDefined();
|
||||
expect(sortie?.status === 'blocked').toBe(offset === 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
|
||||
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
|
||||
|
||||
it('projects the general and chief reserved-turn categories and command order from Ref', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
@@ -208,9 +235,41 @@ describe('buildTurnCommandTable', () => {
|
||||
che_의병모집: '3턴',
|
||||
che_피장파장: '2턴',
|
||||
});
|
||||
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
|
||||
expect.objectContaining({ turnDurationText: expect.any(String) })
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
table.general
|
||||
.flatMap(({ values }) => values)
|
||||
.filter(({ turnDurationText }) => turnDurationText)
|
||||
.map(({ key, turnDurationText }) => [key, turnDurationText])
|
||||
)
|
||||
).toEqual({
|
||||
che_은퇴: '2턴',
|
||||
che_내정특기초기화: '2턴',
|
||||
che_전투특기초기화: '2턴',
|
||||
});
|
||||
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 () => {
|
||||
@@ -219,7 +278,7 @@ describe('buildTurnCommandTable', () => {
|
||||
availableGeneralCommand: {
|
||||
개인: ['휴식'],
|
||||
내정: ['che_물자조달'],
|
||||
군사: ['cr_맹훈련'],
|
||||
군사: ['cr_맹훈련', 'che_전투태세'],
|
||||
},
|
||||
availableChiefCommand: {
|
||||
휴식: ['휴식'],
|
||||
@@ -238,7 +297,16 @@ describe('buildTurnCommandTable', () => {
|
||||
'휴식',
|
||||
'che_물자조달',
|
||||
'cr_맹훈련',
|
||||
'che_전투태세',
|
||||
]);
|
||||
expect(findCommand(table, 'che_전투태세')).toMatchObject({ turnDurationText: '4턴' });
|
||||
expect(
|
||||
Object.fromEntries(table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText])))
|
||||
).toMatchObject({
|
||||
cr_인구이동: '금·쌀 100 × 인구[만]',
|
||||
event_대검병연구: '금 50,000 · 쌀 50,000',
|
||||
event_화륜차연구: '금 100,000 · 쌀 100,000',
|
||||
});
|
||||
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
|
||||
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
|
||||
'휴식',
|
||||
@@ -256,6 +324,105 @@ 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({
|
||||
turnDurationText: '2턴',
|
||||
possible: true,
|
||||
status: 'available',
|
||||
});
|
||||
expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({
|
||||
turnDurationText: '2턴',
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '1턴 더 기다려야 합니다',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: {
|
||||
...buildGeneral(),
|
||||
meta: {
|
||||
killturn: 24,
|
||||
'next_execute_내정 특기 초기화': 37,
|
||||
'next_execute_전투 특기 초기화': 37,
|
||||
},
|
||||
} as GeneralRow,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) {
|
||||
expect(findCommand(table, key)).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '특기가 없습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('projects a multi-turn nation command cooldown before target input', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: {
|
||||
...buildNation(),
|
||||
meta: { next_execute_피장파장: 37 },
|
||||
} as NationRow,
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
expect(findCommand(table, 'che_피장파장')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '1턴 더 기다려야 합니다',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects costs from the current world develcost used by command execution', async () => {
|
||||
const worldState = buildWorldState();
|
||||
(worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120;
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState,
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
const costs = Object.fromEntries(
|
||||
[...table.general, ...table.nation].flatMap(({ values }) =>
|
||||
values.map(({ key, costText }) => [key, costText])
|
||||
)
|
||||
);
|
||||
expect(costs).toMatchObject({
|
||||
che_농지개간: '금 120',
|
||||
che_화계: '금 600 · 쌀 600',
|
||||
che_천도: '금·쌀 600 × 2^거리',
|
||||
che_증축: '금 120,000 · 쌀 120,000',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
|
||||
const buildScenarioTable = async (scenarioId: number) => {
|
||||
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||
@@ -337,6 +504,27 @@ describe('buildTurnCommandTable', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('projects the Ref twenty-year non-aggression end-year window', async () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general: buildGeneral(),
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
nationGenerals: null,
|
||||
});
|
||||
const proposal = table.nation
|
||||
.flatMap((group) => group.values)
|
||||
.find((command) => command.key === 'che_불가침제의');
|
||||
|
||||
expect(proposal?.inputFields.find((field) => field.key === 'year')).toMatchObject({
|
||||
kind: 'number',
|
||||
min: 4,
|
||||
max: 23,
|
||||
defaultValue: 4,
|
||||
});
|
||||
expect(proposal?.inputFields.find((field) => field.key === 'month')).toMatchObject({ defaultValue: 1 });
|
||||
});
|
||||
|
||||
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||
buildTurnCommandTable({
|
||||
@@ -444,15 +632,17 @@ describe('buildTurnCommandTable', () => {
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
const appointment = table.general
|
||||
.flatMap((group) => group.values)
|
||||
.find((command) => command.key === 'che_임관');
|
||||
for (const commandKey of ['che_임관', 'che_등용']) {
|
||||
const command = table.general
|
||||
.flatMap((group) => group.values)
|
||||
.find((candidate) => candidate.key === commandKey);
|
||||
|
||||
expect(appointment).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
expect(command).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
|
||||
@@ -525,7 +715,11 @@ describe('buildTurnCommandTable', () => {
|
||||
magicCoef: 0,
|
||||
cost: 12,
|
||||
rice: 10,
|
||||
requirements: [{ type: 'ReqTech', tech: 2000 }],
|
||||
requirements: [
|
||||
{ type: 'ReqTech', tech: 2000 },
|
||||
{ type: 'ReqRegions', regions: ['중원'] },
|
||||
{ type: 'ReqCities', cities: ['TestCity'] },
|
||||
],
|
||||
attackCoef: {},
|
||||
defenceCoef: {},
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
@@ -583,7 +777,12 @@ describe('buildTurnCommandTable', () => {
|
||||
special: true,
|
||||
attack: 175,
|
||||
defence: 225,
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
info: [
|
||||
'강력하지만 기술이 필요합니다.',
|
||||
'기술력 2000 이상 필요',
|
||||
'중원 지역 소유시 가능',
|
||||
'TestCity 소유시 가능',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,24 @@ describe('Ref command general targets', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
|
||||
const options = buildRefGeneralTargetOptions({
|
||||
actorId: 1,
|
||||
actorNationId: 0,
|
||||
generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })],
|
||||
nationNames: new Map(),
|
||||
cityNames: new Map(),
|
||||
troopNames: new Map(),
|
||||
});
|
||||
expect(options.generalTargets.che_증여?.[0]?.targetNames).toEqual({
|
||||
name: '없음',
|
||||
nationName: null,
|
||||
cityName: null,
|
||||
troopName: null,
|
||||
});
|
||||
expect(options.generalTargets.che_증여?.[0]?.description).not.toContain('없음');
|
||||
});
|
||||
|
||||
it('preserves the distinct Ref filters for gift, abdication, recruitment, and target-based joining', () => {
|
||||
expect(ids('che_증여')).toEqual([1, 2, 3]);
|
||||
expect(ids('che_선양')).toEqual([2, 3]);
|
||||
@@ -67,6 +85,9 @@ describe('Ref command general targets', () => {
|
||||
gold: 100,
|
||||
rice: 200,
|
||||
crew: 900,
|
||||
leadership: 100,
|
||||
strength: 95,
|
||||
intel: 0,
|
||||
train: 80,
|
||||
atmos: 70,
|
||||
troopId: 3,
|
||||
@@ -85,9 +106,31 @@ describe('Ref command general targets', () => {
|
||||
rice: 200,
|
||||
crew: 900,
|
||||
troopId: 3,
|
||||
description: expect.stringContaining('탑승 부대 청룡대'),
|
||||
targetNames: { name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
||||
});
|
||||
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(
|
||||
'통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200'
|
||||
);
|
||||
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000');
|
||||
expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대');
|
||||
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
|
||||
expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([
|
||||
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
|
||||
{ name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
||||
{ name: '부대장', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
||||
]);
|
||||
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('탑승 부대');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,6 +191,24 @@ describe('Ref nation target guidance', () => {
|
||||
},
|
||||
];
|
||||
|
||||
it('indexes real nation and capital names without missing-capital or diplomacy copy', () => {
|
||||
const result = buildRefNationTargetOptions({
|
||||
actorNationId: 1,
|
||||
nations: nations.map((entry) => ({ ...entry, capitalName: undefined })),
|
||||
});
|
||||
expect(result.nations.map((entry) => entry.targetNames)).toEqual(
|
||||
nations.map((entry) => ({ name: entry.name, capitalName: null }))
|
||||
);
|
||||
for (const options of Object.values(result.nationTargets)) {
|
||||
for (const option of options) expect(option.targetNames).toEqual({ name: option.label, capitalName: null });
|
||||
}
|
||||
const populated = buildRefNationTargetOptions({ actorNationId: 1, nations });
|
||||
expect(populated.nations[0]?.targetNames).toEqual({
|
||||
name: nations[0]!.name,
|
||||
capitalName: nations[0]!.capitalName,
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts the currently relevant relation first for each diplomacy command', () => {
|
||||
const result = buildRefNationTargetOptions({ actorNationId: 1, nations });
|
||||
expect(result.nationTargets.che_선전포고?.map((entry) => entry.value)).toEqual([2, 1, 3, 4]);
|
||||
|
||||
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
|
||||
any: {
|
||||
ban: { expire: 4_102_444_800, value: 1 },
|
||||
expired: { expire: 1, value: 9 },
|
||||
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
|
||||
},
|
||||
hwe: {
|
||||
ban: { expire: 4_102_444_800, value: 2 },
|
||||
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 2,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
||||
now: new Date('2026-07-30T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
openAt: new Date(Date.now() + 86_400_000),
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 0,
|
||||
showImgLevel: 3,
|
||||
@@ -227,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
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');
|
||||
}, 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 () => {
|
||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
|
||||
expect(config.rules.allowDirectCreation).toBe(true);
|
||||
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
|
||||
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
@@ -288,6 +299,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
},
|
||||
});
|
||||
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
||||
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
|
||||
expect(created.affinity).toBeLessThanOrEqual(150);
|
||||
expect(created.meta).toMatchObject({
|
||||
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');
|
||||
}
|
||||
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(
|
||||
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||
acceptedEvent.createdAt.getTime()
|
||||
@@ -552,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
||||
);
|
||||
}, 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),
|
||||
$executeRaw: db.$executeRaw.bind(db),
|
||||
};
|
||||
|
||||
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: vi.fn(async () => 1),
|
||||
};
|
||||
Object.assign(db, {
|
||||
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
|
||||
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
|
||||
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
|
||||
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
|
||||
expect(statement.sql).toContain('jsonb_to_recordset');
|
||||
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
|
||||
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
|
||||
expect(statement.sql).toContain('INSERT INTO "general_access_log"');
|
||||
expect(statement.sql).toContain('ORDER BY resolved."general_id"');
|
||||
expect(statement.sql).not.toContain('read_model_revision');
|
||||
expect(statement.sql).not.toContain('read_model_outbox');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { fetchMessagesFromMailbox, invalidateMessages } from '../src/messages/store.js';
|
||||
import { fetchMessagesFromMailbox, insertMessage, tombstoneMessages } from '../src/messages/store.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
@@ -25,6 +25,7 @@ const fixtureNationId = 841;
|
||||
const foreignNationId = fixtureNationId + 1;
|
||||
const fixtureGeneralId = 8_864_243;
|
||||
const foreignGeneralId = fixtureGeneralId + 1;
|
||||
const fixtureCityId = 8_864_246;
|
||||
const fixtureWorldStateId = -8_864_241;
|
||||
const fixtureUserId = 'diplomacy-document-message-src-user';
|
||||
const foreignUserId = 'diplomacy-document-message-dest-user';
|
||||
@@ -63,7 +64,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||
)
|
||||
);
|
||||
@@ -82,6 +83,23 @@ integration('diplomacy document message persistence', () => {
|
||||
};
|
||||
|
||||
const cleanupRouteState = async (): Promise<void> => {
|
||||
await db.logEntry.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ generalId: { in: [fixtureGeneralId, foreignGeneralId] } },
|
||||
{ nationId: { in: [fixtureNationId, foreignNationId] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } });
|
||||
const proposalIds = await db.message.findMany({
|
||||
where: { mailbox: { in: [...fixtureMailboxes] } },
|
||||
select: { id: true },
|
||||
});
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { requestId: { in: proposalIds.map(({ id }) => `messages.respond.diplomatic:${id}`) } },
|
||||
});
|
||||
await db.diplomacy.deleteMany({ where: { srcNationId: { in: [fixtureNationId, foreignNationId] } } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
|
||||
await db.diplomacyLetter.deleteMany({
|
||||
where: {
|
||||
@@ -94,7 +112,10 @@ integration('diplomacy document message persistence', () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await deleteFixtureOutboxes();
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||
where: {
|
||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||
entityId: { in: [...fixtureMailboxes] },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -102,6 +123,7 @@ integration('diplomacy document message persistence', () => {
|
||||
await cleanupRouteState();
|
||||
await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } });
|
||||
await db.city.deleteMany({ where: { id: fixtureCityId } });
|
||||
await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } });
|
||||
};
|
||||
|
||||
@@ -228,8 +250,9 @@ integration('diplomacy document message persistence', () => {
|
||||
type,
|
||||
src: srcMailbox,
|
||||
dest: destMailbox,
|
||||
time: logicalGameTime,
|
||||
timeTick: logicalGameTick,
|
||||
time: expect.any(Date),
|
||||
timeTick: null,
|
||||
occurredGameTick: null,
|
||||
});
|
||||
expect(payload).toMatchObject({
|
||||
src: options.src,
|
||||
@@ -292,7 +315,7 @@ integration('diplomacy document message persistence', () => {
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
|
||||
config: {},
|
||||
meta: {},
|
||||
meta: { serverId: requestPrefix },
|
||||
},
|
||||
});
|
||||
await db.nation.createMany({
|
||||
@@ -301,6 +324,27 @@ integration('diplomacy document message persistence', () => {
|
||||
{ id: foreignNationId, name: '상대국', color: '#654321' },
|
||||
],
|
||||
});
|
||||
await db.city.create({
|
||||
data: {
|
||||
id: fixtureCityId,
|
||||
name: '감사 도시',
|
||||
level: 1,
|
||||
nationId: foreignNationId,
|
||||
population: 1000,
|
||||
populationMax: 1000,
|
||||
agriculture: 100,
|
||||
agricultureMax: 100,
|
||||
commerce: 100,
|
||||
commerceMax: 100,
|
||||
security: 100,
|
||||
securityMax: 100,
|
||||
defence: 100,
|
||||
defenceMax: 100,
|
||||
wall: 100,
|
||||
wallMax: 100,
|
||||
region: 1,
|
||||
},
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
@@ -315,6 +359,7 @@ integration('diplomacy document message persistence', () => {
|
||||
},
|
||||
{
|
||||
id: foreignGeneralId,
|
||||
cityId: fixtureCityId,
|
||||
userId: foreignUserId,
|
||||
name: '상대수뇌',
|
||||
nationId: foreignNationId,
|
||||
@@ -379,7 +424,7 @@ integration('diplomacy document message persistence', () => {
|
||||
await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId);
|
||||
});
|
||||
|
||||
it('keeps permanent messages readable without a clock and does not resurrect them after invalidation', async () => {
|
||||
it('keeps permanent messages readable without a clock and preserves tombstoned content after clock recovery', async () => {
|
||||
const created = await appRouter
|
||||
.createCaller(buildContext('legacy-clock-fallback', fixtureAuth))
|
||||
.diplomacy.sendLetter({
|
||||
@@ -392,7 +437,7 @@ integration('diplomacy document message persistence', () => {
|
||||
where: { mailbox: receiverMailbox, type: 'diplomacy' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK));
|
||||
expect(receiver.validUntilTick).toBeNull();
|
||||
|
||||
await db.worldState.update({
|
||||
where: { id: fixtureWorldStateId },
|
||||
@@ -413,10 +458,10 @@ integration('diplomacy document message persistence', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await invalidateMessages(db, [receiver.id]);
|
||||
await tombstoneMessages(db, [receiver.id]);
|
||||
await expect(
|
||||
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { validUntilTick: true } })
|
||||
).resolves.toEqual({ validUntilTick: 0n });
|
||||
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { tombstonedAtWall: true } })
|
||||
).resolves.toEqual({ tombstonedAtWall: expect.any(Date) });
|
||||
await expect(
|
||||
fetchMessagesFromMailbox({
|
||||
db,
|
||||
@@ -425,7 +470,7 @@ integration('diplomacy document message persistence', () => {
|
||||
limit: 15,
|
||||
fromSeq: 0,
|
||||
})
|
||||
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
|
||||
).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' }));
|
||||
} finally {
|
||||
await db.worldState.update({
|
||||
where: { id: fixtureWorldStateId },
|
||||
@@ -445,7 +490,7 @@ integration('diplomacy document message persistence', () => {
|
||||
limit: 15,
|
||||
fromSeq: 0,
|
||||
})
|
||||
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
|
||||
).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' }));
|
||||
});
|
||||
|
||||
it('stores diplomacy and national copies for both approval and rejection responses', async () => {
|
||||
@@ -558,6 +603,172 @@ integration('diplomacy document message persistence', () => {
|
||||
await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId);
|
||||
});
|
||||
|
||||
it('records ordered document transitions once, bound to durable input and immutable content', async () => {
|
||||
const input = { destNationId: foreignNationId, brief: '감사 문서', detail: '불변 본문' };
|
||||
const sender = appRouter.createCaller(buildContext('audit-send', fixtureAuth));
|
||||
const created = await sender.diplomacy.sendLetter(input);
|
||||
await expect(sender.diplomacy.sendLetter(input)).resolves.toEqual(created);
|
||||
await appRouter.createCaller(buildContext('audit-accept', foreignAuth)).diplomacy.respondLetter({
|
||||
letterId: created.id,
|
||||
agree: true,
|
||||
});
|
||||
await appRouter
|
||||
.createCaller(buildContext('audit-destroy-src', fixtureAuth))
|
||||
.diplomacy.destroyLetter({ letterId: created.id });
|
||||
await appRouter
|
||||
.createCaller(buildContext('audit-destroy-dest', foreignAuth))
|
||||
.diplomacy.destroyLetter({ letterId: created.id });
|
||||
const events = await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { serverId: requestPrefix },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
expect(events.map((event) => event.eventType)).toEqual([
|
||||
'LETTER_PROPOSED',
|
||||
'LETTER_ACCEPTED',
|
||||
'LETTER_DESTROY_REQUESTED',
|
||||
'LETTER_DESTROYED',
|
||||
]);
|
||||
expect(events[0]).toMatchObject({
|
||||
year: 208,
|
||||
month: 4,
|
||||
tick: logicalGameTick,
|
||||
documentId: created.id,
|
||||
before: null,
|
||||
actor: { userId: fixtureUserId, generalId: fixtureGeneralId },
|
||||
});
|
||||
expect(events[1]).toMatchObject({
|
||||
before: { state: 'PROPOSED', destSignerId: null },
|
||||
after: { state: 'ACTIVATED', destSignerId: foreignGeneralId },
|
||||
});
|
||||
expect(events[2]).toMatchObject({ before: { stateOption: null }, after: { stateOption: 'try_destroy_src' } });
|
||||
expect(events[3]).toMatchObject({ before: { state: 'ACTIVATED' }, after: { state: 'CANCELLED' } });
|
||||
expect(new Set(events.map((event) => event.documentHash)).size).toBe(1);
|
||||
for (const event of events) {
|
||||
const journal = await db.inputEvent.findUniqueOrThrow({ where: { requestId: event.requestId! } });
|
||||
expect(event.inputSequence).toBe(journal.sequence);
|
||||
expect(JSON.stringify(event.after)).not.toContain('불변 본문');
|
||||
}
|
||||
await expect(
|
||||
db.diplomacyLetter.update({ where: { id: created.id }, data: { textDetail: '변조' } })
|
||||
).rejects.toThrow('Diplomacy document content is immutable');
|
||||
expect((await db.diplomacyLetter.findUniqueOrThrow({ where: { id: created.id } })).textDetail).toBe(
|
||||
'불변 본문'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['noAggression', 'cancelNA', 'stopWar'] as const)(
|
||||
'records both directions of %s with no duplicate on replay',
|
||||
async (action) => {
|
||||
const initialState = action === 'noAggression' ? 2 : action === 'cancelNA' ? 7 : 0;
|
||||
await db.diplomacy.createMany({
|
||||
data: [
|
||||
{ srcNationId: fixtureNationId, destNationId: foreignNationId, stateCode: initialState, term: 12 },
|
||||
{ srcNationId: foreignNationId, destNationId: fixtureNationId, stateCode: initialState, term: 12 },
|
||||
],
|
||||
});
|
||||
const messageId = await insertMessage(db, {
|
||||
mailbox: fixtureMailboxes[1],
|
||||
msgType: 'diplomacy',
|
||||
srcId: fixtureMailboxes[0],
|
||||
destId: fixtureMailboxes[1],
|
||||
time: logicalGameTime,
|
||||
validUntil: new Date('9999-12-31T00:00:00Z'),
|
||||
payload: {
|
||||
text: '불가침 파기 제의',
|
||||
option: { action, year: 209, month: 4 },
|
||||
src: {
|
||||
generalId: fixtureGeneralId,
|
||||
generalName: '원민수뇌',
|
||||
nationId: fixtureNationId,
|
||||
nationName: '원민국',
|
||||
color: '#123456',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: foreignGeneralId,
|
||||
generalName: '상대수뇌',
|
||||
nationId: foreignNationId,
|
||||
nationName: '상대국',
|
||||
color: '#654321',
|
||||
icon: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
const context = buildContext('audit-relation', foreignAuth);
|
||||
vi.spyOn(context.turnDaemon, 'requestCommand').mockResolvedValueOnce(null).mockResolvedValue({
|
||||
type: 'syncDiplomaticResponse',
|
||||
ok: true,
|
||||
generalId: foreignGeneralId,
|
||||
messageId,
|
||||
nations: 2,
|
||||
diplomacy: 2,
|
||||
cities: 0,
|
||||
});
|
||||
const caller = appRouter.createCaller(context);
|
||||
const input = { generalId: foreignGeneralId, messageId, response: true };
|
||||
await expect(caller.messages.respond(input)).rejects.toThrow(
|
||||
'외교 상태를 게임 엔진에 동기화하지 못했습니다.'
|
||||
);
|
||||
expect(await caller.messages.respond(input)).toEqual({ result: true, reason: 'success' });
|
||||
expect(await caller.messages.respond(input)).toEqual({ result: true, reason: 'success' });
|
||||
const events = await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { serverId: requestPrefix },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
expect(events).toHaveLength(2);
|
||||
expect(new Set(events.map((event) => `${event.srcNationId}:${event.destNationId}`))).toEqual(
|
||||
new Set([`${fixtureNationId}:${foreignNationId}`, `${foreignNationId}:${fixtureNationId}`])
|
||||
);
|
||||
for (const event of events) {
|
||||
expect(event).toMatchObject({
|
||||
category: 'RELATION',
|
||||
eventType: `MESSAGE_ACCEPTED_${action}`,
|
||||
before: { state: initialState, term: 12 },
|
||||
actor: { generalId: foreignGeneralId, messageId },
|
||||
});
|
||||
const current = await db.diplomacy.findUniqueOrThrow({
|
||||
where: {
|
||||
srcNationId_destNationId: {
|
||||
srcNationId: event.srcNationId,
|
||||
destNationId: event.destNationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(event.after).toMatchObject({ state: current.stateCode, term: current.term });
|
||||
}
|
||||
expect(await db.messageAction.findUniqueOrThrow({ where: { messageId } })).toMatchObject({
|
||||
status: 'RESOLVED',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('rolls back the document and notices if the audit insert fails', async () => {
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_audit_document_fixture()`);
|
||||
try {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('audit-failure', fixtureAuth)).diplomacy.sendLetter({
|
||||
destNationId: foreignNationId,
|
||||
brief: '감사 실패',
|
||||
detail: '롤백',
|
||||
})
|
||||
).rejects.toThrow('injected audit insert failure');
|
||||
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
|
||||
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
|
||||
})
|
||||
).toMatchObject({ status: 'FAILED', attempts: 1 });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls letter and message writes back together while retaining the failed API input event', async () => {
|
||||
const failure = new Error('injected diplomacy message transaction rollback');
|
||||
const requestId = 'send-rollback';
|
||||
@@ -573,9 +784,13 @@ integration('diplomacy document message persistence', () => {
|
||||
|
||||
await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull();
|
||||
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||
await expect(db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).resolves.toBe(0);
|
||||
await expect(
|
||||
db.readModelRevision.count({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||
where: {
|
||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||
entityId: { in: [...fixtureMailboxes] },
|
||||
},
|
||||
})
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
|
||||
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||
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.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 outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
@@ -88,7 +87,7 @@ const storedLetter = {
|
||||
};
|
||||
|
||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
||||
const create = vi.fn(async () => ({ id: 9 }));
|
||||
const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
|
||||
let messageId = 100;
|
||||
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
||||
const db = {
|
||||
@@ -159,17 +158,9 @@ describe('diplomacy HTML API boundary', () => {
|
||||
textBrief: '<p><strong>공개</strong></p>',
|
||||
textDetail:
|
||||
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
||||
date: new Date('0185-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
|
||||
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
|
||||
);
|
||||
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
|
||||
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
|
||||
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
|
||||
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
|
||||
});
|
||||
|
||||
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
||||
|
||||
@@ -89,6 +89,7 @@ const classifications = {
|
||||
'tournament.placeBet',
|
||||
],
|
||||
redisProjection: [
|
||||
'tournament.start',
|
||||
'tournament.patchState',
|
||||
'tournament.seedParticipants',
|
||||
'tournament.setBettingEntries',
|
||||
@@ -160,7 +161,7 @@ describe('game-api direct mutation journal inventory', () => {
|
||||
// count independently catches mutations that were added to a router but never mounted.
|
||||
expect(declaredCount).toBe(actual.length);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
expect(classified).toHaveLength(87);
|
||||
expect(classified).toHaveLength(88);
|
||||
expect(actual).toEqual(classified);
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
crew: 100,
|
||||
experience: 1_000,
|
||||
meta: { killturn: 3 },
|
||||
penalty: {},
|
||||
@@ -124,6 +125,9 @@ const globalGenerals = [
|
||||
name: '군주',
|
||||
dedication: 900,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
strength: 80,
|
||||
intel: 80,
|
||||
meta: { killturn: 4, owner_name: '통일유저' },
|
||||
experience: 10_000,
|
||||
}),
|
||||
@@ -132,6 +136,9 @@ const globalGenerals = [
|
||||
name: '외교관',
|
||||
dedication: 800,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 40,
|
||||
meta: { killturn: 2, permission: 'ambassador' },
|
||||
experience: 5_000,
|
||||
}),
|
||||
@@ -140,6 +147,9 @@ const globalGenerals = [
|
||||
name: '제재외교관',
|
||||
dedication: 700,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 40,
|
||||
intel: 80,
|
||||
meta: { killturn: 1, permission: 'ambassador' },
|
||||
penalty: { noTopSecret: true },
|
||||
experience: 2_000,
|
||||
@@ -149,6 +159,9 @@ const globalGenerals = [
|
||||
name: '조언자',
|
||||
dedication: 600,
|
||||
officerLevel: 1,
|
||||
leadership: 30,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
meta: { killturn: 5, permission: 'auditor' },
|
||||
experience: 1_000,
|
||||
}),
|
||||
@@ -161,6 +174,8 @@ const globalGenerals = [
|
||||
dedication: 500,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 65,
|
||||
meta: { killturn: 0 },
|
||||
experience: 20_000,
|
||||
}),
|
||||
@@ -221,7 +236,12 @@ const createContext = (
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
config: { const: { maxLevel: 100, maxDedLevel: 30 } },
|
||||
meta: { isUnited: options.isUnited ?? 0 },
|
||||
meta: {
|
||||
isUnited: options.isUnited ?? 0,
|
||||
killturn: 4,
|
||||
autorun_user: { limit_minutes: 60 },
|
||||
},
|
||||
tickSeconds: 3_600,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -279,6 +299,20 @@ describe('legacy global nation/general directories', () => {
|
||||
);
|
||||
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
||||
for (const result of results) {
|
||||
const serializedNationRows = JSON.stringify(result.nations);
|
||||
for (const privateField of [
|
||||
'totalCrew',
|
||||
'crew',
|
||||
'leadership',
|
||||
'strength',
|
||||
'intel',
|
||||
'killturn',
|
||||
'refreshScoreTotal',
|
||||
'meta',
|
||||
'penalty',
|
||||
]) {
|
||||
expect(serializedNationRows).not.toContain(`"${privateField}"`);
|
||||
}
|
||||
const serializedGeneralRows = JSON.stringify(result.generals);
|
||||
for (const privateField of [
|
||||
'userId',
|
||||
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
|
||||
id: 1,
|
||||
generalCount: 4,
|
||||
cityCount: 1,
|
||||
forceEstimate: {
|
||||
totalGeneralCount: 4,
|
||||
userCombatGeneralCount: 1,
|
||||
userTroops: 8_000,
|
||||
npcCombatGeneralCount: 0,
|
||||
npcTroops: 0,
|
||||
inactiveGeneralCount: 2,
|
||||
},
|
||||
ambassadorNames: ['군주', '외교관'],
|
||||
auditorCount: 1,
|
||||
});
|
||||
expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([
|
||||
['만능장', ['군주']],
|
||||
['무장', ['외교관']],
|
||||
['지장', ['제재외교관']],
|
||||
['무지장', ['조언자']],
|
||||
]);
|
||||
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
|
||||
expect(result[1]?.officers[0]).toMatchObject({
|
||||
officerLevel: 12,
|
||||
general: { id: 10, name: '군주' },
|
||||
});
|
||||
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
|
||||
expect(JSON.stringify(result)).not.toContain('totalCrew');
|
||||
});
|
||||
|
||||
it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => {
|
||||
const categoryStats = [
|
||||
['만능장', 80, 80, 80],
|
||||
['통장', 70, 20, 20],
|
||||
['무장', 70, 80, 40],
|
||||
['지장', 70, 40, 80],
|
||||
['평범장', 60, 50, 50],
|
||||
['무지장', 30, 50, 50],
|
||||
['무능장', 30, 10, 10],
|
||||
] as const;
|
||||
const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) =>
|
||||
directoryGeneral({
|
||||
id: 101 + index,
|
||||
name,
|
||||
nationId: 1,
|
||||
leadership,
|
||||
strength,
|
||||
intel,
|
||||
meta: { killturn: 4 },
|
||||
})
|
||||
);
|
||||
const [nation] = await appRouter
|
||||
.createCaller(
|
||||
createContext({
|
||||
directory: {
|
||||
nations: [nationRows[0]!],
|
||||
generals: categoryGenerals,
|
||||
cities: [{ id: 1, name: '허창', nationId: 1 }],
|
||||
},
|
||||
}).context
|
||||
)
|
||||
.world.getNationDirectory();
|
||||
|
||||
expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name));
|
||||
expect(nation?.forceEstimate).toEqual({
|
||||
totalGeneralCount: 7,
|
||||
userCombatGeneralCount: 5,
|
||||
userTroops: 35_000,
|
||||
npcCombatGeneralCount: 0,
|
||||
npcTroops: 0,
|
||||
inactiveGeneralCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
|
||||
|
||||
@@ -3,37 +3,123 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
||||
|
||||
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
|
||||
const buildDatabase = (
|
||||
mode: 'realtime' | 'manual' = 'realtime',
|
||||
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
|
||||
): DatabaseClient =>
|
||||
({
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
||||
clockTick: 36_000_000n,
|
||||
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
clockPhase: phase,
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
})),
|
||||
},
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
describe('current game time projection', () => {
|
||||
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => {
|
||||
it('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 preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||
expect(preopen).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
now: new Date('2026-08-21T10:30:00.000Z'),
|
||||
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
tick: -108_000_000,
|
||||
mode: 'realtime',
|
||||
phase: 'PREOPEN',
|
||||
running: false,
|
||||
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||
const opened = await loadCurrentGameTime(
|
||||
buildDatabase('realtime', 'RUNNING'),
|
||||
new Date('2026-08-21T11:00:05.000Z')
|
||||
);
|
||||
expect(opened).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||
tick: 36_300_000,
|
||||
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||
tick: 300_000,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
});
|
||||
@@ -43,8 +129,8 @@ describe('current game time projection', () => {
|
||||
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
now: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tick: 0,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
});
|
||||
|
||||
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(3);
|
||||
|
||||
const lockStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(lockStatement.sql).toContain('pg_advisory_xact_lock');
|
||||
expect(lockStatement.values).toContain('general-access:persistence');
|
||||
|
||||
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
|
||||
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
|
||||
expect(periodStatement.values).toContain(now);
|
||||
expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||
|
||||
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
const memberStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
||||
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
|
||||
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
|
||||
expect(memberStatement.sql).toContain('SET online = traffic_period.online');
|
||||
|
||||
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
||||
const accessStatement = executeRaw.mock.calls[2]![0] as { sql: string; values: unknown[] };
|
||||
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(accessStatement.values).toContain(7);
|
||||
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
|
||||
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
||||
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(now);
|
||||
});
|
||||
|
||||
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
|
||||
@@ -322,18 +326,14 @@ describe('general access tracking', () => {
|
||||
: [{ id: 41 }];
|
||||
}),
|
||||
$executeRaw: vi.fn(async (query: unknown) => {
|
||||
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
|
||||
const sql = (query as { sql?: string }).sql ?? '';
|
||||
if (sql.includes('INSERT INTO input_event')) {
|
||||
events.push('input-event-create');
|
||||
}
|
||||
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async () => 0),
|
||||
inputEvent: {
|
||||
update: vi.fn(async (args: { data: { status: string } }) => {
|
||||
if (args.data.status === 'FAILED') events.push('input-event-failed');
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
general: {
|
||||
|
||||
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
|
||||
expect(resolveRefreshScoreText(12_800)).toBe('헐...');
|
||||
});
|
||||
|
||||
it('matches the Ref remaining-minute calculation and one-turn rollover', () => {
|
||||
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull();
|
||||
it('matches the Ref remaining-minute calculation against the current game clock', () => {
|
||||
const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:19:00.000Z'), currentGameTime)).toBe(0);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:20:00.000Z'), null)).toBeNull();
|
||||
});
|
||||
|
||||
it('moves the first reserved month only after the general turn bucket has passed', () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
|
||||
it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
|
||||
const persistedPayload = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
@@ -101,6 +101,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
const currentCommand = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
userId: 'user-7',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
};
|
||||
const create = async () => {
|
||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||
};
|
||||
@@ -115,18 +123,14 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
transport.sendCommand(currentCommand)
|
||||
).resolves.toBe('vote-reward');
|
||||
|
||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
...currentCommand,
|
||||
...changedIdentity,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ const createContext = (options: {
|
||||
troopLeaderAction?: string | null;
|
||||
refreshScore?: number;
|
||||
refreshScoreTotal?: number;
|
||||
gameClockNow?: Date;
|
||||
rankRows?: Array<{ generalId?: number; type: string; value: number }>;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
@@ -164,6 +165,10 @@ const createContext = (options: {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: { upgradeLimit: 20 } },
|
||||
clockBaseTime: options.gameClockNow ?? null,
|
||||
clockTick: options.gameClockNow ? 0n : null,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: options.gameClockNow ?? null,
|
||||
})),
|
||||
},
|
||||
logEntry: {
|
||||
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
|
||||
troopLeaderAction: '휴식',
|
||||
refreshScore: 3,
|
||||
refreshScoreTotal: 1_141,
|
||||
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
|
||||
retirementYear: 70,
|
||||
defenceTrain: 80,
|
||||
killTurn: 6,
|
||||
remainingMinutes: null,
|
||||
remainingMinutes: 7,
|
||||
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
|
||||
refreshScore: { current: 3, total: 1_141, text: '열심' },
|
||||
},
|
||||
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
|
||||
defence_train: 80,
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the removed automatic diplomacy setting before dispatching it to ENGINE', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).general.setMySetting({
|
||||
use_auto_nation_diplomacy: 1,
|
||||
} as never)
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends settings directly to ENGINE without creating an API input event', async () => {
|
||||
const transaction = vi.fn(async () => {
|
||||
throw new Error('API transaction must not run');
|
||||
@@ -928,6 +945,52 @@ describe('in-game my information ownership', () => {
|
||||
});
|
||||
|
||||
describe('battle-center general and user permissions', () => {
|
||||
it.each([
|
||||
{ role: '연수 미달', officerLevel: 1, belong: 2, permission: 'normal', penalty: {}, level: 0 },
|
||||
{ role: '연수 충족', officerLevel: 1, belong: 3, permission: 'normal', penalty: {}, level: 1 },
|
||||
{ role: '연수 초과', officerLevel: 1, belong: 4, permission: 'normal', penalty: {}, level: 1 },
|
||||
{ role: '지방 관직', officerLevel: 2, belong: 0, permission: 'normal', penalty: {}, level: 1 },
|
||||
{ role: '수뇌', officerLevel: 5, belong: 0, permission: 'normal', penalty: {}, level: 2 },
|
||||
{ role: '군주', officerLevel: 12, belong: 0, permission: 'normal', penalty: {}, level: 4 },
|
||||
{ role: '외교권자', officerLevel: 1, belong: 0, permission: 'ambassador', penalty: {}, level: 4 },
|
||||
{ role: '조언자', officerLevel: 1, belong: 0, permission: 'auditor', penalty: {}, level: 3 },
|
||||
{ role: '수뇌 금지', officerLevel: 12, belong: 4, permission: 'normal', penalty: { noChief: true }, level: 0 },
|
||||
{
|
||||
role: '기밀 제한',
|
||||
officerLevel: 12,
|
||||
belong: 4,
|
||||
permission: 'normal',
|
||||
penalty: { noTopSecret: true },
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
role: '외교 제한',
|
||||
officerLevel: 12,
|
||||
belong: 4,
|
||||
permission: 'normal',
|
||||
penalty: { noAmbassador: true },
|
||||
level: 2,
|
||||
},
|
||||
])('Ref 감찰부 권한: $role', async ({ officerLevel, belong, permission, penalty, level }) => {
|
||||
const me = buildGeneral({ officerLevel, meta: { belong, permission }, penalty });
|
||||
const other = buildGeneral({ id: 8, userId: 'user-8' });
|
||||
const fixture = createContext({ me, targets: [me, other], nationMeta: { secretLimit: 3 } });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
if (level < 1) {
|
||||
await expect(caller.nation.getBattleCenter()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
} else {
|
||||
await expect(caller.nation.getBattleCenter()).resolves.toMatchObject({ me: { permissionLevel: level } });
|
||||
}
|
||||
for (const type of ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'] as const) {
|
||||
const request = caller.nation.getGeneralLog({ generalId: other.id, type });
|
||||
if (level < 1 || (type === 'generalAction' && level < 2)) {
|
||||
await expect(request).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
} else {
|
||||
await expect(request).resolves.toMatchObject({ generalId: other.id, type });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('distinguishes an ordinary member, a tenured member, and an auditor', async () => {
|
||||
const ordinary = createContext({
|
||||
me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }),
|
||||
|
||||
@@ -64,6 +64,14 @@ const state: TurnWorldState = {
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||
clockBaseTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'MANUAL',
|
||||
clockRevision: 1,
|
||||
deadlineGeneration: 1,
|
||||
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
||||
};
|
||||
|
||||
@@ -176,6 +184,14 @@ integration('inherit owner lookup private messages', () => {
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: state.clockBaseTime,
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
clockMode: state.clockMode ?? 'manual',
|
||||
clockWallAnchor: state.clockWallAnchor,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
||||
clockPhase: state.clockPhase ?? 'MANUAL',
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||
meta: state.meta as GamePrisma.InputJsonValue,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js';
|
||||
|
||||
describe('inherit reset turn time Ref compatibility', () => {
|
||||
it('matches the Ref PHP deterministic seed, offset, and displayed minute', () => {
|
||||
it('matches the Ref PHP deterministic seed, offset, and displayed minutes and seconds', () => {
|
||||
const result = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
@@ -12,7 +12,7 @@ describe('inherit reset turn time Ref compatibility', () => {
|
||||
});
|
||||
|
||||
expect(result.nextTurnTimeBase).toBeCloseTo(302.5143852464758, 12);
|
||||
expect(result.nextTurnTimeLabel).toBe('00:05');
|
||||
expect(result.nextTurnTimeLabel).toBe('05:02');
|
||||
});
|
||||
|
||||
it('uses the prior pending base as the next deterministic seed input', () => {
|
||||
|
||||
@@ -110,6 +110,7 @@ const buildContext = (options: {
|
||||
rankRows?: Array<{ type: string; value: number }>;
|
||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||
configConst?: Record<string, unknown>;
|
||||
scenarioCode?: string;
|
||||
configMap?: Record<string, unknown>;
|
||||
daemonResult?: TurnDaemonCommandResult;
|
||||
requestId?: string;
|
||||
@@ -141,9 +142,10 @@ const buildContext = (options: {
|
||||
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const activeWorldState =
|
||||
options.configConst === undefined && options.configMap === undefined
|
||||
? worldState
|
||||
? { ...worldState, scenarioCode: options.scenarioCode ?? worldState.scenarioCode }
|
||||
: {
|
||||
...worldState,
|
||||
scenarioCode: options.scenarioCode ?? worldState.scenarioCode,
|
||||
config: {
|
||||
...worldState.config,
|
||||
...(options.configConst === undefined ? {} : { const: options.configConst }),
|
||||
@@ -285,6 +287,21 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an out-of-range inheritance buff level with a user-facing message', async () => {
|
||||
const fixture = buildContext({});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
type: 'warAvoidRatio',
|
||||
level: 6,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
|
||||
const fixture = buildContext({
|
||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||
@@ -420,6 +437,26 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores source order for unique candidates when a scenario config has JSONB key order', async () => {
|
||||
const fixture = buildContext({
|
||||
scenarioCode: '905',
|
||||
configConst: {
|
||||
allItems: {
|
||||
item: { che_필살_둔갑천서: 1, che_의술_정력견혈산: 1 },
|
||||
horse: { che_명마_15_적토마: 1, che_명마_07_백마: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||
expect(status.availableUnique.map(({ key }) => key)).toEqual([
|
||||
'che_명마_07_백마',
|
||||
'che_명마_15_적토마',
|
||||
'che_의술_정력견혈산',
|
||||
'che_필살_둔갑천서',
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
|
||||
const createdAt = new Date('2026-07-26T00:00:00Z');
|
||||
const fixture = buildContext({
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const journalGeneralIds = [9_980_081, 9_980_082] as const;
|
||||
const clockScenarioCode = 'input-event-boundary';
|
||||
|
||||
const journalBoundaryRouter = router({
|
||||
mutate: procedure
|
||||
@@ -56,6 +57,21 @@ integration('API input event boundary', () => {
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
|
||||
const base = new Date('2099-09-03T00:00:00.000Z');
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: clockScenarioCode,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: base,
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: base,
|
||||
clockPhase: 'RUNNING',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -68,6 +84,7 @@ integration('API input event boundary', () => {
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
|
||||
await close?.();
|
||||
});
|
||||
|
||||
@@ -518,6 +535,10 @@ integration('API input event boundary', () => {
|
||||
expect(event.actorUserId).toBe('user-7');
|
||||
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||
expect(event.acceptedGameTick).toBeNull();
|
||||
expect(event.acceptedClockRevision).toBeNull();
|
||||
expect(event.acceptedDeadlineGeneration).toBeNull();
|
||||
expect(event.processingGameTick).toBeNull();
|
||||
await expect(
|
||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
||||
).resolves.toBe(requestId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||
import { procedure, router } from '../src/trpc.js';
|
||||
import { procedure, router, wallProcedure } from '../src/trpc.js';
|
||||
|
||||
const testRouter = router({
|
||||
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
|
||||
@@ -14,6 +14,14 @@ const testRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const wallTestRouter = router({
|
||||
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
|
||||
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
|
||||
ctx.changeJournal?.mark('front.general', 7);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const createContext = (payload: unknown = {}) => {
|
||||
const order: string[] = [];
|
||||
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
||||
@@ -28,6 +36,9 @@ const createContext = (payload: unknown = {}) => {
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
attempts: 0,
|
||||
acceptedGameTick: 100n,
|
||||
acceptedClockRevision: 3n,
|
||||
acceptedDeadlineGeneration: 2n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -36,8 +47,19 @@ const createContext = (payload: unknown = {}) => {
|
||||
});
|
||||
const transaction = {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: vi.fn(async () => {
|
||||
order.push('accepted');
|
||||
$executeRaw: vi.fn(async (query: { sql?: string }) => {
|
||||
const sql = query.sql ?? '';
|
||||
order.push(
|
||||
sql.includes('pg_advisory_xact_lock')
|
||||
? 'clock-fence'
|
||||
: sql.includes("status = 'PROCESSING'")
|
||||
? 'processing'
|
||||
: sql.includes("status = 'SUCCEEDED'")
|
||||
? 'succeeded'
|
||||
: sql.includes("status = 'FAILED'")
|
||||
? 'failed'
|
||||
: 'accepted'
|
||||
);
|
||||
return 1;
|
||||
}),
|
||||
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
||||
@@ -88,6 +110,7 @@ describe('API input-event change journal boundary', () => {
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'clock-fence',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
@@ -112,6 +135,7 @@ describe('API input-event change journal boundary', () => {
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'clock-fence',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
@@ -126,4 +150,24 @@ describe('API input-event change journal boundary', () => {
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
expect(fixture.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
|
||||
const fixture = createContext();
|
||||
|
||||
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.order).toEqual([
|
||||
'transaction-begin',
|
||||
'accepted',
|
||||
'locked',
|
||||
'processing',
|
||||
'savepoint',
|
||||
'handler',
|
||||
'journal',
|
||||
'succeeded',
|
||||
'savepoint-release',
|
||||
'commit',
|
||||
'wake',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
@@ -42,11 +42,16 @@ const buildContext = (
|
||||
turnDaemonLease: {
|
||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
||||
},
|
||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
||||
} as unknown as DatabaseClient,
|
||||
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
describe('lobby season state', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||
@@ -96,15 +101,18 @@ describe('lobby season state', () => {
|
||||
expect(result.turnEngineRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
||||
it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
|
||||
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||
const wallNow = new Date('2099-08-21T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(wallNow);
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||
tick: 36_000_000n,
|
||||
baseTime: wallAnchor,
|
||||
tick: 0n,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
}
|
||||
@@ -112,13 +120,22 @@ describe('lobby season state', () => {
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z');
|
||||
expect(result.serverTime).toBe(wallNow.toISOString());
|
||||
expect(result.clockMode).toBe('realtime');
|
||||
expect(result.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
||||
});
|
||||
|
||||
it('reports direct creation and possession as independent acquisition modes', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
|
||||
.lobby.info();
|
||||
|
||||
expect(result.directGeneralCreationEnabled).toBe(false);
|
||||
expect(result.npcPossessionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves zero as the first official game index', async () => {
|
||||
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ const buildContext = (
|
||||
tickSeconds: 3600,
|
||||
...(options.preopenClock
|
||||
? {
|
||||
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
|
||||
clockTick: -180_000_000n,
|
||||
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
|
||||
}
|
||||
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
|
||||
});
|
||||
|
||||
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z'));
|
||||
vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
|
||||
const context = buildContext({ hasVoted: true, preopenClock: true });
|
||||
|
||||
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
|
||||
|
||||
@@ -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 { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { tombstoneMessages } from '../src/messages/store.js';
|
||||
import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
@@ -23,6 +23,51 @@ integration('message deletion tombstone persistence', () => {
|
||||
|
||||
afterAll(async () => close?.());
|
||||
|
||||
it('persists an ordinary wall-time envelope without creating a game action', async () => {
|
||||
const rollback = new Error('rollback ordinary message envelope fixture');
|
||||
await expect(
|
||||
db.$transaction(async (transaction) => {
|
||||
const target = {
|
||||
generalId: 7,
|
||||
generalName: '보낸이',
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
const id = await persistMessageEnvelope(
|
||||
transaction,
|
||||
{
|
||||
mailbox: 9999,
|
||||
msgType: 'public',
|
||||
srcId: target.generalId,
|
||||
destId: 9999,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
payload: {
|
||||
src: target,
|
||||
dest: target,
|
||||
text: '일반 메시지는 WALL_TIME envelope만 저장한다.',
|
||||
option: {},
|
||||
},
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const message = await transaction.message.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { action: true },
|
||||
});
|
||||
expect(message.createdAtWall).toBeInstanceOf(Date);
|
||||
expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000);
|
||||
expect(message.occurredGameTick).toBeNull();
|
||||
expect(message.action).toBeNull();
|
||||
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
|
||||
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
|
||||
const rollback = new Error('rollback message tombstone fixture');
|
||||
await expect(
|
||||
@@ -70,6 +115,7 @@ integration('message deletion tombstone persistence', () => {
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(row.validUntil).toEqual(validUntil);
|
||||
expect(row.tombstonedAtWall).not.toBeNull();
|
||||
expect(row.message).toMatchObject({
|
||||
text: '삭제된 메시지입니다.',
|
||||
option: { invalid: true },
|
||||
@@ -81,4 +127,43 @@ integration('message deletion tombstone persistence', () => {
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
|
||||
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
|
||||
const rollback = new Error('rollback wall deletion fixture');
|
||||
await expect(
|
||||
db.$transaction(async (transaction) => {
|
||||
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
`;
|
||||
const draft = (text: string) => ({
|
||||
mailbox: 7,
|
||||
type: 'private' as const,
|
||||
src: 7,
|
||||
dest: 8,
|
||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
createdAtWall: nowWall,
|
||||
message: {
|
||||
src: { generalId: 7 },
|
||||
dest: { generalId: 8 },
|
||||
text,
|
||||
option: {},
|
||||
},
|
||||
});
|
||||
const deletable = await transaction.message.create({
|
||||
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
|
||||
});
|
||||
const expired = await transaction.message.create({
|
||||
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
|
||||
});
|
||||
|
||||
expect(
|
||||
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
|
||||
).toEqual([deletable.id]);
|
||||
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
|
||||
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user