Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
957a599608 | ||
|
|
73e1743a3d | ||
|
|
3a5d4e7833 | ||
|
|
3f4e5095b5 | ||
|
|
a00f46dfc8 | ||
|
|
9c73085353 | ||
|
|
604b951eff | ||
|
|
09f4e60df4 | ||
|
|
d191960ea1 | ||
|
|
c1fd79da19 | ||
|
|
d8c9a63915 | ||
|
|
c39527bdd7 | ||
|
|
d57fd3e553 | ||
|
|
bdb4e22028 | ||
|
|
b2be86a9a2 | ||
|
|
69f461612d | ||
|
|
9d5447dd3f | ||
|
|
27049dae1f | ||
|
|
09dfe96ff1 | ||
|
|
b14d8d52f6 | ||
|
|
4d64978cb6 | ||
|
|
d3443ec7f5 | ||
|
|
63da0921cb | ||
|
|
6dcc914f94 | ||
|
|
d3b2bcd155 | ||
|
|
ce7d15eba9 | ||
|
|
8488bf1aae | ||
|
|
71d7abcda4 | ||
|
|
332bd9a3dd | ||
|
|
3437cd99f4 | ||
|
|
1d53e6f226 | ||
|
|
0157ff6a6b | ||
|
|
5d220de739 | ||
|
|
07897d9765 | ||
|
|
01fdb51b6e | ||
|
|
746bbe7e7d | ||
|
|
f009c50df5 | ||
|
|
38e516de6d |
@@ -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은 128–4096 token 범위로 제한해 주세요.
|
||||
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
|
||||
로컬 분석을 계속해 주세요.
|
||||
Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
|
||||
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
|
||||
worktree 정리가 요청 범위라면 clean status와
|
||||
`git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
|
||||
이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 삼국지 모의전투 HiDCHe core2026
|
||||
|
||||
`core2026`은 `../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm
|
||||
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작은 ref 구현과 실제 실행
|
||||
결과를 기준으로 검증합니다.
|
||||
`core2026`은 PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
|
||||
발전하는 TypeScript pnpm 모노레포입니다. 계승 영역은 Ref의 동작·상태·권한과
|
||||
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
||||
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
||||
|
||||
## 저장소 구성
|
||||
|
||||
@@ -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의
|
||||
|
||||
@@ -70,6 +70,9 @@ type WorldClockFields =
|
||||
| 'clockTick'
|
||||
| 'clockMode'
|
||||
| 'clockWallAnchor'
|
||||
| 'clockRecoveryStartTick'
|
||||
| 'clockRecoveryEndTick'
|
||||
| 'clockRecoveryStartWallAt'
|
||||
| 'lastTurnTick'
|
||||
| 'clockPhase'
|
||||
| 'clockRevision'
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
|
||||
color: '#000000',
|
||||
};
|
||||
|
||||
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
|
||||
|
||||
export const resolveNationInfo = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number
|
||||
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
|
||||
|
||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`,
|
||||
icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
readModelOutboxPayloadToDiplomacyMailboxes,
|
||||
readModelOutboxPayloadToChanges,
|
||||
readModelOutboxPayloadToMessageMailboxes,
|
||||
type ReadModelDomain,
|
||||
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
||||
'access.general',
|
||||
'dashboard.global',
|
||||
'messages.mailbox',
|
||||
'messages.diplomacyMailbox',
|
||||
'tournament',
|
||||
'betting',
|
||||
]);
|
||||
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
||||
this.db,
|
||||
async (payload) => {
|
||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||
if (mailboxes.length > 0) {
|
||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
||||
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
|
||||
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
|
||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
|
||||
}
|
||||
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||
const changes = readModelOutboxPayloadToChanges(payload);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
|
||||
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
||||
const seen = new Set<string>();
|
||||
return identities.filter((identity) => {
|
||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
|
||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
|
||||
const changes = eventChanges(event);
|
||||
if (!changes) return false;
|
||||
const generalId = identity.generalId;
|
||||
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
|
||||
return [
|
||||
changes.generalIds,
|
||||
changes.mapGeneralIds ?? changes.generalIds,
|
||||
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
|
||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||
);
|
||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
||||
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||
const mailboxes =
|
||||
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
|
||||
const diplomacyMailboxes =
|
||||
event.type === 'messageCreated'
|
||||
? event.msgType === 'diplomacy'
|
||||
? [event.mailbox]
|
||||
: []
|
||||
: (event.diplomacyMailboxes ?? []);
|
||||
return viewers.some(
|
||||
(identity) =>
|
||||
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
|
||||
(identity.canReadDiplomacy &&
|
||||
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||
)
|
||||
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
|
||||
export const publishRealtimeMessageChanges = async (
|
||||
redis: RedisConnector['client'],
|
||||
profileName: string,
|
||||
mailboxes: readonly number[]
|
||||
mailboxes: readonly number[],
|
||||
diplomacyMailboxes: readonly number[] = []
|
||||
): Promise<void> => {
|
||||
if (mailboxes.length === 0) return;
|
||||
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
|
||||
await publishRealtimeEvent(redis, profileName, {
|
||||
type: 'messagesChanged',
|
||||
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||
...(diplomacyMailboxes.length > 0
|
||||
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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']);
|
||||
};
|
||||
|
||||
|
||||
@@ -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자 이하여야 합니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user