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 작업 지침
|
||||||
|
|
||||||
## 적용 범위와 목표
|
## 적용 범위와 문서 역할
|
||||||
|
|
||||||
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운
|
이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
|
||||||
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
|
AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
|
||||||
지침 순으로 적용합니다.
|
보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
|
||||||
|
|
||||||
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
|
상위 지침의 상대 경로는 `sam_rebuild/`, 이 파일의 경로는 `core2026/` 기준입니다.
|
||||||
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
|
독립 checkout에서 상위 작업공간 문서를 참조할 수 없으면 실제 작업공간 위치를
|
||||||
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
|
확인하고, 확인하지 못한 규칙이나 Ref 근거를 읽었다고 보고하지 않습니다.
|
||||||
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
|
|
||||||
|
|
||||||
## 현재 기준과 저장소 경계
|
| 작업 | 상세 기준 |
|
||||||
|
| -------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| 구조·파일 책임 | [README](README.md#저장소-구성), [개발자 핸드북](docs/developer/index.md) |
|
||||||
|
| Ref 비교·계측·분류 | [상위 호환성 계약](../AGENTS.md#호환성-계약), [매핑](../docs/ref-core2026-mapping.md) |
|
||||||
|
| 테스트 준비·실행·오류 복구 | [테스트 정책](docs/testing-policy.md) |
|
||||||
|
| CSS·실제 Chromium 비교 | [CSS 구조](docs/frontend-css-architecture.md), [호환 검증](docs/frontend-legacy-parity.md) |
|
||||||
|
| DB 이관 | [이관 절차](tools/legacy-db-migration/README.md) |
|
||||||
|
| 배포 대상 선택 | [환경 라우팅](../docs/docker-environment-routing.md) |
|
||||||
|
| prefix·빌드 변수 | [Caddy 계약](docs/e2e-caddy-routing.md) |
|
||||||
|
| Gateway·profile 릴리스 | [릴리스 운영](docs/release-operations.md) |
|
||||||
|
|
||||||
- `core2026/`은 제품 구현 저장소입니다. gateway/game frontend·API, game engine,
|
PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
|
||||||
공통 package, Prisma와 검증 도구가 실제로 존재합니다.
|
매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
|
||||||
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`입니다.
|
각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
|
||||||
`devel`은 변경하지 않는 기준선이고 비교 fixture·계측은 `ng_compare`에만
|
|
||||||
두어 주세요.
|
|
||||||
- 아키텍처 매핑과 보고서는 별도 상위 저장소의
|
|
||||||
`../docs/ref-core2026-mapping.md`, `../report/`에 있습니다. 두 Git 경계의
|
|
||||||
commit과 상태를 혼동하지 말아 주세요.
|
|
||||||
- 이미지의 운영 소유자는 외부 Caddy의 `/image/*`입니다. 제품 저장소에 무단
|
|
||||||
복제하거나 앱 경로로 rewrite하지 말아 주세요.
|
|
||||||
|
|
||||||
작업 시작과 종료에 최소한 다음을 확인해 주세요.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git status --short --branch
|
|
||||||
git remote -v
|
|
||||||
git rev-parse HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
상위 매핑/보고서를 바꾸면 상위 저장소에서도 같은 검사를 별도로 수행해 주세요.
|
|
||||||
dirty 파일은 사용자의 변경일 수 있으므로 관련 없는 수정, 정리, stage,
|
|
||||||
commit 또는 삭제를 하지 말아 주세요.
|
|
||||||
|
|
||||||
## 완료 상태를 해석하는 법
|
## 완료 상태를 해석하는 법
|
||||||
|
|
||||||
@@ -55,87 +44,19 @@ commit 또는 삭제를 하지 말아 주세요.
|
|||||||
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
|
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
|
||||||
코드와 commit으로 다시 확인해 주세요.
|
코드와 commit으로 다시 확인해 주세요.
|
||||||
|
|
||||||
## 실제 구조와 책임
|
## 도메인 조립과 비교
|
||||||
|
|
||||||
- `app/gateway-frontend`: 가입·로그인, 로비, 계정, 관리자 운영 UI
|
|
||||||
- `app/gateway-api`: 계정·세션·profile 정책, operation queue와 PM2
|
|
||||||
orchestration
|
|
||||||
- `app/game-frontend`: profile별 게임 SPA와 ref 호환 UI
|
|
||||||
- `app/game-api`: tRPC/SSE, 조회·입력 API와 battle/auction/tournament worker
|
|
||||||
- `app/game-engine`: turn daemon, scheduler, 월간 lifecycle와 DB flush
|
|
||||||
- `packages/common`: 타입, 직렬화, RNG와 공통 유틸리티
|
|
||||||
- `packages/logic`: 전투·명령·월간 action과 typed action module 도메인 로직
|
|
||||||
- `packages/infra`: gateway/game Prisma schema, migration과 client
|
|
||||||
- `tools/integration-tests`: PostgreSQL/Redis 및 ref↔core 차등
|
|
||||||
- `tools/frontend-legacy-parity`: 실제 Chromium 비교
|
|
||||||
- `tools/legacy-db-migration`: 장기보존 데이터 CLI 이관
|
|
||||||
|
|
||||||
`tools/build-scripts/build-server.mjs`는 현재 profile resource 복사
|
|
||||||
placeholder입니다. 이를 완성된 배포 bundle이나 검증된 profile build로 설명하지
|
|
||||||
않습니다. 운영 build/reset/open은 gateway operation, commit별 worktree와
|
|
||||||
orchestrator 실행 경로를 조사해 주세요.
|
|
||||||
|
|
||||||
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
|
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
|
||||||
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
|
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
|
||||||
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`의
|
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`의
|
||||||
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
|
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
|
||||||
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언해
|
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
|
||||||
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md`에
|
상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
|
||||||
있습니다.
|
|
||||||
|
|
||||||
## 레거시 매핑과 비교
|
차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
|
||||||
|
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
|
||||||
기능을 변경하기 전에 다음을 end-to-end로 연결해 주세요.
|
되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
|
||||||
|
따릅니다.
|
||||||
1. ref entry point와 호출 순서
|
|
||||||
2. PHP domain class, SQL read/write, template/CSS/JS와 사용자 출력
|
|
||||||
3. core service/router/action/loader/flush와 frontend 호출부
|
|
||||||
4. session/auth, validation, transaction, 비동기 경계와 오류 복구
|
|
||||||
5. RNG 생성·소비와 persistence 순서
|
|
||||||
|
|
||||||
`../docs/ref-core2026-mapping.md`에는 1:1 대응을 억지로 만들지 말고 1:N/N:1
|
|
||||||
소유권과 변환 지점을 기록해 주세요. `확인`, `부분 확인`, `가설`, `미구현`,
|
|
||||||
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
|
|
||||||
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
|
|
||||||
|
|
||||||
Ref 비교 전에 대상 계약도 구분해 주세요.
|
|
||||||
|
|
||||||
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
|
|
||||||
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
|
|
||||||
- 신규 기능·밸런스·안전·운영·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 권한과 운영 안전은 신규 기능이라는
|
|
||||||
이름만으로 완화하지 않습니다.
|
|
||||||
|
|
||||||
## 인증과 권한
|
## 인증과 권한
|
||||||
|
|
||||||
@@ -182,18 +103,18 @@ API request
|
|||||||
|
|
||||||
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
|
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
|
||||||
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
|
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
|
||||||
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가
|
추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
|
||||||
소비하는 RNG call을 생략하지 말아 주세요.
|
소비하는 RNG call을 생략하지 말아 주세요.
|
||||||
|
|
||||||
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
|
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
|
||||||
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기
|
side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
|
||||||
|
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
|
||||||
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
|
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
|
||||||
|
|
||||||
## 프론트엔드와 룩앤필
|
## 프론트엔드와 룩앤필
|
||||||
|
|
||||||
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는
|
UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
|
||||||
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
|
||||||
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
|
|
||||||
|
|
||||||
- gateway `/gateway/`, game `/che/`와 `/hwe/` prefix에서 direct navigation,
|
- gateway `/gateway/`, game `/che/`와 `/hwe/` prefix에서 direct navigation,
|
||||||
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
|
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
|
||||||
@@ -210,20 +131,10 @@ layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
|||||||
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
||||||
일괄 disable하지 말아 주세요.
|
일괄 disable하지 말아 주세요.
|
||||||
|
|
||||||
UI를 변경하면 실제 Chromium을 사용해 주세요.
|
Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
|
||||||
|
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
|
||||||
- ref와 core에 같은 Chromium, viewport, device scale, zoom, locale, font,
|
|
||||||
image와 로그인/test data를 사용해 주세요.
|
|
||||||
- 전체·영역 screenshot과 `getBoundingClientRect()`,
|
|
||||||
`getComputedStyle()`을 수집해 주세요.
|
|
||||||
- `hover()`, focus, pointer down/up, checked/selected/disabled, dropdown,
|
|
||||||
modal과 transition 상태를 실제 interaction으로 만들어 주세요.
|
|
||||||
- pixel diff mask는 시간·난수 등 불가피한 영역만 최소화하고 이유를 기록해 주세요.
|
|
||||||
- auth redirect만 확인하고 visual parity라고 주장하지 말아 주세요.
|
|
||||||
- 민감정보가 보이는 artifact는 저장하거나 report에 넣지 말아 주세요.
|
|
||||||
|
|
||||||
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
|
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
|
||||||
`docs/frontend-legacy-parity.md`를 따라 주세요.
|
`docs/frontend-legacy-parity.md`를 따릅니다.
|
||||||
|
|
||||||
## DB와 레거시 데이터 이관
|
## DB와 레거시 데이터 이관
|
||||||
|
|
||||||
@@ -263,94 +174,29 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
|
|||||||
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
|
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
|
||||||
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
|
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
|
||||||
|
|
||||||
현재 외부 호스트 계약은 `0.0.0.0` bind와 `/gateway/`, `/che/`, `/hwe/`
|
컨테이너 서비스는 필요한 listener를 `0.0.0.0`에 bind합니다. 로컬 E2E의
|
||||||
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지
|
`/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
|
||||||
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요.
|
대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
|
||||||
|
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
|
||||||
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다.
|
HTTPS 검증과 구분합니다.
|
||||||
|
|
||||||
```sh
|
|
||||||
VITE_APP_BASE_PATH=/gateway \
|
|
||||||
VITE_GATEWAY_API_URL=/gateway/api/trpc \
|
|
||||||
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
|
|
||||||
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
|
|
||||||
pnpm --filter @sammo-ts/gateway-frontend build
|
|
||||||
```
|
|
||||||
|
|
||||||
profile별 정확한 포트와 game frontend/API 변수는
|
|
||||||
`docs/e2e-caddy-routing.md`에서 확인하고 현재 인프라와 대조해 주세요.
|
|
||||||
|
|
||||||
## 개발과 검증 절차
|
## 개발과 검증 절차
|
||||||
|
|
||||||
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요.
|
상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
|
||||||
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요.
|
검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
|
||||||
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다.
|
명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
|
||||||
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요.
|
[테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
|
||||||
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
|
|
||||||
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
|
|
||||||
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
|
|
||||||
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
|
|
||||||
|
|
||||||
기본 정적 검사는 실제 루트 script를 사용해 주세요.
|
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
|
||||||
|
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
|
||||||
```sh
|
`packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
|
||||||
CI=1 pnpm typecheck
|
- Vitest filter에 불필요한 `--`를 넣지 않습니다.
|
||||||
pnpm check:architecture
|
|
||||||
pnpm lint
|
|
||||||
pnpm test
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
루트 `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 뒤에 불필요한 `--`를 넣지 말아 주세요.
|
|
||||||
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
|
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
|
||||||
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요.
|
- frontend package의 `test` placeholder는 UI 검증이 아닙니다. 해당 Playwright
|
||||||
해당 Playwright script 또는 legacy parity suite를 사용해 주세요.
|
script 또는 legacy parity suite를 실행합니다.
|
||||||
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use`
|
- ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
|
||||||
(nvm 환경은 `nvm use`) 후
|
오래된 report만 보고 명령을 가정하지 않습니다.
|
||||||
`pnpm test:bootstrap`을 실행해 install, Prisma client와
|
- 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
|
||||||
common → logic → infra → game-engine → API 산출물을 준비해 주세요. 이미
|
|
||||||
install된 checkout은 `pnpm test:prepare`를 사용합니다. Playwright
|
|
||||||
`webServer` 실패를 이 준비가 끝나기 전 제품 회귀로 분류하지 말아 주세요.
|
|
||||||
- E2E port가 다른 worktree와 겹치면 설정 파일을 임시 수정하지 말고
|
|
||||||
`PLAYWRIGHT_FRONTEND_PORT`, `PLAYWRIGHT_GATEWAY_FRONTEND_PORT` 또는
|
|
||||||
`FRONTEND_PARITY_*_PORT`를 지정합니다. 같은 game frontend `dist`를 쓰는
|
|
||||||
profile production run은 직렬로 실행합니다.
|
|
||||||
- 전체 lint/test의 기존 실패가 있으면 targeted 결과와 baseline 재현 결과를
|
|
||||||
구분하고, 관련 없는 기대값을 완화해 숨기지 말아 주세요.
|
|
||||||
|
|
||||||
외부 서비스 없는 기본 integration:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test:integration
|
|
||||||
```
|
|
||||||
|
|
||||||
전용 PostgreSQL/Redis를 준비한 조건부 전체 경계:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test:integration:conditional
|
|
||||||
```
|
|
||||||
|
|
||||||
ref 명령과 UI entry point:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm check:legacy:general
|
|
||||||
pnpm check:legacy:nation
|
|
||||||
pnpm test:e2e:frontend-legacy
|
|
||||||
```
|
|
||||||
|
|
||||||
각 command가 요구하는 ref checkout, Docker, DB URL, secret과 fixture는 관련
|
|
||||||
docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report나 제안 문서만 보고
|
|
||||||
실행하지 말아 주세요.
|
|
||||||
|
|
||||||
## 코드 스타일
|
## 코드 스타일
|
||||||
|
|
||||||
@@ -374,42 +220,22 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
|
|||||||
|
|
||||||
## 문서와 보고서
|
## 문서와 보고서
|
||||||
|
|
||||||
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요.
|
변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
|
||||||
|
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
|
||||||
|
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
|
||||||
|
|
||||||
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계
|
보고서의 필수 내용과 저장소별 commit 기록은
|
||||||
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약
|
[상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
|
||||||
- `docs/*`: core2026 내부 구현·운영 상세
|
|
||||||
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
|
|
||||||
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
|
|
||||||
|
|
||||||
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
|
|
||||||
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
|
|
||||||
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
|
|
||||||
|
|
||||||
## Git, worktree와 commit
|
## Git, worktree와 commit
|
||||||
|
|
||||||
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다.
|
[상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
|
||||||
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에
|
[기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
|
||||||
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요.
|
충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
|
||||||
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요.
|
않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
|
||||||
- ref 비교 변경과 core 제품 변경, 상위 mapping/report 변경은 각 Git
|
|
||||||
저장소에서 별도 commit해 주세요.
|
|
||||||
- 생성물, `.env`, DB volume, log, coverage, screenshot, test-results와
|
|
||||||
secret을 commit하지 말아 주세요.
|
|
||||||
- worktree 정리는 clean status와
|
|
||||||
`git merge-base --is-ancestor HEAD <baseline>`을 모두 확인한 뒤 non-force
|
|
||||||
제거해 주세요. 이름, 나이 또는 uncommitted 여부만으로 삭제하지 말아 주세요.
|
|
||||||
- 사용자가 commit을 요청하지 않은 일반 작업은 diff와 권장 commit 경계를
|
|
||||||
준비하되 임의로 commit하지 말아 주세요.
|
|
||||||
|
|
||||||
## Qwen 보조 분석
|
Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
|
||||||
|
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
|
||||||
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만
|
worktree 정리가 요청 범위라면 clean status와
|
||||||
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을
|
`git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
|
||||||
보내지 말아 주세요.
|
이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
|
||||||
|
|
||||||
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
|
|
||||||
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
|
|
||||||
- 응답 budget은 128–4096 token 범위로 제한해 주세요.
|
|
||||||
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
|
|
||||||
로컬 분석을 계속해 주세요.
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# 삼국지 모의전투 HiDCHe core2026
|
# 삼국지 모의전투 HiDCHe core2026
|
||||||
|
|
||||||
`core2026`은 `../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm
|
`core2026`은 PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
|
||||||
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작은 ref 구현과 실제 실행
|
발전하는 TypeScript pnpm 모노레포입니다. 계승 영역은 Ref의 동작·상태·권한과
|
||||||
결과를 기준으로 검증합니다.
|
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
||||||
|
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
||||||
|
|
||||||
## 저장소 구성
|
## 저장소 구성
|
||||||
|
|
||||||
@@ -252,9 +253,11 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
|
|||||||
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
|
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
|
||||||
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
|
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
|
||||||
|
|
||||||
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한
|
활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
|
||||||
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE,
|
`/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
|
||||||
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다.
|
[환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
|
||||||
|
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
|
||||||
|
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
|
||||||
|
|
||||||
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
|
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
|
||||||
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
|
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ type WorldClockFields =
|
|||||||
| 'clockTick'
|
| 'clockTick'
|
||||||
| 'clockMode'
|
| 'clockMode'
|
||||||
| 'clockWallAnchor'
|
| 'clockWallAnchor'
|
||||||
|
| 'clockRecoveryStartTick'
|
||||||
|
| 'clockRecoveryEndTick'
|
||||||
|
| 'clockRecoveryStartWallAt'
|
||||||
| 'lastTurnTick'
|
| 'lastTurnTick'
|
||||||
| 'clockPhase'
|
| 'clockPhase'
|
||||||
| 'clockRevision'
|
| 'clockRevision'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GameApiContext, WorldStateRow } from '../context.js';
|
import type { GameApiContext, WorldStateRow } from '../context.js';
|
||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||||
|
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
||||||
|
|
||||||
export type MapCityCompact = [number, number, number, number, number, number];
|
export type MapCityCompact = [number, number, number, number, number, number];
|
||||||
@@ -16,6 +17,7 @@ export type BaseMapResult = {
|
|||||||
initialLevel: number;
|
initialLevel: number;
|
||||||
increaseYears: number;
|
increaseYears: number;
|
||||||
};
|
};
|
||||||
|
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
|
||||||
cityList: MapCityCompact[];
|
cityList: MapCityCompact[];
|
||||||
nationList: MapNationCompact[];
|
nationList: MapNationCompact[];
|
||||||
};
|
};
|
||||||
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
|
|||||||
const BASE_MAP_TTL_SECONDS = 30;
|
const BASE_MAP_TTL_SECONDS = 30;
|
||||||
const PUBLIC_MAP_TTL_SECONDS = 600;
|
const PUBLIC_MAP_TTL_SECONDS = 600;
|
||||||
|
|
||||||
const resolveStartYear = (worldState: WorldStateRow): number => {
|
const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||||
const startYear = scenarioMeta.startYear;
|
const startYear = scenarioMeta.startYear;
|
||||||
@@ -87,6 +89,28 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다.
|
||||||
|
export const resolveMapUniqueItemLimit = (
|
||||||
|
worldState: Pick<WorldStateRow, 'config' | 'meta' | 'currentYear'>
|
||||||
|
): BaseMapResult['uniqueItemLimit'] => {
|
||||||
|
const config = resolveUniqueConfig(asRecord(asRecord(worldState.config).const));
|
||||||
|
const startYear = resolveStartYear(worldState);
|
||||||
|
const relativeYear = worldState.currentYear - startYear;
|
||||||
|
const slotCount = Object.keys(config.allItems).length;
|
||||||
|
let count = Math.min(1, slotCount);
|
||||||
|
for (const [targetYear, targetCount] of config.maxUniqueItemLimit) {
|
||||||
|
const nextCount = Math.min(targetCount, slotCount);
|
||||||
|
if (relativeYear < targetYear) {
|
||||||
|
if (nextCount !== count) {
|
||||||
|
return { count, until: { year: startYear + targetYear - 1, month: 12 } };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
count = nextCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { count, until: null };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
|
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -154,7 +178,10 @@ const loadBaseMap = async (
|
|||||||
}
|
}
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(cached) as BaseMapResult;
|
const parsed = JSON.parse(cached) as BaseMapResult;
|
||||||
|
if (parsed.uniqueItemLimit) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cache parse errors.
|
// Ignore cache parse errors.
|
||||||
}
|
}
|
||||||
@@ -207,6 +234,7 @@ const loadBaseMap = async (
|
|||||||
year: worldState.currentYear,
|
year: worldState.currentYear,
|
||||||
month: worldState.currentMonth,
|
month: worldState.currentMonth,
|
||||||
techLevelLimit: resolveTechLevelLimit(worldState),
|
techLevelLimit: resolveTechLevelLimit(worldState),
|
||||||
|
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
|
||||||
cityList,
|
cityList,
|
||||||
nationList,
|
nationList,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,10 +57,17 @@ const formatMessageTime = (value: Date): string => {
|
|||||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
||||||
const payload = parsePayload(row.message);
|
const payload = parsePayload(row.message);
|
||||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
||||||
const actionUnavailable =
|
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
|
||||||
actionStatus !== null &&
|
const actionState =
|
||||||
(actionStatus !== 'PENDING' ||
|
actionStatus === null
|
||||||
(row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick));
|
? null
|
||||||
|
: actionStatus === 'PENDING'
|
||||||
|
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
|
||||||
|
? 'expired'
|
||||||
|
: 'pending'
|
||||||
|
: actionStatus === 'RESOLVED'
|
||||||
|
? 'resolved'
|
||||||
|
: 'unavailable';
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
@@ -68,8 +75,8 @@ const toMessageView = (row: MessageRow, currentGameTick: bigint | null): Message
|
|||||||
dest: row.type === 'public' ? null : payload.dest,
|
dest: row.type === 'public' ? null : payload.dest,
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
option:
|
option:
|
||||||
actionUnavailable && payload.option && typeof payload.option === 'object'
|
actionState !== null && payload.option && typeof payload.option === 'object'
|
||||||
? { ...payload.option, used: true, invalid: true }
|
? { ...payload.option, actionState, used: actionState !== 'pending' }
|
||||||
: (payload.option ?? null),
|
: (payload.option ?? null),
|
||||||
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
|
|||||||
color: '#000000',
|
color: '#000000',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
|
|
||||||
|
|
||||||
export const resolveNationInfo = async (
|
export const resolveNationInfo = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number
|
nationId: number
|
||||||
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
|
|||||||
|
|
||||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||||
const nation = await resolveNationInfo(db, general.nationId);
|
const nation = await resolveNationInfo(db, general.nationId);
|
||||||
const picture = general.picture?.trim() || 'default.jpg';
|
|
||||||
return {
|
return {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`,
|
icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
|
readModelOutboxPayloadToDiplomacyMailboxes,
|
||||||
readModelOutboxPayloadToChanges,
|
readModelOutboxPayloadToChanges,
|
||||||
readModelOutboxPayloadToMessageMailboxes,
|
readModelOutboxPayloadToMessageMailboxes,
|
||||||
type ReadModelDomain,
|
type ReadModelDomain,
|
||||||
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
|||||||
'access.general',
|
'access.general',
|
||||||
'dashboard.global',
|
'dashboard.global',
|
||||||
'messages.mailbox',
|
'messages.mailbox',
|
||||||
|
'messages.diplomacyMailbox',
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
]);
|
]);
|
||||||
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
|||||||
this.db,
|
this.db,
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||||
if (mailboxes.length > 0) {
|
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
|
||||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
|
||||||
|
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
|
||||||
}
|
}
|
||||||
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||||
const changes = readModelOutboxPayloadToChanges(payload);
|
const changes = readModelOutboxPayloadToChanges(payload);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
|
|||||||
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return identities.filter((identity) => {
|
return identities.filter((identity) => {
|
||||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
|
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
|
|||||||
const changes = eventChanges(event);
|
const changes = eventChanges(event);
|
||||||
if (!changes) return false;
|
if (!changes) return false;
|
||||||
const generalId = identity.generalId;
|
const generalId = identity.generalId;
|
||||||
|
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
|
||||||
return [
|
return [
|
||||||
changes.generalIds,
|
changes.generalIds,
|
||||||
changes.mapGeneralIds ?? changes.generalIds,
|
changes.mapGeneralIds ?? changes.generalIds,
|
||||||
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
|
|||||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||||
);
|
);
|
||||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||||
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
const mailboxes =
|
||||||
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
|
||||||
|
const diplomacyMailboxes =
|
||||||
|
event.type === 'messageCreated'
|
||||||
|
? event.msgType === 'diplomacy'
|
||||||
|
? [event.mailbox]
|
||||||
|
: []
|
||||||
|
: (event.diplomacyMailboxes ?? []);
|
||||||
|
return viewers.some(
|
||||||
|
(identity) =>
|
||||||
|
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
|
||||||
|
(identity.canReadDiplomacy &&
|
||||||
|
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||||
|
)
|
||||||
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
|
|||||||
export const publishRealtimeMessageChanges = async (
|
export const publishRealtimeMessageChanges = async (
|
||||||
redis: RedisConnector['client'],
|
redis: RedisConnector['client'],
|
||||||
profileName: string,
|
profileName: string,
|
||||||
mailboxes: readonly number[]
|
mailboxes: readonly number[],
|
||||||
|
diplomacyMailboxes: readonly number[] = []
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (mailboxes.length === 0) return;
|
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
|
||||||
await publishRealtimeEvent(redis, profileName, {
|
await publishRealtimeEvent(redis, profileName, {
|
||||||
type: 'messagesChanged',
|
type: 'messagesChanged',
|
||||||
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||||
|
...(diplomacyMailboxes.length > 0
|
||||||
|
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
|
|||||||
await sendMessage(store, { ...draft, msgType: 'national' });
|
await sendMessage(store, { ...draft, msgType: 'national' });
|
||||||
}
|
}
|
||||||
|
|
||||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
|
||||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||||
|
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { z } from 'zod';
|
|||||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||||
import {
|
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
GamePrisma,
|
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
import {
|
import {
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
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 inheritConst = resolveInheritConstants(worldState);
|
||||||
const inheritTotalPoint = ctx.auth?.user.id
|
const inheritTotalPoint = ctx.auth?.user.id
|
||||||
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
||||||
@@ -376,6 +377,7 @@ export const joinRouter = router({
|
|||||||
npcGeneralCount,
|
npcGeneralCount,
|
||||||
},
|
},
|
||||||
inherit: {
|
inherit: {
|
||||||
|
enabled: history?.status !== 'COMPLETED',
|
||||||
totalPoint: inheritTotalPoint,
|
totalPoint: inheritTotalPoint,
|
||||||
costs: {
|
costs: {
|
||||||
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
||||||
@@ -595,10 +597,10 @@ export const joinRouter = router({
|
|||||||
message: 'World state is not initialized.',
|
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({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const worldState = await transaction.worldState.findFirst();
|
const worldState = await transaction.worldState.findFirst();
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
|
|||||||
clockMode: gameTime.mode ?? 'realtime',
|
clockMode: gameTime.mode ?? 'realtime',
|
||||||
clockRunning: gameTime.running,
|
clockRunning: gameTime.running,
|
||||||
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||||
|
clockRecovery: gameTime.recovery ?? null,
|
||||||
turnEngineRunning,
|
turnEngineRunning,
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
npcMode: worldState.config.npcMode ?? 0,
|
npcMode: worldState.config.npcMode ?? 0,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
|
|||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
|
option: { ...(message.option ?? {}), permissionRedacted: true },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -76,9 +77,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
|||||||
return value === true || value === 1 || value === '1';
|
return value === true || value === 1 || value === '1';
|
||||||
};
|
};
|
||||||
|
|
||||||
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
|
const markMessageMailboxes = (
|
||||||
|
ctx: Pick<GameApiContext, 'changeJournal'>,
|
||||||
|
mailboxes: Iterable<number>,
|
||||||
|
msgType?: MessageType
|
||||||
|
): void => {
|
||||||
|
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
|
||||||
for (const mailbox of mailboxes) {
|
for (const mailbox of mailboxes) {
|
||||||
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
ctx.changeJournal?.mark(domain, mailbox);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,7 +321,11 @@ export const messagesRouter = router({
|
|||||||
message.msgType === 'national'
|
message.msgType === 'national'
|
||||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||||
: null;
|
: null;
|
||||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
markMessageMailboxes(
|
||||||
|
ctx,
|
||||||
|
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
|
||||||
|
message.msgType
|
||||||
|
);
|
||||||
return { ok: true, deletedIds };
|
return { ok: true, deletedIds };
|
||||||
}),
|
}),
|
||||||
respond: engineAuthedProcedure
|
respond: engineAuthedProcedure
|
||||||
@@ -333,6 +343,9 @@ export const messagesRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||||
}
|
}
|
||||||
const action = message.payload.option?.action;
|
const action = message.payload.option?.action;
|
||||||
|
if (message.payload.option?.invalid === true) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
||||||
|
}
|
||||||
if (action === 'scout' || action === 'raiseInvader') {
|
if (action === 'scout' || action === 'raiseInvader') {
|
||||||
if (!ctx.auth) {
|
if (!ctx.auth) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
@@ -620,7 +633,7 @@ export const messagesRouter = router({
|
|||||||
: msgType === 'private'
|
: msgType === 'private'
|
||||||
? general.id
|
? general.id
|
||||||
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
|
||||||
|
|
||||||
return { msgType, msgId: result.receiverId };
|
return { msgType, msgId: result.receiverId };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
|||||||
name: general.name,
|
name: general.name,
|
||||||
npcState: general.npcState,
|
npcState: general.npcState,
|
||||||
injury: general.injury,
|
injury: general.injury,
|
||||||
|
baseStats: {
|
||||||
|
leadership: general.leadership,
|
||||||
|
strength: general.strength,
|
||||||
|
intelligence: general.intel,
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
leadership: woundedStat(general.leadership, general.injury),
|
leadership: woundedStat(general.leadership, general.injury),
|
||||||
strength: woundedStat(general.strength, general.injury),
|
strength: woundedStat(general.strength, general.injury),
|
||||||
|
|||||||
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveCommandEnv = (
|
const resolveCommandEnv = (
|
||||||
config: Record<string, unknown>
|
config: Record<string, unknown>,
|
||||||
|
worldMeta: Record<string, unknown>
|
||||||
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
||||||
const constValues = asRecord(config.const ?? config.consts);
|
const constValues = asRecord(config.const ?? config.consts);
|
||||||
|
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||||
return {
|
return {
|
||||||
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
develCost: resolveNumberFromKeys(
|
||||||
|
worldMeta,
|
||||||
|
['develcost', 'develCost', 'develrate'],
|
||||||
|
configuredDevelCost
|
||||||
|
),
|
||||||
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
||||||
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
||||||
};
|
};
|
||||||
@@ -409,7 +415,7 @@ export const npcRouter = router({
|
|||||||
|
|
||||||
const config = asRecord(worldState.config);
|
const config = asRecord(worldState.config);
|
||||||
const stat = resolveScenarioStat(config);
|
const stat = resolveScenarioStat(config);
|
||||||
const env = resolveCommandEnv(config);
|
const env = resolveCommandEnv(config, worldMeta);
|
||||||
const unitSetName = resolveUnitSetName(config, 'che');
|
const unitSetName = resolveUnitSetName(config, 'che');
|
||||||
const nationTech = readNumber(nation.tech, 0);
|
const nationTech = readNumber(nation.tech, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { TournamentState } from '../../tournament/types.js';
|
|||||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
||||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||||
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
|
import { 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);
|
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
const adminProcedure = engineAuthedProcedure
|
||||||
const roles = ctx.auth?.user.roles ?? [];
|
.use(({ ctx, next }) => {
|
||||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
const roles = ctx.auth?.user.roles ?? [];
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||||
}
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
||||||
return next();
|
}
|
||||||
});
|
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>(
|
const withTournamentClockMutation = async <T>(
|
||||||
ctx: {
|
ctx: {
|
||||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
db: Parameters<typeof loadCurrentGameTime>[0];
|
||||||
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
|
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
|
||||||
profile: { name: string };
|
profile: { name: string };
|
||||||
|
tournamentMutationLockHeld?: boolean;
|
||||||
},
|
},
|
||||||
store: TournamentStore,
|
store: TournamentStore,
|
||||||
operation: () => Promise<T>
|
operation: () => Promise<T>
|
||||||
@@ -69,7 +79,9 @@ const withTournamentClockMutation = async <T>(
|
|||||||
deadlineGeneration: fence.generation,
|
deadlineGeneration: fence.generation,
|
||||||
dateToTick: gameTime.dateToTick,
|
dateToTick: gameTime.dateToTick,
|
||||||
};
|
};
|
||||||
return store.withClockContext(clockContext, () => store.withMutationLock(operation));
|
return store.withClockContext(clockContext, () =>
|
||||||
|
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const withTournamentBetClockMutation = async <T>(
|
const withTournamentBetClockMutation = async <T>(
|
||||||
@@ -103,6 +115,9 @@ const withTournamentBetClockMutation = async <T>(
|
|||||||
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
||||||
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
|
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
|
||||||
|
|
||||||
|
const tournamentJoinCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
||||||
|
requestId ? `${requestId}:tournamentJoin:${step}` : undefined;
|
||||||
|
|
||||||
const zTournamentState = z.object({
|
const zTournamentState = z.object({
|
||||||
stage: z.number().int().min(0),
|
stage: z.number().int().min(0),
|
||||||
phase: 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 };
|
return { state, totals, myTotals, totalAmount, myAmount };
|
||||||
}),
|
}),
|
||||||
join: authedProcedure.mutation(async ({ ctx }) => {
|
// 참가비는 ENGINE transaction이 차감한다. API input-event transaction으로
|
||||||
|
// 감싸면 clock advisory lock을 쥔 채 child ENGINE event를 기다리게 된다.
|
||||||
|
join: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return withTournamentClockMutation(ctx, store, async () => {
|
||||||
@@ -476,6 +493,7 @@ export const tournamentRouter = router({
|
|||||||
const develCost = resolveCurrentDevelCost(worldState);
|
const develCost = resolveCurrentDevelCost(worldState);
|
||||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||||
reason: 'tournamentJoin',
|
reason: 'tournamentJoin',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||||
});
|
});
|
||||||
@@ -511,6 +529,7 @@ export const tournamentRouter = router({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.turnDaemon.requestCommand({
|
await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||||
reason: 'tournamentJoinRollback',
|
reason: 'tournamentJoinRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -224,7 +224,10 @@ export const troopRouter = router({
|
|||||||
name: troop.name,
|
name: troop.name,
|
||||||
nationId: troop.nationId,
|
nationId: troop.nationId,
|
||||||
turnTime: leader?.turnTime.toISOString() ?? null,
|
turnTime: leader?.turnTime.toISOString() ?? null,
|
||||||
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [],
|
reservedCommands:
|
||||||
|
leader?.npcState === 5
|
||||||
|
? ['집합', '집합', '집합', '집합', '집합']
|
||||||
|
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
|
||||||
leader: leader
|
leader: leader
|
||||||
? {
|
? {
|
||||||
id: leader.id,
|
id: leader.id,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
assertReservedTurnActionAvailable,
|
assertReservedTurnActionAvailable,
|
||||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
|
loadEquipmentTradeItemOrder,
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
type TurnCommandInputOptions,
|
type TurnCommandInputOptions,
|
||||||
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
traits,
|
traits,
|
||||||
moduleBundle,
|
moduleBundle,
|
||||||
map,
|
map,
|
||||||
|
itemOrder,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
general.cityId > 0
|
general.cityId > 0
|
||||||
? ctx.db.city.findUnique({
|
? ctx.db.city.findUnique({
|
||||||
@@ -310,6 +312,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
officerLevel: true,
|
officerLevel: true,
|
||||||
gold: true,
|
gold: true,
|
||||||
rice: true,
|
rice: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
crew: true,
|
crew: true,
|
||||||
train: true,
|
train: true,
|
||||||
atmos: true,
|
atmos: true,
|
||||||
@@ -327,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
loadBattleSimTraitOptions(),
|
loadBattleSimTraitOptions(),
|
||||||
moduleBundlePromise,
|
moduleBundlePromise,
|
||||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||||
|
loadEquipmentTradeItemOrder(worldState.scenarioCode),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||||
@@ -380,10 +386,17 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
||||||
});
|
});
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
itemOrder,
|
||||||
configConst: asRecord(asRecord(worldState.config).const),
|
configConst: asRecord(asRecord(worldState.config).const),
|
||||||
itemModules: moduleBundle.itemModules,
|
itemModules: moduleBundle.itemModules,
|
||||||
currentSecurity: city?.security ?? 0,
|
currentSecurity: city?.security ?? 0,
|
||||||
generalGold: general.gold,
|
generalGold: general.gold,
|
||||||
|
ownedItems: {
|
||||||
|
horse: general.horseCode,
|
||||||
|
weapon: general.weaponCode,
|
||||||
|
book: general.bookCode,
|
||||||
|
item: general.itemCode,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
cities: cities.map((entry) => ({
|
cities: cities.map((entry) => ({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||||
@@ -102,10 +102,7 @@ export const hasPollEnded = (
|
|||||||
time: CurrentGameTime
|
time: CurrentGameTime
|
||||||
): boolean =>
|
): boolean =>
|
||||||
Boolean(poll.closed_at) ||
|
Boolean(poll.closed_at) ||
|
||||||
Boolean(
|
Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
|
||||||
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 => {
|
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
@@ -290,7 +287,9 @@ export const voteRouter = router({
|
|||||||
userCnt,
|
userCnt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
submitVote: authedProcedure
|
// 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
|
||||||
|
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
|
||||||
|
submitVote: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
voteId: z.number().int().positive(),
|
voteId: z.number().int().positive(),
|
||||||
@@ -366,7 +365,6 @@ export const voteRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.changeJournal?.mark('front.general', general.id);
|
|
||||||
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
||||||
}),
|
}),
|
||||||
addComment: authedProcedure
|
addComment: authedProcedure
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
shouldReloadRealtimeViewerIdentity,
|
shouldReloadRealtimeViewerIdentity,
|
||||||
toPublicRealtimeEvent,
|
toPublicRealtimeEvent,
|
||||||
} from './realtime/publicEvent.js';
|
} from './realtime/publicEvent.js';
|
||||||
|
import { resolveNationPermission } from './router/nation/shared.js';
|
||||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||||
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||||
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
||||||
@@ -313,11 +314,24 @@ export const createGameApiServer = async () => {
|
|||||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||||
const general = await postgres.prisma.general.findFirst({
|
const general = await postgres.prisma.general.findFirst({
|
||||||
where: { userId: auth.user.id, npcState: 0 },
|
where: { userId: auth.user.id, npcState: 0 },
|
||||||
select: { id: true, cityId: true, nationId: true },
|
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||||
});
|
});
|
||||||
return general
|
if (!general) {
|
||||||
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
|
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||||
: { generalId: null, cityId: null, nationId: null };
|
}
|
||||||
|
const nation =
|
||||||
|
general.nationId > 0
|
||||||
|
? await postgres.prisma.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
generalId: general.id,
|
||||||
|
cityId: general.cityId,
|
||||||
|
nationId: general.nationId,
|
||||||
|
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
let viewerIdentity = await loadViewerIdentity();
|
let viewerIdentity = await loadViewerIdentity();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
GameClock,
|
GameClock,
|
||||||
|
readTurnRecovery,
|
||||||
inferClockPhase,
|
inferClockPhase,
|
||||||
parseGameClockPhase,
|
parseGameClockPhase,
|
||||||
type GameClockMode,
|
type GameClockMode,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
} from '@sammo-ts/common';
|
} from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
import type { DatabaseClient } from '../context.js';
|
||||||
|
import { readTurnRuntimeReady } from '@sammo-ts/infra';
|
||||||
|
|
||||||
export interface CurrentGameTime {
|
export interface CurrentGameTime {
|
||||||
now: Date;
|
now: Date;
|
||||||
@@ -17,6 +19,8 @@ export interface CurrentGameTime {
|
|||||||
revision?: number | null;
|
revision?: number | null;
|
||||||
deadlineGeneration?: number | null;
|
deadlineGeneration?: number | null;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
runtimeReady?: boolean;
|
||||||
|
recovery?: { startsAt: string; endsAt: string } | null;
|
||||||
startsAt: Date | null;
|
startsAt: Date | null;
|
||||||
dateToTick(date: Date): number | null;
|
dateToTick(date: Date): number | null;
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
clockTick: true,
|
clockTick: true,
|
||||||
clockMode: true,
|
clockMode: true,
|
||||||
clockWallAnchor: true,
|
clockWallAnchor: true,
|
||||||
|
clockRecoveryStartTick: true,
|
||||||
|
clockRecoveryEndTick: true,
|
||||||
|
clockRecoveryStartWallAt: true,
|
||||||
tickSeconds: true,
|
tickSeconds: true,
|
||||||
clockPhase: true,
|
clockPhase: true,
|
||||||
clockRevision: true,
|
clockRevision: true,
|
||||||
@@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
tick: storedTick,
|
tick: storedTick,
|
||||||
mode,
|
mode,
|
||||||
wallAnchor: state.clockWallAnchor,
|
wallAnchor: state.clockWallAnchor,
|
||||||
|
recovery: readTurnRecovery(state),
|
||||||
turnSeconds: state.tickSeconds,
|
turnSeconds: state.tickSeconds,
|
||||||
phase,
|
phase,
|
||||||
revision,
|
revision,
|
||||||
});
|
});
|
||||||
const tick = clock.nowTick(wallNow);
|
const runtimeReady =
|
||||||
const running = phase === 'RUNNING' && mode === 'realtime';
|
phase !== 'RUNNING' ||
|
||||||
|
mode !== 'realtime' ||
|
||||||
|
state.clockRecoveryStartTick === undefined ||
|
||||||
|
(await readTurnRuntimeReady(db, state.clockRevision));
|
||||||
|
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
|
||||||
|
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
|
||||||
return {
|
return {
|
||||||
now: clock.tickToDate(tick),
|
now: clock.tickToDate(tick),
|
||||||
wallNow,
|
wallNow,
|
||||||
@@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
revision,
|
revision,
|
||||||
deadlineGeneration,
|
deadlineGeneration,
|
||||||
running,
|
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),
|
dateToTick: (date) => clock.dateToTick(date),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const ensureRedisClockFence = async (
|
|||||||
allowedPhases: readonly GameClockPhase[]
|
allowedPhases: readonly GameClockPhase[]
|
||||||
): Promise<ActiveRedisClockFence | null> => {
|
): Promise<ActiveRedisClockFence | null> => {
|
||||||
if (
|
if (
|
||||||
|
gameTime.runtimeReady === false ||
|
||||||
!gameTime.phase ||
|
!gameTime.phase ||
|
||||||
!allowedPhases.includes(gameTime.phase) ||
|
!allowedPhases.includes(gameTime.phase) ||
|
||||||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
|
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
|
||||||
@@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async (
|
|||||||
profileName: string,
|
profileName: string,
|
||||||
gameTime: CurrentGameTime
|
gameTime: CurrentGameTime
|
||||||
): Promise<ActiveRedisClockFence | null> => {
|
): Promise<ActiveRedisClockFence | null> => {
|
||||||
|
if (!gameTime.running) return null;
|
||||||
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
|
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 type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||||
|
|
||||||
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export interface TurnCommandInputField {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
legacyWidthMax?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
defaultValue?: TurnCommandOptionValue | boolean;
|
defaultValue?: TurnCommandOptionValue | boolean;
|
||||||
constValue?: TurnCommandOptionValue;
|
constValue?: TurnCommandOptionValue;
|
||||||
@@ -119,22 +121,60 @@ const plainLegacyInfo = (value: string): string =>
|
|||||||
.replace(/\s+/gu, ' ')
|
.replace(/\s+/gu, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
|
||||||
|
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
|
||||||
|
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
|
||||||
|
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const scenario = await loadScenarioDefinitionById(Number(normalized));
|
||||||
|
const configConst = scenario.config.const;
|
||||||
|
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
|
||||||
|
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
|
||||||
|
} catch (error) {
|
||||||
|
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
|
||||||
|
if (isRecord(error) && error.code === 'ENOENT') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const buildEquipmentTradeItemOptions = (options: {
|
export const buildEquipmentTradeItemOptions = (options: {
|
||||||
configConst: Record<string, unknown>;
|
configConst: Record<string, unknown>;
|
||||||
|
itemOrder?: readonly string[];
|
||||||
itemModules: readonly EquipmentTradeItemModule[];
|
itemModules: readonly EquipmentTradeItemModule[];
|
||||||
currentSecurity: number;
|
currentSecurity: number;
|
||||||
generalGold: number;
|
generalGold: number;
|
||||||
|
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
|
||||||
}): TurnCommandInputOptions['items'] => {
|
}): TurnCommandInputOptions['items'] => {
|
||||||
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
|
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
|
||||||
const items: TurnCommandInputOptions['items'] = {
|
const items: TurnCommandInputOptions['items'] = {};
|
||||||
horse: [{ value: 'None', label: '판매/해제' }],
|
const catalog = new Map(options.itemModules.map((item) => [item.key, item]));
|
||||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
// Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다.
|
||||||
book: [{ value: 'None', label: '판매/해제' }],
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
item: [{ value: 'None', label: '판매/해제' }],
|
const ownedCode = options.ownedItems[slot];
|
||||||
};
|
const ownedItem = ownedCode && ownedCode !== 'None' ? catalog.get(ownedCode) : undefined;
|
||||||
|
const slotName = STATIC_LABELS.itemType?.[slot] ?? slot;
|
||||||
|
items[slot] = [
|
||||||
|
{
|
||||||
|
value: 'None',
|
||||||
|
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
||||||
|
description: ownedItem
|
||||||
|
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
||||||
|
: ownedCode && ownedCode !== 'None'
|
||||||
|
? '보유 장비 정보를 확인할 수 없습니다.'
|
||||||
|
: '현재 보유한 장비가 없습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of options.itemModules) {
|
// 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
|
||||||
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
|
const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
|
||||||
|
for (const key of orderedKeys) {
|
||||||
|
const item = catalog.get(key);
|
||||||
|
if (!item?.buyable || !purchasableItemKeys.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cost = item.cost ?? 0;
|
const cost = item.cost ?? 0;
|
||||||
@@ -328,6 +368,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
|
|||||||
required,
|
required,
|
||||||
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
|
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
|
||||||
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
|
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
|
||||||
|
legacyWidthMax: key === 'nationName' ? 18 : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +606,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
|
|||||||
getLegacyStringWidth(nationName) < 1 ||
|
getLegacyStringWidth(nationName) < 1 ||
|
||||||
getLegacyStringWidth(nationName) > 18)
|
getLegacyStringWidth(nationName) > 18)
|
||||||
) {
|
) {
|
||||||
throwLegacyBasicTurnArgError();
|
throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface GeneralTargetSource {
|
|||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
gold?: number;
|
gold?: number;
|
||||||
rice?: number;
|
rice?: number;
|
||||||
|
leadership?: number;
|
||||||
|
strength?: number;
|
||||||
|
intel?: number;
|
||||||
crew?: number;
|
crew?: number;
|
||||||
train?: number;
|
train?: number;
|
||||||
atmos?: number;
|
atmos?: number;
|
||||||
@@ -64,7 +67,7 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||||
const label = (() => {
|
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})`;
|
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
||||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||||
})();
|
})();
|
||||||
@@ -80,13 +83,43 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
? `탑승 부대 ${troopLabel}`
|
? `탑승 부대 ${troopLabel}`
|
||||||
: '탑승 부대 없음',
|
: '탑승 부대 없음',
|
||||||
].filter((value): value is string => Boolean(value));
|
].filter((value): value is string => Boolean(value));
|
||||||
|
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
||||||
|
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
||||||
|
const assignmentDetails =
|
||||||
|
action === 'che_발령'
|
||||||
|
? [
|
||||||
|
entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음',
|
||||||
|
[
|
||||||
|
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
||||||
|
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
||||||
|
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
[
|
||||||
|
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||||
|
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||||
|
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
[
|
||||||
|
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||||
|
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · '),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
: undefined;
|
||||||
if (isTroopExit) {
|
if (isTroopExit) {
|
||||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
label,
|
label,
|
||||||
description: details.join(' · '),
|
description: assignmentDetails ?? details.join(' · '),
|
||||||
...(availableNow === undefined ? {} : { availableNow }),
|
...(availableNow === undefined ? {} : { availableNow }),
|
||||||
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
||||||
...(entry.rice === undefined ? {} : { rice: entry.rice }),
|
...(entry.rice === undefined ? {} : { rice: entry.rice }),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
|
loadEquipmentTradeItemOrder,
|
||||||
buildTurnCommandInputFields,
|
buildTurnCommandInputFields,
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
sanitizeReservedTurnArgs,
|
sanitizeReservedTurnArgs,
|
||||||
@@ -112,12 +113,22 @@ describe('turn command argument input', () => {
|
|||||||
).toThrow('턴이 입력되지 않았습니다.');
|
).toThrow('턴이 입력되지 않았습니다.');
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
|
||||||
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
|
||||||
|
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
|
||||||
|
);
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
|
||||||
'턴이 입력되지 않았습니다.'
|
'턴이 입력되지 않았습니다.'
|
||||||
);
|
);
|
||||||
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
|
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the Ref fullwidth nation-name limit to command input clients', async () => {
|
||||||
|
const specs = await loadGeneralTurnCommandSpecs(['che_건국']);
|
||||||
|
expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
|
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
|
||||||
expect(
|
expect(
|
||||||
sanitizeReservedTurnArgs({
|
sanitizeReservedTurnArgs({
|
||||||
@@ -212,20 +223,109 @@ describe('turn command argument input', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves scenario order while filtering unavailable catalog entries in every slot', () => {
|
||||||
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
configConst: {
|
||||||
|
allItems: { [slot]: { z_last_code: 0, missing: 0, unique: 1, blocked: 0, a_first_code: -1 } },
|
||||||
|
},
|
||||||
|
itemModules: [
|
||||||
|
{ ...buildShopItem('a_first_code', '첫 코드'), slot },
|
||||||
|
{ ...buildShopItem('z_last_code', '마지막 코드'), slot },
|
||||||
|
{ ...buildShopItem('unique', '유니크'), slot },
|
||||||
|
{ ...buildShopItem('blocked', '비매품'), slot, buyable: false },
|
||||||
|
],
|
||||||
|
currentSecurity: 5000,
|
||||||
|
generalGold: 1000,
|
||||||
|
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
});
|
||||||
|
expect(items[slot].map((item) => item.value)).toEqual(['None', 'z_last_code', 'a_first_code']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores original scenario order after DB key reordering without restoring removed purchase permissions', async () => {
|
||||||
|
const scenario = await loadScenarioDefinitionById(2701);
|
||||||
|
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
|
||||||
|
const originalKeys = Object.keys(pool.item!);
|
||||||
|
const buyableKeys = originalKeys.filter((key) => pool.item![key]! <= 0);
|
||||||
|
const removedKey = buyableKeys[1]!;
|
||||||
|
const reorderedPool = Object.fromEntries(Object.entries(pool.item!).reverse());
|
||||||
|
reorderedPool[removedKey] = 1;
|
||||||
|
reorderedPool.runtime_added = 0;
|
||||||
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
configConst: { allItems: { item: reorderedPool } },
|
||||||
|
itemOrder: await loadEquipmentTradeItemOrder('scenario_2701.json'),
|
||||||
|
itemModules: [...buyableKeys]
|
||||||
|
.reverse()
|
||||||
|
.concat('runtime_added')
|
||||||
|
.map((key) => buildShopItem(key, key)),
|
||||||
|
currentSecurity: 5000,
|
||||||
|
generalGold: 1000,
|
||||||
|
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
});
|
||||||
|
expect(items.item.map((item) => item.value)).toEqual([
|
||||||
|
'None',
|
||||||
|
...buyableKeys.filter((key) => key !== removedKey),
|
||||||
|
'runtime_added',
|
||||||
|
]);
|
||||||
|
expect((await loadEquipmentTradeItemOrder('1'))?.filter((key) => key.startsWith('che_치료'))).toEqual([
|
||||||
|
'che_치료_환약',
|
||||||
|
]);
|
||||||
|
expect(await loadEquipmentTradeItemOrder('unknown')).toBeUndefined();
|
||||||
|
expect(await loadEquipmentTradeItemOrder('999999')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
|
||||||
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
|
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
|
||||||
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
configConst: {},
|
||||||
|
itemModules: [owned],
|
||||||
|
currentSecurity: 0,
|
||||||
|
generalGold: 0,
|
||||||
|
ownedItems: { horse: null, weapon: null, book: null, item: null, [slot]: owned.key },
|
||||||
|
});
|
||||||
|
expect(items[slot]).toEqual([
|
||||||
|
{ value: 'None', label: '보유 유니크 판매', description: '소유 물품 판매 · 판매가 5,000금' },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
parseReservedTurnArgs('general', 'che_장비매매', { itemType: slot, itemCode: 'None' })
|
||||||
|
).resolves.toEqual({ itemType: slot, itemCode: 'None' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
configConst: {},
|
configConst: {},
|
||||||
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
itemModules: [
|
||||||
|
buildShopItem('event_전투특기_격노', '격노의 비급'),
|
||||||
|
buildShopItem('che_계략_향낭', '향낭'),
|
||||||
|
buildShopItem('che_계략_이추', '이추'),
|
||||||
|
buildShopItem('che_훈련_청주', '청주'),
|
||||||
|
buildShopItem('che_사기_탁주', '탁주'),
|
||||||
|
buildShopItem('che_저격_수극', '수극'),
|
||||||
|
buildShopItem('che_치료_환약', '환약'),
|
||||||
|
],
|
||||||
currentSecurity: 5000,
|
currentSecurity: 5000,
|
||||||
generalGold: 1000,
|
generalGold: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
|
expect(items.item.map((item) => item.value)).toEqual([
|
||||||
|
'None',
|
||||||
|
'che_치료_환약',
|
||||||
|
'che_저격_수극',
|
||||||
|
'che_사기_탁주',
|
||||||
|
'che_훈련_청주',
|
||||||
|
'che_계략_이추',
|
||||||
|
'che_계략_향낭',
|
||||||
|
]);
|
||||||
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
configConst: {
|
configConst: {
|
||||||
allItems: {
|
allItems: {
|
||||||
item: {
|
item: {
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ describe('Ref command general targets', () => {
|
|||||||
gold: 100,
|
gold: 100,
|
||||||
rice: 200,
|
rice: 200,
|
||||||
crew: 900,
|
crew: 900,
|
||||||
|
leadership: 100,
|
||||||
|
strength: 95,
|
||||||
|
intel: 0,
|
||||||
train: 80,
|
train: 80,
|
||||||
atmos: 70,
|
atmos: 70,
|
||||||
troopId: 3,
|
troopId: 3,
|
||||||
@@ -88,11 +91,18 @@ describe('Ref command general targets', () => {
|
|||||||
description: expect.stringContaining('탑승 부대 청룡대'),
|
description: expect.stringContaining('탑승 부대 청룡대'),
|
||||||
});
|
});
|
||||||
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
|
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([
|
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
|
||||||
'본인 (업)',
|
'본인 (업)',
|
||||||
'부대원 (업)',
|
'부대원 (업)',
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
|
|||||||
any: {
|
any: {
|
||||||
ban: { expire: 4_102_444_800, value: 1 },
|
ban: { expire: 4_102_444_800, value: 1 },
|
||||||
expired: { expire: 1, value: 9 },
|
expired: { expire: 1, value: 9 },
|
||||||
|
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
|
||||||
},
|
},
|
||||||
hwe: {
|
hwe: {
|
||||||
ban: { expire: 4_102_444_800, value: 2 },
|
ban: { expire: 4_102_444_800, value: 2 },
|
||||||
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
await seedScenarioToDatabase({
|
await seedScenarioToDatabase({
|
||||||
scenarioId: 2,
|
scenarioId: 2,
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
now: new Date('2026-07-30T12:00:00.000Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
|
openAt: new Date(Date.now() + 86_400_000),
|
||||||
turnTermMinutes: 5,
|
turnTermMinutes: 5,
|
||||||
npcMode: 0,
|
npcMode: 0,
|
||||||
showImgLevel: 3,
|
showImgLevel: 3,
|
||||||
@@ -227,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
create: { userId, key, value },
|
create: { userId, key, value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 이 fixture는 특기 없는 일반 생성/reload를 검증한다. 1% 천재 추첨은 별도 계약이다.
|
||||||
|
const seededWorld = await db.worldState.findFirstOrThrow();
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: seededWorld.id },
|
||||||
|
data: {
|
||||||
|
meta: { ...(seededWorld.meta as Record<string, GamePrisma.InputJsonValue>), genius: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
await startRuntime('create-general-integration-daemon');
|
await startRuntime('create-general-integration-daemon');
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
@@ -289,6 +299,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
|
||||||
expect(created.affinity).toBeLessThanOrEqual(150);
|
expect(created.affinity).toBeLessThanOrEqual(150);
|
||||||
expect(created.meta).toMatchObject({
|
expect(created.meta).toMatchObject({
|
||||||
createdBy: 'join',
|
createdBy: 'join',
|
||||||
@@ -305,6 +316,9 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
throw new Error('created general must have an initial access timestamp');
|
throw new Error('created general must have an initial access timestamp');
|
||||||
}
|
}
|
||||||
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||||
|
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
|
||||||
|
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
|
||||||
|
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
|
||||||
expect(
|
expect(
|
||||||
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||||
acceptedEvent.createdAt.getTime()
|
acceptedEvent.createdAt.getTime()
|
||||||
@@ -553,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
||||||
);
|
);
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
it.each([
|
||||||
|
{ phase: 'SUSPENDED', united: 0, finalized: false },
|
||||||
|
{ phase: 'SUSPENDED', united: 2, finalized: true },
|
||||||
|
{ phase: 'COMPLETED', united: 3, finalized: true },
|
||||||
|
])(
|
||||||
|
'creates a visitor in $phase/$united without reopening season records',
|
||||||
|
async ({ phase, united, finalized }) => {
|
||||||
|
await stopRuntime('prepare frozen participation');
|
||||||
|
const original = await db.worldState.findFirstOrThrow();
|
||||||
|
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
|
||||||
|
const visitorId = `visitor-${phase}-${united}`;
|
||||||
|
const auth = buildAuth(visitorId, '방문자', 7000 + united);
|
||||||
|
await db.inheritancePoint.create({ data: { userId: visitorId, key: 'previous', value: 5000 } });
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: original.id },
|
||||||
|
data: {
|
||||||
|
clockPhase: phase,
|
||||||
|
meta: {
|
||||||
|
...(original.meta as Record<string, GamePrisma.InputJsonValue>),
|
||||||
|
isUnited: united,
|
||||||
|
isunited: united,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.gameHistory.update({
|
||||||
|
where: { serverId: profile },
|
||||||
|
data: { status: finalized ? 'COMPLETED' : 'OPEN' },
|
||||||
|
});
|
||||||
|
const readRecords = async () => ({
|
||||||
|
points: await db.inheritancePoint.findMany({ where: { userId: visitorId }, orderBy: { id: 'asc' } }),
|
||||||
|
logs: await db.inheritanceLog.count({ where: { userId: visitorId } }),
|
||||||
|
baseline: await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }),
|
||||||
|
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
|
||||||
|
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
|
||||||
|
oldGenerals: await db.oldGeneral.count({ where: { serverId: profile } }),
|
||||||
|
});
|
||||||
|
const before = await readRecords();
|
||||||
|
try {
|
||||||
|
await startRuntime(`visitor-${phase}-${united}`);
|
||||||
|
const caller = appRouter.createCaller(buildContext(`visitor-${phase}-${united}`, auth));
|
||||||
|
expect((await caller.join.getConfig()).inherit.enabled).toBe(!finalized);
|
||||||
|
const input = {
|
||||||
|
name: `방문${united}`,
|
||||||
|
pic: false,
|
||||||
|
leadership: 55,
|
||||||
|
strength: 55,
|
||||||
|
intel: 55,
|
||||||
|
character: 'che_안전' as const,
|
||||||
|
};
|
||||||
|
if (finalized) {
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext(`visitor-paid-${united}`, auth))
|
||||||
|
.join.createGeneral({ ...input, inheritTurntimeZone: 7 })
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(await readRecords()).toEqual(before);
|
||||||
|
}
|
||||||
|
const result = await caller.join.createGeneral(input);
|
||||||
|
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
|
||||||
|
userId: visitorId,
|
||||||
|
npcState: 0,
|
||||||
|
});
|
||||||
|
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
||||||
|
clockPhase: phase,
|
||||||
|
clockTick: original.clockTick,
|
||||||
|
lastTurnTick: original.lastTurnTick,
|
||||||
|
});
|
||||||
|
if (finalized) expect(await readRecords()).toEqual(before);
|
||||||
|
else
|
||||||
|
expect(
|
||||||
|
await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } })
|
||||||
|
).toBe(1);
|
||||||
|
await stopRuntime('verify visitor persisted');
|
||||||
|
await startRuntime(`visitor-reload-${phase}-${united}`);
|
||||||
|
expect(runtime?.world.getGeneralById(result.generalId)?.userId).toBe(visitorId);
|
||||||
|
} finally {
|
||||||
|
await stopRuntime('restore participation fixture');
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: original.id },
|
||||||
|
data: { clockPhase: original.clockPhase, meta: original.meta! },
|
||||||
|
});
|
||||||
|
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
|
||||||
|
await startRuntime('participation-fixture-restored');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
|||||||
changes.some(
|
changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
Array.isArray(change) &&
|
Array.isArray(change) &&
|
||||||
change[0] === 'messages.mailbox' &&
|
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await deleteFixtureOutboxes();
|
await deleteFixtureOutboxes();
|
||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
where: {
|
||||||
|
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||||
|
entityId: { in: [...fixtureMailboxes] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
db.readModelRevision.count({
|
db.readModelRevision.count({
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
where: {
|
||||||
|
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||||
|
entityId: { in: [...fixtureMailboxes] },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
|||||||
changes.some(
|
changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
Array.isArray(change) &&
|
Array.isArray(change) &&
|
||||||
change[0] === 'messages.mailbox' &&
|
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
where: {
|
||||||
|
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||||
|
entityId: { in: [...fixtureMailboxes] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||||
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||||
|
|||||||
@@ -23,6 +23,82 @@ const buildDatabase = (
|
|||||||
}) as unknown as DatabaseClient;
|
}) as unknown as DatabaseClient;
|
||||||
|
|
||||||
describe('current game time projection', () => {
|
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 () => {
|
it('projects negative realtime ticks until the future opening anchor', async () => {
|
||||||
const db = buildDatabase();
|
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);
|
expect(result.canRespondDiplomacy).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
it.each([null, 'RESOLVED'])(
|
||||||
const diplomacyRow = {
|
'redacts recent and old diplomacy content below secret permission 3 (%s)',
|
||||||
id: 19,
|
async (actionStatus) => {
|
||||||
mailbox: 9001,
|
const diplomacyRow = {
|
||||||
type: 'diplomacy',
|
id: 19,
|
||||||
src: 9002,
|
action_status: actionStatus,
|
||||||
dest: 9001,
|
expires_game_tick: null,
|
||||||
time: new Date(),
|
mailbox: 9001,
|
||||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
type: 'diplomacy',
|
||||||
message: {
|
src: 9002,
|
||||||
src: {
|
dest: 9001,
|
||||||
generalId: 8,
|
time: new Date(),
|
||||||
generalName: '외교관',
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
nationId: 2,
|
message: {
|
||||||
nationName: '촉',
|
src: {
|
||||||
color: '#000000',
|
generalId: 8,
|
||||||
icon: '',
|
generalName: '외교관',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#ffffff',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '보이면 안 되는 외교 본문',
|
||||||
|
option: { action: 'noAggression' },
|
||||||
},
|
},
|
||||||
dest: {
|
};
|
||||||
generalId: 0,
|
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||||
generalName: '',
|
const { caller } = buildContext({
|
||||||
nationId: 1,
|
$queryRaw: queryRaw,
|
||||||
nationName: '위',
|
nation: {
|
||||||
color: '#ffffff',
|
findMany: vi.fn(async () => []),
|
||||||
icon: '',
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||||
},
|
},
|
||||||
text: '보이면 안 되는 외교 본문',
|
});
|
||||||
option: { action: 'noAggression' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
|
||||||
const { caller } = buildContext({
|
|
||||||
$queryRaw: queryRaw,
|
|
||||||
nation: {
|
|
||||||
findMany: vi.fn(async () => []),
|
|
||||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const recent = await caller.messages.getRecent({ generalId: general.id });
|
const recent = await caller.messages.getRecent({ generalId: general.id });
|
||||||
const old = await caller.messages.getOld({
|
const old = await caller.messages.getOld({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
type: 'diplomacy',
|
type: 'diplomacy',
|
||||||
to: 20,
|
to: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(recent.permission).toBe(2);
|
expect(recent.permission).toBe(2);
|
||||||
expect(recent.diplomacy[0]).toMatchObject({
|
expect(recent.diplomacy[0]).toMatchObject({
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { action: 'noAggression' },
|
option: { action: 'noAggression', permissionRedacted: true },
|
||||||
});
|
});
|
||||||
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||||
expect(old.diplomacy[0]).toMatchObject({
|
expect(old.diplomacy[0]).toMatchObject({
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { action: 'noAggression' },
|
option: { action: 'noAggression', permissionRedacted: true },
|
||||||
});
|
});
|
||||||
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||||
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||||
@@ -368,8 +373,8 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(result.msgType).toBe('diplomacy');
|
expect(result.msgType).toBe('diplomacy');
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
expect(changeJournal.snapshot()).toEqual([
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
{ domain: 'messages.mailbox', entityId: 9000 },
|
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
|
||||||
{ domain: 'messages.mailbox', entityId: 9001 },
|
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -837,6 +842,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
proposerCurrentNationId?: number;
|
proposerCurrentNationId?: number;
|
||||||
proposerNationMeta?: Record<string, unknown>;
|
proposerNationMeta?: Record<string, unknown>;
|
||||||
diplomacyState?: number;
|
diplomacyState?: number;
|
||||||
|
invalid?: boolean;
|
||||||
response?: boolean;
|
response?: boolean;
|
||||||
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -891,6 +897,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
text: '외교 제안',
|
text: '외교 제안',
|
||||||
option: {
|
option: {
|
||||||
action,
|
action,
|
||||||
|
...(options?.invalid ? { invalid: true } : {}),
|
||||||
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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 () => {
|
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
||||||
const setup = buildDiplomaticContext();
|
const setup = buildDiplomaticContext();
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => {
|
|||||||
const ally = general({
|
const ally = general({
|
||||||
id: 3,
|
id: 3,
|
||||||
userId: 'u3',
|
userId: 'u3',
|
||||||
|
injury: 25,
|
||||||
gold: 3000,
|
gold: 3000,
|
||||||
crew: 200,
|
crew: 200,
|
||||||
train: 80,
|
train: 80,
|
||||||
@@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => {
|
|||||||
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
||||||
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
||||||
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
|
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
|
||||||
|
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
|
||||||
|
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
stats: { leadership: 52, strength: 45, intelligence: 37 },
|
||||||
|
});
|
||||||
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
||||||
expect(result.generals[0]?.reservedCommands).toEqual([
|
expect(result.generals[0]?.reservedCommands).toEqual([
|
||||||
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
||||||
|
|||||||
@@ -161,6 +161,20 @@ describe('NPC policy router', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the live world development cost instead of the scenario snapshot', async () => {
|
||||||
|
const fixture = createContext({
|
||||||
|
world: {
|
||||||
|
...baseWorld,
|
||||||
|
config: { ...baseWorld.config, const: { develCost: 100 } },
|
||||||
|
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
||||||
|
|
||||||
|
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
|
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
|
||||||
const reader = { ...baseGeneral, officerLevel: 2 };
|
const reader = { ...baseGeneral, officerLevel: 2 };
|
||||||
const requestCommand = vi.fn(async () => ({
|
const requestCommand = vi.fn(async () => ({
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||||
const logs = await db.logEntry.findMany({
|
const logs = await db.logEntry.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -671,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
|
|
||||||
await db.npcSelectionToken.update({
|
await db.npcSelectionToken.update({
|
||||||
where: { ownerUserId: rejectedUserId },
|
where: { ownerUserId: rejectedUserId },
|
||||||
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') },
|
data: { validUntilTick: -1n },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
||||||
@@ -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(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
|
||||||
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
|
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
|
it.each(['SUSPENDED', 'COMPLETED'])(
|
||||||
|
'reserves and possesses an NPC in %s without changing finalized records',
|
||||||
|
async (phase) => {
|
||||||
|
await stopRuntime('prepare frozen possession');
|
||||||
|
const original = await db.worldState.findFirstOrThrow();
|
||||||
|
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
|
||||||
|
const visitorId = `npc-visitor-${phase}`;
|
||||||
|
const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802);
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: original.id },
|
||||||
|
data: {
|
||||||
|
clockPhase: phase,
|
||||||
|
meta: { ...(original.meta as Record<string, GamePrisma.InputJsonValue>), isUnited: 2, isunited: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
|
||||||
|
const records = async () => ({
|
||||||
|
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
|
||||||
|
old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
|
||||||
|
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
|
||||||
|
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
|
||||||
|
});
|
||||||
|
const before = await records();
|
||||||
|
try {
|
||||||
|
await startRuntime(`npc-visitor-${phase}`);
|
||||||
|
const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor));
|
||||||
|
const candidates = await caller.join.listPossessCandidates({});
|
||||||
|
const picked = candidates.candidates[0]!;
|
||||||
|
expect(picked).toBeDefined();
|
||||||
|
await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce });
|
||||||
|
expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({
|
||||||
|
userId: visitorId,
|
||||||
|
npcState: 1,
|
||||||
|
});
|
||||||
|
expect(await records()).toEqual(before);
|
||||||
|
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
||||||
|
clockPhase: phase,
|
||||||
|
clockTick: original.clockTick,
|
||||||
|
lastTurnTick: original.lastTurnTick,
|
||||||
|
});
|
||||||
|
await stopRuntime('verify frozen possession reload');
|
||||||
|
await startRuntime(`npc-visitor-reload-${phase}`);
|
||||||
|
expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId);
|
||||||
|
} finally {
|
||||||
|
await stopRuntime('restore frozen possession');
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: original.id },
|
||||||
|
data: { clockPhase: original.clockPhase, meta: original.meta! },
|
||||||
|
});
|
||||||
|
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
|
||||||
|
await startRuntime('frozen-possession-restored');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveNationPermission } from '../src/router/nation/shared.js';
|
||||||
|
|
||||||
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
||||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
||||||
} from '../src/realtime/publicEvent.js';
|
} from '../src/realtime/publicEvent.js';
|
||||||
|
|
||||||
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
|
||||||
const refreshGrant = 'opaque-grant';
|
const refreshGrant = 'opaque-grant';
|
||||||
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
||||||
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
||||||
@@ -198,6 +199,37 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
|
||||||
|
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
|
||||||
|
const blockedViewer = { ...viewer, canReadDiplomacy: false };
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent(
|
||||||
|
{
|
||||||
|
type: 'messageCreated',
|
||||||
|
at: '2026-09-04T00:00:00Z',
|
||||||
|
mailbox,
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
messageId: 1,
|
||||||
|
senderId: 2,
|
||||||
|
},
|
||||||
|
[blockedViewer]
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
|
||||||
|
blockedViewer,
|
||||||
|
])
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
|
||||||
|
blockedViewer,
|
||||||
|
])
|
||||||
|
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
|
||||||
|
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||||
|
});
|
||||||
|
|
||||||
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
||||||
const event: RealtimeEvent = {
|
const event: RealtimeEvent = {
|
||||||
type: 'messagesChanged',
|
type: 'messagesChanged',
|
||||||
@@ -225,6 +257,12 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
viewer
|
viewer
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldReloadRealtimeViewerIdentity(
|
||||||
|
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
|
||||||
|
viewer
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges previous and committed identities across an ownership transition', () => {
|
it('merges previous and committed identities across an ownership transition', () => {
|
||||||
@@ -251,3 +289,28 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('diplomatic notification recipient roles', () => {
|
||||||
|
it.each([
|
||||||
|
['군주', 12, 'normal', {}, true],
|
||||||
|
['외교권자', 1, 'ambassador', {}, true],
|
||||||
|
['조언자', 1, 'auditor', {}, true],
|
||||||
|
['일반 장수', 1, 'normal', {}, false],
|
||||||
|
['수뇌', 11, 'normal', {}, false],
|
||||||
|
['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false],
|
||||||
|
] as const)(
|
||||||
|
'%s receives only eligible nation diplomacy invalidations',
|
||||||
|
(_, officerLevel, permission, penalty, allowed) => {
|
||||||
|
const canReadDiplomacy =
|
||||||
|
resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3;
|
||||||
|
expect(canReadDiplomacy).toBe(allowed);
|
||||||
|
const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] };
|
||||||
|
expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual(
|
||||||
|
allowed ? { type: 'messagesInvalidated', refreshGrant } : null
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }])
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const payload = (
|
|||||||
| 'access.general'
|
| 'access.general'
|
||||||
| 'dashboard.global'
|
| 'dashboard.global'
|
||||||
| 'messages.mailbox'
|
| 'messages.mailbox'
|
||||||
|
| 'messages.diplomacyMailbox'
|
||||||
| 'tournament'
|
| 'tournament'
|
||||||
| 'betting'
|
| 'betting'
|
||||||
) => ({
|
) => ({
|
||||||
@@ -19,7 +20,11 @@ const payload = (
|
|||||||
changes: [
|
changes: [
|
||||||
[
|
[
|
||||||
domain,
|
domain,
|
||||||
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
|
domain === 'front.general' || domain === 'access.general'
|
||||||
|
? 7
|
||||||
|
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
|
||||||
|
? 9999
|
||||||
|
: 0,
|
||||||
'1',
|
'1',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -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 () => {
|
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
||||||
let releaseFirst: (() => void) | undefined;
|
let releaseFirst: (() => void) | undefined;
|
||||||
const first = new Promise<readonly object[]>((resolve) => {
|
const first = new Promise<readonly object[]>((resolve) => {
|
||||||
|
|||||||
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
await seedScenarioToDatabase({
|
await seedScenarioToDatabase({
|
||||||
scenarioId: 903,
|
scenarioId: 903,
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
now: new Date('2026-07-30T12:00:00.000Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
|
openAt: new Date(Date.now() + 86_400_000),
|
||||||
turnTermMinutes: 5,
|
turnTermMinutes: 5,
|
||||||
npcMode: 2,
|
npcMode: 2,
|
||||||
showImgLevel: 3,
|
showImgLevel: 3,
|
||||||
@@ -274,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
||||||
});
|
});
|
||||||
|
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
|
||||||
|
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
|
||||||
|
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
|
||||||
if (!initialAccess.lastRefresh) {
|
if (!initialAccess.lastRefresh) {
|
||||||
throw new Error('selected general must have an initial access timestamp');
|
throw new Error('selected general must have an initial access timestamp');
|
||||||
}
|
}
|
||||||
@@ -660,4 +664,50 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
it.each(['SUSPENDED', 'COMPLETED'])(
|
||||||
|
'creates from the selection pool while %s',
|
||||||
|
async (phase) => {
|
||||||
|
await runtime!.lifecycle.stop('prepare frozen pool creation');
|
||||||
|
await daemonLoop;
|
||||||
|
await runtime!.close();
|
||||||
|
const original = await db.worldState.findFirstOrThrow();
|
||||||
|
await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } });
|
||||||
|
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
|
||||||
|
runtime = await createTurnDaemonRuntime({
|
||||||
|
profile,
|
||||||
|
databaseUrl: databaseUrl!,
|
||||||
|
enableDatabaseFlush: true,
|
||||||
|
enableLeaseHeartbeat: false,
|
||||||
|
leaseOwnerId: `frozen-pool-${phase}`,
|
||||||
|
});
|
||||||
|
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
|
||||||
|
daemonLoop = runtime.lifecycle.start();
|
||||||
|
await turnDaemon.requestStatus(10_000);
|
||||||
|
const visitorId = `pool-visitor-${phase}`;
|
||||||
|
const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } };
|
||||||
|
const readRecords = async () => ({
|
||||||
|
hall: await db.hallOfFame.count({ where: { serverId: profile } }),
|
||||||
|
old: await db.oldGeneral.count({ where: { serverId: profile } }),
|
||||||
|
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
|
||||||
|
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
|
||||||
|
});
|
||||||
|
const before = await readRecords();
|
||||||
|
const candidates = await appRouter
|
||||||
|
.createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth))
|
||||||
|
.join.getSelectionPool();
|
||||||
|
const result = await appRouter
|
||||||
|
.createCaller(buildContext(`pool-create-${phase}`, visitorAuth))
|
||||||
|
.join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' });
|
||||||
|
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
|
||||||
|
userId: visitorId,
|
||||||
|
});
|
||||||
|
expect(await readRecords()).toEqual(before);
|
||||||
|
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
||||||
|
clockPhase: phase,
|
||||||
|
clockTick: original.clockTick,
|
||||||
|
lastTurnTick: original.lastTurnTick,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
30_000
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
createGamePostgresConnector,
|
||||||
|
createRedisConnector,
|
||||||
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||||
|
import { buildTournamentKeys } from '../src/tournament/keys.js';
|
||||||
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||||
|
|
||||||
|
integration.each([
|
||||||
|
'setState',
|
||||||
|
'patchState',
|
||||||
|
'setParticipants',
|
||||||
|
'setMatches',
|
||||||
|
'setBettingEntries',
|
||||||
|
'seedParticipants',
|
||||||
|
'cancel',
|
||||||
|
])(
|
||||||
|
'serializes %s behind a joining user without holding the ENGINE clock lock',
|
||||||
|
async (adminAction) => {
|
||||||
|
const url = databaseUrl!;
|
||||||
|
const connector = createGamePostgresConnector({ url });
|
||||||
|
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
|
||||||
|
await connector.connect();
|
||||||
|
await redisConnector.connect();
|
||||||
|
const db = connector.prisma;
|
||||||
|
const redis = redisConnector.client;
|
||||||
|
const profileName = 'che:tournament-lock-integration';
|
||||||
|
const keys = buildTournamentKeys(profileName);
|
||||||
|
const redisKeys = [...Object.values(keys), `${keys.stateKey}:mutation-lock`];
|
||||||
|
const prefix = 'integration:tournament-lock:';
|
||||||
|
const nextAt = new Date().toISOString();
|
||||||
|
let notifyJoin: () => void;
|
||||||
|
const joinAtDaemon = new Promise<void>((resolve) => {
|
||||||
|
notifyJoin = resolve;
|
||||||
|
});
|
||||||
|
let notifyAdmin: () => void;
|
||||||
|
const adminAtRedis = new Promise<void>((resolve) => {
|
||||||
|
notifyAdmin = resolve;
|
||||||
|
});
|
||||||
|
const transport = new DatabaseTurnDaemonTransport(db, 4_000);
|
||||||
|
try {
|
||||||
|
await redis.del(redisKeys);
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: -991900 } });
|
||||||
|
await db.general.deleteMany({ where: { id: 991900 } });
|
||||||
|
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
|
||||||
|
await db.turnDaemonLease.create({
|
||||||
|
data: {
|
||||||
|
profile: profileName,
|
||||||
|
ownerId: 'audit',
|
||||||
|
leaseUntil: new Date(Date.now() + 60000),
|
||||||
|
clockReady: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: -991900,
|
||||||
|
scenarioCode: 'audit',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 60,
|
||||||
|
clockBaseTime: new Date(),
|
||||||
|
clockTick: 0n,
|
||||||
|
clockWallAnchor: new Date(),
|
||||||
|
clockPhase: 'RUNNING',
|
||||||
|
clockRevision: 1n,
|
||||||
|
config: { const: { develCost: 10 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.general.create({
|
||||||
|
data: { id: 991900, userId: 'audit-user', name: 'audit', turnTime: new Date(), gold: 1000 },
|
||||||
|
});
|
||||||
|
await redis.set(
|
||||||
|
keys.stateKey,
|
||||||
|
JSON.stringify({
|
||||||
|
stage: 1,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: false,
|
||||||
|
openYear: 200,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const base: Partial<GameApiContext> = {
|
||||||
|
db,
|
||||||
|
redis,
|
||||||
|
profile: { id: 'che', scenario: 'tournament-lock-integration', name: profileName },
|
||||||
|
auth: {
|
||||||
|
version: 1,
|
||||||
|
profile: profileName,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
expiresAt: '2999-01-01T00:00:00Z',
|
||||||
|
sessionId: 'audit',
|
||||||
|
user: { id: 'audit-user', username: 'audit', displayName: 'audit', roles: [] },
|
||||||
|
sanctions: {},
|
||||||
|
},
|
||||||
|
turnDaemon: {
|
||||||
|
sendCommand: transport.sendCommand.bind(transport),
|
||||||
|
requestStatus: transport.requestStatus.bind(transport),
|
||||||
|
requestCommand: async (command) => {
|
||||||
|
notifyJoin!();
|
||||||
|
await adminAtRedis;
|
||||||
|
const requestId = await transport.sendCommand(command);
|
||||||
|
return db.$transaction(async (tx) => {
|
||||||
|
// 이전 순서에서는 관리자가 DB lock을 보유하여 별도 ENGINE 연결이 막힌다.
|
||||||
|
await tx.$executeRawUnsafe("SET LOCAL lock_timeout = '500ms'");
|
||||||
|
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||||
|
if (command.type !== 'adjustGeneralResources') throw new Error('unexpected command');
|
||||||
|
const result = {
|
||||||
|
type: 'adjustGeneralResources' as const,
|
||||||
|
ok: true as const,
|
||||||
|
processed: 1,
|
||||||
|
missing: 0,
|
||||||
|
totalGoldDelta: -10,
|
||||||
|
totalRiceDelta: 0,
|
||||||
|
};
|
||||||
|
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result } });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const joinContext = { ...base, requestId: `${prefix}join` } as GameApiContext;
|
||||||
|
const adminRedis = new Proxy(redis, {
|
||||||
|
get(target, property) {
|
||||||
|
if (property === 'set')
|
||||||
|
return async (...args: Parameters<typeof redis.set>) => {
|
||||||
|
if (args[0] === `${keys.stateKey}:mutation-lock`) notifyAdmin!();
|
||||||
|
return redis.set(...args);
|
||||||
|
};
|
||||||
|
const value = Reflect.get(target, property);
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const adminContext = {
|
||||||
|
...base,
|
||||||
|
requestId: `${prefix}admin`,
|
||||||
|
redis: adminRedis,
|
||||||
|
auth: { ...base.auth!, user: { ...base.auth!.user, roles: ['admin'] } },
|
||||||
|
} as GameApiContext;
|
||||||
|
const callAdmin = async (context: GameApiContext) => {
|
||||||
|
const caller = appRouter.createCaller(context).tournament;
|
||||||
|
switch (adminAction) {
|
||||||
|
case 'setState':
|
||||||
|
return caller.setState({
|
||||||
|
stage: 1,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: false,
|
||||||
|
openYear: 200,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt,
|
||||||
|
});
|
||||||
|
case 'patchState':
|
||||||
|
return caller.patchState({ auto: true });
|
||||||
|
case 'setParticipants':
|
||||||
|
return caller.setParticipants([]);
|
||||||
|
case 'setMatches':
|
||||||
|
return caller.setMatches([]);
|
||||||
|
case 'setBettingEntries':
|
||||||
|
return caller.setBettingEntries([]);
|
||||||
|
case 'seedParticipants':
|
||||||
|
return caller.seedParticipants({ generalIds: [991900] });
|
||||||
|
default:
|
||||||
|
return caller.cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const join = appRouter.createCaller(joinContext).tournament.join();
|
||||||
|
const joinOutcome = join.then(
|
||||||
|
(value) => ({ ok: true, value }),
|
||||||
|
(error) => ({ ok: false, error })
|
||||||
|
);
|
||||||
|
await joinAtDaemon;
|
||||||
|
const admin = callAdmin(adminContext);
|
||||||
|
const adminOutcome = admin.then(
|
||||||
|
(value) => ({ ok: true, value }),
|
||||||
|
(error) => ({ ok: false, error })
|
||||||
|
);
|
||||||
|
await adminAtRedis;
|
||||||
|
const [joined, managed] = await Promise.all([joinOutcome, adminOutcome]);
|
||||||
|
expect(joined).toMatchObject({ ok: true, value: { ok: true, count: 1 } });
|
||||||
|
expect(managed).toMatchObject({ ok: true, value: { ok: true } });
|
||||||
|
const revision = await redis.get(keys.sourceRevisionKey);
|
||||||
|
const engineCount = await db.inputEvent.count({
|
||||||
|
where: { requestId: { startsWith: prefix }, target: 'ENGINE' },
|
||||||
|
});
|
||||||
|
// API 입력 원장은 유지한다. 동일 요청 재실행은 Redis/환불 command를 다시 쓰지 않는다.
|
||||||
|
await expect(callAdmin(adminContext)).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(await redis.get(keys.sourceRevisionKey)).toBe(revision);
|
||||||
|
expect(await db.inputEvent.count({ where: { requestId: { startsWith: prefix }, target: 'ENGINE' } })).toBe(
|
||||||
|
engineCount
|
||||||
|
);
|
||||||
|
const inputEvent = await db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${prefix}admin:tournament.${adminAction}` },
|
||||||
|
});
|
||||||
|
expect(inputEvent).toMatchObject({ target: 'API', status: 'SUCCEEDED', attempts: 1 });
|
||||||
|
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
|
||||||
|
const retryContext = { ...adminContext, requestId: `${prefix}retry` };
|
||||||
|
const beforeFailure = await redis.get(keys.sourceRevisionKey);
|
||||||
|
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'SUSPENDED' } });
|
||||||
|
await expect(callAdmin(retryContext)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
||||||
|
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
|
||||||
|
expect(await redis.get(keys.sourceRevisionKey)).toBe(beforeFailure);
|
||||||
|
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'RUNNING' } });
|
||||||
|
await expect(callAdmin(retryContext)).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
await db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${prefix}retry:tournament.${adminAction}` },
|
||||||
|
})
|
||||||
|
).toMatchObject({ status: 'SUCCEEDED', attempts: 2 });
|
||||||
|
} finally {
|
||||||
|
await redis.del(redisKeys);
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
|
||||||
|
await db.general.deleteMany({ where: { id: 991900 } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: -991900 } });
|
||||||
|
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
|
||||||
|
await redisConnector.disconnect();
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
);
|
||||||
@@ -277,15 +277,33 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
nextAt: '2026-07-26T01:00:00.000Z',
|
nextAt: '2026-07-26T01:00:00.000Z',
|
||||||
});
|
});
|
||||||
await redis.set('sammo:che:default:tournament:participants', '[]');
|
await redis.set('sammo:che:default:tournament:participants', '[]');
|
||||||
const caller = appRouter.createCaller(
|
const context = buildContext({
|
||||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
|
redis,
|
||||||
);
|
transport,
|
||||||
|
generals: [general],
|
||||||
|
userId: 'user-1',
|
||||||
|
develCost: 200,
|
||||||
|
requestId: 'http:tournament-join',
|
||||||
|
});
|
||||||
|
const outerApiTransaction = vi.fn(async () => {
|
||||||
|
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
|
||||||
|
});
|
||||||
|
Object.assign(context.db, { $transaction: outerApiTransaction });
|
||||||
|
const caller = appRouter.createCaller(context);
|
||||||
|
|
||||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||||
|
|
||||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||||
|
expect(transport.commands).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||||
|
reason: 'tournamentJoin',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||||
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
|
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
|
||||||
const snapshot = await caller.tournament.getSnapshot();
|
const snapshot = await caller.tournament.getSnapshot();
|
||||||
expect(snapshot.participants).toHaveLength(1);
|
expect(snapshot.participants).toHaveLength(1);
|
||||||
|
|||||||
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
|
||||||
|
const fixture = buildContext({
|
||||||
|
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
|
||||||
|
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await appRouter.createCaller(fixture.context).troop.getList();
|
||||||
|
|
||||||
|
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a troop only for the general owned by the authenticated user', async () => {
|
it('creates a troop only for the general owned by the authenticated user', async () => {
|
||||||
const { context, requestCommand } = buildContext({
|
const { context, requestCommand } = buildContext({
|
||||||
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
import {
|
||||||
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
|
createGamePostgresConnector,
|
||||||
|
tryGameSchemaAdvisoryXactLock,
|
||||||
|
type GamePrismaClient,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
|
|||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
|
||||||
|
const requestId = 'integration:vote-comment-timestamp:submit';
|
||||||
|
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
|
||||||
|
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
|
||||||
|
const context: Partial<GameApiContext> = {
|
||||||
|
requestId,
|
||||||
|
db,
|
||||||
|
auth,
|
||||||
|
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
|
||||||
|
turnDaemon: {
|
||||||
|
sendCommand: transport.sendCommand.bind(transport),
|
||||||
|
requestStatus: transport.requestStatus.bind(transport),
|
||||||
|
requestCommand: async (command) => {
|
||||||
|
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
|
||||||
|
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
|
||||||
|
const acceptedId = await transport.sendCommand(command);
|
||||||
|
expect(acceptedId).toBe(engineRequestId);
|
||||||
|
await db.$transaction(async (transaction) => {
|
||||||
|
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const event = await transaction.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: acceptedId },
|
||||||
|
});
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
target: 'ENGINE',
|
||||||
|
status: 'PENDING',
|
||||||
|
actorUserId: fixtureUserId,
|
||||||
|
});
|
||||||
|
await transaction.inputEvent.update({
|
||||||
|
where: { requestId: acceptedId },
|
||||||
|
data: {
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
result: {
|
||||||
|
type: 'voteReward',
|
||||||
|
ok: true,
|
||||||
|
voteId: fixtureId,
|
||||||
|
generalId: fixtureId,
|
||||||
|
awardedUnique: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return transport.requestCommand(command);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const caller = appRouter.createCaller(context as GameApiContext);
|
||||||
|
|
||||||
|
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
wonLottery: false,
|
||||||
|
});
|
||||||
|
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
|
||||||
|
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
|
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
|
||||||
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||||
SELECT current_setting('TIMEZONE') AS "timeZone"
|
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||||
|
|||||||
@@ -216,6 +216,40 @@ const buildContext = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('vote router actor and permission boundaries', () => {
|
describe('vote router actor and permission boundaries', () => {
|
||||||
|
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
|
||||||
|
const fixture = buildContext({ requestId: 'vote-boundary' });
|
||||||
|
const transaction = vi.fn(async () => {
|
||||||
|
throw new Error('API transaction blocks the vote ENGINE clock fence');
|
||||||
|
});
|
||||||
|
Object.assign(fixture.context.db, { $transaction: transaction });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
||||||
|
).resolves.toEqual({ ok: true, wonLottery: false });
|
||||||
|
expect(transaction).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
|
||||||
|
userId: 'user-1',
|
||||||
|
generalId: 7,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ auth: null, code: 'UNAUTHORIZED' },
|
||||||
|
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
|
||||||
|
])(
|
||||||
|
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
|
||||||
|
async ({ auth, code }) => {
|
||||||
|
const fixture = buildContext({ auth });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
||||||
|
).rejects.toMatchObject({ code });
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
|
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
|
||||||
const now = new Date('2026-07-26T00:00:00Z');
|
const now = new Date('2026-07-26T00:00:00Z');
|
||||||
const time = {
|
const time = {
|
||||||
@@ -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(
|
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
|
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js';
|
import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js';
|
||||||
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
|
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
|
||||||
|
|
||||||
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
|
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
|
|||||||
db: { $queryRaw: queryRaw },
|
db: { $queryRaw: queryRaw },
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
|
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
|
||||||
'sammo:map:base:hwe:scenario_2400:pg12'
|
|
||||||
);
|
|
||||||
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
|
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
|
||||||
'sammo:map:public:hwe:scenario_2400:pg12'
|
'sammo:map:public:hwe:scenario_2400:pg12'
|
||||||
);
|
);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
||||||
expect(statement.sql).toContain('read_model_revision_meta');
|
expect(statement.sql).toContain('read_model_revision_meta');
|
||||||
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'");
|
expect(statement.sql).toContain('revision."domain" = \'map.world\'');
|
||||||
expect(statement.values).toEqual([]);
|
expect(statement.values).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
|
|||||||
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
|
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
|
||||||
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
|
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
|
||||||
|
|
||||||
|
expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null });
|
||||||
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
|
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
|
||||||
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
|
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
|
||||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||||
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
|
|||||||
expect(shared).not.toHaveProperty('shownByGeneralList');
|
expect(shared).not.toHaveProperty('shownByGeneralList');
|
||||||
expect(shared).not.toHaveProperty('myCity');
|
expect(shared).not.toHaveProperty('myCity');
|
||||||
expect(shared).not.toHaveProperty('myNation');
|
expect(shared).not.toHaveProperty('myNation');
|
||||||
|
// 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다.
|
||||||
|
delete shared.uniqueItemLimit;
|
||||||
|
cache.set(cache.keys().next().value!, JSON.stringify(shared));
|
||||||
|
const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true });
|
||||||
|
expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null });
|
||||||
|
expect(redis.set).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
|
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
|
||||||
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
|
|||||||
redis,
|
redis,
|
||||||
db: {
|
db: {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) },
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
|
|||||||
expect(redis.set).not.toHaveBeenCalled();
|
expect(redis.set).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('map unique ownership limit', () => {
|
||||||
|
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
|
||||||
|
currentYear,
|
||||||
|
meta: { scenarioMeta: { startYear: 180 } },
|
||||||
|
config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[179, 1, 182],
|
||||||
|
[181, 1, 182],
|
||||||
|
[182, 1, 182],
|
||||||
|
[183, 2, 189],
|
||||||
|
[189, 2, 189],
|
||||||
|
[190, 3, 199],
|
||||||
|
[200, 4, null],
|
||||||
|
])('projects year %i and the inclusive last month', (year, count, untilYear) => {
|
||||||
|
expect(resolveMapUniqueItemLimit(state(year!))).toEqual({
|
||||||
|
count,
|
||||||
|
until: untilYear === null ? null : { year: untilYear, month: 12 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => {
|
||||||
|
expect(
|
||||||
|
resolveMapUniqueItemLimit(
|
||||||
|
state(181, {
|
||||||
|
maxUniqueItemLimit: [
|
||||||
|
[-1, 1],
|
||||||
|
[2, 1],
|
||||||
|
[4, 3],
|
||||||
|
[8, 4],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toEqual({ count: 1, until: { year: 183, month: 12 } });
|
||||||
|
expect(
|
||||||
|
resolveMapUniqueItemLimit(
|
||||||
|
state(183, {
|
||||||
|
allItems: { horse: {}, weapon: {} },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toEqual({ count: 2, until: null });
|
||||||
|
expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
|
|||||||
nationId: options.bidder.nationId,
|
nationId: options.bidder.nationId,
|
||||||
nationName: options.nation?.name ?? '재야',
|
nationName: options.nation?.name ?? '재야',
|
||||||
color: options.nation?.color ?? '#000000',
|
color: options.nation?.color ?? '#000000',
|
||||||
icon: options.bidder.picture ?? '',
|
icon: resolveMessageTargetIcon(options.bidder),
|
||||||
},
|
},
|
||||||
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
||||||
time: new Date(options.time.getTime()),
|
time: new Date(options.time.getTime()),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
||||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
@@ -124,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
|
|||||||
nationId: options.bidder.nationId,
|
nationId: options.bidder.nationId,
|
||||||
nationName: options.nation?.name ?? '재야',
|
nationName: options.nation?.name ?? '재야',
|
||||||
color: options.nation?.color ?? '#000000',
|
color: options.nation?.color ?? '#000000',
|
||||||
icon: options.bidder.picture ?? '',
|
icon: resolveMessageTargetIcon(options.bidder),
|
||||||
},
|
},
|
||||||
text: options.text,
|
text: options.text,
|
||||||
time: new Date(options.time.getTime()),
|
time: new Date(options.time.getTime()),
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
|
|
||||||
async drain(): Promise<TurnDaemonCommand[]> {
|
async drain(): Promise<TurnDaemonCommand[]> {
|
||||||
const local = this.localQueue.splice(0, this.localQueue.length);
|
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();
|
const remote = await this.claimPending();
|
||||||
return local.concat(remote);
|
return local.concat(remote);
|
||||||
}
|
}
|
||||||
@@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
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 suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
||||||
const currentRevision = world?.clockRevision ?? null;
|
const currentRevision = world?.clockRevision ?? null;
|
||||||
const maintenanceSuspended =
|
const maintenanceSuspended =
|
||||||
@@ -158,6 +169,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
AND (
|
AND (
|
||||||
${gameplayAllowed}
|
${gameplayAllowed}
|
||||||
OR "event_type" = 'getStatus'
|
OR "event_type" = 'getStatus'
|
||||||
|
OR (
|
||||||
|
${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'}
|
||||||
|
AND "event_type" IN (
|
||||||
|
'joinCreateGeneral', 'npcPossessGeneral',
|
||||||
|
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
|
||||||
|
)
|
||||||
|
)
|
||||||
OR (
|
OR (
|
||||||
${suspendedTournamentBetCommand}
|
${suspendedTournamentBetCommand}
|
||||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TurnDaemonLeaseLostError extends Error {
|
export class TurnDaemonLeaseLostError extends Error {
|
||||||
constructor(profile: string) {
|
constructor(profile: string, reason?: string) {
|
||||||
super(`Turn daemon lease was lost for profile "${profile}".`);
|
super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
|
||||||
this.name = 'TurnDaemonLeaseLostError';
|
this.name = 'TurnDaemonLeaseLostError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
private expiryTimer: NodeJS.Timeout | null = null;
|
private expiryTimer: NodeJS.Timeout | null = null;
|
||||||
private renewalInFlight = false;
|
private renewalInFlight = false;
|
||||||
private lost = false;
|
private lost = false;
|
||||||
|
private lossReason: string | undefined;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
db: GamePrismaClient,
|
db: GamePrismaClient,
|
||||||
@@ -93,6 +94,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
ON CONFLICT ("profile") DO UPDATE
|
ON CONFLICT ("profile") DO UPDATE
|
||||||
SET
|
SET
|
||||||
"owner_id" = EXCLUDED."owner_id",
|
"owner_id" = EXCLUDED."owner_id",
|
||||||
|
"clock_ready" = FALSE,
|
||||||
"lease_until" = EXCLUDED."lease_until",
|
"lease_until" = EXCLUDED."lease_until",
|
||||||
"fencing_epoch" = CASE
|
"fencing_epoch" = CASE
|
||||||
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
||||||
@@ -115,6 +117,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
fencingEpoch: BigInt(row.fencing_epoch),
|
fencingEpoch: BigInt(row.fencing_epoch),
|
||||||
};
|
};
|
||||||
this.lost = false;
|
this.lost = false;
|
||||||
|
this.lossReason = undefined;
|
||||||
this.scheduleExpiryWatchdog(requestStartedAt);
|
this.scheduleExpiryWatchdog(requestStartedAt);
|
||||||
if (this.heartbeatEnabled) {
|
if (this.heartbeatEnabled) {
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
@@ -126,10 +129,22 @@ export class DatabaseTurnDaemonLease {
|
|||||||
return this.token ? { ...this.token } : null;
|
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 {
|
isLost(): boolean {
|
||||||
return this.lost;
|
return this.lost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLossError(): TurnDaemonLeaseLostError {
|
||||||
|
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
|
||||||
|
}
|
||||||
|
|
||||||
async renew(): Promise<boolean> {
|
async renew(): Promise<boolean> {
|
||||||
const token = this.token;
|
const token = this.token;
|
||||||
if (!token || this.lost || this.renewalInFlight) {
|
if (!token || this.lost || this.renewalInFlight) {
|
||||||
@@ -151,7 +166,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
RETURNING "profile", "owner_id", "fencing_epoch"
|
RETURNING "profile", "owner_id", "fencing_epoch"
|
||||||
`);
|
`);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
this.markLost();
|
this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.lost) {
|
if (this.lost) {
|
||||||
@@ -167,7 +182,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
|
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
|
||||||
const token = this.token;
|
const token = this.token;
|
||||||
if (!token || this.lost) {
|
if (!token || this.lost) {
|
||||||
throw new TurnDaemonLeaseLostError(this.profile);
|
throw this.getLossError();
|
||||||
}
|
}
|
||||||
const db = transaction ?? this.db;
|
const db = transaction ?? this.db;
|
||||||
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
||||||
@@ -181,8 +196,8 @@ export class DatabaseTurnDaemonLease {
|
|||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`);
|
`);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
this.markLost();
|
this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
|
||||||
throw new TurnDaemonLeaseLostError(this.profile);
|
throw this.getLossError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,8 +234,10 @@ export class DatabaseTurnDaemonLease {
|
|||||||
}
|
}
|
||||||
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
|
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
|
||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
void this.renew().catch(() => {
|
void this.renew().catch((error: unknown) => {
|
||||||
this.markLost();
|
this.markLost(
|
||||||
|
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
this.heartbeatTimer.unref();
|
this.heartbeatTimer.unref();
|
||||||
@@ -239,7 +256,9 @@ export class DatabaseTurnDaemonLease {
|
|||||||
this.stopExpiryWatchdog();
|
this.stopExpiryWatchdog();
|
||||||
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
|
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
|
||||||
this.expiryTimer = setTimeout(() => {
|
this.expiryTimer = setTimeout(() => {
|
||||||
this.markLost();
|
this.markLost(
|
||||||
|
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
|
||||||
|
);
|
||||||
}, remainingMs);
|
}, remainingMs);
|
||||||
this.expiryTimer.unref();
|
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.lost = true;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stopExpiryWatchdog();
|
this.stopExpiryWatchdog();
|
||||||
|
|||||||
@@ -90,7 +90,20 @@ export class TurnDaemonLifecycle {
|
|||||||
|
|
||||||
start(): Promise<void> {
|
start(): Promise<void> {
|
||||||
if (!this.loopPromise) {
|
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;
|
return this.loopPromise;
|
||||||
}
|
}
|
||||||
@@ -158,6 +171,25 @@ export class TurnDaemonLifecycle {
|
|||||||
continue;
|
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) {
|
if (this.pendingRun) {
|
||||||
await this.runOnce(this.pendingRun);
|
await this.runOnce(this.pendingRun);
|
||||||
this.pendingRun = null;
|
this.pendingRun = null;
|
||||||
@@ -169,23 +201,6 @@ export class TurnDaemonLifecycle {
|
|||||||
await this.clock.sleepMs(200);
|
await this.clock.sleepMs(200);
|
||||||
continue;
|
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') {
|
if (gameClock?.mode === 'manual') {
|
||||||
// Ref observes all generals due before one monthly boundary in
|
// Ref observes all generals due before one monthly boundary in
|
||||||
// a single snapshot. Manual mode advances directly to that
|
// a single snapshot. Manual mode advances directly to that
|
||||||
@@ -222,7 +237,10 @@ export class TurnDaemonLifecycle {
|
|||||||
continue;
|
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) {
|
if (command) {
|
||||||
await this.handleCommand(command);
|
await this.handleCommand(command);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,11 +72,13 @@ export interface TurnStateStore {
|
|||||||
phase?: GameClockPhase;
|
phase?: GameClockPhase;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
deadlineGeneration?: number;
|
deadlineGeneration?: number;
|
||||||
|
startsAt?: Date;
|
||||||
}>;
|
}>;
|
||||||
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
|
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
|
||||||
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
||||||
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
||||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||||
|
projectGameDeadline?(gameTime: Date): Promise<Date>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDaemonControlQueue {
|
export interface TurnDaemonControlQueue {
|
||||||
|
|||||||
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
|
|||||||
return { ...resultFromPersisted(created), alreadyApplied: false };
|
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 (!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_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.');
|
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,
|
earnedPoint: 0,
|
||||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||||
});
|
});
|
||||||
const connector = createGamePostgresConnector({ url: request.databaseUrl });
|
const connector = connectorFactory({ url: request.databaseUrl });
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
return await connector.prisma.$transaction(
|
return await connector.prisma.$transaction(
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
|
|||||||
generalPoolOptions?: GeneralPoolLoaderOptions;
|
generalPoolOptions?: GeneralPoolLoaderOptions;
|
||||||
resetTables?: boolean;
|
resetTables?: boolean;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
|
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
|
||||||
|
wallNow?: Date;
|
||||||
tickSeconds?: number;
|
tickSeconds?: number;
|
||||||
gameClockMode?: GameClockMode;
|
gameClockMode?: GameClockMode;
|
||||||
installOptions?: ScenarioInstallOptions;
|
installOptions?: ScenarioInstallOptions;
|
||||||
@@ -240,14 +242,23 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
||||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||||
const sync = install?.sync ?? false;
|
const sync = install?.sync ?? false;
|
||||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
|
||||||
const gameClockMode = options.gameClockMode ?? 'realtime';
|
const gameClockMode = options.gameClockMode ?? 'realtime';
|
||||||
// A realtime season prepared before its formal opening must not consume
|
// A realtime season prepared before its formal opening must not consume
|
||||||
// wall time while users are only allowed to edit reserved commands.
|
// wall time while users are only allowed to edit reserved commands.
|
||||||
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
|
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
|
||||||
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
|
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({
|
const initialClock = new GameClock({
|
||||||
baseTime: startState.startTime,
|
baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
|
||||||
tick: 0,
|
tick: 0,
|
||||||
mode: gameClockMode,
|
mode: gameClockMode,
|
||||||
wallAnchor: initialClockWallAnchor,
|
wallAnchor: initialClockWallAnchor,
|
||||||
@@ -316,9 +327,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
// monthly pre-handler recalculates the same value at each boundary.
|
// monthly pre-handler recalculates the same value at each boundary.
|
||||||
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
||||||
starttime: formatDateTime(startState.startTime),
|
starttime: formatDateTime(startState.startTime),
|
||||||
turntime: formatDateTime(now),
|
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
||||||
opentime: formatDateTime(now),
|
opentime: formatDateTime(initialClockWallAnchor),
|
||||||
lastTurnTime: formatDateTime(now),
|
lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstGameIdx =
|
const firstGameIdx =
|
||||||
@@ -344,7 +355,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
worldMeta.hiddenSeed = hiddenSeed;
|
worldMeta.hiddenSeed = hiddenSeed;
|
||||||
worldMeta.seededAtWall = now.toISOString();
|
worldMeta.seededAtWall = wallNow.toISOString();
|
||||||
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
|
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
|
||||||
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
|
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
|
||||||
worldMeta.calendarStart = startState.startTime.toISOString();
|
worldMeta.calendarStart = startState.startTime.toISOString();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
|
||||||
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
|
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
|
||||||
@@ -253,7 +254,8 @@ export const createTournamentRewardFinalizer = async (options: {
|
|||||||
}))
|
}))
|
||||||
.filter((entry) => !!entry.userId);
|
.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({
|
await db.inheritancePoint.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_key: { userId: entry.userId!, key: 'tournament' },
|
userId_key: { userId: entry.userId!, key: 'tournament' },
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ const respondToScout = async (options: {
|
|||||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
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 sourceNationName = payload.src.nationName;
|
||||||
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -332,7 +339,7 @@ const respondToRaiseInvader = async (options: {
|
|||||||
reservedTurns,
|
reservedTurns,
|
||||||
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||||
clockWallNow: alignment.resumeWallAt,
|
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
|
||||||
});
|
});
|
||||||
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
||||||
await handler(
|
await handler(
|
||||||
|
|||||||
@@ -1149,6 +1149,8 @@ export class GeneralAI {
|
|||||||
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
|
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
|
||||||
|
|
||||||
let userChiefCount = 0;
|
let userChiefCount = 0;
|
||||||
|
let ambassadorCount = 0;
|
||||||
|
const assignedAmbassadorIds = new Set<number>();
|
||||||
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
|
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
|
||||||
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
|
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
|
||||||
const minNpcKillturn = 36;
|
const minNpcKillturn = 36;
|
||||||
@@ -1162,13 +1164,25 @@ export class GeneralAI {
|
|||||||
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
|
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
|
||||||
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
|
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
|
||||||
userChiefCount += 1;
|
userChiefCount += 1;
|
||||||
chief.meta = { ...chief.meta, permission: 'ambassador' };
|
if (ambassadorCount < 2) {
|
||||||
this.promotionPatches.push({
|
ambassadorCount += 1;
|
||||||
generalId: chief.id,
|
assignedAmbassadorIds.add(chief.id);
|
||||||
officerLevel: chief.officerLevel,
|
chief.meta = { ...chief.meta, permission: 'ambassador' };
|
||||||
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
|
this.promotionPatches.push({
|
||||||
permission: 'ambassador',
|
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 });
|
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
||||||
effectiveOfficerLevel.set(oldChief.id, 1);
|
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.officerLevel = 11;
|
||||||
candidate.meta = {
|
candidate.meta = {
|
||||||
...candidate.meta,
|
...candidate.meta,
|
||||||
@@ -1238,6 +1252,10 @@ export class GeneralAI {
|
|||||||
effectiveOfficerLevel.set(candidate.id, 11);
|
effectiveOfficerLevel.set(candidate.id, 11);
|
||||||
chiefSet |= 1 << 11;
|
chiefSet |= 1 << 11;
|
||||||
userChiefCount += 1;
|
userChiefCount += 1;
|
||||||
|
if (permission) {
|
||||||
|
ambassadorCount += 1;
|
||||||
|
assignedAmbassadorIds.add(candidate.id);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1307,10 +1325,16 @@ export class GeneralAI {
|
|||||||
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
||||||
}
|
}
|
||||||
const permission =
|
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) {
|
if (nextChief.npcState < 2) {
|
||||||
userChiefCount += 1;
|
userChiefCount += 1;
|
||||||
}
|
}
|
||||||
|
if (permission) {
|
||||||
|
ambassadorCount += 1;
|
||||||
|
assignedAmbassadorIds.add(nextChief.id);
|
||||||
|
}
|
||||||
this.promotionPatches.push({
|
this.promotionPatches.push({
|
||||||
generalId: nextChief.id,
|
generalId: nextChief.id,
|
||||||
officerLevel: chiefLevel,
|
officerLevel: chiefLevel,
|
||||||
@@ -1331,6 +1355,25 @@ export class GeneralAI {
|
|||||||
chiefSet |= 1 << chiefLevel;
|
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) {
|
if (chiefSet !== initialChiefSet) {
|
||||||
this.promotionNationMeta = {
|
this.promotionNationMeta = {
|
||||||
...this.nation.meta,
|
...this.nation.meta,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
|
|||||||
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
|
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
|
||||||
import { createTurnDaemonRuntime } from './turnDaemon.js';
|
import { createTurnDaemonRuntime } from './turnDaemon.js';
|
||||||
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
|
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
|
||||||
|
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
||||||
|
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
export interface TurnDaemonCliOptions {
|
export interface TurnDaemonCliOptions {
|
||||||
profile?: string;
|
profile?: string;
|
||||||
@@ -89,6 +91,27 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
|
|||||||
pauseGateIntervalMs,
|
pauseGateIntervalMs,
|
||||||
adminActionIntervalMs,
|
adminActionIntervalMs,
|
||||||
gameClockMode,
|
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({
|
const memoryReporter = createTurnDaemonMemoryReporter({
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { createHash } from 'node:crypto';
|
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 {
|
import {
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
|
persistMessageEnvelope,
|
||||||
|
writeReadModelChangeJournal,
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
type GamePrismaClient,
|
type GamePrismaClient,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
@@ -302,6 +311,47 @@ export const applyNextClockProjection = async (options: {
|
|||||||
throw new Error('Clock projection final RUNNING transition fence failed.');
|
throw new Error('Clock projection final RUNNING transition fence failed.');
|
||||||
}
|
}
|
||||||
const appliedAt = await readDbWall(transaction);
|
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({
|
await transaction.clockProjectionOutbox.update({
|
||||||
where: { id: outbox.id },
|
where: { id: outbox.id },
|
||||||
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
|
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
buildClockAlignmentPlan,
|
buildClockAlignmentPlan,
|
||||||
parseClockAlignmentPolicy,
|
parseClockAlignmentPolicy,
|
||||||
parseGameClockPhase,
|
parseGameClockPhase,
|
||||||
|
readTurnRecovery,
|
||||||
|
readSerializedTurnRecovery,
|
||||||
|
serializeTurnRecovery,
|
||||||
|
type TurnRecoveryWindow,
|
||||||
type ClockAlignmentPolicy,
|
type ClockAlignmentPolicy,
|
||||||
} from '@sammo-ts/common';
|
} from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +47,8 @@ export interface ClockReconciliationResult {
|
|||||||
shiftTicks: number;
|
shiftTicks: number;
|
||||||
alignedTick: number;
|
alignedTick: number;
|
||||||
resumeWallAt: Date;
|
resumeWallAt: Date;
|
||||||
|
recovery?: TurnRecoveryWindow | null;
|
||||||
|
resumeAnchor?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DbWallRow {
|
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 (
|
const readParticipantSnapshots = async (
|
||||||
db: GamePrisma.TransactionClient,
|
db: GamePrisma.TransactionClient,
|
||||||
worldStateId: number,
|
worldStateId: number,
|
||||||
@@ -224,6 +234,13 @@ const readParticipantSnapshots = async (
|
|||||||
where: { id: worldStateId },
|
where: { id: worldStateId },
|
||||||
select: {
|
select: {
|
||||||
clockTick: true,
|
clockTick: true,
|
||||||
|
...(supportsRecoveryColumns(db)
|
||||||
|
? {
|
||||||
|
clockRecoveryStartTick: true,
|
||||||
|
clockRecoveryEndTick: true,
|
||||||
|
clockRecoveryStartWallAt: true,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
clockRevision: true,
|
clockRevision: true,
|
||||||
deadlineGeneration: true,
|
deadlineGeneration: true,
|
||||||
lastTurnTick: true,
|
lastTurnTick: true,
|
||||||
@@ -290,6 +307,7 @@ const readParticipantSnapshots = async (
|
|||||||
snapshot('world-clock', 'REBUILD', [
|
snapshot('world-clock', 'REBUILD', [
|
||||||
{
|
{
|
||||||
clockTick: world.clockTick,
|
clockTick: world.clockTick,
|
||||||
|
...serializeTurnRecovery(readTurnRecovery(world)),
|
||||||
clockRevision: world.clockRevision,
|
clockRevision: world.clockRevision,
|
||||||
deadlineGeneration: world.deadlineGeneration,
|
deadlineGeneration: world.deadlineGeneration,
|
||||||
},
|
},
|
||||||
@@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
|||||||
cutWallAt: Date;
|
cutWallAt: Date;
|
||||||
rateTicksPerSecond: number;
|
rateTicksPerSecond: number;
|
||||||
sourceRevision: number;
|
sourceRevision: number;
|
||||||
|
normalTickAtCutWall?: number;
|
||||||
policy?: ClockAlignmentPolicy;
|
policy?: ClockAlignmentPolicy;
|
||||||
catchUpTicks?: number;
|
catchUpTicks?: number;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
@@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
|||||||
rateTicksPerSecond: options.rateTicksPerSecond,
|
rateTicksPerSecond: options.rateTicksPerSecond,
|
||||||
catchUpTicks: BigInt(catchUpTicks),
|
catchUpTicks: BigInt(catchUpTicks),
|
||||||
participantChecksumBefore: aggregateChecksum(participants),
|
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);
|
await persistInitialParticipants(options.db, options.suspensionId, participants);
|
||||||
@@ -712,6 +735,7 @@ export const startClockSuspension = async (options: {
|
|||||||
authority: ClockOperationAuthority;
|
authority: ClockOperationAuthority;
|
||||||
policy?: ClockAlignmentPolicy;
|
policy?: ClockAlignmentPolicy;
|
||||||
catchUpTicks?: number;
|
catchUpTicks?: number;
|
||||||
|
recoverDurableObservation?: boolean;
|
||||||
}): Promise<ClockSuspensionResult> => {
|
}): Promise<ClockSuspensionResult> => {
|
||||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
||||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
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) {
|
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
|
||||||
throw new Error('Clock suspension requires a fully initialized logical game clock.');
|
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 storedTick = safeNumber(world.clockTick, 'world clock tick');
|
||||||
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
||||||
const clock = new GameClock({
|
const clock = new GameClock({
|
||||||
@@ -769,11 +799,12 @@ export const startClockSuspension = async (options: {
|
|||||||
tick: storedTick,
|
tick: storedTick,
|
||||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||||
wallAnchor: world.clockWallAnchor,
|
wallAnchor: world.clockWallAnchor,
|
||||||
|
recovery: readTurnRecovery(world),
|
||||||
turnSeconds: world.tickSeconds,
|
turnSeconds: world.tickSeconds,
|
||||||
phase,
|
phase,
|
||||||
revision: sourceRevision,
|
revision: sourceRevision,
|
||||||
});
|
});
|
||||||
const cutTick = clock.nowTick(cutWallAt);
|
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
|
||||||
await lockParticipants(db, BigInt(cutTick));
|
await lockParticipants(db, BigInt(cutTick));
|
||||||
await db.worldState.update({
|
await db.worldState.update({
|
||||||
where: { id: worldStateId },
|
where: { id: worldStateId },
|
||||||
@@ -797,6 +828,7 @@ export const startClockSuspension = async (options: {
|
|||||||
detail: asJson({
|
detail: asJson({
|
||||||
authority: options.authority.kind,
|
authority: options.authority.kind,
|
||||||
profileName: options.authority.profileName,
|
profileName: options.authority.profileName,
|
||||||
|
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
|||||||
authority?: ClockOperationAuthority;
|
authority?: ClockOperationAuthority;
|
||||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||||
testResumeWallAt?: Date;
|
testResumeWallAt?: Date;
|
||||||
|
upgradeMaintenancePolicy?: boolean;
|
||||||
}): Promise<ClockReconciliationResult> => {
|
}): Promise<ClockReconciliationResult> => {
|
||||||
const db = options.db;
|
const db = options.db;
|
||||||
const worldStateId = await lockWorld(db);
|
const worldStateId = await lockWorld(db);
|
||||||
@@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
|||||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
||||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
||||||
resumeWallAt: suspension.resumeWallAt,
|
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') {
|
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.');
|
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 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({
|
const plan = buildClockAlignmentPlan({
|
||||||
policy: parseClockAlignmentPolicy(suspension.policy),
|
policy: effectivePolicy,
|
||||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||||
cutTick,
|
cutTick: alignmentCutTick,
|
||||||
cutWall: suspension.cutWallAt,
|
cutWall: suspension.cutWallAt,
|
||||||
resumeWall: resumeWallAt,
|
resumeWall: resumeWallAt,
|
||||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
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);
|
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||||
assertShiftFits(before, plan.shiftTicks);
|
assertShiftFits(before, plan.shiftTicks);
|
||||||
@@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
|||||||
targetGeneration,
|
targetGeneration,
|
||||||
BigInt(plan.shiftTicks),
|
BigInt(plan.shiftTicks),
|
||||||
projectionDeltaMilliseconds,
|
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 after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
||||||
for (const participant of before) {
|
for (const participant of before) {
|
||||||
@@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
|||||||
where: { id: suspension.id },
|
where: { id: suspension.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'RECONCILING',
|
status: 'RECONCILING',
|
||||||
|
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
|
||||||
resumeWallAt,
|
resumeWallAt,
|
||||||
gapTicks: BigInt(plan.gapTicks),
|
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),
|
shiftTicks: BigInt(plan.shiftTicks),
|
||||||
alignedTick: BigInt(plan.alignedTick),
|
alignedTick: BigInt(plan.alignedTick),
|
||||||
participantChecksumBefore: aggregateChecksum(before),
|
participantChecksumBefore: aggregateChecksum(before),
|
||||||
@@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
|||||||
shiftTicks: plan.shiftTicks,
|
shiftTicks: plan.shiftTicks,
|
||||||
alignedTick: plan.alignedTick,
|
alignedTick: plan.alignedTick,
|
||||||
resumeWallAt,
|
resumeWallAt,
|
||||||
|
recovery: plan.recovery ?? null,
|
||||||
|
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: {
|
|||||||
authority: ClockOperationAuthority;
|
authority: ClockOperationAuthority;
|
||||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||||
testResumeWallAt?: Date;
|
testResumeWallAt?: Date;
|
||||||
|
upgradeMaintenancePolicy?: boolean;
|
||||||
}): Promise<ClockReconciliationResult> =>
|
}): Promise<ClockReconciliationResult> =>
|
||||||
runSerializableClockOperation(() =>
|
runSerializableClockOperation(() =>
|
||||||
options.db.$transaction(
|
options.db.$transaction(
|
||||||
@@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: {
|
|||||||
profileName: options.authority.profileName,
|
profileName: options.authority.profileName,
|
||||||
authority: options.authority,
|
authority: options.authority,
|
||||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
||||||
|
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
||||||
@@ -65,6 +66,7 @@ import {
|
|||||||
} from './clockReconciliation.js';
|
} from './clockReconciliation.js';
|
||||||
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
|
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
|
||||||
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
|
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
|
||||||
|
import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
||||||
|
|
||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
hooks: TurnDaemonHooks;
|
hooks: TurnDaemonHooks;
|
||||||
@@ -73,6 +75,7 @@ export interface DatabaseTurnHooks {
|
|||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
|
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
|
||||||
synchronizeClockAuthority(): Promise<boolean>;
|
synchronizeClockAuthority(): Promise<boolean>;
|
||||||
|
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommittedReadModelChangeReceipt {
|
export interface CommittedReadModelChangeReceipt {
|
||||||
@@ -141,6 +144,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
|
|||||||
'clockTick',
|
'clockTick',
|
||||||
'clock_tick',
|
'clock_tick',
|
||||||
'clockWallAnchor',
|
'clockWallAnchor',
|
||||||
|
'clockRecoveryStartTick',
|
||||||
|
'clockRecoveryEndTick',
|
||||||
|
'clockRecoveryStartWallAt',
|
||||||
'clock_wall_anchor',
|
'clock_wall_anchor',
|
||||||
'heartbeat',
|
'heartbeat',
|
||||||
'heartbeatAt',
|
'heartbeatAt',
|
||||||
@@ -448,8 +454,21 @@ const markIds = (journal: ChangeJournal, domain: ReadModelDomain, ids: readonly
|
|||||||
* actor-targeted. General name/nation changes affect the global online list,
|
* actor-targeted. General name/nation changes affect the global online list,
|
||||||
* while frontStatusActorIds is the private actor projection.
|
* 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();
|
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, 'general.content', changes.generalIds);
|
||||||
markIds(journal, 'city.content', changes.cityIds);
|
markIds(journal, 'city.content', changes.cityIds);
|
||||||
markIds(journal, 'nation.content', changes.nationIds);
|
markIds(journal, 'nation.content', changes.nationIds);
|
||||||
@@ -597,7 +616,8 @@ const persistNationBettingOpen = async (
|
|||||||
|
|
||||||
const persistNationBettingFinish = async (
|
const persistNationBettingFinish = async (
|
||||||
prisma: GamePrisma.TransactionClient,
|
prisma: GamePrisma.TransactionClient,
|
||||||
finish: PendingNationBettingFinish
|
finish: PendingNationBettingFinish,
|
||||||
|
recordsFinalized: boolean
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await prisma.$queryRaw`
|
await prisma.$queryRaw`
|
||||||
SELECT id
|
SELECT id
|
||||||
@@ -647,7 +667,7 @@ const persistNationBettingFinish = async (
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const reward of rewards) {
|
for (const reward of recordsFinalized ? [] : rewards) {
|
||||||
if (!reward.userId) {
|
if (!reward.userId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1139,6 +1159,9 @@ export const createDatabaseTurnHooks = async (
|
|||||||
clockTick: BigInt(state.clockTick ?? 0),
|
clockTick: BigInt(state.clockTick ?? 0),
|
||||||
clockMode: state.clockMode ?? 'manual',
|
clockMode: state.clockMode ?? 'manual',
|
||||||
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
|
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)),
|
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
|
||||||
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
|
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
|
||||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||||
@@ -1260,7 +1283,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
}
|
}
|
||||||
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
|
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
|
||||||
const unificationCutTick = unificationCutWallAt
|
const unificationCutTick = unificationCutWallAt
|
||||||
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
|
? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime))
|
||||||
: null;
|
: null;
|
||||||
const suspensionPreparation =
|
const suspensionPreparation =
|
||||||
unificationCutTick !== null
|
unificationCutTick !== null
|
||||||
@@ -1443,11 +1466,12 @@ export const createDatabaseTurnHooks = async (
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recordsFinalized = await areSeasonRecordsFinalized(prisma, asRecord(state.meta).serverId);
|
||||||
for (const betting of pendingNationBettingOpens) {
|
for (const betting of pendingNationBettingOpens) {
|
||||||
await persistNationBettingOpen(prisma, betting);
|
await persistNationBettingOpen(prisma, betting);
|
||||||
}
|
}
|
||||||
for (const finish of pendingNationBettingFinishes) {
|
for (const finish of pendingNationBettingFinishes) {
|
||||||
await persistNationBettingFinish(prisma, finish);
|
await persistNationBettingFinish(prisma, finish, recordsFinalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = asRecord(state.meta);
|
const meta = asRecord(state.meta);
|
||||||
@@ -1456,7 +1480,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
const persistInheritancePointAdjustments = async (
|
const persistInheritancePointAdjustments = async (
|
||||||
entries: typeof inheritancePointAdjustments
|
entries: typeof inheritancePointAdjustments
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (entries.length === 0) {
|
if (recordsFinalized || entries.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
|
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> => {
|
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
|
||||||
if (entries.length === 0) {
|
if (recordsFinalized || entries.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await prisma.inheritanceLog.createMany({
|
await prisma.inheritanceLog.createMany({
|
||||||
@@ -1852,6 +1876,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
await persistYearbookSnapshot(prisma, snapshot);
|
await persistYearbookSnapshot(prisma, snapshot);
|
||||||
}
|
}
|
||||||
const persistedMessageMailboxes: number[] = [];
|
const persistedMessageMailboxes: number[] = [];
|
||||||
|
const persistedDiplomacyMailboxes: number[] = [];
|
||||||
for (const finalization of pendingUnificationFinalizations) {
|
for (const finalization of pendingUnificationFinalizations) {
|
||||||
if (options?.profileName && finalization.profileName !== options.profileName) {
|
if (options?.profileName && finalization.profileName !== options.profileName) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1878,7 +1903,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
expiresGameTick,
|
expiresGameTick,
|
||||||
});
|
});
|
||||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||||
persistedMessageMailboxes.push(draft.mailbox);
|
if (draft.msgType === 'diplomacy') {
|
||||||
|
persistedDiplomacyMailboxes.push(draft.mailbox);
|
||||||
|
} else {
|
||||||
|
persistedMessageMailboxes.push(draft.mailbox);
|
||||||
|
}
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1886,6 +1915,37 @@ export const createDatabaseTurnHooks = async (
|
|||||||
{ sendDestOnly: message.sendDestOnly }
|
{ 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) {
|
if (options?.reservedTurns && persistedReservedTurnChanges) {
|
||||||
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges);
|
||||||
}
|
}
|
||||||
@@ -1904,7 +1964,9 @@ export const createDatabaseTurnHooks = async (
|
|||||||
worldStateId: state.id,
|
worldStateId: state.id,
|
||||||
profileName: options?.profileName ?? 'default',
|
profileName: options?.profileName ?? 'default',
|
||||||
source: 'UNIFICATION_WAIT',
|
source: 'UNIFICATION_WAIT',
|
||||||
|
policy: 'TURN_BOUNDARY',
|
||||||
cutTick,
|
cutTick,
|
||||||
|
normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt),
|
||||||
cutWallAt: suspensionPreparation.cutWallAt,
|
cutWallAt: suspensionPreparation.cutWallAt,
|
||||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
|
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
|
||||||
sourceRevision: state.clockRevision ?? 1,
|
sourceRevision: state.clockRevision ?? 1,
|
||||||
@@ -1963,11 +2025,12 @@ export const createDatabaseTurnHooks = async (
|
|||||||
if (worldReadModelSignature !== worldReadModelBaseline) {
|
if (worldReadModelSignature !== worldReadModelBaseline) {
|
||||||
readModelChanges.worldChanged = true;
|
readModelChanges.worldChanged = true;
|
||||||
}
|
}
|
||||||
const journal = createReadModelChangeJournal(readModelChanges);
|
const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
|
||||||
if (hasDashboardSourceMutation(changes, readModelChanges)) {
|
if (hasDashboardSourceMutation(changes, readModelChanges)) {
|
||||||
journal.mark('dashboard.global');
|
journal.mark('dashboard.global');
|
||||||
}
|
}
|
||||||
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
|
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
|
||||||
|
markIds(journal, 'messages.diplomacyMailbox', uniqueSortedIds(persistedDiplomacyMailboxes));
|
||||||
markIds(journal, 'access.general', accessScoreResetGeneralIds);
|
markIds(journal, 'access.general', accessScoreResetGeneralIds);
|
||||||
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
|
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
|
||||||
journal.mark('betting');
|
journal.mark('betting');
|
||||||
@@ -2057,6 +2120,25 @@ export const createDatabaseTurnHooks = async (
|
|||||||
});
|
});
|
||||||
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
|
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: () =>
|
synchronizeClockAuthority: () =>
|
||||||
prisma.$transaction(async (transaction) => {
|
prisma.$transaction(async (transaction) => {
|
||||||
await options?.turnDaemonLease?.assertActive(transaction);
|
await options?.turnDaemonLease?.assertActive(transaction);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { performance } from 'node:perf_hooks';
|
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';
|
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
export interface GatewayProfileGateOptions {
|
export interface GatewayProfileGateOptions {
|
||||||
@@ -8,6 +9,7 @@ export interface GatewayProfileGateOptions {
|
|||||||
gatewayDatabaseUrl?: string;
|
gatewayDatabaseUrl?: string;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
cacheMs?: number;
|
cacheMs?: number;
|
||||||
|
incidentContext?: () => Record<string, string | number | boolean | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayProfileGate {
|
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> => {
|
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
|
||||||
const connector = createGatewayPostgresConnector({
|
const connector = createGatewayPostgresConnector({
|
||||||
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
|
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
|
||||||
|
connectionTimeoutMillis: 3000,
|
||||||
});
|
});
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
const prisma = connector.prisma;
|
const prisma = connector.prisma;
|
||||||
@@ -54,19 +57,44 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
|
|||||||
return cachedPause;
|
return cachedPause;
|
||||||
},
|
},
|
||||||
async markPaused(error?: unknown): Promise<void> {
|
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 {
|
try {
|
||||||
await prisma.gatewayProfile.updateMany({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
const updated = await tx.gatewayProfile.updateMany({
|
||||||
profileName: options.profileName,
|
where: {
|
||||||
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
|
profileName: options.profileName,
|
||||||
},
|
status: { in: [...PROFILE_STATUSES_MARKABLE_AS_PAUSED] },
|
||||||
data: {
|
OR: [{ status: { not: 'PAUSED' } }, { lastError: { not: message } }, { lastError: null }],
|
||||||
status: 'PAUSED',
|
},
|
||||||
lastError: message,
|
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 {
|
} catch {
|
||||||
|
if (failure) console.error('[turn-daemon] failed to persist runtime incident', failure);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import {
|
import {
|
||||||
asRecord,
|
asRecord,
|
||||||
HALL_OF_FAME_TYPES,
|
HALL_OF_FAME_TYPES,
|
||||||
@@ -403,10 +404,15 @@ export const persistGeneralLifecycleEvents = async (
|
|||||||
data: { refreshScore: 0 },
|
data: { refreshScore: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recordsFinalized = await areSeasonRecordsFinalized(prisma, worldMeta.serverId);
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.outcome === 'detached' || event.outcome === 'deleted') {
|
if (event.outcome === 'detached' || event.outcome === 'deleted') {
|
||||||
await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } });
|
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') {
|
if (event.outcome !== 'deleted' && event.outcome !== 'retired') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
|||||||
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
|
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
|
||||||
revision: number;
|
revision: number;
|
||||||
deadlineGeneration: number;
|
deadlineGeneration: number;
|
||||||
|
startsAt: Date;
|
||||||
}> {
|
}> {
|
||||||
const state = this.world.getGameClockState();
|
const state = this.world.getGameClockState();
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
|||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
revision: state.revision,
|
revision: state.revision,
|
||||||
deadlineGeneration: state.deadlineGeneration,
|
deadlineGeneration: state.deadlineGeneration,
|
||||||
|
startsAt: state.wallAnchor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
|||||||
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
|
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
|
||||||
this.world.advanceGameClockTo(target, wallNow);
|
this.world.advanceGameClockTo(target, wallNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async projectGameDeadline(gameTime: Date): Promise<Date> {
|
||||||
|
return this.world.projectGameDeadline(gameTime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import type {
|
|||||||
TurnSchedule,
|
TurnSchedule,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
|
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
GAME_TICKS_PER_TURN,
|
GAME_TICKS_PER_TURN,
|
||||||
GameClock,
|
GameClock,
|
||||||
assertGameplayCommitAllowed,
|
assertGameplayCommitAllowed,
|
||||||
inferClockPhase,
|
inferClockPhase,
|
||||||
|
JosaUtil,
|
||||||
type GameClockMode,
|
type GameClockMode,
|
||||||
type GameClockPhase,
|
type GameClockPhase,
|
||||||
|
type TurnRecoveryWindow,
|
||||||
} from '@sammo-ts/common';
|
} from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||||
@@ -77,6 +79,7 @@ export interface GeneralTurnResult {
|
|||||||
troopIds?: number[];
|
troopIds?: number[];
|
||||||
};
|
};
|
||||||
destroyedNationIds?: number[];
|
destroyedNationIds?: number[];
|
||||||
|
successorlessNationId?: number;
|
||||||
lifecycleEvent?: GeneralLifecycleEvent;
|
lifecycleEvent?: GeneralLifecycleEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ export interface InMemoryGameClockState {
|
|||||||
tick: number;
|
tick: number;
|
||||||
mode: GameClockMode;
|
mode: GameClockMode;
|
||||||
wallAnchor: Date;
|
wallAnchor: Date;
|
||||||
|
recovery?: TurnRecoveryWindow | null;
|
||||||
lastTurnTick: number;
|
lastTurnTick: number;
|
||||||
phase: GameClockPhase;
|
phase: GameClockPhase;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -147,6 +151,8 @@ export interface DurableClockReconciliationAlignment {
|
|||||||
alignedTick: number;
|
alignedTick: number;
|
||||||
shiftTicks: number;
|
shiftTicks: number;
|
||||||
resumeWallAt: Date;
|
resumeWallAt: Date;
|
||||||
|
resumeAnchor?: Date;
|
||||||
|
recovery?: TurnRecoveryWindow | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
|
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
|
||||||
@@ -654,6 +660,7 @@ export class InMemoryTurnWorld {
|
|||||||
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
|
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
|
||||||
mode: this.state.clockMode ?? 'manual',
|
mode: this.state.clockMode ?? 'manual',
|
||||||
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
||||||
|
recovery: this.state.clockRecovery,
|
||||||
turnSeconds: this.state.tickSeconds,
|
turnSeconds: this.state.tickSeconds,
|
||||||
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
||||||
revision: this.state.clockRevision ?? 1,
|
revision: this.state.clockRevision ?? 1,
|
||||||
@@ -684,6 +691,7 @@ export class InMemoryTurnWorld {
|
|||||||
tick: this.state.clockTick ?? 0,
|
tick: this.state.clockTick ?? 0,
|
||||||
mode: this.state.clockMode ?? 'manual',
|
mode: this.state.clockMode ?? 'manual',
|
||||||
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
|
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
|
||||||
|
recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null,
|
||||||
lastTurnTick: this.state.lastTurnTick ?? 0,
|
lastTurnTick: this.state.lastTurnTick ?? 0,
|
||||||
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
||||||
revision: this.state.clockRevision ?? 1,
|
revision: this.state.clockRevision ?? 1,
|
||||||
@@ -695,6 +703,15 @@ export class InMemoryTurnWorld {
|
|||||||
return this.getGameClock().now(wallNow);
|
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 {
|
promotePreopenAtOpening(wallNow: Date): boolean {
|
||||||
const clock = this.getGameClock();
|
const clock = this.getGameClock();
|
||||||
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
|
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
|
||||||
@@ -759,7 +776,8 @@ export class InMemoryTurnWorld {
|
|||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
clockTick: input.alignedTick,
|
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,
|
lastTurnTick,
|
||||||
lastTurnTime,
|
lastTurnTime,
|
||||||
clockPhase: 'RECONCILING',
|
clockPhase: 'RECONCILING',
|
||||||
@@ -865,6 +883,7 @@ export class InMemoryTurnWorld {
|
|||||||
clockTick: input.tick,
|
clockTick: input.tick,
|
||||||
clockMode: input.mode,
|
clockMode: input.mode,
|
||||||
clockWallAnchor: new Date(input.wallAnchor.getTime()),
|
clockWallAnchor: new Date(input.wallAnchor.getTime()),
|
||||||
|
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
|
||||||
clockPhase: input.phase,
|
clockPhase: input.phase,
|
||||||
clockRevision: input.revision,
|
clockRevision: input.revision,
|
||||||
deadlineGeneration: input.deadlineGeneration,
|
deadlineGeneration: input.deadlineGeneration,
|
||||||
@@ -918,16 +937,17 @@ export class InMemoryTurnWorld {
|
|||||||
skippedTurns: number;
|
skippedTurns: number;
|
||||||
} | null {
|
} | null {
|
||||||
const clock = this.getGameClock();
|
const clock = this.getGameClock();
|
||||||
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
|
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) {
|
||||||
return null;
|
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 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 lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime);
|
||||||
const skippedTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN);
|
// 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
|
||||||
return skippedTurns > threshold ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null;
|
// 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
|
||||||
|
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 {
|
shouldRebaseRealtimeBacklog(wallNow: Date): boolean {
|
||||||
@@ -1180,6 +1200,9 @@ export class InMemoryTurnWorld {
|
|||||||
lastTurnTime: this.state.lastTurnTime.toISOString(),
|
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 currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor;
|
||||||
const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
|
const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
|
||||||
const anchorTick = previousClock.nowTick(anchorWall);
|
const anchorTick = previousClock.nowTick(anchorWall);
|
||||||
@@ -1200,6 +1223,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
tickSeconds: nextTickSeconds,
|
tickSeconds: nextTickSeconds,
|
||||||
|
clockRecovery: null,
|
||||||
clockBaseTime: nextBaseTime,
|
clockBaseTime: nextBaseTime,
|
||||||
clockTick: anchorTick,
|
clockTick: anchorTick,
|
||||||
clockWallAnchor: new Date(anchorWall.getTime()),
|
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) {
|
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
|
||||||
throw new Error('Schedule shift must be a non-zero integer number of minutes.');
|
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 deltaMs = deltaMinutes * 60_000;
|
||||||
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
|
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
|
||||||
const previousClock = this.getGameClock();
|
const previousClock = this.getGameClock();
|
||||||
@@ -1693,6 +1720,7 @@ export class InMemoryTurnWorld {
|
|||||||
tick: this.state.clockTick ?? 0,
|
tick: this.state.clockTick ?? 0,
|
||||||
mode: this.state.clockMode ?? 'manual',
|
mode: this.state.clockMode ?? 'manual',
|
||||||
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
||||||
|
recovery: this.state.clockRecovery,
|
||||||
turnSeconds: this.state.tickSeconds,
|
turnSeconds: this.state.tickSeconds,
|
||||||
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
||||||
revision: this.state.clockRevision ?? 1,
|
revision: this.state.clockRevision ?? 1,
|
||||||
@@ -1708,10 +1736,14 @@ export class InMemoryTurnWorld {
|
|||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
clockBaseTime: nextBaseTime,
|
clockBaseTime: nextBaseTime,
|
||||||
// Rebasing is also the explicit resume checkpoint. Realtime mode
|
// 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다.
|
||||||
// must not replay the operational downtime after an administrator
|
clockWallAnchor: shiftDate(previousClock.wallAnchor),
|
||||||
// deliberately delays or accelerates the game schedule.
|
clockRecovery: this.state.clockRecovery
|
||||||
clockWallAnchor: new Date(wallNow.getTime()),
|
? {
|
||||||
|
...this.state.clockRecovery,
|
||||||
|
startWallAt: shiftDate(this.state.clockRecovery.startWallAt),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
lastTurnTime: nextLastTurnTime,
|
lastTurnTime: nextLastTurnTime,
|
||||||
meta: nextMeta,
|
meta: nextMeta,
|
||||||
};
|
};
|
||||||
@@ -1998,6 +2030,11 @@ export class InMemoryTurnWorld {
|
|||||||
this.removeTroop(troopId);
|
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) {
|
if (result.deleted?.general) {
|
||||||
this.removeGeneral(currentGeneral.id);
|
this.removeGeneral(currentGeneral.id);
|
||||||
}
|
}
|
||||||
@@ -2197,6 +2234,64 @@ export class InMemoryTurnWorld {
|
|||||||
return changes;
|
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 {
|
collapseNation(nationId: number): boolean {
|
||||||
const nation = this.nations.get(nationId);
|
const nation = this.nations.get(nationId);
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
@@ -96,7 +97,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
|
|||||||
export const resolveLegacyPenalty = (
|
export const resolveLegacyPenalty = (
|
||||||
rawPenalty: Record<string, unknown> | undefined,
|
rawPenalty: Record<string, unknown> | undefined,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
acceptedAt: Date
|
requestedAtWall: Date
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
if (!rawPenalty) {
|
if (!rawPenalty) {
|
||||||
return {};
|
return {};
|
||||||
@@ -105,7 +106,7 @@ export const resolveLegacyPenalty = (
|
|||||||
...asRecord(rawPenalty.any),
|
...asRecord(rawPenalty.any),
|
||||||
...asRecord(rawPenalty[profileId]),
|
...asRecord(rawPenalty[profileId]),
|
||||||
};
|
};
|
||||||
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
|
const acceptedAtSeconds = requestedAtWall.getTime() / 1000;
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const [key, rawEntry] of Object.entries(merged)) {
|
for (const [key, rawEntry] of Object.entries(merged)) {
|
||||||
const entry = asRecord(rawEntry);
|
const entry = asRecord(rawEntry);
|
||||||
@@ -613,14 +614,20 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
||||||
const inheritConstants = resolveInheritConstants(worldState);
|
const inheritConstants = resolveInheritConstants(worldState);
|
||||||
const worldMeta = asRecord(worldState.meta);
|
const worldMeta = asRecord(worldState.meta);
|
||||||
|
const recordsFinalized = await areSeasonRecordsFinalized(db, worldMeta.serverId);
|
||||||
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
||||||
const currentInheritancePoint = await applyInheritanceUser(
|
if (recordsFinalized && inheritRequiredPoint > 0) {
|
||||||
db,
|
fail('BAD_REQUEST', '통일 이후에는 유산 포인트를 사용하는 생성 옵션을 적용할 수 없습니다.');
|
||||||
input.userId,
|
}
|
||||||
worldState.currentYear,
|
const currentInheritancePoint = recordsFinalized
|
||||||
worldState.currentMonth
|
? (await db.inheritancePoint.findMany({ where: { userId: input.userId }, select: { value: true } })).reduce(
|
||||||
);
|
(sum, row) => sum + row.value,
|
||||||
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
|
0
|
||||||
|
)
|
||||||
|
: await applyInheritanceUser(db, input.userId, worldState.currentYear, worldState.currentMonth);
|
||||||
|
if (!recordsFinalized) {
|
||||||
|
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
|
||||||
|
}
|
||||||
if (currentInheritancePoint < inheritRequiredPoint) {
|
if (currentInheritancePoint < inheritRequiredPoint) {
|
||||||
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
||||||
}
|
}
|
||||||
@@ -654,12 +661,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
const generalId = world.getNextGeneralId();
|
const generalId = world.getNextGeneralId();
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
buildJoinCreateGeneralSeed(
|
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId)
|
||||||
hiddenSeed,
|
|
||||||
input.seedOwnerIdentity,
|
|
||||||
world.dateToGameTick(acceptedAt),
|
|
||||||
generalId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
||||||
@@ -748,7 +750,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
? input.ownerIconRevision
|
? input.ownerIconRevision
|
||||||
: undefined;
|
: undefined;
|
||||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
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;
|
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||||
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
|
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
|
||||||
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
|
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
|
||||||
@@ -805,7 +807,9 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
lastTurn: { command: DEFAULT_TURN_ACTION },
|
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: {
|
inheritancePoints: {
|
||||||
previous: finalInheritancePoint,
|
previous: finalInheritancePoint,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
@@ -127,7 +128,7 @@ export const createOpenNationBettingHandler = (options: {
|
|||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
nationName: nation?.name ?? '재야',
|
nationName: nation?.name ?? '재야',
|
||||||
color: nation?.color ?? '#000000',
|
color: nation?.color ?? '#000000',
|
||||||
icon: general.picture ?? '',
|
icon: resolveMessageTargetIcon(general),
|
||||||
},
|
},
|
||||||
text,
|
text,
|
||||||
time: now,
|
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 { asNumber, asRecord } from '@sammo-ts/common';
|
||||||
|
import type { TurnWorldState } from './types.js';
|
||||||
|
|
||||||
const DEFAULT_MIN_TURNS = 2;
|
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 => {
|
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
|
||||||
const value = meta.prestart_delete_after;
|
const value = meta.prestart_delete_after;
|
||||||
if (typeof value !== 'string' || !value.trim()) {
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import type {
|
import type {
|
||||||
ActionContextBase,
|
ActionContextBase,
|
||||||
ActionContextBuilder,
|
ActionContextBuilder,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
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';
|
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 =>
|
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 readNextAvailableTurn = (nation: Nation, actionName: string): number | null => {
|
||||||
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
|
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
|
||||||
@@ -2120,7 +2122,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
nationId: currentGeneral.nationId,
|
nationId: currentGeneral.nationId,
|
||||||
nationName: currentNation?.name ?? '재야',
|
nationName: currentNation?.name ?? '재야',
|
||||||
color: currentNation?.color ?? '#000000',
|
color: currentNation?.color ?? '#000000',
|
||||||
icon: currentGeneral.picture ?? '',
|
icon: resolveMessageTargetIcon(currentGeneral),
|
||||||
};
|
};
|
||||||
messages.push({
|
messages.push({
|
||||||
msgType: 'public',
|
msgType: 'public',
|
||||||
@@ -2251,6 +2253,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
? 'retired'
|
? 'retired'
|
||||||
: 'active';
|
: 'active';
|
||||||
let deleteGeneral = false;
|
let deleteGeneral = false;
|
||||||
|
let successorlessNationId: number | undefined;
|
||||||
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
const deletedTroopIds = Array.from(commandDeletedTroopIds);
|
||||||
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
const lifecycleSnapshot = cloneTurnGeneral(currentGeneral);
|
||||||
if (currentGeneral.meta.killturn <= 0) {
|
if (currentGeneral.meta.killturn <= 0) {
|
||||||
@@ -2350,6 +2353,10 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
|
`<Y>${successor.name}</>이 <D><b>${currentNation.name}</b></>의 유지를 이어 받았습니다`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Ref nextRuler()는 후계자가 없으면 군주 삭제 전에
|
||||||
|
// deleteNation($general, true)로 국가 전체를 정산한다.
|
||||||
|
successorlessNationId = currentNation.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentGeneral.troopId === currentGeneral.id) {
|
if (currentGeneral.troopId === currentGeneral.id) {
|
||||||
@@ -2423,6 +2430,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
}
|
}
|
||||||
: undefined),
|
: undefined),
|
||||||
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
|
...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined),
|
||||||
|
...(successorlessNationId !== undefined ? { successorlessNationId } : {}),
|
||||||
lifecycleEvent: {
|
lifecycleEvent: {
|
||||||
generalId: currentGeneral.id,
|
generalId: currentGeneral.id,
|
||||||
outcome: lifecycleOutcome,
|
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 { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
@@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
|||||||
clockTick: true,
|
clockTick: true,
|
||||||
clockMode: true,
|
clockMode: true,
|
||||||
clockWallAnchor: true,
|
clockWallAnchor: true,
|
||||||
|
clockRecoveryStartTick: true,
|
||||||
|
clockRecoveryEndTick: true,
|
||||||
|
clockRecoveryStartWallAt: true,
|
||||||
lastTurnTick: true,
|
lastTurnTick: true,
|
||||||
clockPhase: true,
|
clockPhase: true,
|
||||||
clockRevision: true,
|
clockRevision: true,
|
||||||
@@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
|||||||
shiftTicks: true,
|
shiftTicks: true,
|
||||||
alignedTick: true,
|
alignedTick: true,
|
||||||
resumeWallAt: true,
|
resumeWallAt: true,
|
||||||
|
detail: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let expectedRevision = before.revision;
|
let expectedRevision = before.revision;
|
||||||
@@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
|||||||
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
|
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
|
||||||
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
|
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
|
||||||
resumeWallAt: ledger.resumeWallAt,
|
resumeWallAt: ledger.resumeWallAt,
|
||||||
|
recovery: readSerializedTurnRecovery(ledger.detail),
|
||||||
});
|
});
|
||||||
expectedRevision = targetRevision;
|
expectedRevision = targetRevision;
|
||||||
}
|
}
|
||||||
@@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
|||||||
tick: safeNumber(durable.clockTick, 'durable clock tick'),
|
tick: safeNumber(durable.clockTick, 'durable clock tick'),
|
||||||
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
|
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||||
wallAnchor: durable.clockWallAnchor,
|
wallAnchor: durable.clockWallAnchor,
|
||||||
|
recovery: readTurnRecovery(durable),
|
||||||
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
|
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
|
||||||
phase: parseGameClockPhase(durable.clockPhase),
|
phase: parseGameClockPhase(durable.clockPhase),
|
||||||
revision: durableRevision,
|
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,
|
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
|
||||||
profileName: options.profileName,
|
profileName: options.profileName,
|
||||||
cacheMs: options.pauseGateIntervalMs,
|
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;
|
: null;
|
||||||
if (gatewayGate) {
|
if (gatewayGate) {
|
||||||
@@ -900,6 +913,17 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
turnDaemonLease: turnDaemonLease ?? undefined,
|
turnDaemonLease: turnDaemonLease ?? undefined,
|
||||||
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
|
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({
|
auctionBidder = await createAuctionBidder({
|
||||||
databaseUrl: options.databaseUrl,
|
databaseUrl: options.databaseUrl,
|
||||||
world,
|
world,
|
||||||
@@ -983,6 +1007,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
redisConnector = realtimeRuntime.redisConnector;
|
redisConnector = realtimeRuntime.redisConnector;
|
||||||
hooks = realtimeRuntime.hooks;
|
hooks = realtimeRuntime.hooks;
|
||||||
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
|
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
|
||||||
|
// 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다.
|
||||||
|
// RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다.
|
||||||
|
await turnDaemonLease?.markClockReady();
|
||||||
|
|
||||||
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
||||||
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
||||||
@@ -1046,7 +1073,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
hooks,
|
hooks,
|
||||||
pauseGate: async () => {
|
pauseGate: async () => {
|
||||||
if (turnDaemonLease?.isLost()) {
|
if (turnDaemonLease?.isLost()) {
|
||||||
return true;
|
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
|
||||||
|
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
|
||||||
|
throw turnDaemonLease.getLossError();
|
||||||
}
|
}
|
||||||
const gatewayPaused = (await pauseGate?.()) ?? false;
|
const gatewayPaused = (await pauseGate?.()) ?? false;
|
||||||
const phase = world.getGameClockState().phase;
|
const phase = world.getGameClockState().phase;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
GeneralLastTurn,
|
GeneralLastTurn,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
|
import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common';
|
||||||
|
|
||||||
export interface TurnWorldState {
|
export interface TurnWorldState {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,6 +23,7 @@ export interface TurnWorldState {
|
|||||||
clockTick?: number;
|
clockTick?: number;
|
||||||
clockMode?: GameClockMode;
|
clockMode?: GameClockMode;
|
||||||
clockWallAnchor?: Date;
|
clockWallAnchor?: Date;
|
||||||
|
clockRecovery?: TurnRecoveryWindow | null;
|
||||||
lastTurnTick?: number;
|
lastTurnTick?: number;
|
||||||
clockPhase?: GameClockPhase;
|
clockPhase?: GameClockPhase;
|
||||||
clockRevision?: number;
|
clockRevision?: number;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -156,7 +157,7 @@ export const createUnificationHandler = (options: {
|
|||||||
nationId: winner.id,
|
nationId: winner.id,
|
||||||
nationName: winner.name,
|
nationName: winner.name,
|
||||||
color: winner.color,
|
color: winner.color,
|
||||||
icon: recipient.picture ?? '',
|
icon: resolveMessageTargetIcon(recipient),
|
||||||
},
|
},
|
||||||
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
|
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
|
||||||
time: context.turnTime,
|
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 { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
@@ -95,6 +97,8 @@ const claimGeneration = async (
|
|||||||
}
|
}
|
||||||
return 'ALREADY_APPLIED';
|
return 'ALREADY_APPLIED';
|
||||||
}
|
}
|
||||||
|
// 이전 버전/이관 기수에 generation row가 없어도 통일 기록을 다시 정산하지 않는다.
|
||||||
|
if (await areSeasonRecordsFinalized(transaction, input.serverId)) return 'ALREADY_APPLIED';
|
||||||
await transaction.unificationFinalization.create({
|
await transaction.unificationFinalization.create({
|
||||||
data: {
|
data: {
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
@@ -243,7 +247,7 @@ const cancelPendingUniqueAuctions = async (
|
|||||||
nationId: bidder.nationId,
|
nationId: bidder.nationId,
|
||||||
nationName: nation?.name ?? '재야',
|
nationName: nation?.name ?? '재야',
|
||||||
color: nation?.color ?? '#000000',
|
color: nation?.color ?? '#000000',
|
||||||
icon: bidder.picture ?? '',
|
icon: resolveMessageTargetIcon(bidder),
|
||||||
},
|
},
|
||||||
text: `${planned.auctionId}번 ${planned.title}가 취소되었습니다.`,
|
text: `${planned.auctionId}번 ${planned.title}가 취소되었습니다.`,
|
||||||
time: input.completedAt,
|
time: input.completedAt,
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ import {
|
|||||||
} from './selectPoolService.js';
|
} from './selectPoolService.js';
|
||||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.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 { respondToActionableMessage } from './actionableMessageResponse.js';
|
||||||
import { executeInheritanceAction } from './inheritanceActionService.js';
|
import { executeInheritanceAction } from './inheritanceActionService.js';
|
||||||
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
|
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
|
||||||
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
|
|||||||
throw new Error('Join world state is missing.');
|
throw new Error('Join world state is missing.');
|
||||||
}
|
}
|
||||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
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 {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'joinCreateGeneral',
|
type: 'joinCreateGeneral',
|
||||||
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
|
|||||||
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
||||||
}
|
}
|
||||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
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 {
|
try {
|
||||||
return {
|
return {
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
|
|||||||
const db = requireCommandDatabase(ctx);
|
const db = requireCommandDatabase(ctx);
|
||||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const worldState = ctx.world.getState();
|
const worldState = ctx.world.getState();
|
||||||
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
|
if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
|
||||||
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
|
|
||||||
return {
|
return {
|
||||||
type: 'ensureDieOnPrestartStatus',
|
type: 'ensureDieOnPrestartStatus',
|
||||||
generalId: command.generalId,
|
generalId: command.generalId,
|
||||||
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
|
|||||||
}
|
}
|
||||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const worldState = world.getState();
|
const worldState = world.getState();
|
||||||
const opentime = worldState.meta.opentime as string | undefined;
|
if (hasStartedForPrestartActions(worldState)) {
|
||||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
|
||||||
return {
|
return {
|
||||||
type: 'dieOnPrestart',
|
type: 'dieOnPrestart',
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
|
|||||||
await assertImmediateGeneralActionActor(ctx, command, general);
|
await assertImmediateGeneralActionActor(ctx, command, general);
|
||||||
|
|
||||||
const worldState = world.getState();
|
const worldState = world.getState();
|
||||||
const opentime = worldState.meta.opentime as string | undefined;
|
if (hasStartedForPrestartActions(worldState)) {
|
||||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
|
||||||
return {
|
return {
|
||||||
type: 'buildNationCandidate',
|
type: 'buildNationCandidate',
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
GameClock,
|
GameClock,
|
||||||
|
readTurnRecovery,
|
||||||
asRecord,
|
asRecord,
|
||||||
inferClockPhase,
|
inferClockPhase,
|
||||||
isRecord,
|
isRecord,
|
||||||
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
|||||||
worldState.clockWallAnchor !== null &&
|
worldState.clockWallAnchor !== null &&
|
||||||
worldState.lastTurnTick !== null;
|
worldState.lastTurnTick !== null;
|
||||||
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
|
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
|
||||||
const clockPhase = hasPersistedClock
|
const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
|
||||||
? parseGameClockPhase(worldState.clockPhase)
|
|
||||||
: inferClockPhase(clockMode);
|
|
||||||
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
|
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
|
||||||
const deadlineGeneration = toSafeTick(
|
const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
|
||||||
worldState.deadlineGeneration,
|
|
||||||
'world_state.deadline_generation'
|
|
||||||
);
|
|
||||||
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
|
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
|
||||||
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
|
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
|
||||||
const bootstrapClock = new GameClock({
|
const bootstrapClock = new GameClock({
|
||||||
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
|||||||
tick: 0,
|
tick: 0,
|
||||||
mode: clockMode,
|
mode: clockMode,
|
||||||
wallAnchor: clockWallAnchor,
|
wallAnchor: clockWallAnchor,
|
||||||
|
recovery: readTurnRecovery(worldState),
|
||||||
turnSeconds: worldState.tickSeconds,
|
turnSeconds: worldState.tickSeconds,
|
||||||
phase: clockPhase,
|
phase: clockPhase,
|
||||||
revision: clockRevision,
|
revision: clockRevision,
|
||||||
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
|||||||
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
|
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
|
||||||
mode: clockMode,
|
mode: clockMode,
|
||||||
wallAnchor: clockWallAnchor,
|
wallAnchor: clockWallAnchor,
|
||||||
|
recovery: readTurnRecovery(worldState),
|
||||||
turnSeconds: worldState.tickSeconds,
|
turnSeconds: worldState.tickSeconds,
|
||||||
phase: clockPhase,
|
phase: clockPhase,
|
||||||
revision: clockRevision,
|
revision: clockRevision,
|
||||||
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
|||||||
clockTick: gameClock.tick,
|
clockTick: gameClock.tick,
|
||||||
clockMode,
|
clockMode,
|
||||||
clockWallAnchor: gameClock.wallAnchor,
|
clockWallAnchor: gameClock.wallAnchor,
|
||||||
|
clockRecovery: gameClock.recovery,
|
||||||
lastTurnTick,
|
lastTurnTick,
|
||||||
clockPhase,
|
clockPhase,
|
||||||
clockRevision,
|
clockRevision,
|
||||||
|
|||||||
@@ -52,7 +52,21 @@ const buildWorld = (): InMemoryTurnWorld => {
|
|||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: [actor],
|
generals: [actor],
|
||||||
cities: [],
|
cities: [],
|
||||||
nations: [],
|
nations: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '촉',
|
||||||
|
color: '#000000',
|
||||||
|
level: 1,
|
||||||
|
capitalCityId: 2,
|
||||||
|
chiefGeneralId: 8,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
troops: [],
|
troops: [],
|
||||||
diplomacy: [],
|
diplomacy: [],
|
||||||
events: [],
|
events: [],
|
||||||
@@ -211,6 +225,37 @@ describe('actionable message response', () => {
|
|||||||
expect(world.peekDirtyState().messages).toHaveLength(0);
|
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 () => {
|
it('treats a legacy truthy used value as an invalid scout letter', async () => {
|
||||||
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
for (const row of [buildRow('scout', { option: { action: 'scout', used: 1 } })]) {
|
||||||
const world = buildWorld();
|
const world = buildWorld();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
|
readTurnRuntimeReady,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
@@ -13,11 +14,11 @@ import {
|
|||||||
|
|
||||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||||
|
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
|
||||||
|
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
const enabled =
|
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
||||||
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' &&
|
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
|
||||||
Boolean(process.env.DATABASE_URL) &&
|
|
||||||
Boolean(process.env.REDIS_URL);
|
|
||||||
const describeIntegration = enabled ? describe : describe.skip;
|
const describeIntegration = enabled ? describe : describe.skip;
|
||||||
|
|
||||||
describeIntegration('durable clock reconciliation', () => {
|
describeIntegration('durable clock reconciliation', () => {
|
||||||
@@ -28,6 +29,8 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
const clean = async (): Promise<void> => {
|
const clean = async (): Promise<void> => {
|
||||||
await redis.client.flushDb();
|
await redis.client.flushDb();
|
||||||
await db.$transaction([
|
await db.$transaction([
|
||||||
|
db.readModelOutbox.deleteMany(),
|
||||||
|
db.readModelRevision.deleteMany(),
|
||||||
db.clockProjectionOutbox.deleteMany(),
|
db.clockProjectionOutbox.deleteMany(),
|
||||||
db.clockReconciliationParticipant.deleteMany(),
|
db.clockReconciliationParticipant.deleteMany(),
|
||||||
db.clockSuspension.deleteMany(),
|
db.clockSuspension.deleteMany(),
|
||||||
@@ -47,7 +50,7 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
disconnect = connector.disconnect;
|
disconnect = connector.disconnect;
|
||||||
redis = createRedisConnector({ url: process.env.REDIS_URL! });
|
redis = createRedisConnector({ url: process.env.REDIS_URL! });
|
||||||
@@ -64,6 +67,403 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
await clean();
|
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 () => {
|
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 baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
const futureAnchor = new Date(Date.now() + 3_600_000);
|
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] =
|
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
|
||||||
db.general.findMany({ orderBy: { id: 'asc' } }),
|
db.general.findMany({ orderBy: { id: 'asc' } }),
|
||||||
db.auction.findFirstOrThrow(),
|
db.auction.findFirstOrThrow(),
|
||||||
db.message.findFirstOrThrow(),
|
db.message.findFirstOrThrow(),
|
||||||
db.messageAction.findFirstOrThrow(),
|
db.messageAction.findFirstOrThrow(),
|
||||||
db.votePoll.findFirstOrThrow(),
|
db.votePoll.findFirstOrThrow(),
|
||||||
db.selectPoolEntry.findFirstOrThrow(),
|
db.selectPoolEntry.findFirstOrThrow(),
|
||||||
db.npcSelectionToken.findFirstOrThrow(),
|
db.npcSelectionToken.findFirstOrThrow(),
|
||||||
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
|
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
|
||||||
db.clockProjectionOutbox.findMany(),
|
db.clockProjectionOutbox.findMany(),
|
||||||
]);
|
]);
|
||||||
const alignedTick = BigInt(reconciled.alignedTick);
|
const alignedTick = BigInt(reconciled.alignedTick);
|
||||||
expect(afterWorld).toMatchObject({
|
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 () => {
|
it('recovers only an expired processing lease', async () => {
|
||||||
const expiredId = 'integration:engine:expired';
|
const expiredId = 'integration:engine:expired';
|
||||||
const activeId = 'integration:engine:active';
|
const activeId = 'integration:engine:active';
|
||||||
@@ -313,6 +338,118 @@ integration('database command queue', () => {
|
|||||||
expect(mutation).not.toHaveBeenCalled();
|
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 () => {
|
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 existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||||
const world = existingWorld
|
const world = existingWorld
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { GamePrisma } from '@sammo-ts/infra';
|
|||||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
@@ -59,6 +63,8 @@ const buildFixture = (options: {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
|
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' },
|
meta: { opentime: '2026-08-01T00:00:00.000Z' },
|
||||||
};
|
};
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
@@ -118,6 +124,21 @@ const buildFixture = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('pre-start general deletion', () => {
|
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', () => {
|
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(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
|
||||||
expect(
|
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 () => {
|
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({
|
const gate = await createGatewayProfileGate({
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
gatewayDatabaseUrl: databaseUrl!,
|
gatewayDatabaseUrl: databaseUrl!,
|
||||||
@@ -127,6 +128,19 @@ integration('gateway runtime action consumer', () => {
|
|||||||
status: 'PAUSED',
|
status: 'PAUSED',
|
||||||
lastError: 'running failure',
|
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({
|
await db.gatewayProfile.update({
|
||||||
where: { profileName },
|
where: { profileName },
|
||||||
@@ -137,6 +151,9 @@ integration('gateway runtime action consumer', () => {
|
|||||||
status: 'STOPPED',
|
status: 'STOPPED',
|
||||||
lastError: null,
|
lastError: null,
|
||||||
});
|
});
|
||||||
|
expect(await db.adminAuditEvent.count({ where: { profileName, action: 'runtime.failure' } })).toBe(
|
||||||
|
existingIncidents + 1
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await gate.close();
|
await gate.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -711,7 +711,7 @@ describe('legacy NPC user-chief promotion parity', () => {
|
|||||||
expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null });
|
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({
|
const ruler = makePromotionGeneral({
|
||||||
id: 1,
|
id: 1,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
@@ -745,11 +745,11 @@ describe('legacy NPC user-chief promotion parity', () => {
|
|||||||
const promotion = ai.consumePromotionPatches();
|
const promotion = ai.consumePromotionPatches();
|
||||||
const result = promotion.generals;
|
const result = promotion.generals;
|
||||||
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
|
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
|
||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(2);
|
||||||
expect(promotion.nationMeta).toBeNull();
|
expect(promotion.nationMeta).toBeNull();
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
existingChiefs.map((chief) => ({
|
existingChiefs.slice(1).map((chief) => ({
|
||||||
generalId: chief.id,
|
generalId: chief.id,
|
||||||
officerLevel: chief.officerLevel,
|
officerLevel: chief.officerLevel,
|
||||||
officerCity: 0,
|
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', () => {
|
it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => {
|
||||||
const actor = makePromotionGeneral({
|
const actor = makePromotionGeneral({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -1561,6 +1607,7 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
});
|
});
|
||||||
expect(do집합(ai)?.action).toBe('che_집합');
|
expect(do집합(ai)?.action).toBe('che_집합');
|
||||||
expect(ai.general.meta.killturn).toBe(72);
|
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', () => {
|
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);
|
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 () => {
|
it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => {
|
||||||
const harness = await createTurnTestHarness({
|
const harness = await createTurnTestHarness({
|
||||||
snapshot: makeSnapshot([
|
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: general.id } })).resolves.toBe(0);
|
||||||
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.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 general = archivedGeneral();
|
||||||
const upsert = vi.fn(async () => undefined);
|
const upsert = vi.fn(async () => undefined);
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
gameHistory: { findUnique: vi.fn(async () => ({ status: 'OPEN' })) },
|
||||||
generalAccessLog: {
|
generalAccessLog: {
|
||||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||||
deleteMany: 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),
|
findUnique: vi.fn(async () => null),
|
||||||
},
|
},
|
||||||
gameHistory: {
|
gameHistory: {
|
||||||
|
findUnique: vi.fn(async () => ({ status: 'OPEN' })),
|
||||||
count: vi.fn(async () => 99),
|
count: vi.fn(async () => 99),
|
||||||
},
|
},
|
||||||
hallOfFame: {
|
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 { SystemClock } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
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 { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||||
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
import { TurnDaemonLifecycle } from '../src/lifecycle/turnDaemonLifecycle.js';
|
||||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.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 databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const worldId = 991_731;
|
const worldId = 991_731;
|
||||||
const generalId = 991_731;
|
const generalId = 731;
|
||||||
const cityId = 991_731;
|
const cityId = 731;
|
||||||
const existingNationId = 991_730;
|
const existingNationId = 730;
|
||||||
const requestId = 'integration:engine:immediate-action-uprising';
|
const requestId = 'integration:engine:immediate-action-uprising';
|
||||||
const occupiedUniqueItem = 'che_무기_12_칠성검';
|
const occupiedUniqueItem = 'che_무기_12_칠성검';
|
||||||
|
|
||||||
@@ -97,6 +99,14 @@ const state: TurnWorldState = {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'),
|
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: {
|
meta: {
|
||||||
hiddenSeed: 'immediate-action-integration',
|
hiddenSeed: 'immediate-action-integration',
|
||||||
killturn: 24,
|
killturn: 24,
|
||||||
@@ -155,8 +165,10 @@ integration('immediate general action persistence', () => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
disconnect = () => connector.disconnect();
|
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.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
||||||
await db.logEntry.deleteMany({
|
await db.logEntry.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -169,7 +181,8 @@ integration('immediate general action persistence', () => {
|
|||||||
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
|
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.city.deleteMany({ where: { id: cityId } });
|
||||||
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
@@ -181,6 +194,14 @@ integration('immediate general action persistence', () => {
|
|||||||
currentYear: state.currentYear,
|
currentYear: state.currentYear,
|
||||||
currentMonth: state.currentMonth,
|
currentMonth: state.currentMonth,
|
||||||
tickSeconds: state.tickSeconds,
|
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,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
@@ -257,7 +278,7 @@ integration('immediate general action persistence', () => {
|
|||||||
await disconnect?.();
|
await disconnect?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
|
||||||
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
|
||||||
await db.logEntry.deleteMany({
|
await db.logEntry.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -270,14 +291,194 @@ integration('immediate general action persistence', () => {
|
|||||||
OR: [{ srcNationId: { gte: existingNationId } }, { destNationId: { gte: existingNationId } }],
|
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.city.deleteMany({ where: { id: cityId } });
|
||||||
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
await db.nation.deleteMany({ where: { id: { gte: existingNationId } } });
|
||||||
await db.worldState.deleteMany({ where: { id: worldId } });
|
await db.worldState.deleteMany({ where: { id: worldId } });
|
||||||
await disconnect?.();
|
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 = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: [general],
|
generals: [general],
|
||||||
cities: [
|
cities: [
|
||||||
@@ -376,10 +577,15 @@ integration('immediate general action persistence', () => {
|
|||||||
});
|
});
|
||||||
const stateStore = {
|
const stateStore = {
|
||||||
loadLastTurnTime: async () => new Date(state.lastTurnTime),
|
loadLastTurnTime: async () => new Date(state.lastTurnTime),
|
||||||
loadNextGeneralTurnTime: async () => null,
|
loadNextGeneralTurnTime: async () => general.turnTime,
|
||||||
saveLastTurnTime: async () => {},
|
saveLastTurnTime: async () => {},
|
||||||
loadCheckpoint: async () => undefined,
|
loadCheckpoint: async () => undefined,
|
||||||
saveCheckpoint: async () => {},
|
saveCheckpoint: async () => {},
|
||||||
|
loadGameClock: async () => ({
|
||||||
|
mode: 'realtime' as const,
|
||||||
|
phase: 'PREOPEN' as const,
|
||||||
|
now: world.getGameNow(new Date()),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
const processor = {
|
const processor = {
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -597,5 +803,108 @@ integration('immediate general action persistence', () => {
|
|||||||
chiefGeneralId: generalId,
|
chiefGeneralId: generalId,
|
||||||
rice: 2_000,
|
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 before = world.captureState();
|
||||||
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
||||||
|
|
||||||
world.shiftSchedule(15, claimedAt);
|
world.shiftSchedule(20, claimedAt);
|
||||||
|
|
||||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
|
reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
|
||||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||||
},
|
},
|
||||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||||
@@ -231,13 +231,13 @@ describe('in-memory scenario general pool availability', () => {
|
|||||||
lastTurnTick: 0,
|
lastTurnTick: 0,
|
||||||
});
|
});
|
||||||
const before = world.captureState();
|
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({
|
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
|
||||||
skippedTurns: 4,
|
skippedTurns: 12,
|
||||||
shiftedTicks: 4 * GAME_TICKS_PER_TURN,
|
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);
|
const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
|
||||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -457,4 +457,157 @@ integration('monthly wandering nation persistence', () => {
|
|||||||
await hooks.close();
|
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,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'),
|
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: {
|
meta: {
|
||||||
hiddenSeed: 'immediate-action-test',
|
hiddenSeed: 'immediate-action-test',
|
||||||
killturn: 24,
|
killturn: 24,
|
||||||
@@ -599,102 +600,126 @@ describe('my information world commands', () => {
|
|||||||
expect(nextIntInclusive).not.toHaveBeenCalled();
|
expect(nextIntInclusive).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
|
it.each([
|
||||||
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
{ recruiterNationId: 2, ruler: false },
|
||||||
const recipient = buildGeneral({
|
{ recruiterNationId: 0, ruler: false },
|
||||||
id: 8,
|
{ recruiterNationId: 3, ruler: false },
|
||||||
userId: 'user-8',
|
{ recruiterNationId: 2, ruler: true },
|
||||||
name: '재야장수',
|
])(
|
||||||
nationId: 0,
|
'checks original-nation acceptance with $recruiterNationId / ruler=$ruler',
|
||||||
cityId: 1,
|
async ({ recruiterNationId, ruler }) => {
|
||||||
officerLevel: 0,
|
const originalLastTurn = { command: '전투태세', arg: { term: 3 } };
|
||||||
lastTurn: originalLastTurn,
|
const recipient = buildGeneral({
|
||||||
});
|
id: 8,
|
||||||
const recruiter = buildGeneral({
|
userId: 'user-8',
|
||||||
id: 9,
|
name: '재야장수',
|
||||||
userId: 'user-9',
|
nationId: 0,
|
||||||
name: '등용장수',
|
cityId: 1,
|
||||||
nationId: 2,
|
officerLevel: 0,
|
||||||
cityId: 2,
|
lastTurn: originalLastTurn,
|
||||||
});
|
});
|
||||||
const map = {
|
const recruiter = buildGeneral({
|
||||||
id: 'test',
|
id: 9,
|
||||||
name: 'test',
|
userId: 'user-9',
|
||||||
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
name: '등용장수',
|
||||||
};
|
nationId: 2,
|
||||||
const fixture = buildImmediateActionWorld({
|
cityId: 2,
|
||||||
general: recipient,
|
});
|
||||||
additionalGenerals: [recruiter],
|
const map = {
|
||||||
cities: [
|
id: 'test',
|
||||||
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
name: 'test',
|
||||||
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||||
] as TurnWorldSnapshot['cities'],
|
};
|
||||||
nations: [
|
const fixture = buildImmediateActionWorld({
|
||||||
{
|
general: recipient,
|
||||||
id: 2,
|
additionalGenerals: [recruiter],
|
||||||
name: '등용국',
|
cities: [
|
||||||
color: '#222222',
|
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||||
typeCode: 'che_중립',
|
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||||
level: 1,
|
] as TurnWorldSnapshot['cities'],
|
||||||
capitalCityId: 2,
|
nations: [
|
||||||
chiefGeneralId: recruiter.id,
|
{
|
||||||
gold: 0,
|
id: 2,
|
||||||
rice: 0,
|
name: '등용국',
|
||||||
power: 0,
|
color: '#222222',
|
||||||
meta: { gennum: 1 },
|
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(
|
fixture.world.updateGeneral(recruiter.id, { nationId: recruiterNationId });
|
||||||
executor.execute({
|
if (ruler) {
|
||||||
actionKey: 'che_등용수락',
|
fixture.world.updateGeneral(recipient.id, { officerLevel: 12 });
|
||||||
generalId: recipient.id,
|
const before = fixture.world.captureState();
|
||||||
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
await expect(
|
||||||
args: { destNationId: 2, destGeneralId: recruiter.id },
|
executor.execute({
|
||||||
})
|
actionKey: 'che_등용수락',
|
||||||
).resolves.toEqual({ ok: true });
|
generalId: recipient.id,
|
||||||
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
rng: new RandUtil(new LiteHashDRBG('reject-ruler-letter')),
|
||||||
nationId: 2,
|
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||||
cityId: 2,
|
})
|
||||||
officerLevel: 1,
|
).resolves.toEqual({ ok: false, reason: '군주는 등용장을 수락할 수 없습니다 등용수락 실패.' });
|
||||||
lastTurn: originalLastTurn,
|
expect(fixture.world.captureState()).toEqual(before);
|
||||||
});
|
return;
|
||||||
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
}
|
||||||
experience: recruiter.experience + 100,
|
|
||||||
dedication: recruiter.dedication + 100,
|
await expect(
|
||||||
});
|
executor.execute({
|
||||||
const actionLogs = fixture.world
|
actionKey: 'che_등용수락',
|
||||||
.consumeDirtyState()
|
generalId: recipient.id,
|
||||||
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
|
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
||||||
expect(actionLogs.map((log) => log.text)).toEqual([
|
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||||
expect.stringContaining('레벨업'),
|
})
|
||||||
expect.stringContaining('승급'),
|
).resolves.toEqual({ ok: true });
|
||||||
expect.stringContaining('망명하여 수도로'),
|
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||||
expect.stringContaining('레벨업'),
|
nationId: 2,
|
||||||
expect.stringContaining('승급'),
|
cityId: 2,
|
||||||
expect.stringContaining('등용에 성공했습니다.'),
|
officerLevel: 1,
|
||||||
]);
|
lastTurn: originalLastTurn,
|
||||||
expect(actionLogs.map((log) => log.format)).toEqual([
|
});
|
||||||
LogFormat.PLAIN,
|
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||||
LogFormat.PLAIN,
|
experience: recruiter.experience + 100,
|
||||||
LogFormat.MONTH,
|
dedication: recruiter.dedication + 100,
|
||||||
LogFormat.PLAIN,
|
});
|
||||||
LogFormat.PLAIN,
|
const actionLogs = fixture.world
|
||||||
LogFormat.MONTH,
|
.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 () => {
|
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
|
||||||
const recipient = buildGeneral({
|
const recipient = buildGeneral({
|
||||||
|
|||||||
@@ -39,6 +39,135 @@ const createChief = (userId: string | null): TurnGeneral => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('레거시 사령부 턴 실행 호환성', () => {
|
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([
|
it.each([
|
||||||
{ ownerLabel: '소유 장수', userId: 'test-user', expectedActiveAction: 1 },
|
{ ownerLabel: '소유 장수', userId: 'test-user', expectedActiveAction: 1 },
|
||||||
{ ownerLabel: '무소유 장수', userId: null, expectedActiveAction: undefined },
|
{ ownerLabel: '무소유 장수', userId: null, expectedActiveAction: undefined },
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ describe('NPC 일반 내정 턴', () => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'NPC_무장',
|
name: 'NPC_무장',
|
||||||
|
picture: '롤시나리오/다이애나.png',
|
||||||
|
imageServer: 0,
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
cityId: 1,
|
cityId: 1,
|
||||||
troopId: 0,
|
troopId: 0,
|
||||||
@@ -297,7 +299,12 @@ describe('NPC 일반 내정 턴', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
msgType: 'public',
|
msgType: 'public',
|
||||||
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
|
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,
|
time: logicalGameNow,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
import { DIPLOMACY_STATE } 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 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 { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||||
|
|
||||||
const mockDate = new Date('0180-01-01T00:00:00Z');
|
const mockDate = new Date('0180-01-01T00:00:00Z');
|
||||||
@@ -45,7 +45,7 @@ const createNpcGeneral = (
|
|||||||
npcState,
|
npcState,
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
|
const maxCityStats = (city: ReturnType<typeof buildCompactNpcTestCities>[number]) => ({
|
||||||
...city,
|
...city,
|
||||||
population: city.populationMax,
|
population: city.populationMax,
|
||||||
agriculture: city.agricultureMax,
|
agriculture: city.agricultureMax,
|
||||||
@@ -63,9 +63,9 @@ const readTech = (nation: { meta: Record<string, unknown> }): number => {
|
|||||||
|
|
||||||
describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
||||||
it('기술이 상승하고 기술 등급 및 소모 금이 증가해야 한다', async () => {
|
it('기술이 상승하고 기술 등급 및 소모 금이 증가해야 한다', async () => {
|
||||||
const cities = buildLargeTestCities().map(maxCityStats);
|
const cities = buildCompactNpcTestCities().map(maxCityStats);
|
||||||
const nation1CityIds = [1, 2, 3, 4];
|
const nation1CityIds = [1, 2];
|
||||||
const nation2CityIds = [5, 6, 7, 8, 9];
|
const nation2CityIds = [3, 4, 5];
|
||||||
|
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
if (nation1CityIds.includes(city.id)) {
|
if (nation1CityIds.includes(city.id)) {
|
||||||
@@ -122,7 +122,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
1
|
1
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
for (let i = 0; i < 9; i += 1) {
|
for (let i = 0; i < 3; i += 1) {
|
||||||
generals.push(
|
generals.push(
|
||||||
createNpcGeneral(nextId++, cityId, nationId, 2, {
|
createNpcGeneral(nextId++, cityId, nationId, 2, {
|
||||||
leadership: 80,
|
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(
|
generals.push(
|
||||||
createNpcGeneral(nextId++, cityId, nationId, 2, {
|
createNpcGeneral(nextId++, cityId, nationId, 2, {
|
||||||
leadership: 70,
|
leadership: 70,
|
||||||
@@ -163,7 +163,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
power: 0,
|
power: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
typeCode: 'large_test_map_def',
|
typeCode: 'large_test_map_def',
|
||||||
meta: { tech: 0 },
|
meta: { tech: 950 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -176,7 +176,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
power: 0,
|
power: 0,
|
||||||
level: 1,
|
level: 1,
|
||||||
typeCode: 'large_test_map_def',
|
typeCode: 'large_test_map_def',
|
||||||
meta: { tech: 0 },
|
meta: { tech: 950 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
troops: [],
|
troops: [],
|
||||||
@@ -200,7 +200,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
],
|
],
|
||||||
events: [],
|
events: [],
|
||||||
initialEvents: [],
|
initialEvents: [],
|
||||||
map: LARGE_TEST_MAP as any,
|
map: COMPACT_NPC_TEST_MAP as any,
|
||||||
scenarioConfig: {
|
scenarioConfig: {
|
||||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
iconPath: '',
|
iconPath: '',
|
||||||
@@ -214,7 +214,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
minAvailableRecruitPop: 0,
|
minAvailableRecruitPop: 0,
|
||||||
maxTechLevel: 12000,
|
maxTechLevel: 12000,
|
||||||
},
|
},
|
||||||
environment: { mapName: 'large_test_map', unitSet: 'default' },
|
environment: { mapName: 'compact_npc_test_map', unitSet: 'default' },
|
||||||
},
|
},
|
||||||
scenarioMeta: {
|
scenarioMeta: {
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
@@ -239,7 +239,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
snapshot,
|
snapshot,
|
||||||
state,
|
state,
|
||||||
schedule,
|
schedule,
|
||||||
map: LARGE_TEST_MAP,
|
map: COMPACT_NPC_TEST_MAP,
|
||||||
});
|
});
|
||||||
|
|
||||||
const controlledGeneralId = nation1ChiefId;
|
const controlledGeneralId = nation1ChiefId;
|
||||||
@@ -261,17 +261,14 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
const firstGoldAfter = world.getGeneralById(controlledGeneralId)!.gold;
|
const firstGoldAfter = world.getGeneralById(controlledGeneralId)!.gold;
|
||||||
const firstRecruitCost = firstGoldBefore - firstGoldAfter;
|
const firstRecruitCost = firstGoldBefore - firstGoldAfter;
|
||||||
expect(firstRecruitCost).toBeGreaterThan(0);
|
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 }> = [];
|
const techSnapshots: Array<{ year: number; tech1: number; tech2: number }> = [];
|
||||||
let pendingRecruit = false;
|
let pendingRecruit = false;
|
||||||
let pendingGoldBefore = 0;
|
let pendingGoldBefore = 0;
|
||||||
let secondRecruitCost: number | null = null;
|
let secondRecruitCost: number | null = null;
|
||||||
|
|
||||||
const shouldStop = () => {
|
const exceededSafetyLimit = () => {
|
||||||
const current = world.getState();
|
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) {
|
while (true) {
|
||||||
@@ -294,15 +291,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
const tech1 = readTech(nation1 as { meta: Record<string, unknown> });
|
const tech1 = readTech(nation1 as { meta: Record<string, unknown> });
|
||||||
const tech2 = readTech(nation2 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;
|
pendingRecruit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldStop()) {
|
if (
|
||||||
|
(secondRecruitCost !== null &&
|
||||||
|
getTechLevel(tech1) > initialLevel &&
|
||||||
|
getTechLevel(tech2) > initialLevel) ||
|
||||||
|
exceededSafetyLimit()
|
||||||
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,8 +322,12 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
|
|||||||
expect(finalTech2).toBeGreaterThan(initialTech2);
|
expect(finalTech2).toBeGreaterThan(initialTech2);
|
||||||
expect(getTechLevel(finalTech1)).toBeGreaterThan(initialLevel);
|
expect(getTechLevel(finalTech1)).toBeGreaterThan(initialLevel);
|
||||||
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(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
|
// 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
|
// gold delta smaller than the recruitment price. Exact cost scaling is
|
||||||
// covered by the unit-set/action contract tests rather than this smoke.
|
// 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 { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.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 { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||||
import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js';
|
import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js';
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ const createNpcGeneral = (
|
|||||||
role: {
|
role: {
|
||||||
items: { horse: null, weapon: null, book: null, item: null },
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
personality: null,
|
personality: null,
|
||||||
specialDomestic: null,
|
specialDomestic: id % 2 === 0 ? 'che_경작' : 'che_상재',
|
||||||
specialWar: null,
|
specialWar: id % 2 === 0 ? 'che_보병' : 'che_무쌍',
|
||||||
},
|
},
|
||||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
meta: { killturn: 800 },
|
meta: { killturn: 800 },
|
||||||
@@ -47,7 +47,7 @@ const createNpcGeneral = (
|
|||||||
npcState: 2,
|
npcState: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
|
const maxCityStats = (city: ReturnType<typeof buildCompactNpcTestCities>[number]) => ({
|
||||||
...city,
|
...city,
|
||||||
population: city.populationMax,
|
population: city.populationMax,
|
||||||
agriculture: city.agricultureMax,
|
agriculture: city.agricultureMax,
|
||||||
@@ -156,7 +156,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1';
|
const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1';
|
||||||
const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1';
|
const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1';
|
||||||
const profileStartedAtMs = performance.now();
|
const profileStartedAtMs = performance.now();
|
||||||
const cities = buildLargeTestCities().map(maxCityStats);
|
const cities = buildCompactNpcTestCities().map(maxCityStats);
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
city.nationId = 0;
|
city.nationId = 0;
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generals: TurnGeneral[] = [];
|
const generals: TurnGeneral[] = [];
|
||||||
const initialGeneralCount = rankingAuditEnabled ? 150 : 300;
|
const initialGeneralCount = rankingAuditEnabled ? 150 : 60;
|
||||||
for (let i = 0; i < initialGeneralCount; i += 1) {
|
for (let i = 0; i < initialGeneralCount; i += 1) {
|
||||||
const cityId = cities[i % cities.length]!.id;
|
const cityId = cities[i % cities.length]!.id;
|
||||||
const stats =
|
const stats =
|
||||||
@@ -217,6 +217,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
: { leadership: 75, strength: 10, intelligence: 75 };
|
: { leadership: 75, strength: 10, intelligence: 75 };
|
||||||
generals.push(createNpcGeneral(i + 1, cityId, stats));
|
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 = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: generals as any,
|
generals: generals as any,
|
||||||
@@ -226,7 +230,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
diplomacy: [],
|
diplomacy: [],
|
||||||
events: [],
|
events: [],
|
||||||
initialEvents: [],
|
initialEvents: [],
|
||||||
map: LARGE_TEST_MAP as any,
|
map: COMPACT_NPC_TEST_MAP as any,
|
||||||
scenarioConfig: {
|
scenarioConfig: {
|
||||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
iconPath: '',
|
iconPath: '',
|
||||||
@@ -239,7 +243,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
maxResourceActionAmount: 10000,
|
maxResourceActionAmount: 10000,
|
||||||
minAvailableRecruitPop: 0,
|
minAvailableRecruitPop: 0,
|
||||||
},
|
},
|
||||||
environment: { mapName: 'large_test_map', unitSet: 'default' },
|
environment: { mapName: 'compact_npc_test_map', unitSet: 'default' },
|
||||||
},
|
},
|
||||||
scenarioMeta: {
|
scenarioMeta: {
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
@@ -305,45 +309,44 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
getCollectedLogsCount,
|
getCollectedLogsCount,
|
||||||
getCollectedLogsRange,
|
getCollectedLogsRange,
|
||||||
getAndClearCollectedLogs,
|
getAndClearCollectedLogs,
|
||||||
} =
|
} = await createTurnTestHarness({
|
||||||
await createTurnTestHarness({
|
snapshot,
|
||||||
snapshot,
|
state,
|
||||||
state,
|
schedule,
|
||||||
schedule,
|
map: COMPACT_NPC_TEST_MAP,
|
||||||
map: LARGE_TEST_MAP,
|
worldRef,
|
||||||
worldRef,
|
extraCalendarHandlers: [unificationHandler],
|
||||||
extraCalendarHandlers: [unificationHandler],
|
collectLogs: true,
|
||||||
collectLogs: true,
|
onActionResolved: (payload) => {
|
||||||
onActionResolved: (payload) => {
|
const currentWorld = worldRef.current;
|
||||||
const currentWorld = worldRef.current;
|
if (currentWorld) {
|
||||||
if (currentWorld) {
|
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
|
||||||
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
|
const orphanCities = currentWorld
|
||||||
const orphanCities = currentWorld
|
.listCities()
|
||||||
.listCities()
|
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
|
||||||
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
|
if (orphanCities.length > 0) {
|
||||||
if (orphanCities.length > 0) {
|
throw new Error(
|
||||||
throw new Error(
|
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
|
||||||
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
|
.map((city) => `${city.id}->${city.nationId}`)
|
||||||
.map((city) => `${city.id}->${city.nationId}`)
|
.join(', ')}`
|
||||||
.join(', ')}`
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
|
}
|
||||||
if (payload.kind === 'general') {
|
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
|
||||||
if (payload.actionKey === 'che_출병') {
|
if (payload.kind === 'general') {
|
||||||
sortieCount += 1;
|
if (payload.actionKey === 'che_출병') {
|
||||||
}
|
sortieCount += 1;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (payload.nationId) {
|
return;
|
||||||
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
|
}
|
||||||
}
|
if (payload.nationId) {
|
||||||
if (payload.actionKey === 'che_선전포고') {
|
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
|
||||||
declarationCount += 1;
|
}
|
||||||
}
|
if (payload.actionKey === 'che_선전포고') {
|
||||||
},
|
declarationCount += 1;
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
const memoryProfiler =
|
const memoryProfiler =
|
||||||
memoryProfileEnabled && worldRef.current
|
memoryProfileEnabled && worldRef.current
|
||||||
? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore)
|
? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore)
|
||||||
@@ -410,6 +413,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
|
||||||
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
|
||||||
const foundedNationCount = foundedNations.length;
|
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');
|
memoryProfiler?.sample('nations-founded');
|
||||||
|
|
||||||
await runUntil(
|
await runUntil(
|
||||||
@@ -436,7 +442,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
|
|
||||||
if (declarationCount === 0) {
|
if (declarationCount === 0) {
|
||||||
await runUntil(
|
await runUntil(
|
||||||
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
|
(current) =>
|
||||||
|
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
|
||||||
undefined,
|
undefined,
|
||||||
observeProfileMonth
|
observeProfileMonth
|
||||||
);
|
);
|
||||||
@@ -614,6 +621,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
const logs = getCollectedLogs();
|
const logs = getCollectedLogs();
|
||||||
const hasUnificationLog =
|
const hasUnificationLog =
|
||||||
unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
|
||||||
|
if (!rankingAuditEnabled) {
|
||||||
|
expect(unifiedAt).not.toBeNull();
|
||||||
|
}
|
||||||
if (unifiedAt) {
|
if (unifiedAt) {
|
||||||
expect(meta.isUnited).toBe(2);
|
expect(meta.isUnited).toBe(2);
|
||||||
expect(hasUnificationLog).toBe(true);
|
expect(hasUnificationLog).toBe(true);
|
||||||
@@ -714,8 +724,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
|
|||||||
startMonth: state.currentMonth,
|
startMonth: state.currentMonth,
|
||||||
});
|
});
|
||||||
const reportPath = resolve(
|
const reportPath = resolve(
|
||||||
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ??
|
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ?? 'test-results/npc-unification-memory.json'
|
||||||
'test-results/npc-unification-memory.json'
|
|
||||||
);
|
);
|
||||||
mkdirSync(dirname(reportPath), { recursive: true });
|
mkdirSync(dirname(reportPath), { recursive: true });
|
||||||
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common';
|
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 { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
await db.$executeRawUnsafe(`
|
await db.$executeRawUnsafe(`
|
||||||
ALTER TABLE read_model_outbox
|
ALTER TABLE read_model_outbox
|
||||||
ADD CONSTRAINT ${rollbackConstraint}
|
ADD CONSTRAINT ${rollbackConstraint}
|
||||||
CHECK ((payload->>'version')::integer <> 1)
|
CHECK ((payload->>'version')::integer <> 1) NOT VALID
|
||||||
`);
|
`);
|
||||||
world.updateWorldMeta({ durableFixture: 'must-rollback' });
|
world.updateWorldMeta({ durableFixture: 'must-rollback' });
|
||||||
world.pushLog({
|
world.pushLog({
|
||||||
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
|
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
|
||||||
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
||||||
await expect(db.readModelOutbox.count()).resolves.toBe(5);
|
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';
|
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
|
||||||
|
|
||||||
describe('durable read-model change journal mapping', () => {
|
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', () => {
|
it('maps every final engine invalidation to its precise durable domain', () => {
|
||||||
const changes = {
|
const changes = {
|
||||||
...createEmptyRealtimeReadModelChanges(),
|
...createEmptyRealtimeReadModelChanges(),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
|
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
|
||||||
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
|
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
|
||||||
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('runtime clock shift', () => {
|
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', () => {
|
it('preserves the scenario config when the raw world config is unavailable', () => {
|
||||||
const world = buildWorld();
|
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'],
|
['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'],
|
['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) => {
|
] 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.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
|
||||||
world.queueNeutralAuction({
|
world.queueNeutralAuction({
|
||||||
registrationKey: 'test',
|
registrationKey: 'test',
|
||||||
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
|
|||||||
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]);
|
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();
|
const world = buildWorld();
|
||||||
expect(() => world.shiftSchedule(delta)).toThrow();
|
expect(() => world.shiftSchedule(delta)).toThrow();
|
||||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z');
|
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', () => {
|
it('keeps legacy wall-clock metadata independent from the process timezone', () => {
|
||||||
const world = buildWorld();
|
const world = buildWorld({ tickSeconds: 900 });
|
||||||
|
|
||||||
world.shiftSchedule(-15);
|
world.shiftSchedule(-15);
|
||||||
|
|
||||||
@@ -212,6 +277,26 @@ describe('runtime clock shift', () => {
|
|||||||
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
|
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 () => {
|
it('rejects gameplay commits while the durable clock is suspended', async () => {
|
||||||
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
|
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
|
||||||
|
|
||||||
@@ -220,33 +305,29 @@ describe('runtime clock shift', () => {
|
|||||||
await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/);
|
await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([5, 10, 20, 60])('only skips complete 12-turn blocks for a %i-minute turn', (turnMinutes) => {
|
||||||
[5, 6],
|
|
||||||
[10, 3],
|
|
||||||
[20, 1],
|
|
||||||
])('uses the Ref catch-up threshold for a %i-minute turn', (turnMinutes, threshold) => {
|
|
||||||
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
|
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
|
||||||
const world = buildWorld({
|
for (const turns of [0, 1, 11, 11.999, 12, 13, 23.999, 24, 25]) {
|
||||||
tickSeconds: turnMinutes * 60,
|
const world = buildWorld({
|
||||||
clockBaseTime: wallAnchor,
|
tickSeconds: turnMinutes * 60,
|
||||||
clockTick: 0,
|
clockBaseTime: wallAnchor,
|
||||||
clockMode: 'realtime',
|
clockTick: 0,
|
||||||
clockWallAnchor: wallAnchor,
|
clockMode: 'realtime',
|
||||||
lastTurnTick: 0,
|
clockWallAnchor: wallAnchor,
|
||||||
lastTurnTime: wallAnchor,
|
lastTurnTick: 0,
|
||||||
});
|
lastTurnTime: wallAnchor,
|
||||||
|
});
|
||||||
expect(
|
const resumedAt = new Date(wallAnchor.getTime() + turns * turnMinutes * 60_000);
|
||||||
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + threshold * turnMinutes * 60_000))
|
expect(world.shouldRebaseRealtimeBacklog(resumedAt)).toBe(turns >= 12);
|
||||||
).toBe(false);
|
const result = world.rebaseRealtimeBacklog(resumedAt);
|
||||||
expect(
|
if (turns < 12) expect(result).toBeNull();
|
||||||
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + (threshold + 1) * turnMinutes * 60_000))
|
else expect(result?.skippedTurns).toBe(Math.floor(turns / 12) * 12);
|
||||||
).toBe(true);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips a long realtime backlog while preserving the turn phase and wall-clock display', () => {
|
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 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({
|
const world = buildWorld({
|
||||||
tickSeconds: 300,
|
tickSeconds: 300,
|
||||||
clockBaseTime: wallAnchor,
|
clockBaseTime: wallAnchor,
|
||||||
@@ -267,35 +348,35 @@ describe('runtime clock shift', () => {
|
|||||||
const result = world.rebaseRealtimeBacklog(resumedAt);
|
const result = world.rebaseRealtimeBacklog(resumedAt);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
skippedTurns: 7,
|
skippedTurns: 12,
|
||||||
shiftedTicks: 7 * GAME_TICKS_PER_TURN,
|
shiftedTicks: 12 * GAME_TICKS_PER_TURN,
|
||||||
lastTurnTime: resumedAt.toISOString(),
|
lastTurnTime: '2026-07-30T11:00:00.000Z',
|
||||||
});
|
});
|
||||||
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
|
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
|
||||||
expect(world.getState()).toMatchObject({
|
expect(world.getState()).toMatchObject({
|
||||||
clockTick: 7 * GAME_TICKS_PER_TURN,
|
clockTick: 13 * GAME_TICKS_PER_TURN,
|
||||||
clockWallAnchor: resumedAt,
|
clockWallAnchor: resumedAt,
|
||||||
lastTurnTick: 7 * GAME_TICKS_PER_TURN,
|
lastTurnTick: 12 * GAME_TICKS_PER_TURN,
|
||||||
meta: {
|
meta: {
|
||||||
turntime: '2026-07-30 10:35:00.123456',
|
turntime: '2026-07-30 11:00:00.123456',
|
||||||
starttime: '2026-07-01 00:35:00',
|
starttime: '2026-07-01 01:00:00',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(world.getGeneralById(1)).toMatchObject({
|
expect(world.getGeneralById(1)).toMatchObject({
|
||||||
turnTick: 9 * GAME_TICKS_PER_TURN,
|
turnTick: 14 * GAME_TICKS_PER_TURN,
|
||||||
turnTime: new Date('2026-07-30T10:45:00.000Z'),
|
turnTime: new Date('2026-07-30T11:10:00.000Z'),
|
||||||
});
|
});
|
||||||
expect(world.getCheckpoint()).toMatchObject({
|
expect(world.getCheckpoint()).toMatchObject({
|
||||||
turnTick: 9 * GAME_TICKS_PER_TURN,
|
turnTick: 14 * GAME_TICKS_PER_TURN,
|
||||||
turnTime: '2026-07-30T10:45:00.000Z',
|
turnTime: '2026-07-30T11:10:00.000Z',
|
||||||
});
|
});
|
||||||
expect(world.peekDirtyState()).toMatchObject({
|
expect(world.peekDirtyState()).toMatchObject({
|
||||||
realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN,
|
realtimeBacklogShiftTicks: 12 * GAME_TICKS_PER_TURN,
|
||||||
generals: [],
|
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 base = new Date('2026-07-30T10:00:00.000Z');
|
||||||
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
|
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
|
||||||
const resumedAt = new Date('2026-07-30T11:50: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.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
|
||||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 22 });
|
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
|
||||||
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
|
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', () => {
|
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',
|
type: 'shiftSchedule',
|
||||||
requestId,
|
requestId,
|
||||||
actionId,
|
actionId,
|
||||||
deltaMinutes: -15,
|
deltaMinutes: -20,
|
||||||
} as GamePrisma.InputJsonValue,
|
} as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
|
|||||||
await hooks.close();
|
await hooks.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45: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:55:00.000Z');
|
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z');
|
||||||
expect(await stateStore.loadCheckpoint()).toMatchObject({
|
expect(await stateStore.loadCheckpoint()).toMatchObject({
|
||||||
turnTime: '2099-07-30T09:45:00.000Z',
|
turnTime: '2099-07-30T09:40:00.000Z',
|
||||||
generalId: 0,
|
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 } });
|
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
||||||
expect(storedWorld.meta).toMatchObject({
|
expect(storedWorld.meta).toMatchObject({
|
||||||
lastTurnTime: '2099-07-30T09:45:00.000Z',
|
lastTurnTime: '2099-07-30T09:40:00.000Z',
|
||||||
starttime: '2099-06-30 23:45:00',
|
starttime: '2099-06-30 23:40:00',
|
||||||
});
|
});
|
||||||
expect(storedWorld.clockTick).toBe(0n);
|
expect(storedWorld.clockTick).toBe(0n);
|
||||||
expect(storedWorld.lastTurnTick).toBe(0n);
|
expect(storedWorld.lastTurnTick).toBe(0n);
|
||||||
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
|
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));
|
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
|
||||||
const storedAuctions = await db.auction.findMany({
|
const storedAuctions = await db.auction.findMany({
|
||||||
where: { id: { in: auctionRows.map((auction) => auction.id) } },
|
where: { id: { in: auctionRows.map((auction) => auction.id) } },
|
||||||
});
|
});
|
||||||
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
|
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
|
||||||
expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([
|
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-30T11:00:00.000Z',
|
||||||
'2099-07-30T12:00:00.000Z',
|
'2099-07-30T12:00:00.000Z',
|
||||||
'2099-07-30T13:00:00.000Z',
|
'2099-07-30T13:00:00.000Z',
|
||||||
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
|
|||||||
type: 'shiftSchedule',
|
type: 'shiftSchedule',
|
||||||
ok: true,
|
ok: true,
|
||||||
actionId,
|
actionId,
|
||||||
deltaMinutes: -15,
|
deltaMinutes: -20,
|
||||||
shiftedGenerals: 2,
|
shiftedGenerals: 2,
|
||||||
shiftedAuctions: 1,
|
shiftedAuctions: 1,
|
||||||
},
|
},
|
||||||
@@ -302,7 +303,7 @@ integration('runtime clock shift persistence', () => {
|
|||||||
|
|
||||||
it('atomically rebases a long realtime backlog and open auction deadlines', async () => {
|
it('atomically rebases a long realtime backlog and open auction deadlines', async () => {
|
||||||
const base = new Date('2099-09-01T00:00:00.000Z');
|
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({
|
const row = await db.worldState.create({
|
||||||
data: {
|
data: {
|
||||||
scenarioCode: 'realtime-backlog-rebase',
|
scenarioCode: 'realtime-backlog-rebase',
|
||||||
@@ -408,7 +409,7 @@ integration('runtime clock shift persistence', () => {
|
|||||||
);
|
);
|
||||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
try {
|
try {
|
||||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 });
|
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
|
||||||
await hooks.hooks.flushChanges?.({
|
await hooks.hooks.flushChanges?.({
|
||||||
lastTurnTime: resumedAt.toISOString(),
|
lastTurnTime: resumedAt.toISOString(),
|
||||||
processedGenerals: 0,
|
processedGenerals: 0,
|
||||||
@@ -422,18 +423,18 @@ integration('runtime clock shift persistence', () => {
|
|||||||
|
|
||||||
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
||||||
expect(storedWorld).toMatchObject({
|
expect(storedWorld).toMatchObject({
|
||||||
clockTick: BigInt(7 * GAME_TICKS_PER_TURN),
|
clockTick: BigInt(12 * GAME_TICKS_PER_TURN),
|
||||||
lastTurnTick: BigInt(7 * GAME_TICKS_PER_TURN),
|
lastTurnTick: BigInt(12 * GAME_TICKS_PER_TURN),
|
||||||
clockWallAnchor: resumedAt,
|
clockWallAnchor: resumedAt,
|
||||||
});
|
});
|
||||||
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } });
|
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } });
|
||||||
expect(storedGeneral).toMatchObject({
|
expect(storedGeneral).toMatchObject({
|
||||||
turnTick: BigInt(8 * GAME_TICKS_PER_TURN),
|
turnTick: BigInt(13 * GAME_TICKS_PER_TURN),
|
||||||
turnTime: new Date('2099-09-01T00:40:00.000Z'),
|
turnTime: new Date('2099-09-01T01:05:00.000Z'),
|
||||||
});
|
});
|
||||||
expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({
|
expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({
|
||||||
closeTick: BigInt(9 * GAME_TICKS_PER_TURN),
|
closeTick: BigInt(14 * GAME_TICKS_PER_TURN),
|
||||||
closeAt: new Date('2099-09-01T00:45:00.000Z'),
|
closeAt: new Date('2099-09-01T01:10:00.000Z'),
|
||||||
});
|
});
|
||||||
expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({
|
expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({
|
||||||
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
|
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({
|
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({
|
||||||
ownerUserId: 'rebase-pool-user',
|
ownerUserId: 'rebase-pool-user',
|
||||||
generalId: null,
|
generalId: null,
|
||||||
reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN),
|
reservedUntilTick: BigInt(14 * GAME_TICKS_PER_TURN),
|
||||||
reservedUntil: new Date('2099-09-01T00:45:00.000Z'),
|
reservedUntil: new Date('2099-09-01T01:10:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
|
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 { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
||||||
@@ -94,6 +95,99 @@ const canRun = await canConnectToDatabase(databaseUrl);
|
|||||||
const describeDb = describe.runIf(canRun);
|
const describeDb = describe.runIf(canRun);
|
||||||
|
|
||||||
describeDb('scenario database seed', () => {
|
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 () => {
|
test('persists each blank-land scenario item contract without leaking the shared addon', async () => {
|
||||||
const readPersistedItemContract = async (targetScenarioId: number) => {
|
const readPersistedItemContract = async (targetScenarioId: number) => {
|
||||||
const { applied } = await seedScenarioToDatabase({
|
const { applied } = await seedScenarioToDatabase({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ManualClock } from '@sammo-ts/common';
|
import { ManualClock } from '@sammo-ts/common';
|
||||||
|
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InMemoryControlQueue,
|
InMemoryControlQueue,
|
||||||
@@ -14,6 +15,104 @@ import {
|
|||||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||||
|
|
||||||
describe('TurnDaemonLifecycle', () => {
|
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 () => {
|
it('durably rebases a long realtime backlog before executing another turn', async () => {
|
||||||
const wallNow = new Date('2026-08-23T01:35:00.000Z');
|
const wallNow = new Date('2026-08-23T01:35:00.000Z');
|
||||||
const clock = new ManualClock(wallNow.getTime());
|
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