Author SHA1 Message Date
Hide_D 957a599608 외교 제의 만료와 처리 완료를 삭제 표시에서 분리 2026-09-10 06:03:47 +00:00
Hide_D 73e1743a3d 복구 2배속의 게임·실제 시간 표시 설정과 시계 빠른 전환 추가 2026-09-10 04:22:18 +00:00
Hide_D 3a5d4e7833 메인 턴 당기기와 미루기를 1턴 기본 분할 버튼으로 변경 2026-09-10 02:50:19 +00:00
Hide_D 3f4e5095b5 외교 조회 권한자의 문서와 시스템 메시지 도착 알림 복원 2026-09-10 02:38:23 +00:00
Hide_D a00f46dfc8 관리자 서버 진단에 lease 상태와 영속 장애 이력 추가 2026-09-09 23:36:16 +00:00
Hide_D 9c73085353 현재 복구 migration 기준으로 manifest 검증 기대값 갱신 2026-09-09 23:05:39 +00:00
Hide_D 604b951eff 대기 후 복구 migration과 릴리스 manifest 기준 일치 2026-09-09 23:05:21 +00:00
Hide_D 09f4e60df4 lease 상실 데몬을 종료하고 관리자에게 즉시 오류 기록 2026-09-09 23:04:02 +00:00
Hide_D d191960ea1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-09 22:54:38 +00:00
Hide_D c1fd79da19 메인 지도 연월 툴팁에 유니크 보유 한도 표시 2026-09-08 00:49:39 +00:00
Hide_D d8c9a63915 NPC와 알림 메시지의 장수 아이콘 경로를 공통 처리 2026-09-07 15:28:24 +00:00
Hide_D c39527bdd7 중단 후 대기와 2배속 복구 경계 및 전체 공지 적용 2026-09-07 14:37:55 +00:00
Hide_D d57fd3e553 설문 제목과 알림을 닫을 수 있는 새 창으로 열기 2026-09-07 12:42:27 +00:00
Hide_D bdb4e22028 장비 구매 목록을 시나리오 JSON 순서로 표시 2026-09-07 12:30:47 +00:00
Hide_D b2be86a9a2 fix: 신규 오픈 시각과 게임 시계 원점을 정확히 보존 2026-09-07 01:48:23 +00:00
Hide_D 69f461612d fix: 등용장을 발송 국가 멸망 시 함께 만료 2026-09-06 23:55:20 +00:00
Hide_D 9d5447dd3f fix: 토너먼트 관리자 요청의 Redis와 DB 잠금 순서 통일 2026-09-06 23:32:24 +00:00
Hide_D 27049dae1f fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정 2026-09-06 15:39:30 +00:00
Hide_D 09dfe96ff1 fix: 설문조사 창 닫기 버튼이 팝업을 닫도록 수정 2026-09-06 15:11:05 +00:00
Hide_D b14d8d52f6 feat: 정지·종료 기수의 장수 참여를 허용하고 확정 기록 보호 2026-09-06 13:39:30 +00:00
Hide_D 4d64978cb6 fix: 후계자 없는 군주 사망 시 국가 멸망을 함께 처리 2026-09-06 12:09:09 +00:00
Hide_D d3443ec7f5 refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현 2026-09-06 10:04:33 +00:00
Hide_D 63da0921cb fix: 재개 시 미처리 턴을 실행하고 장기 지연은 12턴씩 보정 2026-09-06 08:06:00 +00:00
Hide_D 6dcc914f94 fix: 운영 중단 복구 시 장수의 지정 턴 시각을 보존 2026-09-06 07:37:51 +00:00
Hide_D d3b2bcd155 fix: 보유 장비 판매 선택지와 판매가 표시를 복원
구매 pool 밖 보유 유니크도 장비 이름으로 판매를 선택할 수 있게 한다. 기존 슬롯명 판매 brief와 None 예약 인자는 유지하고 API 및 Chromium 회귀를 검증한다.
2026-09-06 07:20:31 +00:00
Hide_D ce7d15eba9 feat: 환경 설정으로 메뉴를 확장하고 전용 아이콘 변경을 이동 2026-09-05 14:10:19 +00:00
Hide_D 8488bf1aae fix: 사령부 발령 후보의 부대와 능력치 정보를 보강 2026-09-05 13:48:45 +00:00
Hide_D 71d7abcda4 docs: Qwen 보조 분석 지침 제거 2026-09-05 13:29:21 +00:00
Hide_D 332bd9a3dd 메인 장수 정보에서 상세 통계와 숙련도 표시를 제외 2026-09-05 13:21:08 +00:00
Hide_D 3437cd99f4 fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정 2026-09-05 00:55:33 +00:00
Hide_D 1d53e6f226 docs: 공통 작업 지침 통합 및 충돌 정리 2026-09-04 23:54:53 +00:00
Hide_D 0157ff6a6b 인게임 예약 명령과 장수 상태 표시를 바로잡음 2026-09-04 19:16:56 +00:00
Hide_D 5d220de739 장시간 Ref 행렬과 NPC 회귀 비용을 줄인다 2026-09-04 08:59:10 +00:00
Hide_D 07897d9765 통합 테스트를 실행 권위별로 분리한다 2026-09-04 06:13:31 +00:00
Hide_D 01fdb51b6e 전투 차등 통합 테스트를 일괄 실행한다 2026-09-04 05:53:08 +00:00
Hide_D 746bbe7e7d 토너먼트 참가 트랜잭션 교착을 해소한다 2026-09-04 05:43:08 +00:00
Hide_D f009c50df5 외교 메시지 응답과 알림 권한 정리 2026-09-04 04:39:00 +00:00
Hide_D 38e516de6d 피장파장 연속 실행 판정을 복구한다 2026-09-04 02:02:36 +00:00
214 changed files with 8845 additions and 12621 deletions
+67 -241
View File
@@ -1,40 +1,29 @@
# core2026 작업 지침
## 적용 범위와 목표
## 적용 범위와 문서 역할
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
지침 순으로 적용합니다.
이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
상위 지침의 상대 경로는 `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 변경을 보존합니다.
## 완료 상태를 해석하는 법
@@ -55,87 +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 비교 전에 대상 계약도 구분해 주세요.
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
- 기존 `ng_compare` 이력과 분기 기준을 조사하고 reset/overwrite하지 말아 주세요.
- 계측은 최소·deterministic·가능하면 read-only로 두고 test 환경 guard를
사용해 주세요.
- 계측 유무가 결과, RNG 소비, DB mutation과 출력 순서를 바꾸지 않는지
확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 계승 계약의 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
3. API 요청·응답·오류, session과 인증
4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
이름만으로 완화하지 않습니다.
차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
따릅니다.
## 인증과 권한
@@ -182,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을 확인해 주세요.
@@ -210,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와 레거시 데이터 이관
@@ -263,94 +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
```
루트 `pnpm test``pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
adapter로 분리합니다.
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
- 모든 코드 변경 후 `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만 보고 명령을 가정하지 않습니다.
- 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
## 코드 스타일
@@ -374,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은 1284096 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 여부만으로 삭제하지 않습니다.
+9 -6
View File
@@ -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)입니다.
## 저장소 구성
@@ -252,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의
+3
View File
@@ -70,6 +70,9 @@ type WorldClockFields =
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'clockRecoveryStartTick'
| 'clockRecoveryEndTick'
| 'clockRecoveryStartWallAt'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
+30 -2
View File
@@ -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,
};
+13 -6
View File
@@ -57,10 +57,17 @@ const formatMessageTime = (value: Date): string => {
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
const payload = parsePayload(row.message);
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
const actionUnavailable =
actionStatus !== null &&
(actionStatus !== 'PENDING' ||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
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,
@@ -68,8 +75,8 @@ const toMessageView = (row: MessageRow, currentGameTick: bigint | null): Message
dest: row.type === 'public' ? null : payload.dest,
text: payload.text,
option:
actionUnavailable && payload.option && typeof payload.option === 'object'
? { ...payload.option, used: true, invalid: true }
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)),
};
+1 -4
View File
@@ -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 }),
};
};
+5 -2
View File
@@ -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);
+16 -3
View File
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const 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;
}
+6 -2
View File
@@ -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) }
: {}),
});
};
+3 -2
View File
@@ -86,8 +86,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) => {
+9 -7
View File
@@ -4,11 +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 { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import {
isWarTraitKey,
JOIN_PERSONALITY_TRAIT_KEYS,
@@ -339,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')
@@ -376,6 +377,7 @@ export const joinRouter = router({
npcGeneralCount,
},
inherit: {
enabled: history?.status !== 'COMPLETED',
totalPoint: inheritTotalPoint,
costs: {
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
@@ -595,10 +597,10 @@ export const joinRouter = router({
message: 'World state is not initialized.',
});
}
if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) {
if (!['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'COMPLETED'].includes(clockRows[0].clockPhase)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
});
}
const worldState = await transaction.worldState.findFirst();
+1
View File
@@ -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,
+17 -4
View File
@@ -49,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
return {
...message,
text: '조회 권한이 없는 외교 메시지입니다.',
option: { ...(message.option ?? {}), permissionRedacted: true },
};
});
};
@@ -76,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);
}
};
@@ -315,7 +321,11 @@ export const messagesRouter = router({
message.msgType === 'national'
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
markMessageMailboxes(
ctx,
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
message.msgType
);
return { ok: true, deletedIds };
}),
respond: engineAuthedProcedure
@@ -333,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' });
@@ -620,7 +633,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),
+9 -3
View File
@@ -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);
+29 -10
View File
@@ -8,7 +8,7 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, 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 { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
@@ -38,19 +38,29 @@ 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.' });
}
return next();
});
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: () => Promise<T>
@@ -69,7 +79,9 @@ const withTournamentClockMutation = async <T>(
deadlineGeneration: fence.generation,
dateToTick: gameTime.dateToTick,
};
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
return store.withClockContext(clockContext, () =>
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
);
};
const withTournamentBetClockMutation = async <T>(
@@ -103,6 +115,9 @@ const withTournamentBetClockMutation = async <T>(
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),
phase: z.number().int().min(0),
@@ -453,7 +468,9 @@ 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 withTournamentClockMutation(ctx, store, async () => {
@@ -476,6 +493,7 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
});
@@ -511,6 +529,7 @@ export const tournamentRouter = router({
} catch (error) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
});
+4 -1
View File
@@ -224,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,
+13
View File
@@ -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]));
@@ -380,10 +386,17 @@ 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) => ({
+5 -7
View File
@@ -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,10 +102,7 @@ export const hasPollEnded = (
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
Boolean(
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
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;
@@ -290,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(),
@@ -366,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
+18 -4
View File
@@ -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';
@@ -313,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();
+25 -3
View File
@@ -1,5 +1,6 @@
import {
GameClock,
readTurnRecovery,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
@@ -7,6 +8,7 @@ import {
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
import { readTurnRuntimeReady } from '@sammo-ts/infra';
export interface CurrentGameTime {
now: Date;
@@ -17,6 +19,8 @@ export interface CurrentGameTime {
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean;
runtimeReady?: boolean;
recovery?: { startsAt: string; endsAt: string } | null;
startsAt: Date | null;
dateToTick(date: Date): number | null;
}
@@ -43,6 +47,9 @@ 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,
@@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
recovery: readTurnRecovery(state),
turnSeconds: state.tickSeconds,
phase,
revision,
});
const tick = clock.nowTick(wallNow);
const running = phase === 'RUNNING' && mode === 'realtime';
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,
@@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
revision,
deadlineGeneration,
running,
startsAt: phase === 'PREOPEN' ? 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),
};
};
@@ -39,6 +39,7 @@ const ensureRedisClockFence = async (
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') ||
@@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async (
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
if (!gameTime.running) return null;
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
+50 -9
View File
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -79,6 +80,7 @@ export interface TurnCommandInputField {
required: boolean;
min?: number;
max?: number;
legacyWidthMax?: number;
step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue;
@@ -119,22 +121,60 @@ const plainLegacyInfo = (value: string): string =>
.replace(/\s+/gu, ' ')
.trim();
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
return undefined;
}
try {
const scenario = await loadScenarioDefinitionById(Number(normalized));
const configConst = scenario.config.const;
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
} catch (error) {
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
if (isRecord(error) && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
};
export const buildEquipmentTradeItemOptions = (options: {
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;
@@ -328,6 +368,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,
};
}
@@ -565,7 +606,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18)
) {
throwLegacyBasicTurnArgError();
throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
}
}
};
+35 -2
View File
@@ -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;
@@ -64,7 +67,7 @@ export const buildRefGeneralTargetOptions = (options: {
const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => {
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
if (action === 'che_발령') return `${entry.name} (${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})();
@@ -80,13 +83,43 @@ export const buildRefGeneralTargetOptions = (options: {
? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value));
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
const assignmentDetails =
action === 'che_발령'
? [
entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음',
[
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
]
.filter(Boolean)
.join('\n')
: undefined;
if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
}
return {
value: entry.id,
label,
description: details.join(' · '),
description: assignmentDetails ?? details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }),
...(entry.rice === undefined ? {} : { rice: entry.rice }),
+102 -2
View File
@@ -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: {
+14 -4
View File
@@ -67,6 +67,9 @@ describe('Ref command general targets', () => {
gold: 100,
rice: 200,
crew: 900,
leadership: 100,
strength: 95,
intel: 0,
train: 80,
atmos: 70,
troopId: 3,
@@ -88,11 +91,18 @@ describe('Ref command general targets', () => {
description: expect.stringContaining('탑승 부대 청룡대'),
});
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (부대 없음 · 업)',
'부대원 (청룡대 · 업)',
'부대장 (청룡대 (부대장) · 업)',
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
expect(detailed.generalTargets.che_발령?.[1]?.description).toBe(
'탑승 부대 청룡대\n통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200'
);
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe(
'탑승 부대 없음\n병력 1,000\n금 5,000 · 쌀 4,000'
);
expect(detailed.generalTargets.che_발령?.[2]?.description).toContain('청룡대 (부대장)');
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
'본인 (업)',
'부대원 (업)',
@@ -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);
@@ -289,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',
@@ -305,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()
@@ -553,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
);
});
@@ -63,7 +63,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])
)
);
@@ -94,7 +94,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] },
},
});
};
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).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);
+76
View File
@@ -23,6 +23,82 @@ const buildDatabase = (
}) as unknown as DatabaseClient;
describe('current game time projection', () => {
it('keeps independent workers frozen until this clock revision has a ready daemon', async () => {
const row = {
clockBaseTime: new Date('2026-09-06T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-06T00:00:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: 288_000_000n,
clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'),
};
let ready = false;
const db = {
worldState: { findFirst: vi.fn(async () => row) },
$queryRaw: vi.fn(async () => [{ ready }]),
} as unknown as DatabaseClient;
const now = new Date('2026-09-06T05:00:00Z');
expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false });
ready = true;
expect(await loadCurrentGameTime(db, now)).toMatchObject({
tick: 72_000_000,
running: true,
runtimeReady: true,
recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' },
});
});
it('exposes waiting, 2x and normal speed boundaries from a reloaded recovery', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-09-07T00:00:00Z'),
clockTick: 6000000n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-07T00:35:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 6000000n,
clockRecoveryEndTick: 36000000n,
clockRecoveryStartWallAt: new Date('2026-09-07T00:35:00Z'),
})),
},
$queryRaw: vi.fn(async () => [{ ready: true }]),
} as unknown as DatabaseClient;
for (const now of ['00:24:00', '00:34:59.999']) {
expect(await loadCurrentGameTime(db, new Date(`2026-09-07T${now}Z`))).toMatchObject({
tick: 6000000,
running: false,
startsAt: new Date('2026-09-07T00:35:00Z'),
recovery: { startsAt: '2026-09-07T00:35:00.000Z', endsAt: '2026-09-07T01:00:00.000Z' },
});
}
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:35:00.001Z'))).toMatchObject({
tick: 6000020,
running: true,
startsAt: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:59:59.999Z'))).toMatchObject({ tick: 35999980 });
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00Z'))).toMatchObject({
tick: 36000000,
running: true,
recovery: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00.001Z'))).toMatchObject({ tick: 36000010 });
});
it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') });
});
it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase();
@@ -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 } });
});
});
}
+78 -56
View File
@@ -128,64 +128,69 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(false);
});
it('redacts recent and old diplomacy content below secret permission 3', async () => {
const diplomacyRow = {
id: 19,
mailbox: 9001,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 8,
generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
it.each([null, 'RESOLVED'])(
'redacts recent and old diplomacy content below secret permission 3 (%s)',
async (actionStatus) => {
const diplomacyRow = {
id: 19,
action_status: actionStatus,
expires_game_tick: null,
mailbox: 9001,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 8,
generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
},
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
});
const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({
generalId: general.id,
type: 'diplomacy',
to: 20,
});
const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({
generalId: general.id,
type: 'diplomacy',
to: 20,
});
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
});
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
});
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
});
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression', permissionRedacted: true },
});
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression', permissionRedacted: true },
});
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
}
);
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]);
@@ -368,8 +373,8 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('diplomacy');
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 },
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
]);
});
@@ -837,6 +842,7 @@ describe('messages router missing-flow compatibility', () => {
proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number;
invalid?: boolean;
response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>;
}) => {
@@ -891,6 +897,7 @@ describe('messages router missing-flow compatibility', () => {
text: '외교 제안',
option: {
action,
...(options?.invalid ? { invalid: true } : {}),
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
},
},
@@ -1075,6 +1082,21 @@ describe('messages router missing-flow compatibility', () => {
};
};
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
const setup = buildDiplomaticContext({ invalid: true });
await expect(
setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: true,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
expect(setup.requestCommand).not.toHaveBeenCalled();
});
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
const setup = buildDiplomaticContext();
@@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => {
const ally = general({
id: 3,
userId: 'u3',
injury: 25,
gold: 3000,
crew: 200,
train: 80,
@@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => {
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 52, strength: 45, intelligence: 37 },
});
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(result.generals[0]?.reservedCommands).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
+14
View File
@@ -161,6 +161,20 @@ describe('NPC policy router', () => {
});
});
it('uses the live world development cost instead of the scenario snapshot', async () => {
const fixture = createContext({
world: {
...baseWorld,
config: { ...baseWorld.config, const: { develCost: 100 } },
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
},
});
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
});
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const requestCommand = vi.fn(async () => ({
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
attempts: 1,
actorUserId: userId,
});
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const logs = await db.logEntry.findMany({
where: {
OR: [
@@ -671,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.npcSelectionToken.update({
where: { ownerUserId: rejectedUserId },
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') },
data: { validUntilTick: -1n },
});
await expect(
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
@@ -716,4 +716,59 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
}, 45_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'reserves and possesses an NPC in %s without changing finalized records',
async (phase) => {
await stopRuntime('prepare frozen possession');
const original = await db.worldState.findFirstOrThrow();
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
const visitorId = `npc-visitor-${phase}`;
const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802);
await db.worldState.update({
where: { id: original.id },
data: {
clockPhase: phase,
meta: { ...(original.meta as Record<string, GamePrisma.InputJsonValue>), isUnited: 2, isunited: 2 },
},
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
const records = async () => ({
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await records();
try {
await startRuntime(`npc-visitor-${phase}`);
const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor));
const candidates = await caller.join.listPossessCandidates({});
const picked = candidates.candidates[0]!;
expect(picked).toBeDefined();
await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce });
expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({
userId: visitorId,
npcState: 1,
});
expect(await records()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
await stopRuntime('verify frozen possession reload');
await startRuntime(`npc-visitor-reload-${phase}`);
expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId);
} finally {
await stopRuntime('restore frozen possession');
await db.worldState.update({
where: { id: original.id },
data: { clockPhase: original.clockPhase, meta: original.meta! },
});
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
await startRuntime('frozen-possession-restored');
}
},
30_000
);
});
+64 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { resolveNationPermission } from '../src/router/nation/shared.js';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
@@ -9,7 +10,7 @@ import {
toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
@@ -198,6 +199,37 @@ describe('public realtime event privacy boundary', () => {
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
});
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
const blockedViewer = { ...viewer, canReadDiplomacy: false };
expect(
toPublicRealtimeEvent(
{
type: 'messageCreated',
at: '2026-09-04T00:00:00Z',
mailbox,
msgType: 'diplomacy',
messageId: 1,
senderId: 2,
},
[blockedViewer]
)
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
});
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
const event: RealtimeEvent = {
type: 'messagesChanged',
@@ -225,6 +257,12 @@ describe('public realtime event privacy boundary', () => {
viewer
)
).toBe(false);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
viewer
)
).toBe(true);
});
it('merges previous and committed identities across an ownership transition', () => {
@@ -251,3 +289,28 @@ describe('public realtime event privacy boundary', () => {
});
});
});
describe('diplomatic notification recipient roles', () => {
it.each([
['군주', 12, 'normal', {}, true],
['외교권자', 1, 'ambassador', {}, true],
['조언자', 1, 'auditor', {}, true],
['일반 장수', 1, 'normal', {}, false],
['수뇌', 11, 'normal', {}, false],
['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false],
] as const)(
'%s receives only eligible nation diplomacy invalidations',
(_, officerLevel, permission, penalty, allowed) => {
const canReadDiplomacy =
resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3;
expect(canReadDiplomacy).toBe(allowed);
const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] };
expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual(
allowed ? { type: 'messagesInvalidated', refreshGrant } : null
);
expect(
toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }])
).toBeNull();
}
);
});
@@ -12,6 +12,7 @@ const payload = (
| 'access.general'
| 'dashboard.global'
| 'messages.mailbox'
| 'messages.diplomacyMailbox'
| 'tournament'
| 'betting'
) => ({
@@ -19,7 +20,11 @@ const payload = (
changes: [
[
domain,
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
domain === 'front.general' || domain === 'access.general'
? 7
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
? 9999
: 0,
'1',
],
],
@@ -114,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
});
});
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
type: 'messagesChanged',
mailboxes: [],
diplomacyMailboxes: [9999],
});
});
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => {
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await seedScenarioToDatabase({
scenarioId: 903,
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: 2,
showImgLevel: 3,
@@ -274,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
status: 'SUCCEEDED',
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
});
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp');
}
@@ -660,4 +664,50 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
error: null,
});
}, 30_000);
it.each(['SUSPENDED', 'COMPLETED'])(
'creates from the selection pool while %s',
async (phase) => {
await runtime!.lifecycle.stop('prepare frozen pool creation');
await daemonLoop;
await runtime!.close();
const original = await db.worldState.findFirstOrThrow();
await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } });
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: `frozen-pool-${phase}`,
});
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start();
await turnDaemon.requestStatus(10_000);
const visitorId = `pool-visitor-${phase}`;
const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } };
const readRecords = async () => ({
hall: await db.hallOfFame.count({ where: { serverId: profile } }),
old: await db.oldGeneral.count({ where: { serverId: profile } }),
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
});
const before = await readRecords();
const candidates = await appRouter
.createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth))
.join.getSelectionPool();
const result = await appRouter
.createCaller(buildContext(`pool-create-${phase}`, visitorAuth))
.join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' });
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
userId: visitorId,
});
expect(await readRecords()).toEqual(before);
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
clockPhase: phase,
clockTick: original.clockTick,
lastTurnTick: original.lastTurnTick,
});
},
30_000
);
});
@@ -0,0 +1,232 @@
import { it, expect } from 'vitest';
import {
createGamePostgresConnector,
createRedisConnector,
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
} from '@sammo-ts/infra';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import type { GameApiContext } from '../src/context.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
integration.each([
'setState',
'patchState',
'setParticipants',
'setMatches',
'setBettingEntries',
'seedParticipants',
'cancel',
])(
'serializes %s behind a joining user without holding the ENGINE clock lock',
async (adminAction) => {
const url = databaseUrl!;
const connector = createGamePostgresConnector({ url });
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
await connector.connect();
await redisConnector.connect();
const db = connector.prisma;
const redis = redisConnector.client;
const profileName = 'che:tournament-lock-integration';
const keys = buildTournamentKeys(profileName);
const redisKeys = [...Object.values(keys), `${keys.stateKey}:mutation-lock`];
const prefix = 'integration:tournament-lock:';
const nextAt = new Date().toISOString();
let notifyJoin: () => void;
const joinAtDaemon = new Promise<void>((resolve) => {
notifyJoin = resolve;
});
let notifyAdmin: () => void;
const adminAtRedis = new Promise<void>((resolve) => {
notifyAdmin = resolve;
});
const transport = new DatabaseTurnDaemonTransport(db, 4_000);
try {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await db.turnDaemonLease.create({
data: {
profile: profileName,
ownerId: 'audit',
leaseUntil: new Date(Date.now() + 60000),
clockReady: true,
},
});
await db.worldState.create({
data: {
id: -991900,
scenarioCode: 'audit',
currentYear: 200,
currentMonth: 1,
tickSeconds: 60,
clockBaseTime: new Date(),
clockTick: 0n,
clockWallAnchor: new Date(),
clockPhase: 'RUNNING',
clockRevision: 1n,
config: { const: { develCost: 10 } },
},
});
await db.general.create({
data: { id: 991900, userId: 'audit-user', name: 'audit', turnTime: new Date(), gold: 1000 },
});
await redis.set(
keys.stateKey,
JSON.stringify({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
})
);
const base: Partial<GameApiContext> = {
db,
redis,
profile: { id: 'che', scenario: 'tournament-lock-integration', name: profileName },
auth: {
version: 1,
profile: profileName,
issuedAt: new Date().toISOString(),
expiresAt: '2999-01-01T00:00:00Z',
sessionId: 'audit',
user: { id: 'audit-user', username: 'audit', displayName: 'audit', roles: [] },
sanctions: {},
},
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
notifyJoin!();
await adminAtRedis;
const requestId = await transport.sendCommand(command);
return db.$transaction(async (tx) => {
// 이전 순서에서는 관리자가 DB lock을 보유하여 별도 ENGINE 연결이 막힌다.
await tx.$executeRawUnsafe("SET LOCAL lock_timeout = '500ms'");
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (command.type !== 'adjustGeneralResources') throw new Error('unexpected command');
const result = {
type: 'adjustGeneralResources' as const,
ok: true as const,
processed: 1,
missing: 0,
totalGoldDelta: -10,
totalRiceDelta: 0,
};
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result } });
return result;
});
},
},
};
const joinContext = { ...base, requestId: `${prefix}join` } as GameApiContext;
const adminRedis = new Proxy(redis, {
get(target, property) {
if (property === 'set')
return async (...args: Parameters<typeof redis.set>) => {
if (args[0] === `${keys.stateKey}:mutation-lock`) notifyAdmin!();
return redis.set(...args);
};
const value = Reflect.get(target, property);
return typeof value === 'function' ? value.bind(target) : value;
},
});
const adminContext = {
...base,
requestId: `${prefix}admin`,
redis: adminRedis,
auth: { ...base.auth!, user: { ...base.auth!.user, roles: ['admin'] } },
} as GameApiContext;
const callAdmin = async (context: GameApiContext) => {
const caller = appRouter.createCaller(context).tournament;
switch (adminAction) {
case 'setState':
return caller.setState({
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 200,
openMonth: 1,
termSeconds: 60,
nextAt,
});
case 'patchState':
return caller.patchState({ auto: true });
case 'setParticipants':
return caller.setParticipants([]);
case 'setMatches':
return caller.setMatches([]);
case 'setBettingEntries':
return caller.setBettingEntries([]);
case 'seedParticipants':
return caller.seedParticipants({ generalIds: [991900] });
default:
return caller.cancel();
}
};
const join = appRouter.createCaller(joinContext).tournament.join();
const joinOutcome = join.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await joinAtDaemon;
const admin = callAdmin(adminContext);
const adminOutcome = admin.then(
(value) => ({ ok: true, value }),
(error) => ({ ok: false, error })
);
await adminAtRedis;
const [joined, managed] = await Promise.all([joinOutcome, adminOutcome]);
expect(joined).toMatchObject({ ok: true, value: { ok: true, count: 1 } });
expect(managed).toMatchObject({ ok: true, value: { ok: true } });
const revision = await redis.get(keys.sourceRevisionKey);
const engineCount = await db.inputEvent.count({
where: { requestId: { startsWith: prefix }, target: 'ENGINE' },
});
// API 입력 원장은 유지한다. 동일 요청 재실행은 Redis/환불 command를 다시 쓰지 않는다.
await expect(callAdmin(adminContext)).resolves.toMatchObject({ ok: true });
expect(await redis.get(keys.sourceRevisionKey)).toBe(revision);
expect(await db.inputEvent.count({ where: { requestId: { startsWith: prefix }, target: 'ENGINE' } })).toBe(
engineCount
);
const inputEvent = await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}admin:tournament.${adminAction}` },
});
expect(inputEvent).toMatchObject({ target: 'API', status: 'SUCCEEDED', attempts: 1 });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
const retryContext = { ...adminContext, requestId: `${prefix}retry` };
const beforeFailure = await redis.get(keys.sourceRevisionKey);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'SUSPENDED' } });
await expect(callAdmin(retryContext)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
expect(await redis.get(keys.sourceRevisionKey)).toBe(beforeFailure);
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'RUNNING' } });
await expect(callAdmin(retryContext)).resolves.toMatchObject({ ok: true });
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${prefix}retry:tournament.${adminAction}` },
})
).toMatchObject({ status: 'SUCCEEDED', attempts: 2 });
} finally {
await redis.del(redisKeys);
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
await db.general.deleteMany({ where: { id: 991900 } });
await db.worldState.deleteMany({ where: { id: -991900 } });
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
await redisConnector.disconnect();
await connector.disconnect();
}
},
15000
);
+21 -3
View File
@@ -277,15 +277,33 @@ describe('tournament router permissions and mutations', () => {
nextAt: '2026-07-26T01:00:00.000Z',
});
await redis.set('sammo:che:default:tournament:participants', '[]');
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
);
const context = buildContext({
redis,
transport,
generals: [general],
userId: 'user-1',
develCost: 200,
requestId: 'http:tournament-join',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toHaveLength(1);
+12
View File
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
});
});
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
const fixture = buildContext({
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
result: null,
});
const result = await appRouter.createCaller(fixture.context).troop.getList();
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
});
it('creates a troop only for the general owned by the authenticated user', async () => {
const { context, requestCommand } = buildContext({
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
tryGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
await closeDb?.();
});
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
const requestId = 'integration:vote-comment-timestamp:submit';
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
const context: Partial<GameApiContext> = {
requestId,
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
const acceptedId = await transport.sendCommand(command);
expect(acceptedId).toBe(engineRequestId);
await db.$transaction(async (transaction) => {
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
true
);
const event = await transaction.inputEvent.findUniqueOrThrow({
where: { requestId: acceptedId },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'PENDING',
actorUserId: fixtureUserId,
});
await transaction.inputEvent.update({
where: { requestId: acceptedId },
data: {
status: 'SUCCEEDED',
result: {
type: 'voteReward',
ok: true,
voteId: fixtureId,
generalId: fixtureId,
awardedUnique: false,
},
},
});
});
return transport.requestCommand(command);
},
},
};
const caller = appRouter.createCaller(context as GameApiContext);
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
ok: true,
wonLottery: false,
});
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone"
+35 -1
View File
@@ -216,6 +216,40 @@ const buildContext = (options: {
};
describe('vote router actor and permission boundaries', () => {
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
const fixture = buildContext({ requestId: 'vote-boundary' });
const transaction = vi.fn(async () => {
throw new Error('API transaction blocks the vote ENGINE clock fence');
});
Object.assign(fixture.context.db, { $transaction: transaction });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
generalId: 7,
})
);
});
it.each([
{ auth: null, code: 'UNAUTHORIZED' },
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
])(
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
async ({ auth, code }) => {
const fixture = buildContext({ auth });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).rejects.toMatchObject({ code });
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
);
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z');
const time = {
@@ -259,7 +293,7 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false
);
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
expect(fixture.changeJournal.snapshot()).toEqual([]);
expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled();
});
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameApiContext } from '../src/context.js';
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js';
import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js';
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
db: { $queryRaw: queryRaw },
} as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
'sammo:map:base:hwe:scenario_2400:pg12'
);
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
'sammo:map:public:hwe:scenario_2400:pg12'
);
expect(queryRaw).toHaveBeenCalledTimes(2);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('read_model_revision_meta');
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'");
expect(statement.sql).toContain('revision."domain" = \'map.world\'');
expect(statement.values).toEqual([]);
});
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
expect(redis.set).toHaveBeenCalledTimes(1);
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
expect(shared).not.toHaveProperty('shownByGeneralList');
expect(shared).not.toHaveProperty('myCity');
expect(shared).not.toHaveProperty('myNation');
// 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다.
delete shared.uniqueItemLimit;
cache.set(cache.keys().next().value!, JSON.stringify(shared));
const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true });
expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null });
expect(redis.set).toHaveBeenCalledTimes(2);
});
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
redis,
db: {
$queryRaw: queryRaw,
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) },
worldState: {
findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })),
},
},
} as unknown as GameApiContext;
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
expect(redis.set).not.toHaveBeenCalled();
});
});
describe('map unique ownership limit', () => {
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
currentYear,
meta: { scenarioMeta: { startYear: 180 } },
config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } },
});
it.each([
[179, 1, 182],
[181, 1, 182],
[182, 1, 182],
[183, 2, 189],
[189, 2, 189],
[190, 3, 199],
[200, 4, null],
])('projects year %i and the inclusive last month', (year, count, untilYear) => {
expect(resolveMapUniqueItemLimit(state(year!))).toEqual({
count,
until: untilYear === null ? null : { year: untilYear, month: 12 },
});
});
it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => {
expect(
resolveMapUniqueItemLimit(
state(181, {
maxUniqueItemLimit: [
[-1, 1],
[2, 1],
[4, 3],
[8, 4],
],
})
)
).toEqual({ count: 1, until: { year: 183, month: 12 } });
expect(
resolveMapUniqueItemLimit(
state(183, {
allItems: { horse: {}, weapon: {} },
})
)
).toEqual({ count: 2, until: null });
expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null });
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '',
icon: resolveMessageTargetIcon(options.bidder),
},
text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
time: new Date(options.time.getTime()),
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
@@ -124,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '',
icon: resolveMessageTargetIcon(options.bidder),
},
text: options.text,
time: new Date(options.time.getTime()),
@@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
async drain(): Promise<TurnDaemonCommand[]> {
const local = this.localQueue.splice(0, this.localQueue.length);
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
if (local.some((command) => command.type === 'shutdown')) {
return local;
}
const remote = await this.claimPending();
return local.concat(remote);
}
@@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
});
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
const gameplayAllowed =
!world ||
world.clockPhase === 'PREOPEN' ||
world.clockPhase === 'RUNNING' ||
world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended =
@@ -158,6 +169,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
AND (
${gameplayAllowed}
OR "event_type" = 'getStatus'
OR (
${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'}
AND "event_type" IN (
'joinCreateGeneral', 'npcPossessGeneral',
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
)
)
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
}
export class TurnDaemonLeaseLostError extends Error {
constructor(profile: string) {
super(`Turn daemon lease was lost for profile "${profile}".`);
constructor(profile: string, reason?: string) {
super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
this.name = 'TurnDaemonLeaseLostError';
}
}
@@ -50,6 +50,7 @@ export class DatabaseTurnDaemonLease {
private expiryTimer: NodeJS.Timeout | null = null;
private renewalInFlight = false;
private lost = false;
private lossReason: string | undefined;
private constructor(
db: GamePrismaClient,
@@ -93,6 +94,7 @@ export class DatabaseTurnDaemonLease {
ON CONFLICT ("profile") DO UPDATE
SET
"owner_id" = EXCLUDED."owner_id",
"clock_ready" = FALSE,
"lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
@@ -115,6 +117,7 @@ export class DatabaseTurnDaemonLease {
fencingEpoch: BigInt(row.fencing_epoch),
};
this.lost = false;
this.lossReason = undefined;
this.scheduleExpiryWatchdog(requestStartedAt);
if (this.heartbeatEnabled) {
this.startHeartbeat();
@@ -126,10 +129,22 @@ export class DatabaseTurnDaemonLease {
return this.token ? { ...this.token } : null;
}
async markClockReady(): Promise<void> {
await this.db.$transaction(async (db) => {
await this.assertActive(db);
const token = this.getToken()!;
await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } });
});
}
isLost(): boolean {
return this.lost;
}
getLossError(): TurnDaemonLeaseLostError {
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
}
async renew(): Promise<boolean> {
const token = this.token;
if (!token || this.lost || this.renewalInFlight) {
@@ -151,7 +166,7 @@ export class DatabaseTurnDaemonLease {
RETURNING "profile", "owner_id", "fencing_epoch"
`);
if (rows.length === 0) {
this.markLost();
this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
return false;
}
if (this.lost) {
@@ -167,7 +182,7 @@ export class DatabaseTurnDaemonLease {
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
const token = this.token;
if (!token || this.lost) {
throw new TurnDaemonLeaseLostError(this.profile);
throw this.getLossError();
}
const db = transaction ?? this.db;
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
@@ -181,8 +196,8 @@ export class DatabaseTurnDaemonLease {
FOR UPDATE
`);
if (rows.length === 0) {
this.markLost();
throw new TurnDaemonLeaseLostError(this.profile);
this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
throw this.getLossError();
}
}
@@ -219,8 +234,10 @@ export class DatabaseTurnDaemonLease {
}
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
this.heartbeatTimer = setInterval(() => {
void this.renew().catch(() => {
this.markLost();
void this.renew().catch((error: unknown) => {
this.markLost(
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
);
});
}, intervalMs);
this.heartbeatTimer.unref();
@@ -239,7 +256,9 @@ export class DatabaseTurnDaemonLease {
this.stopExpiryWatchdog();
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
this.expiryTimer = setTimeout(() => {
this.markLost();
this.markLost(
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
);
}, remainingMs);
this.expiryTimer.unref();
}
@@ -251,7 +270,9 @@ export class DatabaseTurnDaemonLease {
}
}
private markLost(): void {
private markLost(reason: string): void {
if (this.lost) return;
this.lossReason = reason;
this.lost = true;
this.stopHeartbeat();
this.stopExpiryWatchdog();
@@ -90,7 +90,20 @@ export class TurnDaemonLifecycle {
start(): Promise<void> {
if (!this.loopPromise) {
this.loopPromise = this.runLoop();
this.loopPromise = this.runLoop().catch(async (error: unknown) => {
// 초기화와 pause gate 실패도 관리자에게 즉시 남긴다. lease를
// 잃은 runtime은 대기 상태로 살아 있으면 안전하게 재개할 수 없다.
this.status.state = 'stopping';
this.status.running = false;
this.status.paused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown lifecycle error.';
try {
await this.hooks?.onRunError?.(error);
} catch {
// 장애 기록 실패가 원래 종료 원인을 덮어쓰지 않게 한다.
}
throw error;
});
}
return this.loopPromise;
}
@@ -158,6 +171,25 @@ export class TurnDaemonLifecycle {
continue;
}
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (gameClock?.phase && gameClock.phase !== 'RUNNING' && gameClock.phase !== 'MANUAL') {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) {
this.status.nextTurnTime = gameClock.startsAt.toISOString();
await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs));
continue;
}
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) {
await this.runOnce(this.pendingRun);
this.pendingRun = null;
@@ -169,23 +201,6 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(200);
continue;
}
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (
gameClock?.phase &&
gameClock.phase !== 'RUNNING' &&
gameClock.phase !== 'MANUAL'
) {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
@@ -222,7 +237,10 @@ export class TurnDaemonLifecycle {
continue;
}
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime);
const command = await this.controlQueue.waitFor(
Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs)
);
if (command) {
await this.handleCommand(command);
}
+2
View File
@@ -72,11 +72,13 @@ export interface TurnStateStore {
phase?: GameClockPhase;
revision?: number;
deadlineGeneration?: number;
startsAt?: Date;
}>;
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
projectGameDeadline?(gameTime: Date): Promise<Date>;
}
export interface TurnDaemonControlQueue {
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
return { ...resultFromPersisted(created), alreadyApplied: false };
};
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => {
export const cancelGame = async (
request: GameCancellationRequest,
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
): Promise<GameCancellationResult> => {
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
earnedPoint: 0,
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
});
const connector = createGamePostgresConnector({ url: request.databaseUrl });
const connector = connectorFactory({ url: request.databaseUrl });
await connector.connect();
try {
return await connector.prisma.$transaction(
+19 -8
View File
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean;
now?: Date;
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
wallNow?: Date;
tickSeconds?: number;
gameClockMode?: GameClockMode;
installOptions?: ScenarioInstallOptions;
@@ -240,14 +242,23 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
const sync = install?.sync ?? false;
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
// Opening is an exact wall instant, independent of the calendar's 12-turn
// grouping. Only the initial year/month uses the legacy calendar alignment.
const initialClockWallAnchor = requestedOpening;
const startState = resolveStartState(
scenario.startYear ?? null,
gameClockMode === 'manual' ? now : requestedOpening,
turnTermMinutes,
sync
);
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({
baseTime: startState.startTime,
baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
tick: 0,
mode: gameClockMode,
wallAnchor: initialClockWallAnchor,
@@ -316,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// monthly pre-handler recalculates the same value at each boundary.
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now),
opentime: formatDateTime(now),
lastTurnTime: formatDateTime(now),
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
};
const firstGameIdx =
@@ -344,7 +355,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
}
worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.seededAtWall = wallNow.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
+3 -1
View File
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
@@ -253,7 +254,8 @@ export const createTournamentRewardFinalizer = async (options: {
}))
.filter((entry) => !!entry.userId);
for (const entry of pointUpdates) {
const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId);
for (const entry of recordsFinalized ? [] : pointUpdates) {
await db.inheritancePoint.upsert({
where: {
userId_key: { userId: entry.userId!, key: 'tournament' },
@@ -192,6 +192,13 @@ const respondToScout = async (options: {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
}
// 등용장은 발신 장수의 현재 소속이 아니라 발송 당시 국가에 귀속된다.
// 멸망 처리 도입 전에 남은 편지도 수락/거절 전에 영구 만료한다.
if (!world.getNationById(payload.src.nationId)) {
await invalidateMessageIds(db, world, [row.id], now);
return { ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' };
}
const sourceNationName = payload.src.nationName;
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
if (response) {
@@ -332,7 +339,7 @@ const respondToRaiseInvader = async (options: {
reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
clockWallNow: alignment.resumeWallAt,
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
});
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler(
+52 -9
View File
@@ -1149,6 +1149,8 @@ export class GeneralAI {
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
let userChiefCount = 0;
let ambassadorCount = 0;
const assignedAmbassadorIds = new Set<number>();
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
const minNpcKillturn = 36;
@@ -1162,13 +1164,25 @@ export class GeneralAI {
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
userChiefCount += 1;
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
if (ambassadorCount < 2) {
ambassadorCount += 1;
assignedAmbassadorIds.add(chief.id);
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
} else if (asRecord(chief.meta).permission === 'ambassador') {
chief.meta = { ...chief.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'normal',
});
}
}
}
@@ -1222,7 +1236,7 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
effectiveOfficerLevel.set(oldChief.id, 1);
}
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
const permission = penalty.noAmbassador !== true && ambassadorCount < 2 ? 'ambassador' : undefined;
candidate.officerLevel = 11;
candidate.meta = {
...candidate.meta,
@@ -1238,6 +1252,10 @@ export class GeneralAI {
effectiveOfficerLevel.set(candidate.id, 11);
chiefSet |= 1 << 11;
userChiefCount += 1;
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(candidate.id);
}
break;
}
}
@@ -1307,10 +1325,16 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
const permission =
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined;
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true && ambassadorCount < 2
? 'ambassador'
: undefined;
if (nextChief.npcState < 2) {
userChiefCount += 1;
}
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(nextChief.id);
}
this.promotionPatches.push({
generalId: nextChief.id,
officerLevel: chiefLevel,
@@ -1331,6 +1355,25 @@ export class GeneralAI {
chiefSet |= 1 << chiefLevel;
}
for (const candidate of generals) {
if (
asRecord(candidate.meta).permission !== 'ambassador' ||
assignedAmbassadorIds.has(candidate.id) ||
this.promotionPatches.some(
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
)
) {
continue;
}
candidate.meta = { ...candidate.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: candidate.id,
officerLevel: effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel,
officerCity: readMetaNumber(asRecord(candidate.meta), 'officer_city', 0),
permission: 'normal',
});
}
if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = {
...this.nation.meta,
+23
View File
@@ -5,6 +5,8 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
import { createTurnDaemonRuntime } from './turnDaemon.js';
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonCliOptions {
profile?: string;
@@ -89,6 +91,27 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
pauseGateIntervalMs,
adminActionIntervalMs,
gameClockMode,
}).catch(async (error: unknown) => {
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
// lifecycle hook이 아직 없으므로 여기서 별도로 관리자에게 기록한다.
if (!(error instanceof TurnDaemonLeaseUnavailableError)) {
try {
const gate = await createGatewayProfileGate({
databaseUrl,
gatewayDatabaseUrl,
profileName,
incidentContext: () => ({ stage: 'startup' }),
});
try {
await gate.markPaused(error);
} finally {
await gate.close();
}
} catch {
/* 원래 시작 실패를 보존한다. */
}
}
throw error;
});
const memoryReporter = createTurnDaemonMemoryReporter({
@@ -1,9 +1,18 @@
import { createHash } from 'node:crypto';
import { GameClock, parseGameClockPhase } from '@sammo-ts/common';
import {
ChangeJournal,
GameClock,
formatServerDateTime,
parseGameClockPhase,
readTurnRecovery,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_PUBLIC, resolveMessageTargetIcon, sendMessage } from '@sammo-ts/logic';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma,
persistMessageEnvelope,
writeReadModelChangeJournal,
acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
@@ -302,6 +311,47 @@ export const applyNextClockProjection = async (options: {
throw new Error('Clock projection final RUNNING transition fence failed.');
}
const appliedAt = await readDbWall(transaction);
// RUNNING 전이와 같은 transaction에 남겨 재시도 시 전체 공지를 중복 발송하지 않는다.
const recovery = readTurnRecovery(world);
const journal = new ChangeJournal();
journal.mark('world.content').mark('map.world');
if (recovery) {
const recoveredClock = new GameClock({
baseTime: world.clockBaseTime!,
tick: Number(world.clockTick),
wallAnchor: world.clockWallAnchor!,
mode: 'realtime',
turnSeconds: world.tickSeconds,
recovery,
});
const endsAt = recoveredClock.tickToWallDate(recovery.endTick);
const system = {
generalId: 0,
generalName: '시스템',
nationId: 0,
nationName: '',
color: '#000000',
icon: resolveMessageTargetIcon(),
};
await sendMessage(
{ insertMessage: (draft) => persistMessageEnvelope(transaction, draft) },
{
msgType: 'public',
src: system,
dest: system,
text: `서버 재개에 따른 2배속 복구 시간: ${formatServerDateTime(recovery.startWallAt)} ~ ${formatServerDateTime(endsAt)} (한국 시각). 시작 전까지 대기하며, 종료 시 정상 속도로 진행합니다.`,
time: appliedAt,
validUntil: new Date('9999-12-31T00:00:00Z'),
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: endsAt.toISOString(),
},
}
);
journal.mark('messages.mailbox', MESSAGE_MAILBOX_PUBLIC);
}
await writeReadModelChangeJournal(transaction, journal.snapshot());
await transaction.clockProjectionOutbox.update({
where: { id: outbox.id },
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
+103 -6
View File
@@ -7,6 +7,10 @@ import {
buildClockAlignmentPlan,
parseClockAlignmentPolicy,
parseGameClockPhase,
readTurnRecovery,
readSerializedTurnRecovery,
serializeTurnRecovery,
type TurnRecoveryWindow,
type ClockAlignmentPolicy,
} from '@sammo-ts/common';
import {
@@ -43,6 +47,8 @@ export interface ClockReconciliationResult {
shiftTicks: number;
alignedTick: number;
resumeWallAt: Date;
recovery?: TurnRecoveryWindow | null;
resumeAnchor?: Date;
}
interface DbWallRow {
@@ -213,6 +219,10 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigi
`);
};
// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다.
const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean =>
Boolean(db.worldState.fields?.clockRecoveryStartTick);
const readParticipantSnapshots = async (
db: GamePrisma.TransactionClient,
worldStateId: number,
@@ -224,6 +234,13 @@ const readParticipantSnapshots = async (
where: { id: worldStateId },
select: {
clockTick: true,
...(supportsRecoveryColumns(db)
? {
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
}
: {}),
clockRevision: true,
deadlineGeneration: true,
lastTurnTick: true,
@@ -290,6 +307,7 @@ const readParticipantSnapshots = async (
snapshot('world-clock', 'REBUILD', [
{
clockTick: world.clockTick,
...serializeTurnRecovery(readTurnRecovery(world)),
clockRevision: world.clockRevision,
deadlineGeneration: world.deadlineGeneration,
},
@@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
cutWallAt: Date;
rateTicksPerSecond: number;
sourceRevision: number;
normalTickAtCutWall?: number;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
}): Promise<void> => {
@@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
rateTicksPerSecond: options.rateTicksPerSecond,
catchUpTicks: BigInt(catchUpTicks),
participantChecksumBefore: aggregateChecksum(participants),
detail: asJson({ authority: 'DAEMON', profileName: options.profileName }),
detail: asJson({
authority: 'DAEMON',
profileName: options.profileName,
normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick),
}),
},
});
await persistInitialParticipants(options.db, options.suspensionId, participants);
@@ -712,6 +735,7 @@ export const startClockSuspension = async (options: {
authority: ClockOperationAuthority;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
recoverDurableObservation?: boolean;
}): Promise<ClockSuspensionResult> => {
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
throw new Error('Clock suspension ID must contain 1-64 characters.');
@@ -761,7 +785,13 @@ export const startClockSuspension = async (options: {
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
throw new Error('Clock suspension requires a fully initialized logical game clock.');
}
const cutWallAt = await readDbWall(db);
if (
options.recoverDurableObservation &&
(options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS')
) {
throw new Error('Only daemon outage recovery may use the durable observation.');
}
const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db);
const storedTick = safeNumber(world.clockTick, 'world clock tick');
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
const clock = new GameClock({
@@ -769,11 +799,12 @@ export const startClockSuspension = async (options: {
tick: storedTick,
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor,
recovery: readTurnRecovery(world),
turnSeconds: world.tickSeconds,
phase,
revision: sourceRevision,
});
const cutTick = clock.nowTick(cutWallAt);
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
await lockParticipants(db, BigInt(cutTick));
await db.worldState.update({
where: { id: worldStateId },
@@ -797,6 +828,7 @@ export const startClockSuspension = async (options: {
detail: asJson({
authority: options.authority.kind,
profileName: options.authority.profileName,
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
}),
},
});
@@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: {
authority?: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> => {
const db = options.db;
const worldStateId = await lockWorld(db);
@@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
resumeWallAt: suspension.resumeWallAt,
recovery: readSerializedTurnRecovery(suspension.detail),
resumeAnchor:
suspension.detail &&
typeof suspension.detail === 'object' &&
!Array.isArray(suspension.detail) &&
typeof suspension.detail.resumeAnchor === 'string'
? new Date(suspension.detail.resumeAnchor)
: (world.clockWallAnchor ?? suspension.resumeWallAt),
};
}
if (suspension.status !== 'SUSPENDED') {
@@ -882,14 +923,41 @@ export const reconcileClockSuspensionInTransaction = async (options: {
throw new Error('A clock reconciliation wall override is allowed only in tests.');
}
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY';
const upgradeMaintenance =
options.upgradeMaintenancePolicy === true &&
supportsRecoveryColumns(db) &&
suspension.source === 'MAINTENANCE' &&
suspension.policy !== 'RECOVER_TURNS';
const effectivePolicy = legacyUnificationWait
? 'TURN_BOUNDARY'
: upgradeMaintenance
? 'RECOVER_TURNS'
: parseClockAlignmentPolicy(suspension.policy);
const alignmentCutTick = legacyUnificationWait
? safeNumber(world.lastTurnTick!, 'unification execution boundary')
: cutTick;
const plan = buildClockAlignmentPlan({
policy: parseClockAlignmentPolicy(suspension.policy),
policy: effectivePolicy,
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
cutTick,
cutTick: alignmentCutTick,
cutWall: suspension.cutWallAt,
resumeWall: resumeWallAt,
ticksPerSecond: suspension.rateTicksPerSecond,
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
normalTick: (() => {
const detail =
suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {};
const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick;
return (
normalAtCut +
Math.trunc(
((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000
)
);
})(),
});
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
assertShiftFits(before, plan.shiftTicks);
@@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: {
targetGeneration,
BigInt(plan.shiftTicks),
projectionDeltaMilliseconds,
resumeWallAt
plan.resumeAnchor ?? resumeWallAt
);
if (supportsRecoveryColumns(db)) {
await db.worldState.update({
where: { id: worldStateId },
data: {
clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null,
clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null,
clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null,
},
});
} else if (plan.recovery) {
throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.');
}
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
for (const participant of before) {
@@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: {
where: { id: suspension.id },
data: {
status: 'RECONCILING',
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
resumeWallAt,
gapTicks: BigInt(plan.gapTicks),
catchUpTicks: BigInt(plan.catchUpTicks),
detail: asJson({
...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {}),
...serializeTurnRecovery(plan.recovery ?? null),
resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(),
...(legacyUnificationWait || upgradeMaintenance
? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick }
: {}),
}),
shiftTicks: BigInt(plan.shiftTicks),
alignedTick: BigInt(plan.alignedTick),
participantChecksumBefore: aggregateChecksum(before),
@@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: plan.shiftTicks,
alignedTick: plan.alignedTick,
resumeWallAt,
recovery: plan.recovery ?? null,
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
};
};
@@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: {
authority: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> =>
runSerializableClockOperation(() =>
options.db.$transaction(
@@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: {
profileName: options.authority.profileName,
authority: options.authority,
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
});
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
+91 -9
View File
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import {
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
@@ -65,6 +66,7 @@ import {
} from './clockReconciliation.js';
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -73,6 +75,7 @@ export interface DatabaseTurnHooks {
close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>;
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
}
export interface CommittedReadModelChangeReceipt {
@@ -141,6 +144,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clockTick',
'clock_tick',
'clockWallAnchor',
'clockRecoveryStartTick',
'clockRecoveryEndTick',
'clockRecoveryStartWallAt',
'clock_wall_anchor',
'heartbeat',
'heartbeatAt',
@@ -448,8 +454,21 @@ const markIds = (journal: ChangeJournal, domain: ReadModelDomain, ids: readonly
* actor-targeted. General name/nation changes affect the global online list,
* while frontStatusActorIds is the private actor projection.
*/
export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges): ChangeJournal => {
export const createReadModelChangeJournal = (
changes: RealtimeReadModelChanges,
commandResult?: TurnDaemonCommandResult
): ChangeJournal => {
const journal = new ChangeJournal();
// 설문 완료 여부는 투표한 actor만의 projection이다. 보상과 같은 ENGINE
// transaction에 기록해야 API 응답 실패·재시도에도 commit 뒤 알림이 보존된다.
if (commandResult?.type === 'voteReward' && commandResult.ok) {
journal.mark('front.general', commandResult.generalId);
}
// 과거 멸망국 등용장의 거부 응답도 action을 만료시킬 수 있다.
// 개인 메시지 응답 뒤에는 성공 여부와 관계없이 해당 수신함을 다시 읽는다.
if (commandResult?.type === 'messageRespond' && commandResult.action === 'scout') {
journal.mark('messages.mailbox', commandResult.generalId);
}
markIds(journal, 'general.content', changes.generalIds);
markIds(journal, 'city.content', changes.cityIds);
markIds(journal, 'nation.content', changes.nationIds);
@@ -597,7 +616,8 @@ const persistNationBettingOpen = async (
const persistNationBettingFinish = async (
prisma: GamePrisma.TransactionClient,
finish: PendingNationBettingFinish
finish: PendingNationBettingFinish,
recordsFinalized: boolean
): Promise<void> => {
await prisma.$queryRaw`
SELECT id
@@ -647,7 +667,7 @@ const persistNationBettingFinish = async (
})),
});
for (const reward of rewards) {
for (const reward of recordsFinalized ? [] : rewards) {
if (!reward.userId) {
continue;
}
@@ -1139,6 +1159,9 @@ export const createDatabaseTurnHooks = async (
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
clockRecoveryStartTick: state.clockRecovery ? BigInt(state.clockRecovery.startTick) : null,
clockRecoveryEndTick: state.clockRecovery ? BigInt(state.clockRecovery.endTick) : null,
clockRecoveryStartWallAt: state.clockRecovery?.startWallAt ?? null,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1),
@@ -1260,7 +1283,7 @@ export const createDatabaseTurnHooks = async (
}
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
const unificationCutTick = unificationCutWallAt
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime))
: null;
const suspensionPreparation =
unificationCutTick !== null
@@ -1443,11 +1466,12 @@ export const createDatabaseTurnHooks = async (
`);
}
const recordsFinalized = await areSeasonRecordsFinalized(prisma, asRecord(state.meta).serverId);
for (const betting of pendingNationBettingOpens) {
await persistNationBettingOpen(prisma, betting);
}
for (const finish of pendingNationBettingFinishes) {
await persistNationBettingFinish(prisma, finish);
await persistNationBettingFinish(prisma, finish, recordsFinalized);
}
const meta = asRecord(state.meta);
@@ -1456,7 +1480,7 @@ export const createDatabaseTurnHooks = async (
const persistInheritancePointAdjustments = async (
entries: typeof inheritancePointAdjustments
): Promise<void> => {
if (entries.length === 0) {
if (recordsFinalized || entries.length === 0) {
return;
}
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
@@ -1482,7 +1506,7 @@ export const createDatabaseTurnHooks = async (
}
};
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
if (entries.length === 0) {
if (recordsFinalized || entries.length === 0) {
return;
}
await prisma.inheritanceLog.createMany({
@@ -1852,6 +1876,7 @@ export const createDatabaseTurnHooks = async (
await persistYearbookSnapshot(prisma, snapshot);
}
const persistedMessageMailboxes: number[] = [];
const persistedDiplomacyMailboxes: number[] = [];
for (const finalization of pendingUnificationFinalizations) {
if (options?.profileName && finalization.profileName !== options.profileName) {
throw new Error(
@@ -1878,7 +1903,11 @@ export const createDatabaseTurnHooks = async (
expiresGameTick,
});
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
if (draft.msgType === 'diplomacy') {
persistedDiplomacyMailboxes.push(draft.mailbox);
} else {
persistedMessageMailboxes.push(draft.mailbox);
}
return id;
},
},
@@ -1886,6 +1915,37 @@ export const createDatabaseTurnHooks = async (
{ sendDestOnly: message.sendDestOnly }
);
}
if (deletedNations.length > 0) {
// 발신자 하야/이적은 등용장을 바꾸지 않는다. 발송 당시 국가가
// 멸망할 때만 같은 transaction에서 action과 구버전 만료 투영을 닫는다.
// 이번 flush에 생성된 편지도 포함하도록 message 저장 뒤에 처리한다.
const letters = await prisma.message.findMany({
where: {
type: 'private',
action: { actionType: 'scout', status: 'PENDING' },
OR: deletedNations.map((nationId) => ({
message: { path: ['src', 'nationId'], equals: nationId },
})),
},
select: { id: true, mailbox: true },
});
if (letters.length > 0) {
const ids = letters.map(({ id }) => id);
const resolvedGameTick = BigInt(state.clockTick ?? state.lastTurnTick ?? 0);
await prisma.messageAction.updateMany({
where: { messageId: { in: ids }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick },
});
await prisma.message.updateMany({
where: { id: { in: ids } },
data: {
validUntil: world.gameTickToDate(Number(resolvedGameTick)),
validUntilTick: resolvedGameTick,
},
});
persistedMessageMailboxes.push(...letters.map(({ mailbox }) => mailbox));
}
}
if (options?.reservedTurns && persistedReservedTurnChanges) {
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
}
@@ -1904,7 +1964,9 @@ export const createDatabaseTurnHooks = async (
worldStateId: state.id,
profileName: options?.profileName ?? 'default',
source: 'UNIFICATION_WAIT',
policy: 'TURN_BOUNDARY',
cutTick,
normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt),
cutWallAt: suspensionPreparation.cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
sourceRevision: state.clockRevision ?? 1,
@@ -1963,11 +2025,12 @@ export const createDatabaseTurnHooks = async (
if (worldReadModelSignature !== worldReadModelBaseline) {
readModelChanges.worldChanged = true;
}
const journal = createReadModelChangeJournal(readModelChanges);
const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
if (hasDashboardSourceMutation(changes, readModelChanges)) {
journal.mark('dashboard.global');
}
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
markIds(journal, 'messages.diplomacyMailbox', uniqueSortedIds(persistedDiplomacyMailboxes));
markIds(journal, 'access.general', accessScoreResetGeneralIds);
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
journal.mark('betting');
@@ -2057,6 +2120,25 @@ export const createDatabaseTurnHooks = async (
});
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
},
prepareRealtimeRecovery: async (recoveryOptions) => {
const token = options?.turnDaemonLease?.getToken();
if (!token) return;
await prepareRealtimeRecovery(
prisma,
{
kind: 'DAEMON',
profileName: token.profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
},
recoveryOptions
);
await prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
}, transactionOptions);
},
synchronizeClockAuthority: () =>
prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
+39 -11
View File
@@ -1,6 +1,7 @@
import { performance } from 'node:perf_hooks';
import { randomUUID } from 'node:crypto';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { describeRuntimeError, gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common';
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
export interface GatewayProfileGateOptions {
@@ -8,6 +9,7 @@ export interface GatewayProfileGateOptions {
gatewayDatabaseUrl?: string;
profileName: string;
cacheMs?: number;
incidentContext?: () => Record<string, string | number | boolean | null>;
}
export interface GatewayProfileGate {
@@ -22,6 +24,7 @@ const PROFILE_STATUSES_MARKABLE_AS_PAUSED = ['PREOPEN', 'RUNNING', 'PAUSED'] as
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
connectionTimeoutMillis: 3000,
});
await connector.connect();
const prisma = connector.prisma;
@@ -54,19 +57,44 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return cachedPause;
},
async markPaused(error?: unknown): Promise<void> {
const message = error instanceof Error ? error.message : error ? String(error) : null;
const failure = error ? describeRuntimeError(error) : null;
const message = failure?.message ?? null;
try {
await prisma.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
},
data: {
status: 'PAUSED',
lastError: message,
},
await prisma.$transaction(async (tx) => {
const updated = await tx.gatewayProfile.updateMany({
where: {
profileName: options.profileName,
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
OR: [{ status: { not: 'PAUSED' } }, { lastError: { not: message } }, { lastError: null }],
},
data: {
status: 'PAUSED',
lastError: message,
},
});
if (updated.count && failure) {
// 상태와 이력을 함께 commit한다. 재개가 lastError를 지워도
// 당시 원인과 실행 좌표는 관리자 감사 저장소에 남는다.
await tx.adminAuditEvent.create({
data: {
correlationId: randomUUID(),
actorUserId: 'system:turn-daemon',
actorUsername: 'turn-daemon',
credentialKind: 'DAEMON',
action: 'runtime.failure',
targetType: 'profile-runtime',
targetId: options.profileName,
profileName: options.profileName,
outcome: 'FAILED',
errorCode: failure.code,
errorMessage: failure.message,
summary: { frames: failure.frames, ...options.incidentContext?.() },
},
});
}
});
} catch {
if (failure) console.error('[turn-daemon] failed to persist runtime incident', failure);
return;
}
},
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import {
asRecord,
HALL_OF_FAME_TYPES,
@@ -403,10 +404,15 @@ export const persistGeneralLifecycleEvents = async (
data: { refreshScore: 0 },
});
const recordsFinalized = await areSeasonRecordsFinalized(prisma, worldMeta.serverId);
for (const event of events) {
if (event.outcome === 'detached' || event.outcome === 'deleted') {
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
}
if (recordsFinalized) {
if (event.outcome === 'retired') await persistPostRetirementRankValues(prisma, event);
continue;
}
if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
continue;
}
@@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
revision: number;
deadlineGeneration: number;
startsAt: Date;
}> {
const state = this.world.getGameClockState();
return {
@@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: state.phase,
revision: state.revision,
deadlineGeneration: state.deadlineGeneration,
startsAt: state.wallAnchor,
};
}
@@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore {
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
this.world.advanceGameClockTo(target, wallNow);
}
async projectGameDeadline(gameTime: Date): Promise<Date> {
return this.world.projectGameDeadline(gameTime);
}
}
+108 -13
View File
@@ -9,14 +9,16 @@ import type {
TurnSchedule,
UnitSetDefinition,
} from '@sammo-ts/logic';
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import {
GAME_TICKS_PER_TURN,
GameClock,
assertGameplayCommitAllowed,
inferClockPhase,
JosaUtil,
type GameClockMode,
type GameClockPhase,
type TurnRecoveryWindow,
} from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -77,6 +79,7 @@ export interface GeneralTurnResult {
troopIds?: number[];
};
destroyedNationIds?: number[];
successorlessNationId?: number;
lifecycleEvent?: GeneralLifecycleEvent;
}
@@ -129,6 +132,7 @@ export interface InMemoryGameClockState {
tick: number;
mode: GameClockMode;
wallAnchor: Date;
recovery?: TurnRecoveryWindow | null;
lastTurnTick: number;
phase: GameClockPhase;
revision: number;
@@ -147,6 +151,8 @@ export interface DurableClockReconciliationAlignment {
alignedTick: number;
shiftTicks: number;
resumeWallAt: Date;
resumeAnchor?: Date;
recovery?: TurnRecoveryWindow | null;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -654,6 +660,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -684,6 +691,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null,
lastTurnTick: this.state.lastTurnTick ?? 0,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -695,6 +703,15 @@ export class InMemoryTurnWorld {
return this.getGameClock().now(wallNow);
}
projectGameDeadline(gameTime: Date): Date {
const clock = this.getGameClock();
return clock.tickToWallDate(clock.dateToTick(gameTime));
}
getNormalGameTick(wallNow: Date): number {
return this.getGameClock().normalNowTick(wallNow);
}
promotePreopenAtOpening(wallNow: Date): boolean {
const clock = this.getGameClock();
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
@@ -759,7 +776,8 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
clockTick: input.alignedTick,
clockWallAnchor: new Date(input.resumeWallAt.getTime()),
clockWallAnchor: new Date((input.resumeAnchor ?? input.resumeWallAt).getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
lastTurnTick,
lastTurnTime,
clockPhase: 'RECONCILING',
@@ -865,6 +883,7 @@ export class InMemoryTurnWorld {
clockTick: input.tick,
clockMode: input.mode,
clockWallAnchor: new Date(input.wallAnchor.getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
clockPhase: input.phase,
clockRevision: input.revision,
deadlineGeneration: input.deadlineGeneration,
@@ -918,16 +937,17 @@ export class InMemoryTurnWorld {
skippedTurns: number;
} | null {
const clock = this.getGameClock();
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) {
return null;
}
const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60));
const threshold = turnMinutes >= 20 ? 1 : turnMinutes >= 10 ? 3 : 6;
const currentTick = clock.nowTick(wallNow);
const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow));
const wallAlignedTick = Math.max(currentTick, clock.normalNowTick(wallNow));
const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime);
const skippedTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN);
return skippedTurns > threshold ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null;
// 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
// 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
const overdueTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN);
const skippedTurns = Math.floor(overdueTurns / 12) * 12;
return skippedTurns > 0 ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null;
}
shouldRebaseRealtimeBacklog(wallNow: Date): boolean {
@@ -1180,6 +1200,9 @@ export class InMemoryTurnWorld {
lastTurnTime: this.state.lastTurnTime.toISOString(),
};
}
if (previousClock.recovery && wallNow < previousClock.tickToWallDate(previousClock.recovery.endTick)) {
throw new Error('복구가 끝난 뒤 기본 턴 길이를 변경할 수 있습니다.');
}
const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor;
const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
const anchorTick = previousClock.nowTick(anchorWall);
@@ -1200,6 +1223,7 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
tickSeconds: nextTickSeconds,
clockRecovery: null,
clockBaseTime: nextBaseTime,
clockTick: anchorTick,
clockWallAnchor: new Date(anchorWall.getTime()),
@@ -1669,10 +1693,13 @@ export class InMemoryTurnWorld {
};
}
shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
shiftSchedule(deltaMinutes: number, _wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
throw new Error('Schedule shift must be a non-zero integer number of minutes.');
}
if ((deltaMinutes * 60) % this.state.tickSeconds !== 0) {
throw new Error('일정 이동은 현재 턴 길이의 정수 배수여야 합니다.');
}
const deltaMs = deltaMinutes * 60_000;
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
const previousClock = this.getGameClock();
@@ -1693,6 +1720,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -1708,10 +1736,14 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
clockBaseTime: nextBaseTime,
// Rebasing is also the explicit resume checkpoint. Realtime mode
// must not replay the operational downtime after an administrator
// deliberately delays or accelerates the game schedule.
clockWallAnchor: new Date(wallNow.getTime()),
// 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다.
clockWallAnchor: shiftDate(previousClock.wallAnchor),
clockRecovery: this.state.clockRecovery
? {
...this.state.clockRecovery,
startWallAt: shiftDate(this.state.clockRecovery.startWallAt),
}
: null,
lastTurnTime: nextLastTurnTime,
meta: nextMeta,
};
@@ -1998,6 +2030,11 @@ export class InMemoryTurnWorld {
this.removeTroop(troopId);
}
}
if (result.successorlessNationId !== undefined) {
// 사망 군주도 삭제 전 archive의 장수 목록과 멸망 로그에 포함한다.
this.generals.set(currentGeneral.id, result.general ?? currentGeneral);
this.dissolveNationWithoutSuccessor(result.successorlessNationId, currentGeneral.id);
}
if (result.deleted?.general) {
this.removeGeneral(currentGeneral.id);
}
@@ -2197,6 +2234,64 @@ export class InMemoryTurnWorld {
return changes;
}
dissolveNationWithoutSuccessor(nationId: number, dyingLordId?: number): boolean {
const nation = this.nations.get(nationId);
if (!nation) {
return false;
}
const members = this.listGenerals().filter((general) => general.nationId === nationId);
const dyingLord = members.find((general) => general.id === dyingLordId);
if (
(dyingLordId !== undefined && dyingLord?.officerLevel !== 12) ||
members.some(
(general) => general.id !== dyingLordId && (general.npcState !== 5 || general.officerLevel === 12)
)
) {
throw new Error(`Nation ${nationId} still has a ruler or successor.`);
}
// Ref nextRuler() -> deleteNation(true): 부대장(npc=5)은 후계자가
// 될 수 없다. 자원 약탈·포상·난수 소비 없이 도시를 공백지로 돌린다.
for (const city of this.listCities()) {
if (city.nationId === nationId) {
this.updateCity(city.id, { nationId: 0, frontState: 0 });
}
}
const orderedMembers = members.sort((left, right) => {
if (left.id === dyingLordId) return 1;
if (right.id === dyingLordId) return -1;
return left.id - right.id;
});
const pushHistory = (): void => {
this.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: `<R><b>【멸망】</b></><D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '은')} <R>멸망</>했습니다.`,
});
};
for (const general of orderedMembers) {
if (general.id === dyingLordId) pushHistory();
// Ref applyDB()는 개인 역사 bucket을 행동 bucket보다 먼저 저장한다.
this.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: general.id,
format: LogFormat.YEAR_MONTH,
text: `<D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '이')} <R>멸망</>`,
});
this.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: general.id,
format: LogFormat.PLAIN,
text: `<D><b>${nation.name}</b></>${JosaUtil.pick(nation.name, '이')} <R>멸망</>했습니다.`,
});
}
// 과거 누락으로 군주가 이미 삭제된 국가의 운영 복구도 같은 정산을 쓴다.
if (dyingLordId === undefined) pushHistory();
return this.collapseNation(nationId);
}
collapseNation(nationId: number): boolean {
const nation = this.nations.get(nationId);
if (!nation) {
@@ -1,3 +1,4 @@
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
import {
@@ -96,7 +97,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
export const resolveLegacyPenalty = (
rawPenalty: Record<string, unknown> | undefined,
profileId: string,
acceptedAt: Date
requestedAtWall: Date
): Record<string, unknown> => {
if (!rawPenalty) {
return {};
@@ -105,7 +106,7 @@ export const resolveLegacyPenalty = (
...asRecord(rawPenalty.any),
...asRecord(rawPenalty[profileId]),
};
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
const acceptedAtSeconds = requestedAtWall.getTime() / 1000;
const result: Record<string, unknown> = {};
for (const [key, rawEntry] of Object.entries(merged)) {
const entry = asRecord(rawEntry);
@@ -613,14 +614,20 @@ export const createGeneralFromJoin = async (options: {
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
const inheritConstants = resolveInheritConstants(worldState);
const worldMeta = asRecord(worldState.meta);
const recordsFinalized = await areSeasonRecordsFinalized(db, worldMeta.serverId);
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
const currentInheritancePoint = await applyInheritanceUser(
db,
input.userId,
worldState.currentYear,
worldState.currentMonth
);
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
if (recordsFinalized && inheritRequiredPoint > 0) {
fail('BAD_REQUEST', '통일 이후에는 유산 포인트를 사용하는 생성 옵션을 적용할 수 없습니다.');
}
const currentInheritancePoint = recordsFinalized
? (await db.inheritancePoint.findMany({ where: { userId: input.userId }, select: { value: true } })).reduce(
(sum, row) => sum + row.value,
0
)
: await applyInheritanceUser(db, input.userId, worldState.currentYear, worldState.currentMonth);
if (!recordsFinalized) {
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
}
if (currentInheritancePoint < inheritRequiredPoint) {
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
}
@@ -654,12 +661,7 @@ export const createGeneralFromJoin = async (options: {
const generalId = world.getNextGeneralId();
const rng = new RandUtil(
new LiteHashDRBG(
buildJoinCreateGeneralSeed(
hiddenSeed,
input.seedOwnerIdentity,
world.dateToGameTick(acceptedAt),
generalId
)
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId)
)
);
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
@@ -748,7 +750,7 @@ export const createGeneralFromJoin = async (options: {
? input.ownerIconRevision
: undefined;
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
const restInheritanceBonus = recordsFinalized ? 0 : await resolveRestInheritanceBonus(db, worldState, input.userId);
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
@@ -805,7 +807,9 @@ export const createGeneralFromJoin = async (options: {
meta: {},
},
lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
// 계정 제재 expire는 Ref member의 Unix wall timestamp다. 가오픈의
// 음수 GAME 좌표로 비교하면 현실에서 만료된 제재가 다시 적용된다.
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, operationalAcceptedAt),
inheritancePoints: {
previous: finalInheritancePoint,
},
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -127,7 +128,7 @@ export const createOpenNationBettingHandler = (options: {
nationId: general.nationId,
nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000',
icon: general.picture ?? '',
icon: resolveMessageTargetIcon(general),
},
text,
time: now,
@@ -0,0 +1,48 @@
import { randomUUID } from 'node:crypto';
import { immediateRecoveryLimitSeconds } from '@sammo-ts/common';
import type { GamePrismaClient } from '@sammo-ts/infra';
import {
readClockDatabaseWall,
reconcileClockSuspension,
startClockSuspension,
type ClockOperationAuthority,
} from './clockReconciliation.js';
/** lease를 획득했지만 clock_ready를 공개하기 전, 중단된 복구 또는 새 정전을 처리한다. */
export const prepareRealtimeRecovery = async (
db: GamePrismaClient,
authority: Extract<ClockOperationAuthority, { kind: 'DAEMON' }>,
options: { paused?: boolean } = {}
): Promise<void> => {
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
if (world.clockMode !== 'realtime') return;
if (world.clockPhase === 'SUSPENDED') {
if (options.paused) return;
const pending = await db.clockSuspension.findFirst({
where: { worldStateId: world.id, source: 'RECOVERY', policy: 'RECOVER_TURNS', status: 'SUSPENDED' },
orderBy: { sourceRevision: 'desc' },
});
if (pending) await reconcileClockSuspension({ db, suspensionId: pending.id, authority });
return;
}
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
const now = await readClockDatabaseWall(db);
// 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다.
// 짧은 중단만 즉시 처리한다. 기준값과 같으면 대기 후 복구한다.
if (
!options.paused &&
now.getTime() - world.clockWallAnchor.getTime() < immediateRecoveryLimitSeconds(world.tickSeconds) * 1_000
)
return;
const suspensionId = `recovery-${randomUUID()}`;
await startClockSuspension({
db,
suspensionId,
source: 'RECOVERY',
policy: 'RECOVER_TURNS',
authority,
recoverDurableObservation: true,
});
// 이전 버전의 profile 상태만 PAUSED였던 경우에도 명시적 재개 전에는 실행하지 않는다.
if (!options.paused) await reconcileClockSuspension({ db, suspensionId, authority });
};
@@ -1,7 +1,22 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import type { TurnWorldState } from './types.js';
const DEFAULT_MIN_TURNS = 2;
/** 표시용 opentime과 재투영된 lastTurnTime 대신 오픈 phase와 실행 cursor로 판정한다. */
export const hasStartedForPrestartActions = (
state: Pick<TurnWorldState, 'clockPhase' | 'lastTurnTick' | 'lastTurnTime' | 'meta'>
): boolean => {
if (state.clockPhase === 'PREOPEN') return false;
if (state.clockPhase === 'COMPLETED' || state.clockPhase === 'SUSPENDED' || state.clockPhase === 'RECONCILING') {
return true;
}
// Ref의 turntime == opentime 경계는 아직 허용하고 최초 월 진행 이후 닫는다.
if (state.lastTurnTick !== undefined) return state.lastTurnTick > 0;
const opentime = typeof state.meta.opentime === 'string' ? new Date(state.meta.opentime) : null;
return opentime !== null && state.lastTurnTime.getTime() > opentime.getTime();
};
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
const value = meta.prestart_delete_after;
if (typeof value !== 'string' || !value.trim()) {
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type {
ActionContextBase,
ActionContextBuilder,
@@ -44,6 +45,7 @@ import {
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { isEqual } from 'es-toolkit';
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
@@ -385,7 +387,7 @@ const normalizeLastTurn = (value: unknown): LegacyLastTurn => {
};
const sameArgs = (left: Record<string, unknown> | undefined, right: Record<string, unknown>): boolean =>
JSON.stringify(left ?? {}) === JSON.stringify(right);
isEqual(left ?? {}, right);
const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => {
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
@@ -2120,7 +2122,7 @@ export const createReservedTurnHandler = async (options: {
nationId: currentGeneral.nationId,
nationName: currentNation?.name ?? '재야',
color: currentNation?.color ?? '#000000',
icon: currentGeneral.picture ?? '',
icon: resolveMessageTargetIcon(currentGeneral),
};
messages.push({
msgType: 'public',
@@ -2251,6 +2253,7 @@ export const createReservedTurnHandler = async (options: {
? 'retired'
: 'active';
let deleteGeneral = false;
let successorlessNationId: number | undefined;
const deletedTroopIds = Array.from(commandDeletedTroopIds);
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
if (currentGeneral.meta.killturn <= 0) {
@@ -2350,6 +2353,10 @@ export const createReservedTurnHandler = async (options: {
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
)
);
} else {
// Ref nextRuler()는 후계자가 없으면 군주 삭제 전에
// deleteNation($general, true)로 국가 전체를 정산한다.
successorlessNationId = currentNation.id;
}
}
if (currentGeneral.troopId === currentGeneral.id) {
@@ -2423,6 +2430,7 @@ export const createReservedTurnHandler = async (options: {
}
: undefined),
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
...(successorlessNationId !== undefined ? { successorlessNationId } : {}),
lifecycleEvent: {
generalId: currentGeneral.id,
outcome: lifecycleOutcome,
@@ -1,4 +1,4 @@
import { parseGameClockPhase } from '@sammo-ts/common';
import { parseGameClockPhase, readTurnRecovery, readSerializedTurnRecovery } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
clockTick: true,
clockMode: true,
clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
lastTurnTick: true,
clockPhase: true,
clockRevision: true,
@@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
shiftTicks: true,
alignedTick: true,
resumeWallAt: true,
detail: true,
},
});
let expectedRevision = before.revision;
@@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
resumeWallAt: ledger.resumeWallAt,
recovery: readSerializedTurnRecovery(ledger.detail),
});
expectedRevision = targetRevision;
}
@@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
tick: safeNumber(durable.clockTick, 'durable clock tick'),
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: durable.clockWallAnchor,
recovery: readTurnRecovery(durable),
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
phase: parseGameClockPhase(durable.clockPhase),
revision: durableRevision,
+14
View File
@@ -0,0 +1,14 @@
import type { DatabaseClient } from '@sammo-ts/infra';
/** 통일 대기/이민족전으로 시계가 재개되어도 확정된 기수의 기록은 다시 열지 않는다. */
export const areSeasonRecordsFinalized = async (
db: Pick<DatabaseClient, 'gameHistory'>,
serverId: unknown
): Promise<boolean> => {
if (typeof serverId !== 'string' || !serverId.trim()) return false;
const history = await db.gameHistory.findUnique({
where: { serverId: serverId.trim() },
select: { status: true },
});
return history?.status === 'COMPLETED';
};
+30 -1
View File
@@ -888,6 +888,19 @@ const createTurnDaemonRuntimeWithLease = async (
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName,
cacheMs: options.pauseGateIntervalMs,
incidentContext: () => {
const state = world.getState();
const clock = world.getGameClockState();
const token = turnDaemonLease?.getToken();
return {
year: state.currentYear,
month: state.currentMonth,
clockPhase: clock.phase,
clockTick: clock.tick,
ownerId: token?.ownerId ?? null,
fencingEpoch: token?.fencingEpoch.toString() ?? null,
};
},
})
: null;
if (gatewayGate) {
@@ -900,6 +913,17 @@ const createTurnDaemonRuntimeWithLease = async (
turnDaemonLease: turnDaemonLease ?? undefined,
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
});
try {
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
} catch (error) {
await Promise.allSettled([
dbHooks.close(),
reservedTurnStoreHandle?.close(),
gatewayGate?.close(),
turnDaemonLease?.close(),
]);
throw error;
}
auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl,
world,
@@ -983,6 +1007,9 @@ const createTurnDaemonRuntimeWithLease = async (
redisConnector = realtimeRuntime.redisConnector;
hooks = realtimeRuntime.hooks;
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
// 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다.
// RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다.
await turnDaemonLease?.markClockReady();
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
@@ -1046,7 +1073,9 @@ const createTurnDaemonRuntimeWithLease = async (
hooks,
pauseGate: async () => {
if (turnDaemonLease?.isLost()) {
return true;
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
throw turnDaemonLease.getLossError();
}
const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase;
+2 -1
View File
@@ -11,7 +11,7 @@ import type {
WorldSnapshot,
GeneralLastTurn,
} from '@sammo-ts/logic';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common';
export interface TurnWorldState {
id: number;
@@ -23,6 +23,7 @@ export interface TurnWorldState {
clockTick?: number;
clockMode?: GameClockMode;
clockWallAnchor?: Date;
clockRecovery?: TurnRecoveryWindow | null;
lastTurnTick?: number;
clockPhase?: GameClockPhase;
clockRevision?: number;
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createHash } from 'node:crypto';
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
@@ -156,7 +157,7 @@ export const createUnificationHandler = (options: {
nationId: winner.id,
nationName: winner.name,
color: winner.color,
icon: recipient.picture ?? '',
icon: resolveMessageTargetIcon(recipient),
},
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
time: context.turnTime,
@@ -1,3 +1,5 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import {
acquireGameSchemaAdvisoryXactLock,
@@ -95,6 +97,8 @@ const claimGeneration = async (
}
return 'ALREADY_APPLIED';
}
// 이전 버전/이관 기수에 generation row가 없어도 통일 기록을 다시 정산하지 않는다.
if (await areSeasonRecordsFinalized(transaction, input.serverId)) return 'ALREADY_APPLIED';
await transaction.unificationFinalization.create({
data: {
generationKey: input.generationKey,
@@ -243,7 +247,7 @@ const cancelPendingUniqueAuctions = async (
nationId: bidder.nationId,
nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000',
icon: bidder.picture ?? '',
icon: resolveMessageTargetIcon(bidder),
},
text: `${planned.auctionId}${planned.title}가 취소되었습니다.`,
time: input.completedAt,
+21 -10
View File
@@ -70,7 +70,12 @@ import {
} from './selectPoolService.js';
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
readPrestartDeleteAfter,
hasStartedForPrestartActions,
} from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
throw new Error('Join world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try {
return {
type: 'joinCreateGeneral',
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = acceptedAt;
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try {
return {
type: 'selectPoolCreate',
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = ctx.world.getState();
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
return {
type: 'ensureDieOnPrestartStatus',
generalId: command.generalId,
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
if (hasStartedForPrestartActions(worldState)) {
return {
type: 'dieOnPrestart',
ok: false,
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
await assertImmediateGeneralActionActor(ctx, command, general);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
if (hasStartedForPrestartActions(worldState)) {
return {
type: 'buildNationCandidate',
ok: false,
+6 -7
View File
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
import { z } from 'zod';
import {
GameClock,
readTurnRecovery,
asRecord,
inferClockPhase,
isRecord,
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockPhase = hasPersistedClock
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(clockMode);
const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
const deadlineGeneration = toSafeTick(
worldState.deadlineGeneration,
'world_state.deadline_generation'
);
const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
tick: 0,
mode: clockMode,
wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
mode: clockMode,
wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
clockTick: gameClock.tick,
clockMode,
clockWallAnchor: gameClock.wallAnchor,
clockRecovery: gameClock.recovery,
lastTurnTick,
clockPhase,
clockRevision,
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
const snapshot: TurnWorldSnapshot = {
generals: [actor],
cities: [],
nations: [],
nations: [
{
id: 2,
name: '촉',
color: '#000000',
level: 1,
capitalCityId: 2,
chiefGeneralId: 8,
gold: 0,
rice: 0,
power: 0,
typeCode: 'che_중립',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
expect(world.peekDirtyState().messages).toHaveLength(0);
});
it.each([true, false])(
'invalidates a surviving letter from a collapsed nation on response=%s',
async (response) => {
const world = buildWorld();
world.removeNation(source.nationId);
const { db, actionUpdateMany, updateMany } = buildDb([[buildRow('scout')]]);
const executor = buildExecutor();
const result = await respondToActionableMessage({
db,
world,
executor,
requestId,
userId: actor.userId!,
generalId: actor.id,
messageId: 29,
response,
});
expect(result).toEqual({ ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' });
expect(executor.execute).not.toHaveBeenCalled();
expect(actionUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { messageId: { in: [29] }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: expect.any(BigInt) },
})
);
expect(updateMany).toHaveBeenCalledOnce();
expect(world.getGeneralById(actor.id)?.nationId).toBe(actor.nationId);
expect(world.peekDirtyState().messages).toHaveLength(0);
}
);
it('treats a legacy truthy used value as an invalid scout letter', async () => {
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
const world = buildWorld();
@@ -1,8 +1,9 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock } from '@sammo-ts/common';
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
import {
createGamePostgresConnector,
readTurnRuntimeReady,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
@@ -13,11 +14,11 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
const enabled =
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' &&
Boolean(process.env.DATABASE_URL) &&
Boolean(process.env.REDIS_URL);
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
const describeIntegration = enabled ? describe : describe.skip;
describeIntegration('durable clock reconciliation', () => {
@@ -28,6 +29,8 @@ describeIntegration('durable clock reconciliation', () => {
const clean = async (): Promise<void> => {
await redis.client.flushDb();
await db.$transaction([
db.readModelOutbox.deleteMany(),
db.readModelRevision.deleteMany(),
db.clockProjectionOutbox.deleteMany(),
db.clockReconciliationParticipant.deleteMany(),
db.clockSuspension.deleteMany(),
@@ -47,7 +50,7 @@ describeIntegration('durable clock reconciliation', () => {
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
const connector = createGamePostgresConnector({ url: databaseUrl! });
db = connector.prisma;
disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! });
@@ -64,6 +67,403 @@ describeIntegration('durable clock reconciliation', () => {
await clean();
});
it('accepts partial starts while rejecting incomplete and off-boundary DB windows', async () => {
const row = await db.worldState.create({
data: {
scenarioCode: 'constraint',
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockRecoveryStartTick: 1n,
clockRecoveryEndTick: BigInt(T),
clockRecoveryStartWallAt: new Date(),
},
});
for (const data of [
{ clockRecoveryStartWallAt: null },
{ clockRecoveryEndTick: BigInt(T + 1) },
{ clockRecoveryStartTick: BigInt(T) },
{ clockRecoveryStartTick: 0n, clockRecoveryEndTick: BigInt(25 * T) },
]) {
await expect(db.worldState.update({ where: { id: row.id }, data })).rejects.toThrow(
'world_state_turn_recovery_window_check'
);
}
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).clockRecoveryStartTick).toBe(1n);
});
it.each([300, 420])('applies startup recovery after %i seconds on a 60-minute server', async (delay) => {
const profile = 'short-startup';
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: BigInt(T / 6),
clockWallAnchor: new Date(Date.now() - delay * 1000),
clockMode: 'realtime',
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
lastTurnTick: 0n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = (await lease.acquire())!;
await prepareRealtimeRecovery(db, {
kind: 'DAEMON',
profileName: profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
});
const world = await db.worldState.findFirstOrThrow();
expect(world.clockPhase).toBe(delay < 360 ? 'RUNNING' : 'RECONCILING');
expect(readTurnRecovery(world) === null).toBe(delay < 360);
} finally {
await lease.close();
}
});
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
const profile = 'recovery-startup';
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 199,
currentMonth: 4,
tickSeconds: 3600,
clockBaseTime: new Date('0199-01-01T00:00:00Z'),
clockTick: repeated ? BigInt(4 * T) : 0n,
clockMode: 'realtime',
clockWallAnchor: new Date(Date.now() - 4 * 3_600_000),
lastTurnTick: repeated ? BigInt(4 * T) : 0n,
...(repeated
? {
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: BigInt(8 * T),
clockRecoveryStartWallAt: new Date(Date.now() - 6 * 3_600_000),
}
: {}),
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = await lease.acquire();
expect(token).not.toBeNull();
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
const authority = {
kind: 'DAEMON' as const,
profileName: profile,
ownerId: token!.ownerId,
fencingEpoch: token!.fencingEpoch,
};
if (!repeated) {
await prepareRealtimeRecovery(db, authority, { paused: true });
const paused = await db.worldState.findFirstOrThrow();
expect(paused.clockPhase).toBe('SUSPENDED');
expect(paused.clockTick).toBe(0n);
expect((await db.clockSuspension.findFirstOrThrow()).status).toBe('SUSPENDED');
await prepareRealtimeRecovery(db, authority, { paused: true });
expect((await db.worldState.findFirstOrThrow()).clockPhase).toBe('SUSPENDED');
}
await prepareRealtimeRecovery(db, authority);
const pending = await db.worldState.findFirstOrThrow();
expect(pending.clockPhase).toBe('RECONCILING');
const recoveredWindow = readTurnRecovery(pending)!;
expect(recoveredWindow).not.toBeNull();
expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 13 : 9) * T);
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
await lease.markClockReady();
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(true);
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
await lease.acquire();
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
await prepareRealtimeRecovery(db, {
kind: 'DAEMON',
profileName: profile,
ownerId: token!.ownerId,
fencingEpoch: token!.fencingEpoch,
});
const reloaded = await db.worldState.findFirstOrThrow();
expect(readTurnRecovery(reloaded)).toEqual(readTurnRecovery(pending));
expect(reloaded.clockRevision).toBe(pending.clockRevision);
} finally {
await lease.close();
}
});
it.each([4, 12, 13, 23, 24])(
'persists recovery for %i turns and reloads the same normal boundary',
async (turns) => {
const now = new Date(Date.now() + 3_600_000);
await db.worldState.create({
data: {
scenarioCode: 'turn-recovery',
currentYear: 199,
currentMonth: 4,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: now,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const generalTick = T + 199_020;
await db.general.create({
data: {
id: 26,
name: '냥냥',
turnTick: BigInt(generalTick),
turnTime: new Date('2026-01-01T01:00:19.902Z'),
meta: { purchasedPhase: true },
},
});
const authority = { kind: 'OFFLINE' as const, profileName: 'recovery-test', reason: 'fixture' };
const suspension = await startClockSuspension({
db,
suspensionId: 'recovery-test',
source: 'MAINTENANCE',
policy: turns === 4 ? 'EXACT' : 'RECOVER_TURNS',
authority,
});
const resumedAt = new Date(suspension.cutWallAt.getTime() + turns * 3_600_000);
const plan = await reconcileClockSuspension({
db,
suspensionId: suspension.suspensionId,
authority,
testResumeWallAt: resumedAt,
upgradeMaintenancePolicy: true,
});
expect(plan.shiftTicks).toBe(Math.floor(turns / 12) * 12 * T);
await applyNextClockProjection({ db, redis: redis.client, workerId: 'recovery-test' });
const row = await db.worldState.findFirstOrThrow();
const recovery = readTurnRecovery(row);
const reloaded = new GameClock({
baseTime: row.clockBaseTime!,
tick: Number(row.clockTick),
wallAnchor: row.clockWallAnchor!,
mode: 'realtime',
turnSeconds: row.tickSeconds,
recovery,
});
expect(row.currentMonth).toBe(4);
expect(row.lastTurnTick).toBe(BigInt(plan.shiftTicks));
const general = await db.general.findUniqueOrThrow({ where: { id: 26 } });
expect(general.turnTick).toBe(BigInt(generalTick + plan.shiftTicks));
expect(general.turnTime.toISOString().slice(14)).toBe('00:19.902Z');
expect(general.meta).toEqual({ purchasedPhase: true });
if (turns % 12 === 0) {
expect(recovery).toBeNull();
} else {
expect(recovery).not.toBeNull();
const end = reloaded.tickToWallDate(recovery!.endTick);
expect(reloaded.nowTick(end)).toBe(recovery!.endTick);
expect(reloaded.executionRate(new Date(end.getTime() - 1))).toBe(2);
expect(reloaded.executionRate(end)).toBe(1);
expect(reloaded.tickToWallDate(recovery!.endTick + 199_020).getTime() - end.getTime()).toBe(19_902);
}
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
expect(retry.recovery).toEqual(plan.recovery);
expect(retry.catchUpTicks).toBe(plan.catchUpTicks);
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'retry' })).toBe('IDLE');
const announcements = await db.message.findMany({ where: { mailbox: 9999 } });
expect(announcements).toHaveLength(recovery ? 1 : 0);
if (recovery) {
expect(announcements[0]!.message).toMatchObject({
src: { generalName: '시스템' },
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: reloaded.tickToWallDate(recovery.endTick).toISOString(),
},
});
expect(await db.messageAction.count()).toBe(0);
expect(
await db.readModelRevision.findFirst({ where: { domain: 'messages.mailbox', entityId: 9999 } })
).toMatchObject({ revision: 1n });
}
}
);
it.each([359999, 360000, 360001, 840000, 12 * 3600000 + 1000])(
'persists strict recovery boundaries for %i ms',
async (gap) => {
const observed = T / 6;
const future = new Date(Date.now() + 3600000);
await db.worldState.create({
data: {
scenarioCode: 'wait-boundary',
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: BigInt(observed),
clockMode: 'realtime',
clockWallAnchor: future,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const authority = { kind: 'OFFLINE' as const, profileName: 'wait-boundary', reason: 'fixture' };
const suspension = await startClockSuspension({
db,
suspensionId: 'wait-boundary',
source: 'MAINTENANCE',
policy: 'RECOVER_TURNS',
authority,
});
const now = new Date(suspension.cutWallAt.getTime() + gap);
const plan = await reconcileClockSuspension({
db,
suspensionId: suspension.suspensionId,
authority,
testResumeWallAt: now,
});
expect(plan.recovery === null).toBe(gap < 360000);
expect(await db.message.count()).toBe(0);
// Redis 장애 후에도 알림은 DB의 RUNNING 전이와 함께 한 번만 저장한다.
await expect(
applyNextClockProjection({
db,
workerId: 'failure',
redis: {
get: (key) => redis.client.get(key),
eval: async (script, options) => {
await redis.client.eval(script, options);
throw new Error('fixture Redis outage');
},
},
})
).rejects.toThrow('fixture Redis outage');
expect(await db.message.count()).toBe(0);
await db.clockProjectionOutbox.updateMany({ data: { availableAt: new Date(0) } });
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'retry' })).toBe('RECOVERED');
const row = await db.worldState.findFirstOrThrow();
const recovery = readTurnRecovery(row);
expect(await db.message.count()).toBe(recovery ? 1 : 0);
const clock = new GameClock({
baseTime: row.clockBaseTime!,
tick: Number(row.clockTick),
wallAnchor: row.clockWallAnchor!,
turnSeconds: row.tickSeconds,
mode: 'realtime',
recovery,
});
if (recovery) {
expect(row.clockWallAnchor).toEqual(recovery.startWallAt);
expect(clock.nowTick(now)).toBe(observed + plan.shiftTicks);
expect(clock.nowTick(new Date(recovery.startWallAt.getTime() - 1))).toBe(observed + plan.shiftTicks);
const end = clock.tickToWallDate(recovery.endTick);
expect(clock.nowTick(end)).toBe(clock.normalNowTick(end));
} else {
expect(clock.nowTick(now)).toBe(observed + gap * 10);
}
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'done' })).toBe('IDLE');
expect(await db.message.count()).toBe(recovery ? 1 : 0);
}
);
it.each([3_142_625, 6 * 3_600_000 + 3_142_625])(
'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload',
async (gapMilliseconds) => {
const baseTime = new Date('2026-09-06T00:00:00.000Z');
const initialTick = 5 * 36_000_000 + 28_879_860;
const clock = new GameClock({
baseTime,
tick: initialTick,
mode: 'realtime',
wallAnchor: new Date(Date.now() + 3_600_000),
turnSeconds: 3_600,
phase: 'RUNNING',
});
const lastTurnTick = 5 * 36_000_000;
// 냥냥과 같은 00:19.902 구매 시각. 5시 턴은 이미 처리되어 다음은 6시다.
const nextTicks = [6 * 36_000_000 + 199_020, 6 * 36_000_000 + 420_010];
await db.worldState.create({
data: {
scenarioCode: 'maintenance-phase',
currentYear: 199,
currentMonth: 2,
tickSeconds: 3_600,
clockBaseTime: baseTime,
clockTick: BigInt(initialTick),
clockMode: 'realtime',
clockWallAnchor: clock.wallAnchor,
lastTurnTick: BigInt(lastTurnTick),
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
await db.general.createMany({
data: nextTicks.map((tick, index) => ({
id: index + 1,
name: `purchased-${index}`,
turnTick: BigInt(tick),
turnTime: clock.tickToDate(tick),
lastTurn: { command: '휴식' },
meta: { killturn: 24 },
})),
});
const authority = { kind: 'OFFLINE' as const, profileName: 'maintenance-phase', reason: 'fixture' };
const suspended = await startClockSuspension({
db,
suspensionId: 'maintenance-phase',
source: 'MAINTENANCE',
policy: 'PRESERVE_SCHEDULE',
authority,
});
const plan = await reconcileClockSuspension({
db,
suspensionId: suspended.suspensionId,
authority,
testResumeWallAt: new Date(suspended.cutWallAt.getTime() + gapMilliseconds),
});
const expectedShift = 0;
expect(plan.shiftTicks).toBe(expectedShift);
expect(plan.catchUpTicks).toBe(gapMilliseconds * 10);
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'phase-test' })).not.toBe(
'IDLE'
);
const reload = createGamePostgresConnector({ url: databaseUrl! });
try {
const world = await reload.prisma.worldState.findFirstOrThrow();
const generals = await reload.prisma.general.findMany({ orderBy: { id: 'asc' } });
expect(world).toMatchObject({
clockPhase: 'RUNNING',
currentYear: 199,
currentMonth: 2,
lastTurnTick: BigInt(lastTurnTick + expectedShift),
});
expect(generals.map((general) => Number(general.turnTick))).toEqual(
nextTicks.map((tick) => tick + expectedShift)
);
expect(generals.map((general) => general.turnTime.toISOString().slice(14))).toEqual([
'00:19.902Z',
'00:42.001Z',
]);
expect(generals.map((general) => general.meta)).toEqual([{ killturn: 24 }, { killturn: 24 }]);
// 미처리된 다음 턴만 남고, 처리한 5시 턴을 다시 만들지 않는다.
expect(Number(generals[0]!.turnTick)).toBeGreaterThan(Number(world.lastTurnTick));
expect((await reload.prisma.clockSuspension.findFirstOrThrow()).status).toBe('APPLIED');
} finally {
await reload.disconnect();
}
}
);
it('preserves every remaining deadline and occurrence across a 65m17.250s exact gap', async () => {
const baseTime = new Date('2026-01-01T00:00:00.000Z');
const futureAnchor = new Date(Date.now() + 3_600_000);
@@ -225,16 +625,16 @@ describeIntegration('durable clock reconciliation', () => {
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
await Promise.all([
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
]);
const alignedTick = BigInt(reconciled.alignedTick);
expect(afterWorld).toMatchObject({
@@ -121,6 +121,31 @@ integration('database command queue', () => {
});
});
it('leaves pending work immediately claimable by a replacement daemon when shutting down', async () => {
const requestId = 'integration:engine:shutdown-pending';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'dieOnPrestart',
actorUserId: 'user-7',
payload: { type: 'dieOnPrestart', requestId, userId: 'user-7', generalId: 7 },
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
queue.enqueue({ type: 'shutdown', reason: 'replacement' });
expect(await queue.drain()).toEqual([{ type: 'shutdown', reason: 'replacement' }]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'PENDING',
attempts: 0,
lockedBy: null,
leaseUntil: null,
});
expect(await new DatabaseTurnDaemonCommandQueue(db).drain()).toMatchObject([
{ type: 'dieOnPrestart', requestId },
]);
});
it('recovers only an expired processing lease', async () => {
const expiredId = 'integration:engine:expired';
const activeId = 'integration:engine:active';
@@ -313,6 +338,118 @@ integration('database command queue', () => {
expect(mutation).not.toHaveBeenCalled();
});
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns',
async (phase) => {
await db.worldState.updateMany({
data: {
clockPhase: phase,
clockMode: 'realtime',
clockTick: 0n,
lastTurnTick: 0n,
clockWallAnchor: new Date(Date.now() + 3_600_000),
},
});
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'buildNationCandidate'] as const;
await db.inputEvent.createMany({
data: types.map((type) => {
const requestId = `integration:engine:preopen:${type}`;
return {
requestId,
target: 'ENGINE' as const,
eventType: type,
actorUserId: 'user-7',
payload: { type, requestId, userId: 'user-7', generalId: 7 },
};
}),
});
const commands = await new DatabaseTurnDaemonCommandQueue(db).drain();
const allowed = ['PREOPEN', 'RUNNING', 'MANUAL'].includes(phase);
expect(commands.map((command) => command.type)).toEqual(allowed ? types : []);
const events = await db.inputEvent.findMany({
where: { requestId: { startsWith: 'integration:engine:preopen:' } },
});
expect(events).toHaveLength(3);
for (const event of events) {
expect(event.status).toBe(allowed ? 'PROCESSING' : 'PENDING');
expect(event.attempts).toBe(allowed ? 1 : 0);
expect(event.processingClockRevision).toBe(allowed ? 1n : null);
expect(event.processingDeadlineGeneration).toBe(allowed ? 1n : null);
if (phase === 'PREOPEN') {
expect(event.processingGameTick).toBeLessThan(0n);
}
}
expect(await db.worldState.findFirst()).toMatchObject({
clockPhase: phase,
clockTick: 0n,
lastTurnTick: 0n,
});
}
);
it.each(['SUSPENDED', 'COMPLETED', 'RECONCILING'])(
'admits only participation commands while the clock is %s',
async (phase) => {
await db.worldState.updateMany({ data: { clockPhase: phase } });
const payloads: TurnDaemonCommand[] = [
{
type: 'joinCreateGeneral',
userId: 'visitor',
ownerDisplayName: '방문자',
seedOwnerIdentity: 'visitor',
name: '방문',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'che_안전',
profileId: 'che',
},
{
type: 'npcPossessGeneral',
userId: 'visitor',
ownerDisplayName: '방문자',
profileId: 'che',
generalId: 7,
tokenNonce: 1,
},
{ type: 'selectPoolReserve', userId: 'visitor', seedOwnerIdentity: 'visitor' },
{
type: 'selectPoolCreate',
userId: 'visitor',
ownerDisplayName: '방문자',
uniqueName: '후보',
personality: 'che_안전',
seedOwnerIdentity: 'visitor',
},
{ type: 'selectPoolReselect', userId: 'visitor', ownerDisplayName: '방문자', uniqueName: '후보' },
{ type: 'vacation', userId: 'visitor', generalId: 7 },
];
const types = payloads.map((payload) => payload.type);
await db.inputEvent.createMany({
data: payloads.map((payload) => ({
requestId: `integration:engine:participation:${payload.type}`,
target: 'ENGINE',
eventType: payload.type,
actorUserId: 'visitor',
payload: payload as GamePrisma.InputJsonValue,
})),
});
const commands = await new DatabaseTurnDaemonCommandQueue(db).drain();
expect(commands.map((command) => command.type)).toEqual(phase === 'RECONCILING' ? [] : types.slice(0, -1));
const pending = await db.inputEvent.findUniqueOrThrow({
where: { requestId: 'integration:engine:participation:vacation' },
});
expect(pending).toMatchObject({ status: 'PENDING', attempts: 0 });
expect(await db.worldState.findFirst()).toMatchObject({
clockPhase: phase,
clockTick: 123n,
clockRevision: 1n,
deadlineGeneration: 1n,
});
}
);
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
+22 -1
View File
@@ -4,7 +4,11 @@ import type { GamePrisma } from '@sammo-ts/infra';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
hasStartedForPrestartActions,
} from '../src/turn/prestartDeletion.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
@@ -59,6 +63,8 @@ const buildFixture = (options: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
lastTurnTick:
options.lastTurnTime && options.lastTurnTime > new Date('2026-08-01T00:00:00.000Z') ? 36_000_000 : 0,
meta: { opentime: '2026-08-01T00:00:00.000Z' },
};
const snapshot: TurnWorldSnapshot = {
@@ -118,6 +124,21 @@ const buildFixture = (options: {
};
describe('pre-start general deletion', () => {
it('uses the opening phase and executed tick despite shifted or ancient display projections', () => {
const state = { lastTurnTime: new Date('2099-01-01T00:00:00Z'), meta: { opentime: '2026-01-01T00:00:00Z' } };
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'PREOPEN', lastTurnTick: 0 })).toBe(false);
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'RUNNING', lastTurnTick: 0 })).toBe(false);
expect(
hasStartedForPrestartActions({
...state,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
clockPhase: 'RUNNING',
lastTurnTick: 1,
})
).toBe(true);
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'COMPLETED', lastTurnTick: 0 })).toBe(true);
});
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
expect(
+41
View File
@@ -0,0 +1,41 @@
import type { City, MapDefinition } from '@sammo-ts/logic';
import { LARGE_TEST_MAP } from './largeTestMap.js';
const COMPACT_CITY_IDS = new Set([1, 2, 3, 4, 5]);
export const COMPACT_NPC_TEST_MAP: MapDefinition = {
id: 'compact_npc_test_map',
name: 'NPC 장기 시뮬레이션용 소형 맵',
cities: LARGE_TEST_MAP.cities
.filter((city) => COMPACT_CITY_IDS.has(city.id))
.map((city) => ({
...city,
connections: city.connections.filter((cityId) => COMPACT_CITY_IDS.has(cityId)),
})),
defaults: { ...LARGE_TEST_MAP.defaults },
};
export const buildCompactNpcTestCities = (): City[] =>
COMPACT_NPC_TEST_MAP.cities.map((city) => ({
id: city.id,
name: city.name,
nationId: 0,
level: city.level,
state: 0,
population: city.initial.population,
populationMax: city.max.population,
agriculture: city.initial.agriculture,
agricultureMax: city.max.agriculture,
commerce: city.initial.commerce,
commerceMax: city.max.commerce,
security: city.initial.security,
securityMax: city.max.security,
supplyState: 1,
frontState: 0,
defence: city.initial.defence,
defenceMax: city.max.defence,
wall: city.initial.wall,
wallMax: city.max.wall,
meta: { trust: 95 },
}));
@@ -112,6 +112,7 @@ integration('gateway runtime action consumer', () => {
});
it('does not overwrite a terminal operator status while reporting a daemon error', async () => {
const existingIncidents = await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } });
const gate = await createGatewayProfileGate({
databaseUrl: databaseUrl!,
gatewayDatabaseUrl: databaseUrl!,
@@ -127,6 +128,19 @@ integration('gateway runtime action consumer', () => {
status: 'PAUSED',
lastError: 'running failure',
});
await gate.markPaused(new Error('running failure'));
const incidents = await db.adminAuditEvent.findMany({ where: { profileName, action: 'runtime.failure' } });
expect(incidents).toHaveLength(existingIncidents + 1);
expect(incidents[0]).toMatchObject({
credentialKind: 'DAEMON',
errorCode: 'Error',
errorMessage: 'running failure',
});
await db.gatewayProfile.update({ where: { profileName }, data: { status: 'RUNNING', lastError: null } });
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
existingIncidents + 1
);
await db.gatewayProfile.update({
where: { profileName },
@@ -137,6 +151,9 @@ integration('gateway runtime action consumer', () => {
status: 'STOPPED',
lastError: null,
});
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
existingIncidents + 1
);
} finally {
await gate.close();
}
@@ -711,7 +711,7 @@ describe('legacy NPC user-chief promotion parity', () => {
expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null });
});
it('does not appoint a fourth user chief in the ordinary NPC-ruler fill pass', () => {
it('keeps NPC-assigned diplomatic authority at two even when three user chiefs exist', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
@@ -745,11 +745,11 @@ describe('legacy NPC user-chief promotion parity', () => {
const promotion = ai.consumePromotionPatches();
const result = promotion.generals;
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
expect(result).toHaveLength(3);
expect(result).toHaveLength(2);
expect(promotion.nationMeta).toBeNull();
expect(result).toEqual(
expect.arrayContaining(
existingChiefs.map((chief) => ({
existingChiefs.slice(1).map((chief) => ({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: 0,
@@ -759,6 +759,52 @@ describe('legacy NPC user-chief promotion parity', () => {
);
});
it('repairs a third diplomatic authority left by an earlier NPC promotion pass', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const existingChiefs = [11, 10, 9].map((officerLevel, index) =>
makePromotionGeneral({
id: index + 2,
npcState: 0,
officerLevel,
meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' },
})
);
const formerChief = makePromotionGeneral({
id: 5,
npcState: 0,
officerLevel: 1,
meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, ...existingChiefs, formerChief],
nation: { level: 6 },
userGenerals: [...existingChiefs, formerChief],
chiefGenerals: [ruler, ...existingChiefs],
});
chooseNpcPromotion(ai);
const promotion = ai.consumePromotionPatches();
expect(promotion.generals).toContainEqual({
generalId: existingChiefs[0]!.id,
officerLevel: 11,
officerCity: 0,
permission: 'normal',
});
expect(promotion.generals).toContainEqual({
generalId: formerChief.id,
officerLevel: 1,
officerCity: 0,
permission: 'normal',
});
});
it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => {
const actor = makePromotionGeneral({
id: 1,
@@ -1561,6 +1607,7 @@ describe('legacy NPC AI final-decision parity', () => {
});
expect(do집합(ai)?.action).toBe('che_집합');
expect(ai.general.meta.killturn).toBe(72);
expect(ai.general.meta.killturn).toBeGreaterThanOrEqual(70);
});
it('does not warp to the rear when recruitment is disabled', () => {
@@ -337,6 +337,62 @@ describe('legacy general turn lifecycle', () => {
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
});
it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => {
const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } });
const troopLeader = makeGeneral({
id: 2,
userId: null,
npcState: 5,
officerLevel: 11,
troopId: 2,
turnTime: new Date(start.getTime() + 3_600_000),
meta: { killturn: 24, officer_city: 1, belong: 5, permission: 'ambassador' },
});
const snapshot = makeSnapshot([leader, troopLeader]);
snapshot.cities[0]!.conflict = { '1': 1 };
const harness = await createTurnTestHarness({ snapshot, state: makeState(), schedule, map });
await harness.runOneTick();
expect(harness.world.getGeneralById(1)).toBeNull();
expect(harness.world.getNationById(1)).toBeNull();
expect(harness.world.getCityById(1)).toMatchObject({ nationId: 0, frontState: 0, conflict: [] });
expect(harness.world.getGeneralById(2)).toMatchObject({
nationId: 0,
officerLevel: 0,
troopId: 0,
gold: troopLeader.gold,
rice: troopLeader.rice,
meta: { officer_city: 0, officerCity: 0, belong: 0, permission: 'normal' },
});
const dirty = harness.world.peekDirtyState();
expect(dirty.deletedNations).toContain(1);
expect(dirty.deletedNationSnapshots[0]?.generalIds).toEqual([1, 2]);
expect(dirty.logs.filter((log) => log.text.includes('【멸망】'))).toHaveLength(1);
});
it('repairs an already orphaned nation once but refuses a nation with a successor', async () => {
const troopLeader = makeGeneral({ id: 2, userId: null, npcState: 5, officerLevel: 11 });
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([troopLeader]),
state: makeState(),
schedule,
map,
});
expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(true);
expect(harness.world.dissolveNationWithoutSuccessor(1)).toBe(false);
expect(harness.world.getGeneralById(2)?.nationId).toBe(0);
const intact = await createTurnTestHarness({
snapshot: makeSnapshot([makeGeneral({ officerLevel: 9 })]),
state: makeState(),
schedule,
map,
});
expect(() => intact.world.dissolveNationWithoutSuccessor(1)).toThrow('still has a ruler or successor');
expect(intact.world.getCityById(1)?.nationId).toBe(1);
expect(intact.world.getNationById(1)).not.toBeNull();
});
it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
@@ -1360,4 +1360,33 @@ integration('general turn lifecycle persistence', () => {
await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0);
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0);
});
it('preserves all finalized settlements and archives even after an invader resume or NPC ownership change', async () => {
await db.gameHistory.update({ where: { serverId }, data: { status: 'COMPLETED' } });
const readRecords = async () => ({
hall: await db.hallOfFame.findMany({ where: { serverId }, orderBy: { id: 'asc' } }),
points: await db.inheritancePoint.findMany({ where: { userId: { in: userIds } }, orderBy: { id: 'asc' } }),
results: await db.inheritanceResult.findMany({ where: { serverId }, orderBy: { id: 'asc' } }),
logs: await db.inheritanceLog.findMany({ where: { userId: { in: userIds } }, orderBy: { id: 'asc' } }),
archives: await db.oldGeneral.findMany({ where: { serverId }, orderBy: { id: 'asc' } }),
});
const before = await readRecords();
for (const united of [1, 2, 3, 0]) {
const general = makeGeneral(generalIds[0]!, userIds[1]!, {
experience: 999999,
meta: { killturn: 0, inheritRandomUnique: true, inherit_active_action: 999999 },
});
await db.$transaction(async (transaction) => {
await persistGeneralLifecycleEvents(
transaction,
[
{ ...event(general, 'retired'), isUnitedAtEvent: united },
{ ...event(general, 'deleted'), isUnitedAtEvent: united },
],
{ serverId, isUnited: united },
{}
);
});
expect(await readRecords()).toEqual(before);
}
});
});
@@ -51,6 +51,7 @@ describe('general lifecycle archive history', () => {
const general = archivedGeneral();
const upsert = vi.fn(async () => undefined);
const prisma = {
gameHistory: { findUnique: vi.fn(async () => ({ status: 'OPEN' })) },
generalAccessLog: {
updateMany: vi.fn(async () => ({ count: 1 })),
deleteMany: vi.fn(async () => ({ count: 1 })),
@@ -146,6 +147,7 @@ describe('general lifecycle archive history', () => {
findUnique: vi.fn(async () => null),
},
gameHistory: {
findUnique: vi.fn(async () => ({ status: 'OPEN' })),
count: vi.fn(async () => 99),
},
hallOfFame: {
@@ -1,9 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SystemClock } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -17,9 +19,9 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 991_731;
const generalId = 991_731;
const cityId = 991_731;
const existingNationId = 991_730;
const generalId = 731;
const cityId = 731;
const existingNationId = 730;
const requestId = 'integration:engine:immediate-action-uprising';
const occupiedUniqueItem = 'che_무기_12_칠성검';
@@ -97,6 +99,14 @@ const state: TurnWorldState = {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T00:00:00.000Z'),
clockTick: 0,
lastTurnTick: 0,
clockMode: 'realtime',
clockPhase: 'PREOPEN',
clockWallAnchor: new Date(Date.now() + 86_400_000),
clockRevision: 1,
deadlineGeneration: 1,
meta: {
hiddenSeed: 'immediate-action-integration',
killturn: 24,
@@ -155,8 +165,10 @@ integration('immediate general action persistence', () => {
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
});
await db.inputEvent.deleteMany({ where: { requestId } });
beforeEach(async () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({
where: {
@@ -169,7 +181,8 @@ integration('immediate general action persistence', () => {
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
},
});
await db.general.deleteMany({ where: { id: generalId } });
await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
await db.city.deleteMany({ where: { id: cityId } });
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
await db.worldState.deleteMany({ where: { id: worldId } });
@@ -181,6 +194,14 @@ integration('immediate general action persistence', () => {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: state.clockMode,
clockPhase: state.clockPhase,
clockWallAnchor: state.clockWallAnchor,
clockRevision: 1n,
deadlineGeneration: 1n,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -257,7 +278,7 @@ integration('immediate general action persistence', () => {
await disconnect?.();
return;
}
await db.inputEvent.deleteMany({ where: { requestId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({
where: {
@@ -270,14 +291,194 @@ integration('immediate general action persistence', () => {
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
},
});
await db.general.deleteMany({ where: { id: generalId } });
await db.message.deleteMany({ where: { mailbox: { in: [generalId, generalId + 1] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, generalId + 1] } } });
await db.city.deleteMany({ where: { id: cityId } });
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
await db.worldState.deleteMany({ where: { id: worldId } });
await disconnect?.();
});
it('flushes and reloads the nation, diplomacy, officer turns, logs, and general state together', async () => {
it.each([
'normal',
'resigned',
'transferred',
'deleted',
'ruler',
'collapsed',
'legacyCollapsed',
'random',
] as const)('persists the recruitment-letter lifecycle: %s', async (mode) => {
const recruiterId = generalId + 1;
await db.general.create({
data: {
id: recruiterId,
name: '권유자',
meta: { killturn: 24 },
nationId: existingNationId,
officerLevel: 1,
cityId,
turnTime: general.turnTime,
},
});
await db.nation.update({
where: { id: existingNationId },
data: { capitalCityId: cityId, meta: { gennum: 1 } },
});
await db.city.update({ where: { id: cityId }, data: { nationId: existingNationId } });
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const handler = createTurnDaemonCommandHandler({ world, scenarioMeta, map });
const flush = () =>
hooks.hooks.flushChanges!({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
try {
const draft = buildScoutMessageDraft({
srcGeneral: world.getGeneralById(recruiterId)!,
destGeneral: world.getGeneralById(generalId)!,
srcNation: world.getNationById(existingNationId),
destNation: null,
time: world.gameTickToDate(0),
});
expect(draft).not.toBeNull();
world.queueMessage(draft!);
await flush();
const letter = await db.message.findFirstOrThrow({
where: { mailbox: generalId },
include: { action: true },
});
expect(letter.action?.status).toBe('PENDING');
const envelopeWallTime = letter.createdAtWall;
if (mode === 'collapsed') {
world.queueMessage({
...draft!,
src: { ...draft!.src, nationId: existingNationId + 10 },
text: '다른 국가의 등용장',
});
world.queueMessage({ ...draft!, option: {}, text: '일반 서신' });
await flush();
}
while (hooks.takeCommittedReadModelChangeReceipt()) {
/* discard setup receipts */
}
if (mode === 'resigned' || mode === 'transferred') {
world.updateGeneral(recruiterId, { nationId: mode === 'resigned' ? 0 : existingNationId + 10 });
} else if (mode === 'deleted') {
world.removeGeneral(recruiterId);
} else if (mode === 'ruler') {
world.updateGeneral(generalId, { nationId: existingNationId + 10, officerLevel: 12 });
} else if (mode === 'random') {
world.updateWorldConfig({ joinMode: 'onlyRandom' });
} else if (mode === 'collapsed' || mode === 'legacyCollapsed') {
world.removeNation(existingNationId);
}
if (mode === 'collapsed') {
// Force failure after nation deletion: the envelope/action and nation must roll back together.
await db.$executeRawUnsafe(
"ALTER TABLE message_action ADD CONSTRAINT scout_test_pending CHECK (status = 'PENDING')"
);
await expect(flush()).rejects.toThrow();
expect(await db.nation.findUnique({ where: { id: existingNationId } })).not.toBeNull();
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
'PENDING'
);
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT scout_test_pending');
}
await flush();
if (mode === 'legacyCollapsed') {
// Simulate an old deployment which deleted the nation but left this action pending.
await db.messageAction.update({
where: { messageId: letter.id },
data: { status: 'PENDING', resolvedGameTick: null },
});
}
const currentLetter = await db.message.findUniqueOrThrow({
where: { id: letter.id },
include: { action: true },
});
expect(currentLetter.createdAtWall).toEqual(envelopeWallTime);
expect(currentLetter.action?.status).toBe(mode === 'collapsed' ? 'RESOLVED' : 'PENDING');
if (mode === 'collapsed') {
expect(currentLetter.validUntilTick).not.toBeNull();
const receipt = hooks.takeCommittedReadModelChangeReceipt();
expect(receipt?.invalidation.revisions).toContainEqual(
expect.objectContaining({
domain: 'messages.mailbox',
entityId: generalId,
})
);
const controls = await db.message.findMany({
where: { mailbox: generalId, id: { not: letter.id } },
include: { action: true },
orderBy: { id: 'asc' },
});
expect(controls.map((entry) => entry.action?.status ?? null)).toEqual(['PENDING', null]);
expect(controls.every((entry) => entry.validUntil.getUTCFullYear() === 9999)).toBe(true);
}
const actionRequestId = `${requestId}:scout:${mode}`;
const payload = {
type: 'messageRespond' as const,
requestId: actionRequestId,
userId: general.userId!,
generalId,
messageId: letter.id,
response: true,
};
await db.inputEvent.create({
data: {
requestId: actionRequestId,
target: 'ENGINE',
eventType: 'messageRespond',
actorUserId: general.userId,
payload,
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const commands = await queue.drain();
expect(commands).toHaveLength(1);
const result = await hooks.hooks.executeCommand!(actionRequestId, async (ctx) => {
const value = await handler.handle(commands[0]!, ctx);
if (!value) throw new Error('missing message response');
return value;
});
if (mode === 'legacyCollapsed') {
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toContainEqual(
expect.objectContaining({ domain: 'messages.mailbox', entityId: generalId })
);
}
const accepted = ['normal', 'resigned', 'transferred', 'deleted'].includes(mode);
expect(result).toMatchObject(
accepted ? { ok: true, reason: 'success' } : { reason: expect.not.stringMatching(/^success$/) }
);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find(({ id }) => id === generalId)).toMatchObject({
nationId: accepted ? existingNationId : mode === 'ruler' ? existingNationId + 10 : 0,
officerLevel: accepted ? 1 : mode === 'ruler' ? 12 : 0,
});
expect((await db.messageAction.findUniqueOrThrow({ where: { messageId: letter.id } })).status).toBe(
mode === 'ruler' || mode === 'random' ? 'PENDING' : 'RESOLVED'
);
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId: actionRequestId } })).status).toBe(
'SUCCEEDED'
);
expect(await queue.drain()).toEqual([]);
} finally {
await db.$executeRawUnsafe('ALTER TABLE message_action DROP CONSTRAINT IF EXISTS scout_test_pending');
await hooks.close();
}
});
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [
@@ -376,10 +577,15 @@ integration('immediate general action persistence', () => {
});
const stateStore = {
loadLastTurnTime: async () => new Date(state.lastTurnTime),
loadNextGeneralTurnTime: async () => null,
loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime' as const,
phase: 'PREOPEN' as const,
now: world.getGameNow(new Date()),
}),
};
const processor = {
run: async () => {
@@ -597,5 +803,108 @@ integration('immediate general action persistence', () => {
chiefGeneralId: generalId,
rice: 2_000,
});
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
});
it('deletes a neutral general after the wall deadline in PREOPEN and commits its durable result once', async () => {
const cutoff = new Date('2026-07-31T00:20:00.000Z');
await db.general.update({
where: { id: generalId },
data: { meta: { ...general.meta, prestart_delete_after: cutoff.toISOString() } },
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (value) => world.restoreState(value),
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
const processor = {
run: vi.fn(async () => {
throw new Error('PREOPEN must not execute scheduled turns');
}),
};
const ids = [':status', ':early', ':delete'].map((suffix) => requestId + suffix);
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'dieOnPrestart'];
await db.inputEvent.createMany({
data: ids.map((id, index) => ({
requestId: id,
target: 'ENGINE',
eventType: types[index]!,
actorUserId: general.userId,
createdAt: new Date(cutoff.getTime() + (index === 2 ? 0 : -1)),
payload: { type: types[index], requestId: id, userId: general.userId, generalId },
})),
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new SystemClock(),
controlQueue: queue,
commandResponder: queue,
commandHandler: handler,
hooks: hooks.hooks,
stateManager,
processor,
getNextTickTime: () => general.turnTime,
stateStore: {
loadLastTurnTime: async () => state.lastTurnTime,
loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime',
phase: 'PREOPEN',
now: world.getGameNow(new Date()),
}),
},
},
{
profile: 'immediate-action-integration',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const loop = lifecycle.start();
try {
await vi.waitFor(
async () => {
const events = await db.inputEvent.findMany({
where: { requestId: { in: ids } },
orderBy: { sequence: 'asc' },
});
expect(events.map((event) => event.status)).toEqual(['SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED']);
expect(events[0]?.result).toMatchObject({
show: true,
available: false,
availableAt: cutoff.toISOString(),
});
expect(events[1]?.result).toMatchObject({
ok: false,
reason: expect.stringContaining('아직 삭제할 수 없습니다'),
});
expect(events[2]?.result).toMatchObject({ ok: true, generalId });
expect(
events.every((event) => event.processingGameTick !== null && event.processingGameTick < 0n)
).toBe(true);
expect(events.every((event) => event.attempts === 1)).toBe(true);
},
{ timeout: 5_000 }
);
} finally {
await lifecycle.stop('pre-opening deletion checked');
await loop;
await hooks.close();
}
expect(processor.run).not.toHaveBeenCalled();
expect(await queue.drain()).toEqual([]);
expect(await db.general.findUnique({ where: { id: generalId } })).toBeNull();
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toBeUndefined();
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
expect(await db.logEntry.count({ where: { scope: 'SYSTEM', text: { contains: '홀연히 모습을' } } })).toBe(1);
});
});
@@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => {
const before = world.captureState();
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
world.shiftSchedule(15, claimedAt);
world.shiftSchedule(20, claimedAt);
expect(world.captureState().generalPoolEntries).toMatchObject([
{
id: 1,
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
},
{ id: 2, reservedUntil: null, reservedUntilTick: null },
@@ -231,13 +231,13 @@ describe('in-memory scenario general pool availability', () => {
lastTurnTick: 0,
});
const before = world.captureState();
const resumedAt = new Date(claimedAt.getTime() + 40 * 60_000);
const resumedAt = new Date(claimedAt.getTime() + 120 * 60_000);
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
skippedTurns: 4,
shiftedTicks: 4 * GAME_TICKS_PER_TURN,
skippedTurns: 12,
shiftedTicks: 12 * GAME_TICKS_PER_TURN,
});
const rebasedReservedUntilTick = 6 * GAME_TICKS_PER_TURN;
const rebasedReservedUntilTick = 14 * GAME_TICKS_PER_TURN;
const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
expect(world.captureState().generalPoolEntries).toMatchObject([
{
@@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => {
await hooks.close();
}
});
it('atomically repairs a rulerless nation with only troop NPCs and preserves its archive', async () => {
await cleanup();
const nation = buildNation(nationIds[1]!, '방랑국', 3, 0);
const city = buildCity(cityIds[0]!, '방랑성', nation.id);
const general = buildGeneral({
id: generalIds[0]!,
name: '부대장',
nationId: nation.id,
cityId: city.id,
officerLevel: 11,
npcState: 5,
gold: 2345,
rice: 6789,
belong: 5,
turnTime: new Date('0195-01-01T00:05:00.000Z'),
});
await db.nation.create({
data: {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
typeCode: nation.typeCode,
chiefGeneralId: 12345,
},
});
await db.city.create({
data: {
id: city.id,
name: city.name,
level: city.level,
nationId: nation.id,
region: 1,
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
frontState: 1,
},
});
await db.general.create({
data: {
id: general.id,
name: general.name,
nationId: nation.id,
cityId: city.id,
officerLevel: 11,
npcState: 5,
gold: general.gold,
rice: general.rice,
turnTime: general.turnTime,
},
});
await db.nationTurn.create({
data: { nationId: nation.id, officerLevel: 12, turnIdx: 0, actionCode: '휴식', arg: {} },
});
const row = await db.worldState.create({
data: {
scenarioCode,
currentYear: 195,
currentMonth: 1,
tickSeconds: 600,
meta: { serverId },
},
});
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
};
const world = new InMemoryTurnWorld(
{
id: row.id,
currentYear: 195,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0195-01-01T00:00:00.000Z'),
meta: { serverId },
},
{
scenarioConfig,
scenarioMeta: {
title: 'test',
startYear: 193,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: { id: 'test', name: 'test', cities: [] },
nations: [nation],
cities: [city],
generals: [general],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
},
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
world.dissolveNationWithoutSuccessor(nation.id);
const result = {
lastTurnTime: world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
};
// A stale clock fence must leave the entire repair uncommitted and retryable.
await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 2 } });
await expect(hooks.hooks.flushChanges!(result)).rejects.toThrow('Game clock fence changed');
expect(await db.nation.count({ where: { id: nation.id } })).toBe(1);
expect((await db.general.findUniqueOrThrow({ where: { id: general.id } })).nationId).toBe(nation.id);
expect(await db.oldNation.count({ where: { serverId } })).toBe(0);
await db.worldState.update({ where: { id: row.id }, data: { clockRevision: 1 } });
await hooks.hooks.flushChanges!(result);
expect(await db.nation.findUnique({ where: { id: nation.id } })).toBeNull();
expect(await db.city.findUniqueOrThrow({ where: { id: city.id } })).toMatchObject({
nationId: 0,
frontState: 0,
});
expect(await db.general.findUniqueOrThrow({ where: { id: general.id } })).toMatchObject({
nationId: 0,
officerLevel: 0,
gold: 2345,
rice: 6789,
});
expect(await db.nationTurn.count({ where: { nationId: nation.id } })).toBe(0);
const archive = await db.oldNation.findUniqueOrThrow({
where: { serverId_nation_sourceId: { serverId, nation: nation.id, sourceId: 0 } },
});
expect(archive.data).toMatchObject({ nation: nation.id, generals: [general.id] });
const logCount = await db.logEntry.count({ where: { text: { contains: '방랑국' } } });
expect(logCount).toBe(3);
expect(world.dissolveNationWithoutSuccessor(nation.id)).toBe(false);
await hooks.hooks.flushChanges!(result);
expect(await db.logEntry.count({ where: { text: { contains: '방랑국' } } })).toBe(logCount);
} finally {
await hooks.close();
}
});
});
@@ -169,6 +169,7 @@ const buildImmediateActionWorld = (options: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'),
lastTurnTick: options.lastTurnTime && options.lastTurnTime > new Date('0180-02-01T00:00:00Z') ? 36_000_000 : 0,
meta: {
hiddenSeed: 'immediate-action-test',
killturn: 24,
@@ -599,102 +600,126 @@ describe('my information world commands', () => {
expect(nextIntInclusive).not.toHaveBeenCalled();
});
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
name: '재야장수',
nationId: 0,
cityId: 1,
officerLevel: 0,
lastTurn: originalLastTurn,
});
const recruiter = buildGeneral({
id: 9,
userId: 'user-9',
name: '등용장수',
nationId: 2,
cityId: 2,
});
const map = {
id: 'test',
name: 'test',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
};
const fixture = buildImmediateActionWorld({
general: recipient,
additionalGenerals: [recruiter],
cities: [
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
] as TurnWorldSnapshot['cities'],
nations: [
{
id: 2,
name: '등용국',
color: '#222222',
typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: recruiter.id,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
it.each([
{ recruiterNationId: 2, ruler: false },
{ recruiterNationId: 0, ruler: false },
{ recruiterNationId: 3, ruler: false },
{ recruiterNationId: 2, ruler: true },
])(
'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
async ({ recruiterNationId, ruler }) => {
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
name: '재야장수',
nationId: 0,
cityId: 1,
officerLevel: 0,
lastTurn: originalLastTurn,
});
const recruiter = buildGeneral({
id: 9,
userId: 'user-9',
name: '등용장수',
nationId: 2,
cityId: 2,
});
const map = {
id: 'test',
name: 'test',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
};
const fixture = buildImmediateActionWorld({
general: recipient,
additionalGenerals: [recruiter],
cities: [
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
] as TurnWorldSnapshot['cities'],
nations: [
{
id: 2,
name: '등용국',
color: '#222222',
typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: recruiter.id,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
},
] as TurnWorldSnapshot['nations'],
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: {
general: ['che_등용'],
nation: [],
},
] as TurnWorldSnapshot['nations'],
map,
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
commandProfile: {
general: ['che_등용'],
nation: [],
},
});
});
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: true });
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
nationId: 2,
cityId: 2,
officerLevel: 1,
lastTurn: originalLastTurn,
});
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100,
});
const actionLogs = fixture.world
.consumeDirtyState()
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
});
fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
if (ruler) {
fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
const before = fixture.world.captureState();
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
expect(fixture.world.captureState()).toEqual(before);
return;
}
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: true });
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
nationId: 2,
cityId: 2,
officerLevel: 1,
lastTurn: originalLastTurn,
});
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100,
});
const actionLogs = fixture.world
.consumeDirtyState()
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
}
);
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
const recipient = buildGeneral({
@@ -39,6 +39,135 @@ const createChief = (userId: string | null): TurnGeneral => ({
});
describe('레거시 사령부 턴 실행 호환성', () => {
it('피장파장은 같은 두 턴의 사기진작과 병행해도 2회째에 완료한다', async () => {
const cities = buildLargeTestCities();
for (const city of cities) {
city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0;
}
const chief = createChief('test-user');
chief.crew = 1_000;
chief.train = 80;
chief.atmos = 80;
const snapshot: TurnWorldSnapshot = {
generals: [chief],
cities,
nations: [
{
id: 1,
name: '테스트국',
color: '#aa0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { strategic_cmd_limit: 0, gennum: 10 },
},
{
id: 2,
name: '상대국',
color: '#0000aa',
capitalCityId: 2,
chiefGeneralId: null,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: {},
},
],
troops: [],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 1200, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: 0, term: 1200, dead: 0, meta: {} },
],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
openingPartYear: 3,
develCost: 10,
baseGold: 1000,
baseRice: 1000,
maxResourceActionAmount: 10000,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 189 } as never,
unitSet: {} as never,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: { seed: 1 },
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const resolvedActions: Array<{ kind: string; actionKey: string; completed?: boolean }> = [];
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
onActionResolved: (event) => resolvedActions.push(event),
});
const counterStrategy = {
action: 'che_피장파장',
args: { destNationId: 2, commandType: 'che_허보' },
};
reservedTurnStore.getNationTurns(1, 12).splice(0, 2, counterStrategy, counterStrategy);
reservedTurnStore.getGeneralTurns(1).splice(
0,
2,
{ action: 'che_사기진작', args: {} },
{ action: 'che_사기진작', args: {} }
);
await runOneTick();
expect(world.getNationById(1)!.meta.turn_last_12).toMatchObject({
command: '피장파장',
term: 1,
});
expect(resolvedActions).toContainEqual(
expect.objectContaining({ kind: 'nation', actionKey: 'che_피장파장', completed: false })
);
expect(resolvedActions).toContainEqual(
expect.objectContaining({ kind: 'general', actionKey: 'che_사기진작', completed: true })
);
// PostgreSQL jsonb는 object key 순서를 보존하지 않는다. 첫 턴의
// turn_last를 DB에 저장했다가 재적재한 상태를 그대로 재현한다.
const nationAfterFirstTurn = world.getNationById(1)!;
world.updateNation(1, {
meta: {
...nationAfterFirstTurn.meta,
turn_last_12: {
command: '피장파장',
arg: { commandType: 'che_허보', destNationId: 2 },
term: 1,
},
},
});
resolvedActions.length = 0;
await runOneTick();
expect(resolvedActions).toContainEqual(
expect.objectContaining({ kind: 'nation', actionKey: 'che_피장파장', completed: true })
);
expect(world.getNationById(2)!.meta.next_execute_허보).toBe(189 * 12 + 1 + 60);
});
it.each([
{ ownerLabel: '소유 장수', userId: 'test-user', expectedActiveAction: 1 },
{ ownerLabel: '무소유 장수', userId: null, expectedActiveAction: undefined },
@@ -105,6 +105,8 @@ describe('NPC 일반 내정 턴', () => {
{
id: 1,
name: 'NPC_무장',
picture: '롤시나리오/다이애나.png',
imageServer: 0,
nationId: 1,
cityId: 1,
troopId: 0,
@@ -297,7 +299,12 @@ describe('NPC 일반 내정 턴', () => {
expect.objectContaining({
msgType: 'public',
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
src: expect.objectContaining({
generalId: 1,
generalName: 'NPC_무장',
nationId: 1,
icon: 'https://sam-image.hided.net/icons/롤시나리오/다이애나.png',
}),
time: logicalGameNow,
})
);
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
import { getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
import { getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { COMPACT_NPC_TEST_MAP, buildCompactNpcTestCities } from './fixtures/compactNpcTestScenario.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
const mockDate = new Date('0180-01-01T00:00:00Z');
@@ -45,7 +45,7 @@ const createNpcGeneral = (
npcState,
});
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
const maxCityStats = (city: ReturnType<typeof buildCompactNpcTestCities>[number]) => ({
...city,
population: city.populationMax,
agriculture: city.agricultureMax,
@@ -63,9 +63,9 @@ const readTech = (nation: { meta: Record<string, unknown> }): number => {
describe('NPC 기술 연구 장기 시뮬레이션', () => {
it('기술이 상승하고 기술 등급 및 소모 금이 증가해야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
const nation1CityIds = [1, 2, 3, 4];
const nation2CityIds = [5, 6, 7, 8, 9];
const cities = buildCompactNpcTestCities().map(maxCityStats);
const nation1CityIds = [1, 2];
const nation2CityIds = [3, 4, 5];
for (const city of cities) {
if (nation1CityIds.includes(city.id)) {
@@ -122,7 +122,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
1
)
);
for (let i = 0; i < 9; i += 1) {
for (let i = 0; i < 3; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 80,
@@ -131,7 +131,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
})
);
}
for (let i = 0; i < 40; i += 1) {
for (let i = 0; i < 6; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
@@ -163,7 +163,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { tech: 0 },
meta: { tech: 950 },
},
{
id: 2,
@@ -176,7 +176,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { tech: 0 },
meta: { tech: 950 },
},
],
troops: [],
@@ -200,7 +200,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP as any,
map: COMPACT_NPC_TEST_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
@@ -214,7 +214,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
minAvailableRecruitPop: 0,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
environment: { mapName: 'compact_npc_test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
@@ -239,7 +239,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
map: COMPACT_NPC_TEST_MAP,
});
const controlledGeneralId = nation1ChiefId;
@@ -261,17 +261,14 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const firstGoldAfter = world.getGeneralById(controlledGeneralId)!.gold;
const firstRecruitCost = firstGoldBefore - firstGoldAfter;
expect(firstRecruitCost).toBeGreaterThan(0);
const firstTechValue = readTech(world.getNationById(1)! as { meta: Record<string, unknown> });
const firstTechLevel = getTechLevel(firstTechValue);
const techSnapshots: Array<{ year: number; tech1: number; tech2: number }> = [];
let pendingRecruit = false;
let pendingGoldBefore = 0;
let secondRecruitCost: number | null = null;
const shouldStop = () => {
const exceededSafetyLimit = () => {
const current = world.getState();
return current.currentYear > 230 || (current.currentYear === 230 && current.currentMonth >= 1);
return current.currentYear > 185 || (current.currentYear === 185 && current.currentMonth >= 1);
};
while (true) {
@@ -294,15 +291,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const tech1 = readTech(nation1 as { meta: Record<string, unknown> });
const tech2 = readTech(nation2 as { meta: Record<string, unknown> });
if (current.currentMonth === 1) {
techSnapshots.push({ year: current.currentYear, tech1, tech2 });
}
techSnapshots.push({ year: current.currentYear, tech1, tech2 });
if (secondRecruitCost === null && getTechLevel(tech1) > firstTechLevel) {
if (secondRecruitCost === null && getTechLevel(tech1) > initialLevel) {
pendingRecruit = true;
}
if (shouldStop()) {
if (
(secondRecruitCost !== null &&
getTechLevel(tech1) > initialLevel &&
getTechLevel(tech2) > initialLevel) ||
exceededSafetyLimit()
) {
break;
}
}
@@ -322,8 +322,12 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
expect(finalTech2).toBeGreaterThan(initialTech2);
expect(getTechLevel(finalTech1)).toBeGreaterThan(initialLevel);
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel);
expect(getTechCost(finalTech1)).toBeGreaterThan(getTechCost(initialTech1));
expect(secondRecruitCost).not.toBeNull();
expect(
secondRecruitCost,
`second recruit was not observed (tech1=${finalTech1}, tech2=${finalTech2}, level=${initialLevel})`
).not.toBeNull();
// Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke.
@@ -7,7 +7,7 @@ import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/l
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { COMPACT_NPC_TEST_MAP, buildCompactNpcTestCities } from './fixtures/compactNpcTestScenario.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js';
@@ -28,8 +28,8 @@ const createNpcGeneral = (
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
specialDomestic: id % 2 === 0 ? 'che_경작' : 'che_상재',
specialWar: id % 2 === 0 ? 'che_보병' : 'che_무쌍',
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 800 },
@@ -47,7 +47,7 @@ const createNpcGeneral = (
npcState: 2,
});
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
const maxCityStats = (city: ReturnType<typeof buildCompactNpcTestCities>[number]) => ({
...city,
population: city.populationMax,
agriculture: city.agricultureMax,
@@ -156,7 +156,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1';
const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1';
const profileStartedAtMs = performance.now();
const cities = buildLargeTestCities().map(maxCityStats);
const cities = buildCompactNpcTestCities().map(maxCityStats);
for (const city of cities) {
city.nationId = 0;
}
@@ -208,7 +208,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
};
const generals: TurnGeneral[] = [];
const initialGeneralCount = rankingAuditEnabled ? 150 : 300;
const initialGeneralCount = rankingAuditEnabled ? 150 : 60;
for (let i = 0; i < initialGeneralCount; i += 1) {
const cityId = cities[i % cities.length]!.id;
const stats =
@@ -217,6 +217,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
: { leadership: 75, strength: 10, intelligence: 75 };
generals.push(createNpcGeneral(i + 1, cityId, stats));
}
expect(new Set(generals.map((general) => general.role.specialDomestic))).toEqual(
new Set(['che_경작', 'che_상재'])
);
expect(new Set(generals.map((general) => general.role.specialWar))).toEqual(new Set(['che_보병', 'che_무쌍']));
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
@@ -226,7 +230,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
diplomacy: [],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP as any,
map: COMPACT_NPC_TEST_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
@@ -239,7 +243,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
maxResourceActionAmount: 10000,
minAvailableRecruitPop: 0,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
environment: { mapName: 'compact_npc_test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
@@ -305,45 +309,44 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
getCollectedLogsCount,
getCollectedLogsRange,
getAndClearCollectedLogs,
} =
await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
} = await createTurnTestHarness({
snapshot,
state,
schedule,
map: COMPACT_NPC_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
return;
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
const memoryProfiler =
memoryProfileEnabled && worldRef.current
? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore)
@@ -410,6 +413,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
const foundedNationCount = foundedNations.length;
const foundedGenerals = world.listGenerals().filter((general) => general.nationId > 0);
expect(foundedGenerals.some((general) => general.role.specialDomestic !== null)).toBe(true);
expect(foundedGenerals.some((general) => general.role.specialWar !== null)).toBe(true);
memoryProfiler?.sample('nations-founded');
await runUntil(
@@ -436,7 +442,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
if (declarationCount === 0) {
await runUntil(
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
(current) =>
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
undefined,
observeProfileMonth
);
@@ -614,6 +621,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const logs = getCollectedLogs();
const hasUnificationLog =
unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
if (!rankingAuditEnabled) {
expect(unifiedAt).not.toBeNull();
}
if (unifiedAt) {
expect(meta.isUnited).toBe(2);
expect(hasUnificationLog).toBe(true);
@@ -714,8 +724,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
startMonth: state.currentMonth,
});
const reportPath = resolve(
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ??
'test-results/npc-unification-memory.json'
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ?? 'test-results/npc-unification-memory.json'
);
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox
ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1)
CHECK ((payload->>'version')::integer <> 1) NOT VALID
`);
world.updateWorldMeta({ durableFixture: 'must-rollback' });
world.pushLog({
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.readModelOutbox.count()).resolves.toBe(5);
await db.inputEvent.update({
where: { requestId },
data: { eventType: 'voteReward', status: 'PROCESSING', result: GamePrisma.DbNull },
});
const voteResult: TurnDaemonCommandResult = {
type: 'voteReward',
ok: true,
voteId: 1,
generalId: directLogGeneralId,
awardedUnique: false,
};
await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1) NOT VALID
`);
await expect(hooks.hooks.executeCommand?.(requestId, async () => voteResult)).rejects.toThrow(
rollbackConstraint
);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'PROCESSING',
result: null,
});
await expect(db.readModelOutbox.count()).resolves.toBe(5);
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT ${rollbackConstraint}`);
await hooks.hooks.executeCommand?.(requestId, async () => voteResult);
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toEqual([
{ domain: 'front.general', entityId: directLogGeneralId, revision: 1n },
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: voteResult,
});
await expect(db.readModelOutbox.count()).resolves.toBe(6);
});
});
@@ -16,6 +16,34 @@ import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
describe('durable read-model change journal mapping', () => {
it.each([false, true])(
'invalidates only the voting actor on successful vote completion (replay=%s)',
(alreadyApplied) => {
expect(
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
type: 'voteReward',
ok: true,
voteId: 1,
generalId: 7,
awardedUnique: false,
alreadyApplied,
}).snapshot()
).toEqual([{ domain: 'front.general', entityId: 7 }]);
}
);
it('does not publish vote completion for a rejected vote', () => {
expect(
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
type: 'voteReward',
ok: false,
voteId: 1,
generalId: 7,
reason: 'closed',
}).snapshot()
).toEqual([]);
});
it('maps every final engine invalidation to its precise durable domain', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
+122 -41
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, planTurnRecovery } from '@sammo-ts/common';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
};
describe('runtime clock shift', () => {
it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => {
const base = new Date('2026-07-30T10:00:00Z');
const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: 4 * GAME_TICKS_PER_TURN,
wallNow: wallAt(40),
turnSeconds: 600,
});
let world = buildWorld({
clockBaseTime: base,
clockTick: 0,
clockWallAnchor: wallAt(40),
clockRecovery: plan.recovery,
clockMode: 'realtime',
clockPhase: 'RUNNING',
lastTurnTick: 0,
});
for (const [id, milliseconds] of [
[1, 19_902],
[2, 42_001],
]) {
world.updateGeneral(id!, {
turnTime: new Date(base.getTime() + milliseconds!),
turnTick: milliseconds! * 60,
});
}
const executions: Array<[number, number, boolean]> = [];
const processorForWorld = () =>
new InMemoryTurnProcessor(world, {
afterExecuteGeneral: async (general, result) => {
executions.push([world.getState().currentMonth, general.id, result.ok]);
},
});
let processor = processorForWorld();
for (let minutes = 45; minutes <= 80; minutes += 5) {
const now = wallAt(minutes);
const target = world.getGameNow(now);
world.advanceGameClockTo(target, now);
const result = await processor.run(target, { budgetMs: 10_000, maxGenerals: 10, catchUpCap: 1 });
expect(result).toMatchObject({ processedGenerals: 2, processedTurns: 1, partial: false });
if (minutes === 60) {
const snapshot = world.captureState();
world = buildWorld();
world.restoreState(snapshot);
processor = processorForWorld();
}
}
expect(executions).toEqual(
Array.from({ length: 8 }, (_, month) => [
[month + 1, 1, true],
[month + 1, 2, true],
]).flat()
);
expect(world.getState().currentMonth).toBe(9);
expect(world.getGeneralById(1)!.turnTime).toEqual(new Date(wallAt(80).getTime() + 19_902));
const next = await processor.run(world.getGameNow(wallAt(90)), {
budgetMs: 10_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(next).toMatchObject({ processedGenerals: 2, processedTurns: 1 });
expect(world.getState().currentMonth).toBe(10);
});
it('preserves the scenario config when the raw world config is unavailable', () => {
const world = buildWorld();
@@ -99,7 +164,7 @@ describe('runtime clock shift', () => {
['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'],
['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'],
] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => {
const world = buildWorld();
const world = buildWorld({ tickSeconds: 900 });
world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
world.queueNeutralAuction({
registrationKey: 'test',
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]);
});
it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
it.each([0, 1.5, 15, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
const world = buildWorld();
expect(() => world.shiftSchedule(delta)).toThrow();
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z');
@@ -140,7 +205,7 @@ describe('runtime clock shift', () => {
});
it('keeps legacy wall-clock metadata independent from the process timezone', () => {
const world = buildWorld();
const world = buildWorld({ tickSeconds: 900 });
world.shiftSchedule(-15);
@@ -212,6 +277,26 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
});
it('moves the formal opening together with an explicit whole-turn schedule shift', () => {
const openAt = new Date('2026-09-06T00:00:00.000Z');
const now = new Date('2026-09-05T00:00:00.000Z');
const world = buildWorld({
clockBaseTime: new Date('2026-07-30T10:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
clockPhase: 'PREOPEN',
});
world.shiftSchedule(20, now);
const shiftedOpen = new Date(openAt.getTime() + 20 * 60_000);
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: shiftedOpen });
expect(world.promotePreopenAtOpening(now)).toBe(false);
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z'));
expect(world.promotePreopenAtOpening(openAt)).toBe(false);
expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true);
});
it('rejects gameplay commits while the durable clock is suspended', async () => {
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
@@ -220,33 +305,29 @@ describe('runtime clock shift', () => {
await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/);
});
it.each([
[5, 6],
[10, 3],
[20, 1],
])('uses the Ref catch-up threshold for a %i-minute turn', (turnMinutes, threshold) => {
it.each([5, 10, 20, 60])('only skips complete 12-turn blocks for a %i-minute turn', (turnMinutes) => {
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
const world = buildWorld({
tickSeconds: turnMinutes * 60,
clockBaseTime: wallAnchor,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: wallAnchor,
lastTurnTick: 0,
lastTurnTime: wallAnchor,
});
expect(
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + threshold * turnMinutes * 60_000))
).toBe(false);
expect(
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + (threshold + 1) * turnMinutes * 60_000))
).toBe(true);
for (const turns of [0, 1, 11, 11.999, 12, 13, 23.999, 24, 25]) {
const world = buildWorld({
tickSeconds: turnMinutes * 60,
clockBaseTime: wallAnchor,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: wallAnchor,
lastTurnTick: 0,
lastTurnTime: wallAnchor,
});
const resumedAt = new Date(wallAnchor.getTime() + turns * turnMinutes * 60_000);
expect(world.shouldRebaseRealtimeBacklog(resumedAt)).toBe(turns >= 12);
const result = world.rebaseRealtimeBacklog(resumedAt);
if (turns < 12) expect(result).toBeNull();
else expect(result?.skippedTurns).toBe(Math.floor(turns / 12) * 12);
}
});
it('skips a long realtime backlog while preserving the turn phase and wall-clock display', () => {
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
const resumedAt = new Date('2026-07-30T10:35:00.000Z');
const resumedAt = new Date('2026-07-30T11:05:00.000Z');
const world = buildWorld({
tickSeconds: 300,
clockBaseTime: wallAnchor,
@@ -267,35 +348,35 @@ describe('runtime clock shift', () => {
const result = world.rebaseRealtimeBacklog(resumedAt);
expect(result).toMatchObject({
skippedTurns: 7,
shiftedTicks: 7 * GAME_TICKS_PER_TURN,
lastTurnTime: resumedAt.toISOString(),
skippedTurns: 12,
shiftedTicks: 12 * GAME_TICKS_PER_TURN,
lastTurnTime: '2026-07-30T11:00:00.000Z',
});
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
expect(world.getState()).toMatchObject({
clockTick: 7 * GAME_TICKS_PER_TURN,
clockTick: 13 * GAME_TICKS_PER_TURN,
clockWallAnchor: resumedAt,
lastTurnTick: 7 * GAME_TICKS_PER_TURN,
lastTurnTick: 12 * GAME_TICKS_PER_TURN,
meta: {
turntime: '2026-07-30 10:35:00.123456',
starttime: '2026-07-01 00:35:00',
turntime: '2026-07-30 11:00:00.123456',
starttime: '2026-07-01 01:00:00',
},
});
expect(world.getGeneralById(1)).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN,
turnTime: new Date('2026-07-30T10:45:00.000Z'),
turnTick: 14 * GAME_TICKS_PER_TURN,
turnTime: new Date('2026-07-30T11:10:00.000Z'),
});
expect(world.getCheckpoint()).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN,
turnTime: '2026-07-30T10:45:00.000Z',
turnTick: 14 * GAME_TICKS_PER_TURN,
turnTime: '2026-07-30T11:10:00.000Z',
});
expect(world.peekDirtyState()).toMatchObject({
realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN,
realtimeBacklogShiftTicks: 12 * GAME_TICKS_PER_TURN,
generals: [],
});
});
it('repairs an already accumulated realtime projection lag during a long rebase', () => {
it('preserves the normal game-to-wall mapping during a whole-year rebase', () => {
const base = new Date('2026-07-30T10:00:00.000Z');
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
const resumedAt = new Date('2026-07-30T11:50:00.000Z');
@@ -310,8 +391,8 @@ describe('runtime clock shift', () => {
});
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 22 });
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
expect(world.getGameNow(resumedAt)).toEqual(new Date('2026-07-30T11:15:00.000Z'));
});
it('does not lose realtime elapsed time when an overdue target is committed later', () => {
@@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule',
requestId,
actionId,
deltaMinutes: -15,
deltaMinutes: -20,
} as GamePrisma.InputJsonValue,
},
});
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
await hooks.close();
}
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z');
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z');
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:40:00.000Z');
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z');
expect(await stateStore.loadCheckpoint()).toMatchObject({
turnTime: '2099-07-30T09:45:00.000Z',
turnTime: '2099-07-30T09:40:00.000Z',
generalId: 0,
});
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
// 예정된 재개 경계까지 대기하며, 그 뒤 첫 장수의 시각을 다시 계산한다.
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:40:00.000Z');
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld.meta).toMatchObject({
lastTurnTime: '2099-07-30T09:45:00.000Z',
starttime: '2099-06-30 23:45:00',
lastTurnTime: '2099-07-30T09:40:00.000Z',
starttime: '2099-06-30 23:40:00',
});
expect(storedWorld.clockTick).toBe(0n);
expect(storedWorld.lastTurnTick).toBe(0n);
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:00:00.000Z');
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
const storedAuctions = await db.auction.findMany({
where: { id: { in: auctionRows.map((auction) => auction.id) } },
});
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([
'2099-07-30T09:45:00.000Z',
'2099-07-30T09:40:00.000Z',
'2099-07-30T11:00:00.000Z',
'2099-07-30T12:00:00.000Z',
'2099-07-30T13:00:00.000Z',
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule',
ok: true,
actionId,
deltaMinutes: -15,
deltaMinutes: -20,
shiftedGenerals: 2,
shiftedAuctions: 1,
},
@@ -302,7 +303,7 @@ integration('runtime clock shift persistence', () => {
it('atomically rebases a long realtime backlog and open auction deadlines', async () => {
const base = new Date('2099-09-01T00:00:00.000Z');
const resumedAt = new Date('2099-09-01T00:35:00.000Z');
const resumedAt = new Date('2099-09-01T01:00:00.000Z');
const row = await db.worldState.create({
data: {
scenarioCode: 'realtime-backlog-rebase',
@@ -408,7 +409,7 @@ integration('runtime clock shift persistence', () => {
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 });
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
await hooks.hooks.flushChanges?.({
lastTurnTime: resumedAt.toISOString(),
processedGenerals: 0,
@@ -422,18 +423,18 @@ integration('runtime clock shift persistence', () => {
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld).toMatchObject({
clockTick: BigInt(7 * GAME_TICKS_PER_TURN),
lastTurnTick: BigInt(7 * GAME_TICKS_PER_TURN),
clockTick: BigInt(12 * GAME_TICKS_PER_TURN),
lastTurnTick: BigInt(12 * GAME_TICKS_PER_TURN),
clockWallAnchor: resumedAt,
});
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } });
expect(storedGeneral).toMatchObject({
turnTick: BigInt(8 * GAME_TICKS_PER_TURN),
turnTime: new Date('2099-09-01T00:40:00.000Z'),
turnTick: BigInt(13 * GAME_TICKS_PER_TURN),
turnTime: new Date('2099-09-01T01:05:00.000Z'),
});
expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({
closeTick: BigInt(9 * GAME_TICKS_PER_TURN),
closeAt: new Date('2099-09-01T00:45:00.000Z'),
closeTick: BigInt(14 * GAME_TICKS_PER_TURN),
closeAt: new Date('2099-09-01T01:10:00.000Z'),
});
expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
@@ -442,8 +443,8 @@ integration('runtime clock shift persistence', () => {
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({
ownerUserId: 'rebase-pool-user',
generalId: null,
reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN),
reservedUntil: new Date('2099-09-01T00:45:00.000Z'),
reservedUntilTick: BigInt(14 * GAME_TICKS_PER_TURN),
reservedUntil: new Date('2099-09-01T01:10:00.000Z'),
});
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
@@ -1,3 +1,4 @@
import { GameClock } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { describe, expect, test } from 'vitest';
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
@@ -94,6 +95,99 @@ const canRun = await canConnectToDatabase(databaseUrl);
const describeDb = describe.runIf(canRun);
describeDb('scenario database seed', () => {
test.each([
{ sync: true, turnMinutes: 60, hour: 10, month: 10, yearOffset: -1 },
{ sync: true, turnMinutes: 60, hour: 1, month: 1, yearOffset: 0 },
{ sync: true, turnMinutes: 60, hour: 13, month: 1, yearOffset: 0 },
{ sync: false, turnMinutes: 60, hour: 10, month: 1, yearOffset: 0 },
{ sync: true, turnMinutes: 5, hour: 10, month: 7, yearOffset: -1 },
])(
'preserves exact opening and its calendar: $sync / $hour / $turnMinutes',
async ({ sync, hour, month, yearOffset, turnMinutes }) => {
const openAt = new Date(2030, 0, 1, hour, 30, 15, 123);
const preopenAt = new Date(openAt.getTime() - 90 * 60_000);
const scenario = await loadScenarioDefinitionById(scenarioId);
await seedScenarioToDatabase({
scenarioId,
databaseUrl,
resetTables: true,
now: preopenAt,
wallNow: preopenAt,
installOptions: { sync, turnTermMinutes: turnMinutes, preopenAt, openAt },
});
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
currentYear: (scenario.startYear ?? 0) + yearOffset,
currentMonth: month,
clockBaseTime: openAt,
clockWallAnchor: openAt,
clockTick: 0n,
lastTurnTick: 0n,
clockPhase: 'PREOPEN',
});
const clock = new GameClock({
baseTime: world.clockBaseTime!,
wallAnchor: world.clockWallAnchor!,
tick: Number(world.clockTick),
mode: 'realtime',
phase: 'PREOPEN',
turnSeconds: world.tickSeconds,
});
expect(clock.nowTick(new Date(openAt.getTime() - 1))).toBeLessThan(0);
expect(clock.nowTick(openAt)).toBe(0);
expect(clock.tickToDate(clock.nowTick(preopenAt))).toEqual(preopenAt);
const afterOpening = new Date(openAt.getTime() + turnMinutes * 60_000);
expect(clock.tickToDate(clock.nowTick(afterOpening))).toEqual(afterOpening);
expect(clock.nowTick(afterOpening)).toBe(36_000_000);
const generals = await connector.prisma.general.findMany({
select: { turnTick: true, turnTime: true },
});
expect(generals.length).toBeGreaterThan(0);
for (const general of generals) {
expect(general.turnTick).toBeGreaterThanOrEqual(0n);
expect(general.turnTime.getTime()).toBeGreaterThanOrEqual(openAt.getTime());
}
} finally {
await connector.disconnect();
}
}
);
test.each([false, true])(
'starts immediately without rounding when the opening is absent or late: %s',
async (late) => {
const wallNow = new Date(2030, 0, 1, 10, 30, 15, 123);
await seedScenarioToDatabase({
scenarioId,
databaseUrl,
resetTables: true,
now: new Date(2030, 0, 1, 1, 0),
wallNow,
installOptions: {
sync: true,
turnTermMinutes: 60,
openAt: late ? new Date(2030, 0, 1, 9, 0) : null,
},
});
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await connector.connect();
await expect(connector.prisma.worldState.findFirstOrThrow()).resolves.toMatchObject({
clockBaseTime: wallNow,
clockWallAnchor: wallNow,
clockPhase: 'RUNNING',
clockTick: 0n,
currentMonth: 10,
});
} finally {
await connector.disconnect();
}
}
);
test('persists each blank-land scenario item contract without leaking the shared addon', async () => {
const readPersistedItemContract = async (targetScenarioId: number) => {
const { applied } = await seedScenarioToDatabase({
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common';
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
import {
InMemoryControlQueue,
@@ -14,6 +15,104 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it.each([false, true])('reports a fatal lease gate and exits even if reporting fails (%s)', async (reportFails) => {
const now = new Date('2026-09-09T17:30:00Z');
const error = new TurnDaemonLeaseLostError('che:default');
const processor = { run: vi.fn() };
const onRunError = vi.fn(async () => {
if (reportFails) throw new Error('gateway unavailable');
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: new InMemoryControlQueue(),
processor,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
},
hooks: { onRunError },
pauseGate: async () => {
throw error;
},
},
{ profile: 'che', defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 } }
);
await expect(lifecycle.start()).rejects.toBe(error);
expect(onRunError).toHaveBeenCalledExactlyOnceWith(error);
expect(processor.run).not.toHaveBeenCalled();
expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message });
});
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING', 'COMPLETED'] as const)(
'does not dispatch an explicit run while the clock phase is %s',
async (phase) => {
const now = new Date('2026-09-05T00:00:00.000Z');
const controlQueue = new InMemoryControlQueue();
controlQueue.enqueue({ type: 'run', reason: 'manual' });
const processor = { run: vi.fn() };
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue,
processor,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => {
controlQueue.enqueue({ type: 'shutdown' });
return { mode: 'realtime', phase, now };
},
},
},
{
profile: 'clock-phase-run-gate',
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
}
);
await lifecycle.start();
expect(processor.run).not.toHaveBeenCalled();
}
);
it('holds even an explicit run during the recovery wait', async () => {
const now = new Date('2026-09-07T00:24:00Z');
const startsAt = new Date('2026-09-07T00:35:00Z');
const controlQueue = new InMemoryControlQueue();
controlQueue.enqueue({ type: 'run', reason: 'manual' });
const processor = { run: vi.fn() };
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue,
processor,
getNextTickTime: (value) => addMinutes(value, 60),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => {
controlQueue.enqueue({ type: 'shutdown' });
return { mode: 'realtime', phase: 'RUNNING', now, startsAt };
},
},
},
{ profile: 'recovery-wait-gate', defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 } }
);
await lifecycle.start();
expect(processor.run).not.toHaveBeenCalled();
});
it('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime());

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