Author SHA1 Message Date
Hide_D d3443ec7f5 refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현 2026-09-06 10:04:33 +00:00
Hide_D 63da0921cb fix: 재개 시 미처리 턴을 실행하고 장기 지연은 12턴씩 보정 2026-09-06 08:06:00 +00:00
Hide_D 6dcc914f94 fix: 운영 중단 복구 시 장수의 지정 턴 시각을 보존 2026-09-06 07:37:51 +00:00
Hide_D d3b2bcd155 fix: 보유 장비 판매 선택지와 판매가 표시를 복원
구매 pool 밖 보유 유니크도 장비 이름으로 판매를 선택할 수 있게 한다. 기존 슬롯명 판매 brief와 None 예약 인자는 유지하고 API 및 Chromium 회귀를 검증한다.
2026-09-06 07:20:31 +00:00
Hide_D ce7d15eba9 feat: 환경 설정으로 메뉴를 확장하고 전용 아이콘 변경을 이동 2026-09-05 14:10:19 +00:00
Hide_D 8488bf1aae fix: 사령부 발령 후보의 부대와 능력치 정보를 보강 2026-09-05 13:48:45 +00:00
Hide_D 71d7abcda4 docs: Qwen 보조 분석 지침 제거 2026-09-05 13:29:21 +00:00
Hide_D 332bd9a3dd 메인 장수 정보에서 상세 통계와 숙련도 표시를 제외 2026-09-05 13:21:08 +00:00
Hide_D 3437cd99f4 fix: 가오픈 명령과 GAME WALL 시간 경계 회귀 수정 2026-09-05 00:55:33 +00:00
Hide_D 1d53e6f226 docs: 공통 작업 지침 통합 및 충돌 정리 2026-09-04 23:54:53 +00:00
Hide_D 0157ff6a6b 인게임 예약 명령과 장수 상태 표시를 바로잡음 2026-09-04 19:16:56 +00:00
Hide_D 5d220de739 장시간 Ref 행렬과 NPC 회귀 비용을 줄인다 2026-09-04 08:59:10 +00:00
Hide_D 07897d9765 통합 테스트를 실행 권위별로 분리한다 2026-09-04 06:13:31 +00:00
Hide_D 01fdb51b6e 전투 차등 통합 테스트를 일괄 실행한다 2026-09-04 05:53:08 +00:00
Hide_D 746bbe7e7d 토너먼트 참가 트랜잭션 교착을 해소한다 2026-09-04 05:43:08 +00:00
Hide_D f009c50df5 외교 메시지 응답과 알림 권한 정리 2026-09-04 04:39:00 +00:00
Hide_D 38e516de6d 피장파장 연속 실행 판정을 복구한다 2026-09-04 02:02:36 +00:00
149 changed files with 4256 additions and 12172 deletions
+67 -241
View File
@@ -1,40 +1,29 @@
# core2026 작업 지침
## 적용 범위와 목표
## 적용 범위와 문서 역할
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
지침 순으로 적용합니다.
이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
상위 지침의 상대 경로는 `sam_rebuild/`, 이 파일의 경로는 `core2026/` 기준입니다.
독립 checkout에서 상위 작업공간 문서를 참조할 수 없으면 실제 작업공간 위치를
확인하고, 확인하지 못한 규칙이나 Ref 근거를 읽었다고 보고하지 않습니다.
## 현재 기준과 저장소 경계
| 작업 | 상세 기준 |
| -------------------------- | ------------------------------------------------------------------------------------------ |
| 구조·파일 책임 | [README](README.md#저장소-구성), [개발자 핸드북](docs/developer/index.md) |
| Ref 비교·계측·분류 | [상위 호환성 계약](../AGENTS.md#호환성-계약), [매핑](../docs/ref-core2026-mapping.md) |
| 테스트 준비·실행·오류 복구 | [테스트 정책](docs/testing-policy.md) |
| CSS·실제 Chromium 비교 | [CSS 구조](docs/frontend-css-architecture.md), [호환 검증](docs/frontend-legacy-parity.md) |
| DB 이관 | [이관 절차](tools/legacy-db-migration/README.md) |
| 배포 대상 선택 | [환경 라우팅](../docs/docker-environment-routing.md) |
| prefix·빌드 변수 | [Caddy 계약](docs/e2e-caddy-routing.md) |
| Gateway·profile 릴리스 | [릴리스 운영](docs/release-operations.md) |
- `core2026/`은 제품 구현 저장소입니다. gateway/game frontend·API, game engine,
공통 package, Prisma와 검증 도구가 실제로 존재합니다.
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`니다.
`devel`은 변경하지 않는 기준선이고 비교 fixture·계측은 `ng_compare`에만
두어 주세요.
- 아키텍처 매핑과 보고서는 별도 상위 저장소의
`../docs/ref-core2026-mapping.md`, `../report/`에 있습니다. 두 Git 경계의
commit과 상태를 혼동하지 말아 주세요.
- 이미지의 운영 소유자는 외부 Caddy의 `/image/*`입니다. 제품 저장소에 무단
복제하거나 앱 경로로 rewrite하지 말아 주세요.
작업 시작과 종료에 최소한 다음을 확인해 주세요.
```sh
git status --short --branch
git remote -v
git rev-parse HEAD
```
상위 매핑/보고서를 바꾸면 상위 저장소에서도 같은 검사를 별도로 수행해 주세요.
dirty 파일은 사용자의 변경일 수 있으므로 관련 없는 수정, 정리, stage,
commit 또는 삭제를 하지 말아 주세요.
PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
## 완료 상태를 해석하는 법
@@ -55,87 +44,19 @@ commit 또는 삭제를 하지 말아 주세요.
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
코드와 commit으로 다시 확인해 주세요.
## 실제 구조와 책임
- `app/gateway-frontend`: 가입·로그인, 로비, 계정, 관리자 운영 UI
- `app/gateway-api`: 계정·세션·profile 정책, operation queue와 PM2
orchestration
- `app/game-frontend`: profile별 게임 SPA와 ref 호환 UI
- `app/game-api`: tRPC/SSE, 조회·입력 API와 battle/auction/tournament worker
- `app/game-engine`: turn daemon, scheduler, 월간 lifecycle와 DB flush
- `packages/common`: 타입, 직렬화, RNG와 공통 유틸리티
- `packages/logic`: 전투·명령·월간 action과 typed action module 도메인 로직
- `packages/infra`: gateway/game Prisma schema, migration과 client
- `tools/integration-tests`: PostgreSQL/Redis 및 ref↔core 차등
- `tools/frontend-legacy-parity`: 실제 Chromium 비교
- `tools/legacy-db-migration`: 장기보존 데이터 CLI 이관
`tools/build-scripts/build-server.mjs`는 현재 profile resource 복사
placeholder입니다. 이를 완성된 배포 bundle이나 검증된 profile build로 설명하지
않습니다. 운영 build/reset/open은 gateway operation, commit별 worktree와
orchestrator 실행 경로를 조사해 주세요.
## 도메인 조립과 비교
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md`
있습니다.
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
## 레거시 매핑과 비교
기능을 변경하기 전에 다음을 end-to-end로 연결해 주세요.
1. ref entry point와 호출 순서
2. PHP domain class, SQL read/write, template/CSS/JS와 사용자 출력
3. core service/router/action/loader/flush와 frontend 호출부
4. session/auth, validation, transaction, 비동기 경계와 오류 복구
5. RNG 생성·소비와 persistence 순서
`../docs/ref-core2026-mapping.md`에는 1:1 대응을 억지로 만들지 말고 1:N/N:1
소유권과 변환 지점을 기록해 주세요. `확인`, `부분 확인`, `가설`, `미구현`,
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
Ref 비교 전에 대상 계약도 구분해 주세요.
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
- 기존 `ng_compare` 이력과 분기 기준을 조사하고 reset/overwrite하지 말아 주세요.
- 계측은 최소·deterministic·가능하면 read-only로 두고 test 환경 guard를
사용해 주세요.
- 계측 유무가 결과, RNG 소비, DB mutation과 출력 순서를 바꾸지 않는지
확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 계승 계약의 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
3. API 요청·응답·오류, session과 인증
4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
이름만으로 완화하지 않습니다.
차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
따릅니다.
## 인증과 권한
@@ -182,18 +103,18 @@ API request
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가
추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
소비하는 RNG call을 생략하지 말아 주세요.
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기
side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
## 프론트엔드와 룩앤필
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
- gateway `/gateway/`, game `/che/``/hwe/` prefix에서 direct navigation,
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
@@ -210,20 +131,10 @@ layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
일괄 disable하지 말아 주세요.
UI를 변경하면 실제 Chromium을 사용해 주세요.
- ref와 core에 같은 Chromium, viewport, device scale, zoom, locale, font,
image와 로그인/test data를 사용해 주세요.
- 전체·영역 screenshot과 `getBoundingClientRect()`,
`getComputedStyle()`을 수집해 주세요.
- `hover()`, focus, pointer down/up, checked/selected/disabled, dropdown,
modal과 transition 상태를 실제 interaction으로 만들어 주세요.
- pixel diff mask는 시간·난수 등 불가피한 영역만 최소화하고 이유를 기록해 주세요.
- auth redirect만 확인하고 visual parity라고 주장하지 말아 주세요.
- 민감정보가 보이는 artifact는 저장하거나 report에 넣지 말아 주세요.
Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
`docs/frontend-legacy-parity.md`를 따라 주세요.
`docs/frontend-legacy-parity.md`를 따릅니다.
## DB와 레거시 데이터 이관
@@ -263,94 +174,29 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
현재 외부 호스트 계약은 `0.0.0.0` bind`/gateway/`, `/che/`, `/hwe/`
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요.
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다.
```sh
VITE_APP_BASE_PATH=/gateway \
VITE_GATEWAY_API_URL=/gateway/api/trpc \
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
pnpm --filter @sammo-ts/gateway-frontend build
```
profile별 정확한 포트와 game frontend/API 변수는
`docs/e2e-caddy-routing.md`에서 확인하고 현재 인프라와 대조해 주세요.
컨테이너 서비스는 필요한 listener를 `0.0.0.0` bind합니다. 로컬 E2E의
`/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
HTTPS 검증과 구분합니다.
## 개발과 검증 절차
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요.
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요.
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다.
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요.
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
[테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
기본 정적 검사는 실제 루트 script를 사용해 주세요.
```sh
CI=1 pnpm typecheck
pnpm check:architecture
pnpm lint
pnpm test
pnpm build
```
루트 `pnpm test``pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
adapter로 분리합니다.
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
`packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
- Vitest filter에 불필요한 `--`를 넣지 않습니다.
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요.
해당 Playwright script 또는 legacy parity suite를 사용해 주세요.
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use`
(nvm 환경은 `nvm use`) 후
`pnpm test:bootstrap`을 실행해 install, Prisma client와
common → logic → infra → game-engine → API 산출물을 준비해 주세요. 이미
install된 checkout은 `pnpm test:prepare`를 사용합니다. Playwright
`webServer` 실패를 이 준비가 끝나기 전 제품 회귀로 분류하지 말아 주세요.
- E2E port가 다른 worktree와 겹치면 설정 파일을 임시 수정하지 말고
`PLAYWRIGHT_FRONTEND_PORT`, `PLAYWRIGHT_GATEWAY_FRONTEND_PORT` 또는
`FRONTEND_PARITY_*_PORT`를 지정합니다. 같은 game frontend `dist`를 쓰는
profile production run은 직렬로 실행합니다.
- 전체 lint/test의 기존 실패가 있으면 targeted 결과와 baseline 재현 결과를
구분하고, 관련 없는 기대값을 완화해 숨기지 말아 주세요.
외부 서비스 없는 기본 integration:
```sh
pnpm test:integration
```
전용 PostgreSQL/Redis를 준비한 조건부 전체 경계:
```sh
pnpm test:integration:conditional
```
ref 명령과 UI entry point:
```sh
pnpm check:legacy:general
pnpm check:legacy:nation
pnpm test:e2e:frontend-legacy
```
각 command가 요구하는 ref checkout, Docker, DB URL, secret과 fixture는 관련
docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report나 제안 문서만 보고
실행하지 말아 주세요.
- frontend package의 `test` placeholder UI 검증이 아닙니다. 해당 Playwright
script 또는 legacy parity suite를 실행합니다.
- ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
오래된 report만 보고 명령을 가정하지 않습니다.
- 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
## 코드 스타일
@@ -374,42 +220,22 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
## 문서와 보고서
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요.
변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약
- `docs/*`: core2026 내부 구현·운영 상세
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
보고서의 필수 내용과 저장소별 commit 기록은
[상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
## Git, worktree와 commit
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다.
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요.
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요.
- ref 비교 변경과 core 제품 변경, 상위 mapping/report 변경은 각 Git
저장소에서 별도 commit해 주세요.
- 생성물, `.env`, DB volume, log, coverage, screenshot, test-results와
secret을 commit하지 말아 주세요.
- worktree 정리는 clean status와
`git merge-base --is-ancestor HEAD <baseline>`을 모두 확인한 뒤 non-force
제거해 주세요. 이름, 나이 또는 uncommitted 여부만으로 삭제하지 말아 주세요.
- 사용자가 commit을 요청하지 않은 일반 작업은 diff와 권장 commit 경계를
준비하되 임의로 commit하지 말아 주세요.
[상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
[기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
## Qwen 보조 분석
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을
보내지 말아 주세요.
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
- 응답 budget은 1284096 token 범위로 제한해 주세요.
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
로컬 분석을 계속해 주세요.
Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
worktree 정리가 요청 범위라면 clean status와
`git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
+9 -6
View File
@@ -1,8 +1,9 @@
# 삼국지 모의전투 HiDCHe core2026
`core2026``../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작ref 구현과 실제 실행
결과를 기준으로 검증합니다.
`core2026` PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
발전하는 TypeScript pnpm 모노레포입니다. 계승 영역Ref의 동작·상태·권한과
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
## 저장소 구성
@@ -252,9 +253,11 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE,
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다.
활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
`/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
[환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
+3
View File
@@ -70,6 +70,9 @@ type WorldClockFields =
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'clockRecoveryStartTick'
| 'clockRecoveryEndTick'
| 'clockRecoveryStartWallAt'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
+5 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import {
readModelOutboxPayloadToDiplomacyMailboxes,
readModelOutboxPayloadToChanges,
readModelOutboxPayloadToMessageMailboxes,
type ReadModelDomain,
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
'access.general',
'dashboard.global',
'messages.mailbox',
'messages.diplomacyMailbox',
'tournament',
'betting',
]);
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
this.db,
async (payload) => {
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
if (mailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
}
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
const changes = readModelOutboxPayloadToChanges(payload);
+16 -3
View File
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const seen = new Set<string>();
return identities.filter((identity) => {
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
const changes = eventChanges(event);
if (!changes) return false;
const generalId = identity.generalId;
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
return [
changes.generalIds,
changes.mapGeneralIds ?? changes.generalIds,
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
);
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
const mailboxes =
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
const diplomacyMailboxes =
event.type === 'messageCreated'
? event.msgType === 'diplomacy'
? [event.mailbox]
: []
: (event.diplomacyMailboxes ?? []);
return viewers.some(
(identity) =>
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
(identity.canReadDiplomacy &&
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
)
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null;
}
+6 -2
View File
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
export const publishRealtimeMessageChanges = async (
redis: RedisConnector['client'],
profileName: string,
mailboxes: readonly number[]
mailboxes: readonly number[],
diplomacyMailboxes: readonly number[] = []
): Promise<void> => {
if (mailboxes.length === 0) return;
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
await publishRealtimeEvent(redis, profileName, {
type: 'messagesChanged',
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
...(diplomacyMailboxes.length > 0
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
: {}),
});
};
+3 -2
View File
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
await sendMessage(store, { ...draft, msgType: 'national' });
}
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
};
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
+1
View File
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
clockMode: gameTime.mode ?? 'realtime',
clockRunning: gameTime.running,
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
clockRecovery: gameTime.recovery ?? null,
turnEngineRunning,
otherTextInfo: worldState.meta.otherTextInfo ?? '',
npcMode: worldState.config.npcMode ?? 0,
+17 -4
View File
@@ -49,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
return {
...message,
text: '조회 권한이 없는 외교 메시지입니다.',
option: { ...(message.option ?? {}), permissionRedacted: true },
};
});
};
@@ -76,9 +77,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
return value === true || value === 1 || value === '1';
};
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
const markMessageMailboxes = (
ctx: Pick<GameApiContext, 'changeJournal'>,
mailboxes: Iterable<number>,
msgType?: MessageType
): void => {
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
for (const mailbox of mailboxes) {
ctx.changeJournal?.mark('messages.mailbox', mailbox);
ctx.changeJournal?.mark(domain, mailbox);
}
};
@@ -315,7 +321,11 @@ export const messagesRouter = router({
message.msgType === 'national'
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
: null;
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
markMessageMailboxes(
ctx,
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
message.msgType
);
return { ok: true, deletedIds };
}),
respond: engineAuthedProcedure
@@ -333,6 +343,9 @@ export const messagesRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
}
const action = message.payload.option?.action;
if (message.payload.option?.invalid === true) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
}
if (action === 'scout' || action === 'raiseInvader') {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -620,7 +633,7 @@ export const messagesRouter = router({
: msgType === 'private'
? general.id
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
return { msgType, msgId: result.receiverId };
}),
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
name: general.name,
npcState: general.npcState,
injury: general.injury,
baseStats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
stats: {
leadership: woundedStat(general.leadership, general.injury),
strength: woundedStat(general.strength, general.injury),
+9 -3
View File
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
};
const resolveCommandEnv = (
config: Record<string, unknown>
config: Record<string, unknown>,
worldMeta: Record<string, unknown>
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
const constValues = asRecord(config.const ?? config.consts);
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
develCost: resolveNumberFromKeys(
worldMeta,
['develcost', 'develCost', 'develrate'],
configuredDevelCost
),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
};
@@ -409,7 +415,7 @@ export const npcRouter = router({
const config = asRecord(worldState.config);
const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config);
const env = resolveCommandEnv(config, worldMeta);
const unitSetName = resolveUnitSetName(config, 'che');
const nationTech = readNumber(nation.tech, 0);
+8 -1
View File
@@ -103,6 +103,9 @@ const withTournamentBetClockMutation = async <T>(
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
const tournamentJoinCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
requestId ? `${requestId}:tournamentJoin:${step}` : undefined;
const zTournamentState = z.object({
stage: z.number().int().min(0),
phase: z.number().int().min(0),
@@ -453,7 +456,9 @@ export const tournamentRouter = router({
return { state, totals, myTotals, totalAmount, myAmount };
}),
join: authedProcedure.mutation(async ({ ctx }) => {
// 참가비는 ENGINE transaction이 차감한다. API input-event transaction으로
// 감싸면 clock advisory lock을 쥔 채 child ENGINE event를 기다리게 된다.
join: engineAuthedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
return withTournamentClockMutation(ctx, store, async () => {
@@ -476,6 +481,7 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
});
@@ -511,6 +517,7 @@ export const tournamentRouter = router({
} catch (error) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
});
+4 -1
View File
@@ -224,7 +224,10 @@ export const troopRouter = router({
name: troop.name,
nationId: troop.nationId,
turnTime: leader?.turnTime.toISOString() ?? null,
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [],
reservedCommands:
leader?.npcState === 5
? ['집합', '집합', '집합', '집합', '집합']
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
leader: leader
? {
id: leader.id,
+9
View File
@@ -310,6 +310,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
officerLevel: true,
gold: true,
rice: true,
leadership: true,
strength: true,
intel: true,
crew: true,
train: true,
atmos: true,
@@ -384,6 +387,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0,
generalGold: general.gold,
ownedItems: {
horse: general.horseCode,
weapon: general.weaponCode,
book: general.bookCode,
item: general.itemCode,
},
});
const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({
+18 -4
View File
@@ -38,6 +38,7 @@ import {
shouldReloadRealtimeViewerIdentity,
toPublicRealtimeEvent,
} from './realtime/publicEvent.js';
import { resolveNationPermission } from './router/nation/shared.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
@@ -313,11 +314,24 @@ export const createGameApiServer = async () => {
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true },
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
});
return general
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
: { generalId: null, cityId: null, nationId: null };
if (!general) {
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
}
const nation =
general.nationId > 0
? await postgres.prisma.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
return {
generalId: general.id,
cityId: general.cityId,
nationId: general.nationId,
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
};
};
let viewerIdentity = await loadViewerIdentity();
+25 -3
View File
@@ -1,5 +1,6 @@
import {
GameClock,
readTurnRecovery,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
@@ -7,6 +8,7 @@ import {
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
import { readTurnRuntimeReady } from '@sammo-ts/infra';
export interface CurrentGameTime {
now: Date;
@@ -17,6 +19,8 @@ export interface CurrentGameTime {
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean;
runtimeReady?: boolean;
recovery?: { startsAt: string; endsAt: string } | null;
startsAt: Date | null;
dateToTick(date: Date): number | null;
}
@@ -43,6 +47,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockTick: true,
clockMode: true,
clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
tickSeconds: true,
clockPhase: true,
clockRevision: true,
@@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
recovery: readTurnRecovery(state),
turnSeconds: state.tickSeconds,
phase,
revision,
});
const tick = clock.nowTick(wallNow);
const running = phase === 'RUNNING' && mode === 'realtime';
const runtimeReady =
phase !== 'RUNNING' ||
mode !== 'realtime' ||
state.clockRecoveryStartTick === undefined ||
(await readTurnRuntimeReady(db, state.clockRevision));
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
return {
now: clock.tickToDate(tick),
wallNow,
@@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
revision,
deadlineGeneration,
running,
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
runtimeReady,
recovery:
clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow
? {
startsAt: clock.recovery.startWallAt.toISOString(),
endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(),
}
: null,
startsAt:
phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date),
};
};
@@ -39,6 +39,7 @@ const ensureRedisClockFence = async (
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => {
if (
gameTime.runtimeReady === false ||
!gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
@@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async (
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
if (!gameTime.running) return null;
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
+23 -7
View File
@@ -79,6 +79,7 @@ export interface TurnCommandInputField {
required: boolean;
min?: number;
max?: number;
legacyWidthMax?: number;
step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue;
@@ -124,14 +125,28 @@ export const buildEquipmentTradeItemOptions = (options: {
itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number;
generalGold: number;
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
}): TurnCommandInputOptions['items'] => {
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
const items: TurnCommandInputOptions['items'] = {};
const catalog = new Map(options.itemModules.map((item) => [item.key, item]));
// Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다.
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const ownedCode = options.ownedItems[slot];
const ownedItem = ownedCode && ownedCode !== 'None' ? catalog.get(ownedCode) : undefined;
const slotName = STATIC_LABELS.itemType?.[slot] ?? slot;
items[slot] = [
{
value: 'None',
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
description: ownedItem
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}`
: ownedCode && ownedCode !== 'None'
? '보유 장비 정보를 확인할 수 없습니다.'
: '현재 보유한 장비가 없습니다.',
},
];
}
for (const item of options.itemModules) {
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
@@ -328,6 +343,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
required,
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
legacyWidthMax: key === 'nationName' ? 18 : undefined,
};
}
@@ -565,7 +581,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18)
) {
throwLegacyBasicTurnArgError();
throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
}
}
};
+35 -2
View File
@@ -11,6 +11,9 @@ export interface GeneralTargetSource {
officerLevel: number;
gold?: number;
rice?: number;
leadership?: number;
strength?: number;
intel?: number;
crew?: number;
train?: number;
atmos?: number;
@@ -64,7 +67,7 @@ export const buildRefGeneralTargetOptions = (options: {
const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => {
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
if (action === 'che_발령') return `${entry.name} (${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})();
@@ -80,13 +83,43 @@ export const buildRefGeneralTargetOptions = (options: {
? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value));
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
const assignmentDetails =
action === 'che_발령'
? [
entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음',
[
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
]
.filter(Boolean)
.join('\n')
: undefined;
if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
}
return {
value: entry.id,
label,
description: details.join(' · '),
description: assignmentDetails ?? details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }),
...(entry.rice === undefined ? {} : { rice: entry.rice }),
+31
View File
@@ -112,12 +112,22 @@ describe('turn command argument input', () => {
).toThrow('턴이 입력되지 않았습니다.');
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
);
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
'턴이 입력되지 않았습니다.'
);
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
});
it('exposes the Ref fullwidth nation-name limit to command input clients', async () => {
const specs = await loadGeneralTurnCommandSpecs(['che_건국']);
expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual(
expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 })
);
});
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
expect(
sanitizeReservedTurnArgs({
@@ -212,8 +222,28 @@ describe('turn command argument input', () => {
);
});
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
const items = buildEquipmentTradeItemOptions({
configConst: {},
itemModules: [owned],
currentSecurity: 0,
generalGold: 0,
ownedItems: { horse: null, weapon: null, book: null, item: null, [slot]: owned.key },
});
expect(items[slot]).toEqual([
{ value: 'None', label: '보유 유니크 판매', description: '소유 물품 판매 · 판매가 5,000금' },
]);
await expect(
parseReservedTurnArgs('general', 'che_장비매매', { itemType: slot, itemCode: 'None' })
).resolves.toEqual({ itemType: slot, itemCode: 'None' });
}
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 5000,
@@ -226,6 +256,7 @@ describe('turn command argument input', () => {
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: {
allItems: {
item: {
+14 -4
View File
@@ -67,6 +67,9 @@ describe('Ref command general targets', () => {
gold: 100,
rice: 200,
crew: 900,
leadership: 100,
strength: 95,
intel: 0,
train: 80,
atmos: 70,
troopId: 3,
@@ -88,11 +91,18 @@ describe('Ref command general targets', () => {
description: expect.stringContaining('탑승 부대 청룡대'),
});
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (부대 없음 · 업)',
'부대원 (청룡대 · 업)',
'부대장 (청룡대 (부대장) · 업)',
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
expect(detailed.generalTargets.che_발령?.[1]?.description).toBe(
'탑승 부대 청룡대\n통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200'
);
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe(
'탑승 부대 없음\n병력 1,000\n금 5,000 · 쌀 4,000'
);
expect(detailed.generalTargets.che_발령?.[2]?.description).toContain('청룡대 (부대장)');
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
'본인 (업)',
'부대원 (업)',
@@ -67,6 +67,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
any: {
ban: { expire: 4_102_444_800, value: 1 },
expired: { expire: 1, value: 9 },
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
},
hwe: {
ban: { expire: 4_102_444_800, value: 2 },
@@ -149,8 +150,9 @@ integration('generic general creation through the durable turn daemon', () => {
await seedScenarioToDatabase({
scenarioId: 2,
databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'),
now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5,
npcMode: 0,
showImgLevel: 3,
@@ -289,6 +291,7 @@ integration('generic general creation through the durable turn daemon', () => {
},
});
expect(created.affinity).toBeGreaterThanOrEqual(1);
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
expect(created.affinity).toBeLessThanOrEqual(150);
expect(created.meta).toMatchObject({
createdBy: 'join',
@@ -305,6 +308,9 @@ integration('generic general creation through the durable turn daemon', () => {
throw new Error('created general must have an initial access timestamp');
}
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
expect(
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
acceptedEvent.createdAt.getTime()
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some(
(change) =>
Array.isArray(change) &&
change[0] === 'messages.mailbox' &&
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
)
);
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await deleteFixtureOutboxes();
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
});
};
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
await expect(
db.readModelRevision.count({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
})
).resolves.toBe(0);
await expect(
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
changes.some(
(change) =>
Array.isArray(change) &&
change[0] === 'messages.mailbox' &&
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
)
);
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
});
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
where: {
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
entityId: { in: [...fixtureMailboxes] },
},
});
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
+35
View File
@@ -23,6 +23,41 @@ const buildDatabase = (
}) as unknown as DatabaseClient;
describe('current game time projection', () => {
it('keeps independent workers frozen until this clock revision has a ready daemon', async () => {
const row = {
clockBaseTime: new Date('2026-09-06T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-06T00:00:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: 288_000_000n,
clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'),
};
let ready = false;
const db = {
worldState: { findFirst: vi.fn(async () => row) },
$queryRaw: vi.fn(async () => [{ ready }]),
} as unknown as DatabaseClient;
const now = new Date('2026-09-06T05:00:00Z');
expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false });
ready = true;
expect(await loadCurrentGameTime(db, now)).toMatchObject({
tick: 72_000_000,
running: true,
runtimeReady: true,
recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' },
});
});
it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') });
});
it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase();
+21 -4
View File
@@ -177,12 +177,12 @@ describe('messages router missing-flow compatibility', () => {
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
option: { action: 'noAggression', permissionRedacted: true },
});
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
option: { action: 'noAggression', permissionRedacted: true },
});
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
});
@@ -368,8 +368,8 @@ describe('messages router missing-flow compatibility', () => {
expect(result.msgType).toBe('diplomacy');
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 },
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
]);
});
@@ -837,6 +837,7 @@ describe('messages router missing-flow compatibility', () => {
proposerCurrentNationId?: number;
proposerNationMeta?: Record<string, unknown>;
diplomacyState?: number;
invalid?: boolean;
response?: boolean;
cities?: Array<{ id: number; nationId: number; frontState: number }>;
}) => {
@@ -891,6 +892,7 @@ describe('messages router missing-flow compatibility', () => {
text: '외교 제안',
option: {
action,
...(options?.invalid ? { invalid: true } : {}),
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
},
},
@@ -1075,6 +1077,21 @@ describe('messages router missing-flow compatibility', () => {
};
};
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
const setup = buildDiplomaticContext({ invalid: true });
await expect(
setup.caller.messages.respond({
generalId: setup.actor.id,
messageId: 31,
response: true,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
expect(setup.requestCommand).not.toHaveBeenCalled();
});
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
const setup = buildDiplomaticContext();
@@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => {
const ally = general({
id: 3,
userId: 'u3',
injury: 25,
gold: 3000,
crew: 200,
train: 80,
@@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => {
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 52, strength: 45, intelligence: 37 },
});
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(result.generals[0]?.reservedCommands).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
+14
View File
@@ -161,6 +161,20 @@ describe('NPC policy router', () => {
});
});
it('uses the live world development cost instead of the scenario snapshot', async () => {
const fixture = createContext({
world: {
...baseWorld,
config: { ...baseWorld.config, const: { develCost: 100 } },
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
},
});
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
});
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const requestCommand = vi.fn(async () => ({
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
attempts: 1,
actorUserId: userId,
});
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const logs = await db.logEntry.findMany({
where: {
OR: [
@@ -671,7 +671,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.npcSelectionToken.update({
where: { ownerUserId: rejectedUserId },
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') },
data: { validUntilTick: -1n },
});
await expect(
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
+38 -1
View File
@@ -9,7 +9,7 @@ import {
toPublicRealtimeEvent as convertPublicRealtimeEvent,
} from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
const refreshGrant = 'opaque-grant';
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
@@ -198,6 +198,37 @@ describe('public realtime event privacy boundary', () => {
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
});
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
const blockedViewer = { ...viewer, canReadDiplomacy: false };
expect(
toPublicRealtimeEvent(
{
type: 'messageCreated',
at: '2026-09-04T00:00:00Z',
mailbox,
msgType: 'diplomacy',
messageId: 1,
senderId: 2,
},
[blockedViewer]
)
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toBeNull();
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
blockedViewer,
])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
expect(
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
).toEqual({ type: 'messagesInvalidated', refreshGrant });
});
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
const event: RealtimeEvent = {
type: 'messagesChanged',
@@ -225,6 +256,12 @@ describe('public realtime event privacy boundary', () => {
viewer
)
).toBe(false);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
viewer
)
).toBe(true);
});
it('merges previous and committed identities across an ownership transition', () => {
@@ -12,6 +12,7 @@ const payload = (
| 'access.general'
| 'dashboard.global'
| 'messages.mailbox'
| 'messages.diplomacyMailbox'
| 'tournament'
| 'betting'
) => ({
@@ -19,7 +20,11 @@ const payload = (
changes: [
[
domain,
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
domain === 'front.general' || domain === 'access.general'
? 7
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
? 9999
: 0,
'1',
],
],
@@ -114,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
});
});
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
type: 'messagesChanged',
mailboxes: [],
diplomacyMailboxes: [9999],
});
});
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => {
@@ -142,8 +142,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'),
now: new Date('2026-07-30T12:00:00.000Z'),
installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5,
npcMode: 2,
showImgLevel: 3,
@@ -274,6 +275,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
status: 'SUCCEEDED',
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
});
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp');
}
+21 -3
View File
@@ -277,15 +277,33 @@ describe('tournament router permissions and mutations', () => {
nextAt: '2026-07-26T01:00:00.000Z',
});
await redis.set('sammo:che:default:tournament:participants', '[]');
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
);
const context = buildContext({
redis,
transport,
generals: [general],
userId: 'user-1',
develCost: 200,
requestId: 'http:tournament-join',
});
const outerApiTransaction = vi.fn(async () => {
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
});
Object.assign(context.db, { $transaction: outerApiTransaction });
const caller = appRouter.createCaller(context);
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toHaveLength(1);
+12
View File
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
});
});
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
const fixture = buildContext({
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
result: null,
});
const result = await appRouter.createCaller(fixture.context).troop.getList();
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
});
it('creates a troop only for the general owned by the authenticated user', async () => {
const { context, requestCommand } = buildContext({
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
@@ -33,6 +33,11 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
async drain(): Promise<TurnDaemonCommand[]> {
const local = this.localQueue.splice(0, this.localQueue.length);
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
if (local.some((command) => command.type === 'shutdown')) {
return local;
}
const remote = await this.claimPending();
return local.concat(remote);
}
@@ -116,7 +121,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
});
const gameplayAllowed = !world || world.clockPhase === 'RUNNING' || world.clockPhase === 'MANUAL';
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
const gameplayAllowed =
!world ||
world.clockPhase === 'PREOPEN' ||
world.clockPhase === 'RUNNING' ||
world.clockPhase === 'MANUAL';
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
const currentRevision = world?.clockRevision ?? null;
const maintenanceSuspended =
@@ -93,6 +93,7 @@ export class DatabaseTurnDaemonLease {
ON CONFLICT ("profile") DO UPDATE
SET
"owner_id" = EXCLUDED."owner_id",
"clock_ready" = FALSE,
"lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
@@ -126,6 +127,14 @@ export class DatabaseTurnDaemonLease {
return this.token ? { ...this.token } : null;
}
async markClockReady(): Promise<void> {
await this.db.$transaction(async (db) => {
await this.assertActive(db);
const token = this.getToken()!;
await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } });
});
}
isLost(): boolean {
return this.lost;
}
@@ -158,6 +158,25 @@ export class TurnDaemonLifecycle {
continue;
}
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (gameClock?.phase && gameClock.phase !== 'RUNNING' && gameClock.phase !== 'MANUAL') {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) {
this.status.nextTurnTime = gameClock.startsAt.toISOString();
await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs));
continue;
}
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) {
await this.runOnce(this.pendingRun);
this.pendingRun = null;
@@ -169,23 +188,6 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(200);
continue;
}
const nowMs = this.clock.nowMs();
const wallNow = new Date(nowMs);
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
await this.stateStore.promotePreopenAtOpening(wallNow);
gameClock = await this.stateStore.loadGameClock?.(wallNow);
}
if (
gameClock?.phase &&
gameClock.phase !== 'RUNNING' &&
gameClock.phase !== 'MANUAL'
) {
this.status.nextTurnTime = undefined;
await this.clock.sleepMs(500);
continue;
}
if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
@@ -222,7 +224,10 @@ export class TurnDaemonLifecycle {
continue;
}
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime);
const command = await this.controlQueue.waitFor(
Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs)
);
if (command) {
await this.handleCommand(command);
}
+2
View File
@@ -72,11 +72,13 @@ export interface TurnStateStore {
phase?: GameClockPhase;
revision?: number;
deadlineGeneration?: number;
startsAt?: Date;
}>;
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
projectGameDeadline?(gameTime: Date): Promise<Date>;
}
export interface TurnDaemonControlQueue {
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
return { ...resultFromPersisted(created), alreadyApplied: false };
};
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => {
export const cancelGame = async (
request: GameCancellationRequest,
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
): Promise<GameCancellationResult> => {
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
earnedPoint: 0,
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
});
const connector = createGamePostgresConnector({ url: request.databaseUrl });
const connector = connectorFactory({ url: request.databaseUrl });
await connector.connect();
try {
return await connector.prisma.$transaction(
+12 -4
View File
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean;
now?: Date;
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
wallNow?: Date;
tickSeconds?: number;
gameClockMode?: GameClockMode;
installOptions?: ScenarioInstallOptions;
@@ -244,8 +246,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
const openingFloor = cutTurn(requestedOpening, turnTermMinutes);
const initialClockWallAnchor =
gameClockMode === 'manual'
? requestedOpening
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0));
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({
baseTime: startState.startTime,
tick: 0,
@@ -317,7 +325,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now),
opentime: formatDateTime(now),
opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now),
};
@@ -344,7 +352,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
}
worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.seededAtWall = wallNow.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
@@ -332,7 +332,7 @@ const respondToRaiseInvader = async (options: {
reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
clockWallNow: alignment.resumeWallAt,
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
});
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler(
+52 -9
View File
@@ -1149,6 +1149,8 @@ export class GeneralAI {
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
let userChiefCount = 0;
let ambassadorCount = 0;
const assignedAmbassadorIds = new Set<number>();
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
const minNpcKillturn = 36;
@@ -1162,13 +1164,25 @@ export class GeneralAI {
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
userChiefCount += 1;
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
if (ambassadorCount < 2) {
ambassadorCount += 1;
assignedAmbassadorIds.add(chief.id);
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
} else if (asRecord(chief.meta).permission === 'ambassador') {
chief.meta = { ...chief.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'normal',
});
}
}
}
@@ -1222,7 +1236,7 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
effectiveOfficerLevel.set(oldChief.id, 1);
}
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
const permission = penalty.noAmbassador !== true && ambassadorCount < 2 ? 'ambassador' : undefined;
candidate.officerLevel = 11;
candidate.meta = {
...candidate.meta,
@@ -1238,6 +1252,10 @@ export class GeneralAI {
effectiveOfficerLevel.set(candidate.id, 11);
chiefSet |= 1 << 11;
userChiefCount += 1;
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(candidate.id);
}
break;
}
}
@@ -1307,10 +1325,16 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
const permission =
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined;
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true && ambassadorCount < 2
? 'ambassador'
: undefined;
if (nextChief.npcState < 2) {
userChiefCount += 1;
}
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(nextChief.id);
}
this.promotionPatches.push({
generalId: nextChief.id,
officerLevel: chiefLevel,
@@ -1331,6 +1355,25 @@ export class GeneralAI {
chiefSet |= 1 << chiefLevel;
}
for (const candidate of generals) {
if (
asRecord(candidate.meta).permission !== 'ambassador' ||
assignedAmbassadorIds.has(candidate.id) ||
this.promotionPatches.some(
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
)
) {
continue;
}
candidate.meta = { ...candidate.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: candidate.id,
officerLevel: effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel,
officerCity: readMetaNumber(asRecord(candidate.meta), 'officer_city', 0),
permission: 'normal',
});
}
if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = {
...this.nation.meta,
+103 -6
View File
@@ -7,6 +7,10 @@ import {
buildClockAlignmentPlan,
parseClockAlignmentPolicy,
parseGameClockPhase,
readTurnRecovery,
readSerializedTurnRecovery,
serializeTurnRecovery,
type TurnRecoveryWindow,
type ClockAlignmentPolicy,
} from '@sammo-ts/common';
import {
@@ -43,6 +47,8 @@ export interface ClockReconciliationResult {
shiftTicks: number;
alignedTick: number;
resumeWallAt: Date;
recovery?: TurnRecoveryWindow | null;
resumeAnchor?: Date;
}
interface DbWallRow {
@@ -213,6 +219,10 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigi
`);
};
// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다.
const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean =>
Boolean(db.worldState.fields?.clockRecoveryStartTick);
const readParticipantSnapshots = async (
db: GamePrisma.TransactionClient,
worldStateId: number,
@@ -224,6 +234,13 @@ const readParticipantSnapshots = async (
where: { id: worldStateId },
select: {
clockTick: true,
...(supportsRecoveryColumns(db)
? {
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
}
: {}),
clockRevision: true,
deadlineGeneration: true,
lastTurnTick: true,
@@ -290,6 +307,7 @@ const readParticipantSnapshots = async (
snapshot('world-clock', 'REBUILD', [
{
clockTick: world.clockTick,
...serializeTurnRecovery(readTurnRecovery(world)),
clockRevision: world.clockRevision,
deadlineGeneration: world.deadlineGeneration,
},
@@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
cutWallAt: Date;
rateTicksPerSecond: number;
sourceRevision: number;
normalTickAtCutWall?: number;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
}): Promise<void> => {
@@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
rateTicksPerSecond: options.rateTicksPerSecond,
catchUpTicks: BigInt(catchUpTicks),
participantChecksumBefore: aggregateChecksum(participants),
detail: asJson({ authority: 'DAEMON', profileName: options.profileName }),
detail: asJson({
authority: 'DAEMON',
profileName: options.profileName,
normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick),
}),
},
});
await persistInitialParticipants(options.db, options.suspensionId, participants);
@@ -712,6 +735,7 @@ export const startClockSuspension = async (options: {
authority: ClockOperationAuthority;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
recoverDurableObservation?: boolean;
}): Promise<ClockSuspensionResult> => {
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
throw new Error('Clock suspension ID must contain 1-64 characters.');
@@ -761,7 +785,13 @@ export const startClockSuspension = async (options: {
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
throw new Error('Clock suspension requires a fully initialized logical game clock.');
}
const cutWallAt = await readDbWall(db);
if (
options.recoverDurableObservation &&
(options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS')
) {
throw new Error('Only daemon outage recovery may use the durable observation.');
}
const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db);
const storedTick = safeNumber(world.clockTick, 'world clock tick');
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
const clock = new GameClock({
@@ -769,11 +799,12 @@ export const startClockSuspension = async (options: {
tick: storedTick,
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor,
recovery: readTurnRecovery(world),
turnSeconds: world.tickSeconds,
phase,
revision: sourceRevision,
});
const cutTick = clock.nowTick(cutWallAt);
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
await lockParticipants(db, BigInt(cutTick));
await db.worldState.update({
where: { id: worldStateId },
@@ -797,6 +828,7 @@ export const startClockSuspension = async (options: {
detail: asJson({
authority: options.authority.kind,
profileName: options.authority.profileName,
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
}),
},
});
@@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: {
authority?: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> => {
const db = options.db;
const worldStateId = await lockWorld(db);
@@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
resumeWallAt: suspension.resumeWallAt,
recovery: readSerializedTurnRecovery(suspension.detail),
resumeAnchor:
suspension.detail &&
typeof suspension.detail === 'object' &&
!Array.isArray(suspension.detail) &&
typeof suspension.detail.resumeAnchor === 'string'
? new Date(suspension.detail.resumeAnchor)
: (world.clockWallAnchor ?? suspension.resumeWallAt),
};
}
if (suspension.status !== 'SUSPENDED') {
@@ -882,14 +923,41 @@ export const reconcileClockSuspensionInTransaction = async (options: {
throw new Error('A clock reconciliation wall override is allowed only in tests.');
}
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY';
const upgradeMaintenance =
options.upgradeMaintenancePolicy === true &&
supportsRecoveryColumns(db) &&
suspension.source === 'MAINTENANCE' &&
suspension.policy !== 'RECOVER_TURNS';
const effectivePolicy = legacyUnificationWait
? 'TURN_BOUNDARY'
: upgradeMaintenance
? 'RECOVER_TURNS'
: parseClockAlignmentPolicy(suspension.policy);
const alignmentCutTick = legacyUnificationWait
? safeNumber(world.lastTurnTick!, 'unification execution boundary')
: cutTick;
const plan = buildClockAlignmentPlan({
policy: parseClockAlignmentPolicy(suspension.policy),
policy: effectivePolicy,
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
cutTick,
cutTick: alignmentCutTick,
cutWall: suspension.cutWallAt,
resumeWall: resumeWallAt,
ticksPerSecond: suspension.rateTicksPerSecond,
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
normalTick: (() => {
const detail =
suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {};
const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick;
return (
normalAtCut +
Math.trunc(
((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000
)
);
})(),
});
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
assertShiftFits(before, plan.shiftTicks);
@@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: {
targetGeneration,
BigInt(plan.shiftTicks),
projectionDeltaMilliseconds,
resumeWallAt
plan.resumeAnchor ?? resumeWallAt
);
if (supportsRecoveryColumns(db)) {
await db.worldState.update({
where: { id: worldStateId },
data: {
clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null,
clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null,
clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null,
},
});
} else if (plan.recovery) {
throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.');
}
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
for (const participant of before) {
@@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: {
where: { id: suspension.id },
data: {
status: 'RECONCILING',
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
resumeWallAt,
gapTicks: BigInt(plan.gapTicks),
catchUpTicks: BigInt(plan.catchUpTicks),
detail: asJson({
...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {}),
...serializeTurnRecovery(plan.recovery ?? null),
resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(),
...(legacyUnificationWait || upgradeMaintenance
? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick }
: {}),
}),
shiftTicks: BigInt(plan.shiftTicks),
alignedTick: BigInt(plan.alignedTick),
participantChecksumBefore: aggregateChecksum(before),
@@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: plan.shiftTicks,
alignedTick: plan.alignedTick,
resumeWallAt,
recovery: plan.recovery ?? null,
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
};
};
@@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: {
authority: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> =>
runSerializableClockOperation(() =>
options.db.$transaction(
@@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: {
profileName: options.authority.profileName,
authority: options.authority,
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
});
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
+37 -2
View File
@@ -65,6 +65,7 @@ import {
} from './clockReconciliation.js';
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -73,6 +74,7 @@ export interface DatabaseTurnHooks {
close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>;
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
}
export interface CommittedReadModelChangeReceipt {
@@ -141,6 +143,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clockTick',
'clock_tick',
'clockWallAnchor',
'clockRecoveryStartTick',
'clockRecoveryEndTick',
'clockRecoveryStartWallAt',
'clock_wall_anchor',
'heartbeat',
'heartbeatAt',
@@ -1139,6 +1144,9 @@ export const createDatabaseTurnHooks = async (
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
clockRecoveryStartTick: state.clockRecovery ? BigInt(state.clockRecovery.startTick) : null,
clockRecoveryEndTick: state.clockRecovery ? BigInt(state.clockRecovery.endTick) : null,
clockRecoveryStartWallAt: state.clockRecovery?.startWallAt ?? null,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1),
@@ -1260,7 +1268,7 @@ export const createDatabaseTurnHooks = async (
}
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
const unificationCutTick = unificationCutWallAt
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime))
: null;
const suspensionPreparation =
unificationCutTick !== null
@@ -1852,6 +1860,7 @@ export const createDatabaseTurnHooks = async (
await persistYearbookSnapshot(prisma, snapshot);
}
const persistedMessageMailboxes: number[] = [];
const persistedDiplomacyMailboxes: number[] = [];
for (const finalization of pendingUnificationFinalizations) {
if (options?.profileName && finalization.profileName !== options.profileName) {
throw new Error(
@@ -1878,7 +1887,11 @@ export const createDatabaseTurnHooks = async (
expiresGameTick,
});
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
if (draft.msgType === 'diplomacy') {
persistedDiplomacyMailboxes.push(draft.mailbox);
} else {
persistedMessageMailboxes.push(draft.mailbox);
}
return id;
},
},
@@ -1904,7 +1917,9 @@ export const createDatabaseTurnHooks = async (
worldStateId: state.id,
profileName: options?.profileName ?? 'default',
source: 'UNIFICATION_WAIT',
policy: 'TURN_BOUNDARY',
cutTick,
normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt),
cutWallAt: suspensionPreparation.cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
sourceRevision: state.clockRevision ?? 1,
@@ -1968,6 +1983,7 @@ export const createDatabaseTurnHooks = async (
journal.mark('dashboard.global');
}
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
markIds(journal, 'messages.diplomacyMailbox', uniqueSortedIds(persistedDiplomacyMailboxes));
markIds(journal, 'access.general', accessScoreResetGeneralIds);
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
journal.mark('betting');
@@ -2057,6 +2073,25 @@ export const createDatabaseTurnHooks = async (
});
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
},
prepareRealtimeRecovery: async (recoveryOptions) => {
const token = options?.turnDaemonLease?.getToken();
if (!token) return;
await prepareRealtimeRecovery(
prisma,
{
kind: 'DAEMON',
profileName: token.profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
},
recoveryOptions
);
await prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
}, transactionOptions);
},
synchronizeClockAuthority: () =>
prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
@@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
revision: number;
deadlineGeneration: number;
startsAt: Date;
}> {
const state = this.world.getGameClockState();
return {
@@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: state.phase,
revision: state.revision,
deadlineGeneration: state.deadlineGeneration,
startsAt: state.wallAnchor,
};
}
@@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore {
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
this.world.advanceGameClockTo(target, wallNow);
}
async projectGameDeadline(gameTime: Date): Promise<Date> {
return this.world.projectGameDeadline(gameTime);
}
}
+42 -12
View File
@@ -17,6 +17,7 @@ import {
inferClockPhase,
type GameClockMode,
type GameClockPhase,
type TurnRecoveryWindow,
} from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -129,6 +130,7 @@ export interface InMemoryGameClockState {
tick: number;
mode: GameClockMode;
wallAnchor: Date;
recovery?: TurnRecoveryWindow | null;
lastTurnTick: number;
phase: GameClockPhase;
revision: number;
@@ -147,6 +149,8 @@ export interface DurableClockReconciliationAlignment {
alignedTick: number;
shiftTicks: number;
resumeWallAt: Date;
resumeAnchor?: Date;
recovery?: TurnRecoveryWindow | null;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -654,6 +658,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -684,6 +689,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null,
lastTurnTick: this.state.lastTurnTick ?? 0,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -695,6 +701,15 @@ export class InMemoryTurnWorld {
return this.getGameClock().now(wallNow);
}
projectGameDeadline(gameTime: Date): Date {
const clock = this.getGameClock();
return clock.tickToWallDate(clock.dateToTick(gameTime));
}
getNormalGameTick(wallNow: Date): number {
return this.getGameClock().normalNowTick(wallNow);
}
promotePreopenAtOpening(wallNow: Date): boolean {
const clock = this.getGameClock();
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
@@ -759,7 +774,8 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
clockTick: input.alignedTick,
clockWallAnchor: new Date(input.resumeWallAt.getTime()),
clockWallAnchor: new Date((input.resumeAnchor ?? input.resumeWallAt).getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
lastTurnTick,
lastTurnTime,
clockPhase: 'RECONCILING',
@@ -865,6 +881,7 @@ export class InMemoryTurnWorld {
clockTick: input.tick,
clockMode: input.mode,
clockWallAnchor: new Date(input.wallAnchor.getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
clockPhase: input.phase,
clockRevision: input.revision,
deadlineGeneration: input.deadlineGeneration,
@@ -918,16 +935,17 @@ export class InMemoryTurnWorld {
skippedTurns: number;
} | null {
const clock = this.getGameClock();
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) {
return null;
}
const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60));
const threshold = turnMinutes >= 20 ? 1 : turnMinutes >= 10 ? 3 : 6;
const currentTick = clock.nowTick(wallNow);
const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow));
const wallAlignedTick = Math.max(currentTick, clock.normalNowTick(wallNow));
const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime);
const skippedTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN);
return skippedTurns > threshold ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null;
// 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
// 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
const overdueTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN);
const skippedTurns = Math.floor(overdueTurns / 12) * 12;
return skippedTurns > 0 ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null;
}
shouldRebaseRealtimeBacklog(wallNow: Date): boolean {
@@ -1180,6 +1198,9 @@ export class InMemoryTurnWorld {
lastTurnTime: this.state.lastTurnTime.toISOString(),
};
}
if (previousClock.recovery && wallNow < previousClock.tickToWallDate(previousClock.recovery.endTick)) {
throw new Error('복구가 끝난 뒤 기본 턴 길이를 변경할 수 있습니다.');
}
const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor;
const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
const anchorTick = previousClock.nowTick(anchorWall);
@@ -1200,6 +1221,7 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
tickSeconds: nextTickSeconds,
clockRecovery: null,
clockBaseTime: nextBaseTime,
clockTick: anchorTick,
clockWallAnchor: new Date(anchorWall.getTime()),
@@ -1669,10 +1691,13 @@ export class InMemoryTurnWorld {
};
}
shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
shiftSchedule(deltaMinutes: number, _wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
throw new Error('Schedule shift must be a non-zero integer number of minutes.');
}
if ((deltaMinutes * 60) % this.state.tickSeconds !== 0) {
throw new Error('일정 이동은 현재 턴 길이의 정수 배수여야 합니다.');
}
const deltaMs = deltaMinutes * 60_000;
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
const previousClock = this.getGameClock();
@@ -1693,6 +1718,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -1708,10 +1734,14 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
clockBaseTime: nextBaseTime,
// Rebasing is also the explicit resume checkpoint. Realtime mode
// must not replay the operational downtime after an administrator
// deliberately delays or accelerates the game schedule.
clockWallAnchor: new Date(wallNow.getTime()),
// 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다.
clockWallAnchor: shiftDate(previousClock.wallAnchor),
clockRecovery: this.state.clockRecovery
? {
...this.state.clockRecovery,
startWallAt: shiftDate(this.state.clockRecovery.startWallAt),
}
: null,
lastTurnTime: nextLastTurnTime,
meta: nextMeta,
};
@@ -96,7 +96,7 @@ export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
export const resolveLegacyPenalty = (
rawPenalty: Record<string, unknown> | undefined,
profileId: string,
acceptedAt: Date
requestedAtWall: Date
): Record<string, unknown> => {
if (!rawPenalty) {
return {};
@@ -105,7 +105,7 @@ export const resolveLegacyPenalty = (
...asRecord(rawPenalty.any),
...asRecord(rawPenalty[profileId]),
};
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
const acceptedAtSeconds = requestedAtWall.getTime() / 1000;
const result: Record<string, unknown> = {};
for (const [key, rawEntry] of Object.entries(merged)) {
const entry = asRecord(rawEntry);
@@ -654,12 +654,7 @@ export const createGeneralFromJoin = async (options: {
const generalId = world.getNextGeneralId();
const rng = new RandUtil(
new LiteHashDRBG(
buildJoinCreateGeneralSeed(
hiddenSeed,
input.seedOwnerIdentity,
world.dateToGameTick(acceptedAt),
generalId
)
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt), generalId)
)
);
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
@@ -805,7 +800,9 @@ export const createGeneralFromJoin = async (options: {
meta: {},
},
lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
// 계정 제재 expire는 Ref member의 Unix wall timestamp다. 가오픈의
// 음수 GAME 좌표로 비교하면 현실에서 만료된 제재가 다시 적용된다.
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, operationalAcceptedAt),
inheritancePoints: {
previous: finalInheritancePoint,
},
@@ -0,0 +1,43 @@
import { randomUUID } from 'node:crypto';
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() < world.tickSeconds * 1_000) return;
const suspensionId = `recovery-${randomUUID()}`;
await startClockSuspension({
db,
suspensionId,
source: 'RECOVERY',
policy: 'RECOVER_TURNS',
authority,
recoverDurableObservation: true,
});
// 이전 버전의 profile 상태만 PAUSED였던 경우에도 명시적 재개 전에는 실행하지 않는다.
if (!options.paused) await reconcileClockSuspension({ db, suspensionId, authority });
};
@@ -1,7 +1,22 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import type { TurnWorldState } from './types.js';
const DEFAULT_MIN_TURNS = 2;
/** 표시용 opentime과 재투영된 lastTurnTime 대신 오픈 phase와 실행 cursor로 판정한다. */
export const hasStartedForPrestartActions = (
state: Pick<TurnWorldState, 'clockPhase' | 'lastTurnTick' | 'lastTurnTime' | 'meta'>
): boolean => {
if (state.clockPhase === 'PREOPEN') return false;
if (state.clockPhase === 'COMPLETED' || state.clockPhase === 'SUSPENDED' || state.clockPhase === 'RECONCILING') {
return true;
}
// Ref의 turntime == opentime 경계는 아직 허용하고 최초 월 진행 이후 닫는다.
if (state.lastTurnTick !== undefined) return state.lastTurnTick > 0;
const opentime = typeof state.meta.opentime === 'string' ? new Date(state.meta.opentime) : null;
return opentime !== null && state.lastTurnTime.getTime() > opentime.getTime();
};
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
const value = meta.prestart_delete_after;
if (typeof value !== 'string' || !value.trim()) {
@@ -44,6 +44,7 @@ import {
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { isEqual } from 'es-toolkit';
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
@@ -385,7 +386,7 @@ const normalizeLastTurn = (value: unknown): LegacyLastTurn => {
};
const sameArgs = (left: Record<string, unknown> | undefined, right: Record<string, unknown>): boolean =>
JSON.stringify(left ?? {}) === JSON.stringify(right);
isEqual(left ?? {}, right);
const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => {
const raw = asRecord(nation.meta)[`next_execute_${actionName}`];
@@ -1,4 +1,4 @@
import { parseGameClockPhase } from '@sammo-ts/common';
import { parseGameClockPhase, readTurnRecovery, readSerializedTurnRecovery } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
clockTick: true,
clockMode: true,
clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
lastTurnTick: true,
clockPhase: true,
clockRevision: true,
@@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
shiftTicks: true,
alignedTick: true,
resumeWallAt: true,
detail: true,
},
});
let expectedRevision = before.revision;
@@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
resumeWallAt: ledger.resumeWallAt,
recovery: readSerializedTurnRecovery(ledger.detail),
});
expectedRevision = targetRevision;
}
@@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
tick: safeNumber(durable.clockTick, 'durable clock tick'),
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: durable.clockWallAnchor,
recovery: readTurnRecovery(durable),
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
phase: parseGameClockPhase(durable.clockPhase),
revision: durableRevision,
+14
View File
@@ -900,6 +900,17 @@ const createTurnDaemonRuntimeWithLease = async (
turnDaemonLease: turnDaemonLease ?? undefined,
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
});
try {
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
} catch (error) {
await Promise.allSettled([
dbHooks.close(),
reservedTurnStoreHandle?.close(),
gatewayGate?.close(),
turnDaemonLease?.close(),
]);
throw error;
}
auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl,
world,
@@ -983,6 +994,9 @@ const createTurnDaemonRuntimeWithLease = async (
redisConnector = realtimeRuntime.redisConnector;
hooks = realtimeRuntime.hooks;
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
// 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다.
// RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다.
await turnDaemonLease?.markClockReady();
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
+2 -1
View File
@@ -11,7 +11,7 @@ import type {
WorldSnapshot,
GeneralLastTurn,
} from '@sammo-ts/logic';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common';
export interface TurnWorldState {
id: number;
@@ -23,6 +23,7 @@ export interface TurnWorldState {
clockTick?: number;
clockMode?: GameClockMode;
clockWallAnchor?: Date;
clockRecovery?: TurnRecoveryWindow | null;
lastTurnTick?: number;
clockPhase?: GameClockPhase;
clockRevision?: number;
+21 -10
View File
@@ -70,7 +70,12 @@ import {
} from './selectPoolService.js';
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
readPrestartDeleteAfter,
hasStartedForPrestartActions,
} from './prestartDeletion.js';
import { respondToActionableMessage } from './actionableMessageResponse.js';
import { executeInheritanceAction } from './inheritanceActionService.js';
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
throw new Error('Join world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
const processingGameTick = Reflect.get(command, 'processingGameTick');
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try {
return {
type: 'joinCreateGeneral',
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = acceptedAt;
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
try {
return {
type: 'selectPoolCreate',
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
const db = requireCommandDatabase(ctx);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = ctx.world.getState();
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
return {
type: 'ensureDieOnPrestartStatus',
generalId: command.generalId,
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
if (hasStartedForPrestartActions(worldState)) {
return {
type: 'dieOnPrestart',
ok: false,
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
await assertImmediateGeneralActionActor(ctx, command, general);
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
if (hasStartedForPrestartActions(worldState)) {
return {
type: 'buildNationCandidate',
ok: false,
+6 -7
View File
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
import { z } from 'zod';
import {
GameClock,
readTurnRecovery,
asRecord,
inferClockPhase,
isRecord,
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockPhase = hasPersistedClock
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(clockMode);
const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
const deadlineGeneration = toSafeTick(
worldState.deadlineGeneration,
'world_state.deadline_generation'
);
const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
tick: 0,
mode: clockMode,
wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
mode: clockMode,
wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
clockTick: gameClock.tick,
clockMode,
clockWallAnchor: gameClock.wallAnchor,
clockRecovery: gameClock.recovery,
lastTurnTick,
clockPhase,
clockRevision,
@@ -1,8 +1,9 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock } from '@sammo-ts/common';
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
import {
createGamePostgresConnector,
readTurnRuntimeReady,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
@@ -13,11 +14,11 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
const enabled =
process.env.CLOCK_RECONCILIATION_INTEGRATION === '1' &&
Boolean(process.env.DATABASE_URL) &&
Boolean(process.env.REDIS_URL);
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
const describeIntegration = enabled ? describe : describe.skip;
describeIntegration('durable clock reconciliation', () => {
@@ -47,7 +48,7 @@ describeIntegration('durable clock reconciliation', () => {
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: process.env.DATABASE_URL! });
const connector = createGamePostgresConnector({ url: databaseUrl! });
db = connector.prisma;
disconnect = connector.disconnect;
redis = createRedisConnector({ url: process.env.REDIS_URL! });
@@ -64,6 +65,247 @@ describeIntegration('durable clock reconciliation', () => {
await clean();
});
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 ? 12 : 8) * 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();
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);
}
);
it.each([3_142_625, 6 * 3_600_000 + 3_142_625])(
'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload',
async (gapMilliseconds) => {
const baseTime = new Date('2026-09-06T00:00:00.000Z');
const initialTick = 5 * 36_000_000 + 28_879_860;
const clock = new GameClock({
baseTime,
tick: initialTick,
mode: 'realtime',
wallAnchor: new Date(Date.now() + 3_600_000),
turnSeconds: 3_600,
phase: 'RUNNING',
});
const lastTurnTick = 5 * 36_000_000;
// 냥냥과 같은 00:19.902 구매 시각. 5시 턴은 이미 처리되어 다음은 6시다.
const nextTicks = [6 * 36_000_000 + 199_020, 6 * 36_000_000 + 420_010];
await db.worldState.create({
data: {
scenarioCode: 'maintenance-phase',
currentYear: 199,
currentMonth: 2,
tickSeconds: 3_600,
clockBaseTime: baseTime,
clockTick: BigInt(initialTick),
clockMode: 'realtime',
clockWallAnchor: clock.wallAnchor,
lastTurnTick: BigInt(lastTurnTick),
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
await db.general.createMany({
data: nextTicks.map((tick, index) => ({
id: index + 1,
name: `purchased-${index}`,
turnTick: BigInt(tick),
turnTime: clock.tickToDate(tick),
lastTurn: { command: '휴식' },
meta: { killturn: 24 },
})),
});
const authority = { kind: 'OFFLINE' as const, profileName: 'maintenance-phase', reason: 'fixture' };
const suspended = await startClockSuspension({
db,
suspensionId: 'maintenance-phase',
source: 'MAINTENANCE',
policy: 'PRESERVE_SCHEDULE',
authority,
});
const plan = await reconcileClockSuspension({
db,
suspensionId: suspended.suspensionId,
authority,
testResumeWallAt: new Date(suspended.cutWallAt.getTime() + gapMilliseconds),
});
const expectedShift = 0;
expect(plan.shiftTicks).toBe(expectedShift);
expect(plan.catchUpTicks).toBe(gapMilliseconds * 10);
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'phase-test' })).not.toBe(
'IDLE'
);
const reload = createGamePostgresConnector({ url: databaseUrl! });
try {
const world = await reload.prisma.worldState.findFirstOrThrow();
const generals = await reload.prisma.general.findMany({ orderBy: { id: 'asc' } });
expect(world).toMatchObject({
clockPhase: 'RUNNING',
currentYear: 199,
currentMonth: 2,
lastTurnTick: BigInt(lastTurnTick + expectedShift),
});
expect(generals.map((general) => Number(general.turnTick))).toEqual(
nextTicks.map((tick) => tick + expectedShift)
);
expect(generals.map((general) => general.turnTime.toISOString().slice(14))).toEqual([
'00:19.902Z',
'00:42.001Z',
]);
expect(generals.map((general) => general.meta)).toEqual([{ killturn: 24 }, { killturn: 24 }]);
// 미처리된 다음 턴만 남고, 처리한 5시 턴을 다시 만들지 않는다.
expect(Number(generals[0]!.turnTick)).toBeGreaterThan(Number(world.lastTurnTick));
expect((await reload.prisma.clockSuspension.findFirstOrThrow()).status).toBe('APPLIED');
} finally {
await reload.disconnect();
}
}
);
it('preserves every remaining deadline and occurrence across a 65m17.250s exact gap', async () => {
const baseTime = new Date('2026-01-01T00:00:00.000Z');
const futureAnchor = new Date(Date.now() + 3_600_000);
@@ -225,16 +467,16 @@ describeIntegration('durable clock reconciliation', () => {
const [afterWorld, generals, auction, message, messageAction, vote, pool, token, ledger, outboxes] =
await Promise.all([
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
db.worldState.findUniqueOrThrow({ where: { id: world.id } }),
db.general.findMany({ orderBy: { id: 'asc' } }),
db.auction.findFirstOrThrow(),
db.message.findFirstOrThrow(),
db.messageAction.findFirstOrThrow(),
db.votePoll.findFirstOrThrow(),
db.selectPoolEntry.findFirstOrThrow(),
db.npcSelectionToken.findFirstOrThrow(),
db.clockSuspension.findUniqueOrThrow({ where: { id: suspended.suspensionId } }),
db.clockProjectionOutbox.findMany(),
]);
const alignedTick = BigInt(reconciled.alignedTick);
expect(afterWorld).toMatchObject({
@@ -121,6 +121,31 @@ integration('database command queue', () => {
});
});
it('leaves pending work immediately claimable by a replacement daemon when shutting down', async () => {
const requestId = 'integration:engine:shutdown-pending';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'dieOnPrestart',
actorUserId: 'user-7',
payload: { type: 'dieOnPrestart', requestId, userId: 'user-7', generalId: 7 },
},
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
queue.enqueue({ type: 'shutdown', reason: 'replacement' });
expect(await queue.drain()).toEqual([{ type: 'shutdown', reason: 'replacement' }]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'PENDING',
attempts: 0,
lockedBy: null,
leaseUntil: null,
});
expect(await new DatabaseTurnDaemonCommandQueue(db).drain()).toMatchObject([
{ type: 'dieOnPrestart', requestId },
]);
});
it('recovers only an expired processing lease', async () => {
const expiredId = 'integration:engine:expired';
const activeId = 'integration:engine:active';
@@ -313,6 +338,55 @@ integration('database command queue', () => {
expect(mutation).not.toHaveBeenCalled();
});
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns',
async (phase) => {
await db.worldState.updateMany({
data: {
clockPhase: phase,
clockMode: 'realtime',
clockTick: 0n,
lastTurnTick: 0n,
clockWallAnchor: new Date(Date.now() + 3_600_000),
},
});
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'buildNationCandidate'] as const;
await db.inputEvent.createMany({
data: types.map((type) => {
const requestId = `integration:engine:preopen:${type}`;
return {
requestId,
target: 'ENGINE' as const,
eventType: type,
actorUserId: 'user-7',
payload: { type, requestId, userId: 'user-7', generalId: 7 },
};
}),
});
const commands = await new DatabaseTurnDaemonCommandQueue(db).drain();
const allowed = ['PREOPEN', 'RUNNING', 'MANUAL'].includes(phase);
expect(commands.map((command) => command.type)).toEqual(allowed ? types : []);
const events = await db.inputEvent.findMany({
where: { requestId: { startsWith: 'integration:engine:preopen:' } },
});
expect(events).toHaveLength(3);
for (const event of events) {
expect(event.status).toBe(allowed ? 'PROCESSING' : 'PENDING');
expect(event.attempts).toBe(allowed ? 1 : 0);
expect(event.processingClockRevision).toBe(allowed ? 1n : null);
expect(event.processingDeadlineGeneration).toBe(allowed ? 1n : null);
if (phase === 'PREOPEN') {
expect(event.processingGameTick).toBeLessThan(0n);
}
}
expect(await db.worldState.findFirst()).toMatchObject({
clockPhase: phase,
clockTick: 0n,
lastTurnTick: 0n,
});
}
);
it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => {
const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
const world = existingWorld
+22 -1
View File
@@ -4,7 +4,11 @@ import type { GamePrisma } from '@sammo-ts/infra';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
import {
buildPrestartDeleteAfter,
formatPrestartDeleteAfter,
hasStartedForPrestartActions,
} from '../src/turn/prestartDeletion.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
@@ -59,6 +63,8 @@ const buildFixture = (options: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
lastTurnTick:
options.lastTurnTime && options.lastTurnTime > new Date('2026-08-01T00:00:00.000Z') ? 36_000_000 : 0,
meta: { opentime: '2026-08-01T00:00:00.000Z' },
};
const snapshot: TurnWorldSnapshot = {
@@ -118,6 +124,21 @@ const buildFixture = (options: {
};
describe('pre-start general deletion', () => {
it('uses the opening phase and executed tick despite shifted or ancient display projections', () => {
const state = { lastTurnTime: new Date('2099-01-01T00:00:00Z'), meta: { opentime: '2026-01-01T00:00:00Z' } };
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'PREOPEN', lastTurnTick: 0 })).toBe(false);
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'RUNNING', lastTurnTick: 0 })).toBe(false);
expect(
hasStartedForPrestartActions({
...state,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
clockPhase: 'RUNNING',
lastTurnTick: 1,
})
).toBe(true);
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'COMPLETED', lastTurnTick: 0 })).toBe(true);
});
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
expect(
+41
View File
@@ -0,0 +1,41 @@
import type { City, MapDefinition } from '@sammo-ts/logic';
import { LARGE_TEST_MAP } from './largeTestMap.js';
const COMPACT_CITY_IDS = new Set([1, 2, 3, 4, 5]);
export const COMPACT_NPC_TEST_MAP: MapDefinition = {
id: 'compact_npc_test_map',
name: 'NPC 장기 시뮬레이션용 소형 맵',
cities: LARGE_TEST_MAP.cities
.filter((city) => COMPACT_CITY_IDS.has(city.id))
.map((city) => ({
...city,
connections: city.connections.filter((cityId) => COMPACT_CITY_IDS.has(cityId)),
})),
defaults: { ...LARGE_TEST_MAP.defaults },
};
export const buildCompactNpcTestCities = (): City[] =>
COMPACT_NPC_TEST_MAP.cities.map((city) => ({
id: city.id,
name: city.name,
nationId: 0,
level: city.level,
state: 0,
population: city.initial.population,
populationMax: city.max.population,
agriculture: city.initial.agriculture,
agricultureMax: city.max.agriculture,
commerce: city.initial.commerce,
commerceMax: city.max.commerce,
security: city.initial.security,
securityMax: city.max.security,
supplyState: 1,
frontState: 0,
defence: city.initial.defence,
defenceMax: city.max.defence,
wall: city.initial.wall,
wallMax: city.max.wall,
meta: { trust: 95 },
}));
@@ -711,7 +711,7 @@ describe('legacy NPC user-chief promotion parity', () => {
expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null });
});
it('does not appoint a fourth user chief in the ordinary NPC-ruler fill pass', () => {
it('keeps NPC-assigned diplomatic authority at two even when three user chiefs exist', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
@@ -745,11 +745,11 @@ describe('legacy NPC user-chief promotion parity', () => {
const promotion = ai.consumePromotionPatches();
const result = promotion.generals;
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
expect(result).toHaveLength(3);
expect(result).toHaveLength(2);
expect(promotion.nationMeta).toBeNull();
expect(result).toEqual(
expect.arrayContaining(
existingChiefs.map((chief) => ({
existingChiefs.slice(1).map((chief) => ({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: 0,
@@ -759,6 +759,52 @@ describe('legacy NPC user-chief promotion parity', () => {
);
});
it('repairs a third diplomatic authority left by an earlier NPC promotion pass', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const existingChiefs = [11, 10, 9].map((officerLevel, index) =>
makePromotionGeneral({
id: index + 2,
npcState: 0,
officerLevel,
meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' },
})
);
const formerChief = makePromotionGeneral({
id: 5,
npcState: 0,
officerLevel: 1,
meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, ...existingChiefs, formerChief],
nation: { level: 6 },
userGenerals: [...existingChiefs, formerChief],
chiefGenerals: [ruler, ...existingChiefs],
});
chooseNpcPromotion(ai);
const promotion = ai.consumePromotionPatches();
expect(promotion.generals).toContainEqual({
generalId: existingChiefs[0]!.id,
officerLevel: 11,
officerCity: 0,
permission: 'normal',
});
expect(promotion.generals).toContainEqual({
generalId: formerChief.id,
officerLevel: 1,
officerCity: 0,
permission: 'normal',
});
});
it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => {
const actor = makePromotionGeneral({
id: 1,
@@ -1561,6 +1607,7 @@ describe('legacy NPC AI final-decision parity', () => {
});
expect(do집합(ai)?.action).toBe('che_집합');
expect(ai.general.meta.killturn).toBe(72);
expect(ai.general.meta.killturn).toBeGreaterThanOrEqual(70);
});
it('does not warp to the rear when recruitment is disabled', () => {
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SystemClock } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -97,6 +97,14 @@ const state: TurnWorldState = {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T00:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T00:00:00.000Z'),
clockTick: 0,
lastTurnTick: 0,
clockMode: 'realtime',
clockPhase: 'PREOPEN',
clockWallAnchor: new Date(Date.now() + 86_400_000),
clockRevision: 1,
deadlineGeneration: 1,
meta: {
hiddenSeed: 'immediate-action-integration',
killturn: 24,
@@ -155,8 +163,10 @@ integration('immediate general action persistence', () => {
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
});
await db.inputEvent.deleteMany({ where: { requestId } });
beforeEach(async () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({
where: {
@@ -181,6 +191,14 @@ integration('immediate general action persistence', () => {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: state.clockMode,
clockPhase: state.clockPhase,
clockWallAnchor: state.clockWallAnchor,
clockRevision: 1n,
deadlineGeneration: 1n,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -257,7 +275,7 @@ integration('immediate general action persistence', () => {
await disconnect?.();
return;
}
await db.inputEvent.deleteMany({ where: { requestId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.auction.deleteMany({ where: { targetCode: occupiedUniqueItem } });
await db.logEntry.deleteMany({
where: {
@@ -277,7 +295,7 @@ integration('immediate general action persistence', () => {
await disconnect?.();
});
it('flushes and reloads the nation, diplomacy, officer turns, logs, and general state together', async () => {
it('commits pre-opening uprising with rollback/retry while scheduled turns remain stopped', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [
@@ -376,10 +394,15 @@ integration('immediate general action persistence', () => {
});
const stateStore = {
loadLastTurnTime: async () => new Date(state.lastTurnTime),
loadNextGeneralTurnTime: async () => null,
loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime' as const,
phase: 'PREOPEN' as const,
now: world.getGameNow(new Date()),
}),
};
const processor = {
run: async () => {
@@ -597,5 +620,108 @@ integration('immediate general action persistence', () => {
chiefGeneralId: generalId,
rice: 2_000,
});
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
});
it('deletes a neutral general after the wall deadline in PREOPEN and commits its durable result once', async () => {
const cutoff = new Date('2026-07-31T00:20:00.000Z');
await db.general.update({
where: { id: generalId },
data: { meta: { ...general.meta, prestart_delete_after: cutoff.toISOString() } },
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (value) => world.restoreState(value),
});
const queue = new DatabaseTurnDaemonCommandQueue(db);
const processor = {
run: vi.fn(async () => {
throw new Error('PREOPEN must not execute scheduled turns');
}),
};
const ids = [':status', ':early', ':delete'].map((suffix) => requestId + suffix);
const types = ['ensureDieOnPrestartStatus', 'dieOnPrestart', 'dieOnPrestart'];
await db.inputEvent.createMany({
data: ids.map((id, index) => ({
requestId: id,
target: 'ENGINE',
eventType: types[index]!,
actorUserId: general.userId,
createdAt: new Date(cutoff.getTime() + (index === 2 ? 0 : -1)),
payload: { type: types[index], requestId: id, userId: general.userId, generalId },
})),
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new SystemClock(),
controlQueue: queue,
commandResponder: queue,
commandHandler: handler,
hooks: hooks.hooks,
stateManager,
processor,
getNextTickTime: () => general.turnTime,
stateStore: {
loadLastTurnTime: async () => state.lastTurnTime,
loadNextGeneralTurnTime: async () => general.turnTime,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({
mode: 'realtime',
phase: 'PREOPEN',
now: world.getGameNow(new Date()),
}),
},
},
{
profile: 'immediate-action-integration',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const loop = lifecycle.start();
try {
await vi.waitFor(
async () => {
const events = await db.inputEvent.findMany({
where: { requestId: { in: ids } },
orderBy: { sequence: 'asc' },
});
expect(events.map((event) => event.status)).toEqual(['SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED']);
expect(events[0]?.result).toMatchObject({
show: true,
available: false,
availableAt: cutoff.toISOString(),
});
expect(events[1]?.result).toMatchObject({
ok: false,
reason: expect.stringContaining('아직 삭제할 수 없습니다'),
});
expect(events[2]?.result).toMatchObject({ ok: true, generalId });
expect(
events.every((event) => event.processingGameTick !== null && event.processingGameTick < 0n)
).toBe(true);
expect(events.every((event) => event.attempts === 1)).toBe(true);
},
{ timeout: 5_000 }
);
} finally {
await lifecycle.stop('pre-opening deletion checked');
await loop;
await hooks.close();
}
expect(processor.run).not.toHaveBeenCalled();
expect(await queue.drain()).toEqual([]);
expect(await db.general.findUnique({ where: { id: generalId } })).toBeNull();
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toBeUndefined();
expect(reloaded.state).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0, lastTurnTick: 0 });
expect(await db.logEntry.count({ where: { scope: 'SYSTEM', text: { contains: '홀연히 모습을' } } })).toBe(1);
});
});
@@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => {
const before = world.captureState();
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
world.shiftSchedule(15, claimedAt);
world.shiftSchedule(20, claimedAt);
expect(world.captureState().generalPoolEntries).toMatchObject([
{
id: 1,
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
},
{ id: 2, reservedUntil: null, reservedUntilTick: null },
@@ -231,13 +231,13 @@ describe('in-memory scenario general pool availability', () => {
lastTurnTick: 0,
});
const before = world.captureState();
const resumedAt = new Date(claimedAt.getTime() + 40 * 60_000);
const resumedAt = new Date(claimedAt.getTime() + 120 * 60_000);
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
skippedTurns: 4,
shiftedTicks: 4 * GAME_TICKS_PER_TURN,
skippedTurns: 12,
shiftedTicks: 12 * GAME_TICKS_PER_TURN,
});
const rebasedReservedUntilTick = 6 * GAME_TICKS_PER_TURN;
const rebasedReservedUntilTick = 14 * GAME_TICKS_PER_TURN;
const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
expect(world.captureState().generalPoolEntries).toMatchObject([
{
@@ -169,6 +169,7 @@ const buildImmediateActionWorld = (options: {
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('0180-01-01T00:00:00Z'),
lastTurnTick: options.lastTurnTime && options.lastTurnTime > new Date('0180-02-01T00:00:00Z') ? 36_000_000 : 0,
meta: {
hiddenSeed: 'immediate-action-test',
killturn: 24,
@@ -39,6 +39,135 @@ const createChief = (userId: string | null): TurnGeneral => ({
});
describe('레거시 사령부 턴 실행 호환성', () => {
it('피장파장은 같은 두 턴의 사기진작과 병행해도 2회째에 완료한다', async () => {
const cities = buildLargeTestCities();
for (const city of cities) {
city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0;
}
const chief = createChief('test-user');
chief.crew = 1_000;
chief.train = 80;
chief.atmos = 80;
const snapshot: TurnWorldSnapshot = {
generals: [chief],
cities,
nations: [
{
id: 1,
name: '테스트국',
color: '#aa0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { strategic_cmd_limit: 0, gennum: 10 },
},
{
id: 2,
name: '상대국',
color: '#0000aa',
capitalCityId: 2,
chiefGeneralId: null,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: {},
},
],
troops: [],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 1200, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: 0, term: 1200, dead: 0, meta: {} },
],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
openingPartYear: 3,
develCost: 10,
baseGold: 1000,
baseRice: 1000,
maxResourceActionAmount: 10000,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 189 } as never,
unitSet: {} as never,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: { seed: 1 },
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const resolvedActions: Array<{ kind: string; actionKey: string; completed?: boolean }> = [];
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
onActionResolved: (event) => resolvedActions.push(event),
});
const counterStrategy = {
action: 'che_피장파장',
args: { destNationId: 2, commandType: 'che_허보' },
};
reservedTurnStore.getNationTurns(1, 12).splice(0, 2, counterStrategy, counterStrategy);
reservedTurnStore.getGeneralTurns(1).splice(
0,
2,
{ action: 'che_사기진작', args: {} },
{ action: 'che_사기진작', args: {} }
);
await runOneTick();
expect(world.getNationById(1)!.meta.turn_last_12).toMatchObject({
command: '피장파장',
term: 1,
});
expect(resolvedActions).toContainEqual(
expect.objectContaining({ kind: 'nation', actionKey: 'che_피장파장', completed: false })
);
expect(resolvedActions).toContainEqual(
expect.objectContaining({ kind: 'general', actionKey: 'che_사기진작', completed: true })
);
// PostgreSQL jsonb는 object key 순서를 보존하지 않는다. 첫 턴의
// turn_last를 DB에 저장했다가 재적재한 상태를 그대로 재현한다.
const nationAfterFirstTurn = world.getNationById(1)!;
world.updateNation(1, {
meta: {
...nationAfterFirstTurn.meta,
turn_last_12: {
command: '피장파장',
arg: { commandType: 'che_허보', destNationId: 2 },
term: 1,
},
},
});
resolvedActions.length = 0;
await runOneTick();
expect(resolvedActions).toContainEqual(
expect.objectContaining({ kind: 'nation', actionKey: 'che_피장파장', completed: true })
);
expect(world.getNationById(2)!.meta.next_execute_허보).toBe(189 * 12 + 1 + 60);
});
it.each([
{ ownerLabel: '소유 장수', userId: 'test-user', expectedActiveAction: 1 },
{ ownerLabel: '무소유 장수', userId: null, expectedActiveAction: undefined },
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
import { getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
import { getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { COMPACT_NPC_TEST_MAP, buildCompactNpcTestCities } from './fixtures/compactNpcTestScenario.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
const mockDate = new Date('0180-01-01T00:00:00Z');
@@ -45,7 +45,7 @@ const createNpcGeneral = (
npcState,
});
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
const maxCityStats = (city: ReturnType<typeof buildCompactNpcTestCities>[number]) => ({
...city,
population: city.populationMax,
agriculture: city.agricultureMax,
@@ -63,9 +63,9 @@ const readTech = (nation: { meta: Record<string, unknown> }): number => {
describe('NPC 기술 연구 장기 시뮬레이션', () => {
it('기술이 상승하고 기술 등급 및 소모 금이 증가해야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
const nation1CityIds = [1, 2, 3, 4];
const nation2CityIds = [5, 6, 7, 8, 9];
const cities = buildCompactNpcTestCities().map(maxCityStats);
const nation1CityIds = [1, 2];
const nation2CityIds = [3, 4, 5];
for (const city of cities) {
if (nation1CityIds.includes(city.id)) {
@@ -122,7 +122,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
1
)
);
for (let i = 0; i < 9; i += 1) {
for (let i = 0; i < 3; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 80,
@@ -131,7 +131,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
})
);
}
for (let i = 0; i < 40; i += 1) {
for (let i = 0; i < 6; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
@@ -163,7 +163,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { tech: 0 },
meta: { tech: 950 },
},
{
id: 2,
@@ -176,7 +176,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { tech: 0 },
meta: { tech: 950 },
},
],
troops: [],
@@ -200,7 +200,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP as any,
map: COMPACT_NPC_TEST_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
@@ -214,7 +214,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
minAvailableRecruitPop: 0,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
environment: { mapName: 'compact_npc_test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
@@ -239,7 +239,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
map: COMPACT_NPC_TEST_MAP,
});
const controlledGeneralId = nation1ChiefId;
@@ -261,17 +261,14 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const firstGoldAfter = world.getGeneralById(controlledGeneralId)!.gold;
const firstRecruitCost = firstGoldBefore - firstGoldAfter;
expect(firstRecruitCost).toBeGreaterThan(0);
const firstTechValue = readTech(world.getNationById(1)! as { meta: Record<string, unknown> });
const firstTechLevel = getTechLevel(firstTechValue);
const techSnapshots: Array<{ year: number; tech1: number; tech2: number }> = [];
let pendingRecruit = false;
let pendingGoldBefore = 0;
let secondRecruitCost: number | null = null;
const shouldStop = () => {
const exceededSafetyLimit = () => {
const current = world.getState();
return current.currentYear > 230 || (current.currentYear === 230 && current.currentMonth >= 1);
return current.currentYear > 185 || (current.currentYear === 185 && current.currentMonth >= 1);
};
while (true) {
@@ -294,15 +291,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const tech1 = readTech(nation1 as { meta: Record<string, unknown> });
const tech2 = readTech(nation2 as { meta: Record<string, unknown> });
if (current.currentMonth === 1) {
techSnapshots.push({ year: current.currentYear, tech1, tech2 });
}
techSnapshots.push({ year: current.currentYear, tech1, tech2 });
if (secondRecruitCost === null && getTechLevel(tech1) > firstTechLevel) {
if (secondRecruitCost === null && getTechLevel(tech1) > initialLevel) {
pendingRecruit = true;
}
if (shouldStop()) {
if (
(secondRecruitCost !== null &&
getTechLevel(tech1) > initialLevel &&
getTechLevel(tech2) > initialLevel) ||
exceededSafetyLimit()
) {
break;
}
}
@@ -322,8 +322,12 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
expect(finalTech2).toBeGreaterThan(initialTech2);
expect(getTechLevel(finalTech1)).toBeGreaterThan(initialLevel);
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel);
expect(getTechCost(finalTech1)).toBeGreaterThan(getTechCost(initialTech1));
expect(secondRecruitCost).not.toBeNull();
expect(
secondRecruitCost,
`second recruit was not observed (tech1=${finalTech1}, tech2=${finalTech2}, level=${initialLevel})`
).not.toBeNull();
// Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke.
@@ -7,7 +7,7 @@ import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/l
import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js';
import { COMPACT_NPC_TEST_MAP, buildCompactNpcTestCities } from './fixtures/compactNpcTestScenario.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js';
@@ -28,8 +28,8 @@ const createNpcGeneral = (
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
specialDomestic: id % 2 === 0 ? 'che_경작' : 'che_상재',
specialWar: id % 2 === 0 ? 'che_보병' : 'che_무쌍',
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 800 },
@@ -47,7 +47,7 @@ const createNpcGeneral = (
npcState: 2,
});
const maxCityStats = (city: ReturnType<typeof buildLargeTestCities>[number]) => ({
const maxCityStats = (city: ReturnType<typeof buildCompactNpcTestCities>[number]) => ({
...city,
population: city.populationMax,
agriculture: city.agricultureMax,
@@ -156,7 +156,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1';
const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1';
const profileStartedAtMs = performance.now();
const cities = buildLargeTestCities().map(maxCityStats);
const cities = buildCompactNpcTestCities().map(maxCityStats);
for (const city of cities) {
city.nationId = 0;
}
@@ -208,7 +208,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
};
const generals: TurnGeneral[] = [];
const initialGeneralCount = rankingAuditEnabled ? 150 : 300;
const initialGeneralCount = rankingAuditEnabled ? 150 : 60;
for (let i = 0; i < initialGeneralCount; i += 1) {
const cityId = cities[i % cities.length]!.id;
const stats =
@@ -217,6 +217,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
: { leadership: 75, strength: 10, intelligence: 75 };
generals.push(createNpcGeneral(i + 1, cityId, stats));
}
expect(new Set(generals.map((general) => general.role.specialDomestic))).toEqual(
new Set(['che_경작', 'che_상재'])
);
expect(new Set(generals.map((general) => general.role.specialWar))).toEqual(new Set(['che_보병', 'che_무쌍']));
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
@@ -226,7 +230,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
diplomacy: [],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP as any,
map: COMPACT_NPC_TEST_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
@@ -239,7 +243,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
maxResourceActionAmount: 10000,
minAvailableRecruitPop: 0,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
environment: { mapName: 'compact_npc_test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
@@ -305,45 +309,44 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
getCollectedLogsCount,
getCollectedLogsRange,
getAndClearCollectedLogs,
} =
await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
} = await createTurnTestHarness({
snapshot,
state,
schedule,
map: COMPACT_NPC_TEST_MAP,
worldRef,
extraCalendarHandlers: [unificationHandler],
collectLogs: true,
onActionResolved: (payload) => {
const currentWorld = worldRef.current;
if (currentWorld) {
const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id));
const orphanCities = currentWorld
.listCities()
.filter((city) => city.nationId > 0 && !nationIds.has(city.nationId));
if (orphanCities.length > 0) {
throw new Error(
`orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities
.map((city) => `${city.id}->${city.nationId}`)
.join(', ')}`
);
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
return;
}
lastResolvedAction = `${payload.kind}:${payload.actionKey}`;
if (payload.kind === 'general') {
if (payload.actionKey === 'che_출병') {
sortieCount += 1;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
return;
}
if (payload.nationId) {
lastNationAiState.set(payload.nationId, payload.aiState ?? null);
}
if (payload.actionKey === 'che_선전포고') {
declarationCount += 1;
}
},
});
const memoryProfiler =
memoryProfileEnabled && worldRef.current
? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore)
@@ -410,6 +413,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const foundedNations = world.listNations().filter((nation) => nation.level > 0);
expect(foundedNations.length).toBeGreaterThanOrEqual(2);
const foundedNationCount = foundedNations.length;
const foundedGenerals = world.listGenerals().filter((general) => general.nationId > 0);
expect(foundedGenerals.some((general) => general.role.specialDomestic !== null)).toBe(true);
expect(foundedGenerals.some((general) => general.role.specialWar !== null)).toBe(true);
memoryProfiler?.sample('nations-founded');
await runUntil(
@@ -436,7 +442,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
if (declarationCount === 0) {
await runUntil(
(current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
(current) =>
current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1),
undefined,
observeProfileMonth
);
@@ -614,6 +621,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
const logs = getCollectedLogs();
const hasUnificationLog =
unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
if (!rankingAuditEnabled) {
expect(unifiedAt).not.toBeNull();
}
if (unifiedAt) {
expect(meta.isUnited).toBe(2);
expect(hasUnificationLog).toBe(true);
@@ -714,8 +724,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
startMonth: state.currentMonth,
});
const reportPath = resolve(
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ??
'test-results/npc-unification-memory.json'
process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ?? 'test-results/npc-unification-memory.json'
);
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
+122 -41
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, planTurnRecovery } from '@sammo-ts/common';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
};
describe('runtime clock shift', () => {
it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => {
const base = new Date('2026-07-30T10:00:00Z');
const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: 4 * GAME_TICKS_PER_TURN,
wallNow: wallAt(40),
turnSeconds: 600,
});
let world = buildWorld({
clockBaseTime: base,
clockTick: 0,
clockWallAnchor: wallAt(40),
clockRecovery: plan.recovery,
clockMode: 'realtime',
clockPhase: 'RUNNING',
lastTurnTick: 0,
});
for (const [id, milliseconds] of [
[1, 19_902],
[2, 42_001],
]) {
world.updateGeneral(id!, {
turnTime: new Date(base.getTime() + milliseconds!),
turnTick: milliseconds! * 60,
});
}
const executions: Array<[number, number, boolean]> = [];
const processorForWorld = () =>
new InMemoryTurnProcessor(world, {
afterExecuteGeneral: async (general, result) => {
executions.push([world.getState().currentMonth, general.id, result.ok]);
},
});
let processor = processorForWorld();
for (let minutes = 45; minutes <= 80; minutes += 5) {
const now = wallAt(minutes);
const target = world.getGameNow(now);
world.advanceGameClockTo(target, now);
const result = await processor.run(target, { budgetMs: 10_000, maxGenerals: 10, catchUpCap: 1 });
expect(result).toMatchObject({ processedGenerals: 2, processedTurns: 1, partial: false });
if (minutes === 60) {
const snapshot = world.captureState();
world = buildWorld();
world.restoreState(snapshot);
processor = processorForWorld();
}
}
expect(executions).toEqual(
Array.from({ length: 8 }, (_, month) => [
[month + 1, 1, true],
[month + 1, 2, true],
]).flat()
);
expect(world.getState().currentMonth).toBe(9);
expect(world.getGeneralById(1)!.turnTime).toEqual(new Date(wallAt(80).getTime() + 19_902));
const next = await processor.run(world.getGameNow(wallAt(90)), {
budgetMs: 10_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(next).toMatchObject({ processedGenerals: 2, processedTurns: 1 });
expect(world.getState().currentMonth).toBe(10);
});
it('preserves the scenario config when the raw world config is unavailable', () => {
const world = buildWorld();
@@ -99,7 +164,7 @@ describe('runtime clock shift', () => {
['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'],
['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'],
] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => {
const world = buildWorld();
const world = buildWorld({ tickSeconds: 900 });
world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
world.queueNeutralAuction({
registrationKey: 'test',
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]);
});
it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
it.each([0, 1.5, 15, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
const world = buildWorld();
expect(() => world.shiftSchedule(delta)).toThrow();
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z');
@@ -140,7 +205,7 @@ describe('runtime clock shift', () => {
});
it('keeps legacy wall-clock metadata independent from the process timezone', () => {
const world = buildWorld();
const world = buildWorld({ tickSeconds: 900 });
world.shiftSchedule(-15);
@@ -212,6 +277,26 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
});
it('moves the formal opening together with an explicit whole-turn schedule shift', () => {
const openAt = new Date('2026-09-06T00:00:00.000Z');
const now = new Date('2026-09-05T00:00:00.000Z');
const world = buildWorld({
clockBaseTime: new Date('2026-07-30T10:00:00.000Z'),
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
clockPhase: 'PREOPEN',
});
world.shiftSchedule(20, now);
const shiftedOpen = new Date(openAt.getTime() + 20 * 60_000);
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: shiftedOpen });
expect(world.promotePreopenAtOpening(now)).toBe(false);
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z'));
expect(world.promotePreopenAtOpening(openAt)).toBe(false);
expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true);
});
it('rejects gameplay commits while the durable clock is suspended', async () => {
const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' });
@@ -220,33 +305,29 @@ describe('runtime clock shift', () => {
await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/);
});
it.each([
[5, 6],
[10, 3],
[20, 1],
])('uses the Ref catch-up threshold for a %i-minute turn', (turnMinutes, threshold) => {
it.each([5, 10, 20, 60])('only skips complete 12-turn blocks for a %i-minute turn', (turnMinutes) => {
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
const world = buildWorld({
tickSeconds: turnMinutes * 60,
clockBaseTime: wallAnchor,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: wallAnchor,
lastTurnTick: 0,
lastTurnTime: wallAnchor,
});
expect(
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + threshold * turnMinutes * 60_000))
).toBe(false);
expect(
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + (threshold + 1) * turnMinutes * 60_000))
).toBe(true);
for (const turns of [0, 1, 11, 11.999, 12, 13, 23.999, 24, 25]) {
const world = buildWorld({
tickSeconds: turnMinutes * 60,
clockBaseTime: wallAnchor,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: wallAnchor,
lastTurnTick: 0,
lastTurnTime: wallAnchor,
});
const resumedAt = new Date(wallAnchor.getTime() + turns * turnMinutes * 60_000);
expect(world.shouldRebaseRealtimeBacklog(resumedAt)).toBe(turns >= 12);
const result = world.rebaseRealtimeBacklog(resumedAt);
if (turns < 12) expect(result).toBeNull();
else expect(result?.skippedTurns).toBe(Math.floor(turns / 12) * 12);
}
});
it('skips a long realtime backlog while preserving the turn phase and wall-clock display', () => {
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
const resumedAt = new Date('2026-07-30T10:35:00.000Z');
const resumedAt = new Date('2026-07-30T11:05:00.000Z');
const world = buildWorld({
tickSeconds: 300,
clockBaseTime: wallAnchor,
@@ -267,35 +348,35 @@ describe('runtime clock shift', () => {
const result = world.rebaseRealtimeBacklog(resumedAt);
expect(result).toMatchObject({
skippedTurns: 7,
shiftedTicks: 7 * GAME_TICKS_PER_TURN,
lastTurnTime: resumedAt.toISOString(),
skippedTurns: 12,
shiftedTicks: 12 * GAME_TICKS_PER_TURN,
lastTurnTime: '2026-07-30T11:00:00.000Z',
});
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
expect(world.getState()).toMatchObject({
clockTick: 7 * GAME_TICKS_PER_TURN,
clockTick: 13 * GAME_TICKS_PER_TURN,
clockWallAnchor: resumedAt,
lastTurnTick: 7 * GAME_TICKS_PER_TURN,
lastTurnTick: 12 * GAME_TICKS_PER_TURN,
meta: {
turntime: '2026-07-30 10:35:00.123456',
starttime: '2026-07-01 00:35:00',
turntime: '2026-07-30 11:00:00.123456',
starttime: '2026-07-01 01:00:00',
},
});
expect(world.getGeneralById(1)).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN,
turnTime: new Date('2026-07-30T10:45:00.000Z'),
turnTick: 14 * GAME_TICKS_PER_TURN,
turnTime: new Date('2026-07-30T11:10:00.000Z'),
});
expect(world.getCheckpoint()).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN,
turnTime: '2026-07-30T10:45:00.000Z',
turnTick: 14 * GAME_TICKS_PER_TURN,
turnTime: '2026-07-30T11:10:00.000Z',
});
expect(world.peekDirtyState()).toMatchObject({
realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN,
realtimeBacklogShiftTicks: 12 * GAME_TICKS_PER_TURN,
generals: [],
});
});
it('repairs an already accumulated realtime projection lag during a long rebase', () => {
it('preserves the normal game-to-wall mapping during a whole-year rebase', () => {
const base = new Date('2026-07-30T10:00:00.000Z');
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
const resumedAt = new Date('2026-07-30T11:50:00.000Z');
@@ -310,8 +391,8 @@ describe('runtime clock shift', () => {
});
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 22 });
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
expect(world.getGameNow(resumedAt)).toEqual(new Date('2026-07-30T11:15:00.000Z'));
});
it('does not lose realtime elapsed time when an overdue target is committed later', () => {
@@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule',
requestId,
actionId,
deltaMinutes: -15,
deltaMinutes: -20,
} as GamePrisma.InputJsonValue,
},
});
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
await hooks.close();
}
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z');
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z');
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:40:00.000Z');
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z');
expect(await stateStore.loadCheckpoint()).toMatchObject({
turnTime: '2099-07-30T09:45:00.000Z',
turnTime: '2099-07-30T09:40:00.000Z',
generalId: 0,
});
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
// 예정된 재개 경계까지 대기하며, 그 뒤 첫 장수의 시각을 다시 계산한다.
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:40:00.000Z');
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld.meta).toMatchObject({
lastTurnTime: '2099-07-30T09:45:00.000Z',
starttime: '2099-06-30 23:45:00',
lastTurnTime: '2099-07-30T09:40:00.000Z',
starttime: '2099-06-30 23:40:00',
});
expect(storedWorld.clockTick).toBe(0n);
expect(storedWorld.lastTurnTick).toBe(0n);
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:00:00.000Z');
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
const storedAuctions = await db.auction.findMany({
where: { id: { in: auctionRows.map((auction) => auction.id) } },
});
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([
'2099-07-30T09:45:00.000Z',
'2099-07-30T09:40:00.000Z',
'2099-07-30T11:00:00.000Z',
'2099-07-30T12:00:00.000Z',
'2099-07-30T13:00:00.000Z',
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule',
ok: true,
actionId,
deltaMinutes: -15,
deltaMinutes: -20,
shiftedGenerals: 2,
shiftedAuctions: 1,
},
@@ -302,7 +303,7 @@ integration('runtime clock shift persistence', () => {
it('atomically rebases a long realtime backlog and open auction deadlines', async () => {
const base = new Date('2099-09-01T00:00:00.000Z');
const resumedAt = new Date('2099-09-01T00:35:00.000Z');
const resumedAt = new Date('2099-09-01T01:00:00.000Z');
const row = await db.worldState.create({
data: {
scenarioCode: 'realtime-backlog-rebase',
@@ -408,7 +409,7 @@ integration('runtime clock shift persistence', () => {
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 });
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
await hooks.hooks.flushChanges?.({
lastTurnTime: resumedAt.toISOString(),
processedGenerals: 0,
@@ -422,18 +423,18 @@ integration('runtime clock shift persistence', () => {
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld).toMatchObject({
clockTick: BigInt(7 * GAME_TICKS_PER_TURN),
lastTurnTick: BigInt(7 * GAME_TICKS_PER_TURN),
clockTick: BigInt(12 * GAME_TICKS_PER_TURN),
lastTurnTick: BigInt(12 * GAME_TICKS_PER_TURN),
clockWallAnchor: resumedAt,
});
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } });
expect(storedGeneral).toMatchObject({
turnTick: BigInt(8 * GAME_TICKS_PER_TURN),
turnTime: new Date('2099-09-01T00:40:00.000Z'),
turnTick: BigInt(13 * GAME_TICKS_PER_TURN),
turnTime: new Date('2099-09-01T01:05:00.000Z'),
});
expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({
closeTick: BigInt(9 * GAME_TICKS_PER_TURN),
closeAt: new Date('2099-09-01T00:45:00.000Z'),
closeTick: BigInt(14 * GAME_TICKS_PER_TURN),
closeAt: new Date('2099-09-01T01:10:00.000Z'),
});
expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
@@ -442,8 +443,8 @@ integration('runtime clock shift persistence', () => {
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({
ownerUserId: 'rebase-pool-user',
generalId: null,
reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN),
reservedUntil: new Date('2099-09-01T00:45:00.000Z'),
reservedUntilTick: BigInt(14 * GAME_TICKS_PER_TURN),
reservedUntil: new Date('2099-09-01T01:10:00.000Z'),
});
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
@@ -14,6 +14,41 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
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('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime());
@@ -611,7 +611,7 @@ integration('unification finalization transaction', () => {
const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } });
expect(suspension).toMatchObject({
source: 'UNIFICATION_WAIT',
policy: 'EXACT',
policy: 'TURN_BOUNDARY',
status: 'SUSPENDED',
sourceRevision: 1n,
targetRevision: 2n,
@@ -143,7 +143,7 @@ describe('HWE-shaped unification invader resume', () => {
tickSeconds: 60,
lastTurnTime: liveLastTurnTime,
clockBaseTime: new Date('2026-08-19T12:00:00.000Z'),
clockTick: 19_980_000_000,
clockTick: 19_944_000_000,
clockMode: 'realtime',
clockWallAnchor: liveWallAnchor,
lastTurnTick: 19_944_000_000,
@@ -202,7 +202,7 @@ describe('HWE-shaped unification invader resume', () => {
actorUserId: recipient.userId,
target: 'ENGINE',
eventType: 'messageRespond',
processingGameTick: 19_980_000_000n,
processingGameTick: 19_944_000_000n,
})),
},
messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) },
@@ -214,11 +214,11 @@ describe('HWE-shaped unification invader resume', () => {
id: 1014,
mailbox: recipient.id,
type: 'private',
time: world.gameTickToDate(19_980_000_000),
time: world.gameTickToDate(19_944_000_000),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
actionType: 'raiseInvader',
actionStatus: 'PENDING',
createdGameTick: 19_980_000_000n,
createdGameTick: 19_944_000_000n,
expiresGameTick: null,
message,
},
@@ -256,13 +256,22 @@ describe('HWE-shaped unification invader resume', () => {
gapTicks: 108_000_000,
catchUpTicks: 0,
shiftTicks: 108_000_000,
alignedTick: 20_088_000_000,
alignedTick: 20_052_000_000,
resumeWallAt: acceptedAt,
resumeAnchor: new Date('2026-08-20T06:25:00Z'),
}),
});
const processor = new InMemoryTurnProcessor(world);
const clock = new ManualClock(acceptedAt.getTime());
vi.spyOn(queue, 'waitFor').mockImplementation(async (milliseconds) => {
await clock.sleepMs(milliseconds ?? 0);
const pending = await queue.drain();
for (const command of pending.slice(1)) queue.enqueue(command);
return pending[0] ?? null;
});
const runWallTimes: Date[] = [];
const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => {
runWallTimes.push(new Date(clock.nowMs()));
const result = await processor.run(...args);
if (world.getState().currentMonth === 4) {
queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' });
@@ -307,7 +316,7 @@ describe('HWE-shaped unification invader resume', () => {
expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2);
expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({
where: { messageId: { in: [1014] }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: 20_088_000_000n },
data: { status: 'RESOLVED', resolvedGameTick: 20_052_000_000n },
});
expect(world.getState()).toMatchObject({
currentYear: 226,
@@ -324,11 +333,11 @@ describe('HWE-shaped unification invader resume', () => {
expect(initialInvaderTurnTimes).toHaveLength(10);
expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary);
expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([
'2026-08-20T06:24:58.611Z',
'2026-08-20T06:25:00.000Z',
]);
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z');
expect(run).toHaveBeenCalled();
expect(runWallTimes.every((time) => time >= new Date('2026-08-20T06:25:00Z'))).toBe(true);
const nextMonth = addMinutes(liveLastTurnTime, 4);
expect(run.mock.calls.every(([targetTime]) => targetTime <= nextMonth)).toBe(true);
expect(run.mock.calls.at(-1)![0]).toEqual(nextMonth);
expect(lifecycle.getStatus().lastTurnTime).toBe(nextMonth.toISOString());
});
});
+212 -43
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { readFile, writeFile } from 'node:fs/promises';
import { buildRefGeneralTargetOptions } from '../../game-api/src/turns/commandTargets.js';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -183,29 +184,67 @@ const inputOptions = {
description: '금 3,000 · 쌀 500 · 병력 1,500',
},
],
che_발령: [
{
value: 1,
label: '장수 (부대 없음 · 업)',
crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500',
},
{
value: 2,
label: '관우 (청룡대 (부대장) · 업)',
crew: 1200,
troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200',
},
{
value: 3,
label: '여포NPC (청룡대 · 업)',
npcState: 2,
crew: 1500,
troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500',
},
],
che_발령:
buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: [
{
id: 1,
name: '장수',
nationId: 1,
cityId: 1,
npcState: 0,
officerLevel: 12,
leadership: 70,
strength: 80,
intel: 90,
crew: 500,
train: 0,
atmos: 0,
gold: 5000,
rice: 400,
troopId: 0,
},
{
id: 2,
name: '관우',
nationId: 1,
cityId: 1,
npcState: 0,
officerLevel: 5,
leadership: 100,
strength: 95,
intel: 70,
crew: 1200,
train: 80,
atmos: 70,
gold: 100,
rice: 4000,
troopId: 2,
},
{
id: 3,
name: '여포NPC',
nationId: 1,
cityId: 1,
npcState: 2,
officerLevel: 0,
leadership: 90,
strength: 100,
intel: 0,
crew: 1500,
train: 100,
atmos: 100,
gold: 3000,
rice: 500,
troopId: 2,
},
],
nationNames: new Map([[1, '아국']]),
cityNames: new Map([[1, '업']]),
troopNames: new Map([[2, '청룡대']]),
}).generalTargets.che_발령 ?? [],
che_부대탈퇴지시: [
{
value: 3,
@@ -1112,7 +1151,15 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
{
key: 'nationName',
label: '국가명',
kind: 'text',
required: true,
min: 1,
max: 18,
legacyWidthMax: 18,
},
{
key: 'nationType',
label: '국가 성향',
@@ -1182,6 +1229,9 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
const mobilePicker = page.getByTestId('command-picker');
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차');
await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible();
await expect(mobilePicker.getByRole('button', { name: '입력', exact: true })).toBeDisabled();
await mobilePicker.getByLabel('국가명').fill('신국');
const mobileColorType = mobilePicker.getByLabel('국기 색상');
await mobileColorType.click();
@@ -1813,11 +1863,7 @@ test('enters general and nation command arguments and sends exact values', async
await chiefForm.getByRole('button', { name: '쌀', exact: true }).click();
await chiefForm.locator('input[type=number]').fill('300');
const chiefTarget = chiefForm.locator('#command-arg-destGeneralId');
await expect(chiefTarget.locator('option')).toHaveText([
'장수 (아국 · 업)',
'여포NPC (아국 · 업)',
'관우 (아국 · 업)',
]);
await expect(chiefTarget.locator('option')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
await chiefTarget.selectOption('3');
const geometry = await chiefForm.evaluate((element) => {
const row = element.querySelector('.argument-row');
@@ -1905,9 +1951,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await expect(form).toContainText('가격');
await expect(form).toContainText('군량');
await expect(form).toContainText('표준적인 보병입니다.');
await expect(form.getByRole('button', { name: '정예병 선택 불가', exact: true })).toHaveCount(0);
await expect(form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true })).toHaveCount(0);
await form.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true });
const unavailable = form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true });
await expect(unavailable).toBeVisible();
await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)');
await expect(unavailable.locator('.crew-info')).toHaveText(
@@ -2069,7 +2115,19 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
const mercenaryForm = picker.getByTestId('recruitment-command-form');
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
await page.keyboard.press('Escape');
await mercenaryForm.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
const unavailableMercenary = mercenaryForm.getByRole('button', {
name: '정예병 현재 실행 불가, 예약 가능',
exact: true,
});
await unavailableMercenary.click();
await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25');
await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled();
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText(
'【정예병】 2500명 모병'
);
expect(JSON.stringify(requests)).toContain('"crewType":1101');
await expect(picker).toHaveCount(0);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
});
@@ -2565,17 +2623,14 @@ test('offers Ref amount presets and rich, command-specific general lists', async
form = picker.getByTestId('command-argument-form');
generalList = form.getByTestId('general-target-list');
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
'장수 (부대 없음 · 업)',
'관우 (청룡대 (부대장) · 업)',
'여포NPC (청룡대 · 업)',
]);
await expect(generalList.locator('.target-option strong')).toHaveText([
'장수 (부대 없음 · 업)',
'관우 (청룡대 (부대장) · 업)',
'여포NPC (청룡대 · 업)',
'장수 (업)',
'관우 (업)',
'여포NPC (업)',
]);
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
await expect(generalList).not.toContainText('아국');
await expect(generalList).not.toContainText('탑승 부대');
await expect(generalList).toContainText('탑승 부대 청룡대 (부대장)');
await expect(generalList).toContainText('통솔 100 · 무력 95 · 지력 70');
await picker.screenshot({ path: test.info().outputPath('chief-assignment-option-format-desktop.png') });
await page.goto('/che/chief-center');
@@ -2623,7 +2678,7 @@ test('offers Ref amount presets and rich, command-specific general lists', async
{
turn: 7,
command: '발령',
labels: ['장수 (부대 없음 · 업)', '관우 (청룡대 (부대장) · 업)', '여포NPC (청룡대 · 업)'],
labels: ['장수 (업)', '관우 (업)', '여포NPC (업)'],
},
];
for (const entry of mobilePersonnelCases) {
@@ -2645,6 +2700,120 @@ test('offers Ref amount presets and rich, command-specific general lists', async
await page.screenshot({ path: test.info().outputPath('chief-personnel-option-format-mobile.png'), fullPage: true });
});
for (const width of [1200, 500]) {
test(`shows complete assignment candidate details with wrapping at ${width}px`, async ({ page }, testInfo) => {
const longName = '아주긴이름의발령후보장수';
const longTroop = '먼도시로함께이동하는아주긴이름의원정부대'.repeat(3);
const assignmentOptions = inputOptions.generalTargets.che_발령.map((option) =>
option.value === 3
? {
...option,
label: `${longName} (업)`,
description: option.description?.replace('청룡대', longTroop),
}
: option
);
const table = {
...commandTable,
inputOptions: {
...inputOptions,
generalTargets: { ...inputOptions.generalTargets, che_발령: assignmentOptions },
},
};
const requests = await install(page, false, table);
await page.setViewportSize({ width, height: 900 });
await page.goto(gamePath('chief-center'));
await page.reload();
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /발령/ }).click();
const form = picker.getByTestId('command-argument-form');
const list = form.getByTestId('general-target-list');
const cards = list.locator('.target-option');
await expect(cards).toHaveCount(3);
await expect(cards.nth(0)).toContainText('탑승 부대 없음');
await expect(cards.nth(0)).toContainText('훈련 0 · 사기 0');
await expect(cards.nth(1)).toContainText('탑승 부대 청룡대 (부대장)');
await expect(cards.nth(1)).toContainText('통솔 100 · 무력 95 · 지력 70');
await expect(cards.nth(1)).toContainText('병력 1,200 · 훈련 80 · 사기 70');
await expect(cards.nth(1)).toContainText('금 100 · 쌀 4,000');
await expect(list).not.toContainText('아국');
await form.locator('#command-arg-destGeneralId').selectOption('2');
await expect(cards.nth(1)).toHaveAttribute('aria-pressed', 'true');
await cards.nth(2).hover();
await cards.nth(2).focus();
await page.keyboard.press('Enter');
await expect(cards.nth(2)).toHaveAttribute('aria-pressed', 'true');
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('3');
await expect(form.locator('.assignment-detail')).toContainText(longTroop);
await expect(cards.nth(2).locator('strong')).toHaveCSS('color', 'rgb(0, 255, 255)');
await cards.nth(1).click();
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('2');
await cards.nth(2).click();
await form.locator('#command-arg-destCityId').selectOption('2');
await page.evaluate(async () => {
await document.fonts.ready;
});
const geometry = await picker.evaluate((element) => {
const measure = (node: Element) => {
const html = node as HTMLElement;
const style = getComputedStyle(node);
return {
text: html.innerText,
rect: node.getBoundingClientRect().toJSON(),
overflow: html.scrollWidth - html.clientWidth,
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
color: style.color,
background: style.backgroundColor,
font: style.font,
lineHeight: style.lineHeight,
};
};
return {
url: location.href,
dpr: devicePixelRatio,
viewport: innerWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
picker: measure(element),
cards: [...element.querySelectorAll('.assignment-target-list .target-option')].map(measure),
details: [...element.querySelectorAll('.assignment-target-list small')].map(measure),
images: [...element.querySelectorAll('img')].map((img) => ({
src: img.getAttribute('src'),
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
objectFit: getComputedStyle(img).objectFit,
})),
};
});
expect(geometry.documentOverflow).toBe(0);
expect(geometry.picker.overflow).toBe(0);
for (const card of geometry.cards) expect(card.overflow).toBe(0);
for (const detail of geometry.details) {
expect(detail.overflow).toBe(0);
expect(detail.whiteSpace).toBe('pre-line');
expect(detail.rect.height).toBeGreaterThanOrEqual(parseFloat(detail.lineHeight) * 4 - 1);
}
await writeFile(testInfo.outputPath('assignment-geometry.json'), JSON.stringify(geometry, null, 2));
await writeFile(
testInfo.outputPath('assignment-dom.html'),
await picker.evaluate((element) => element.outerHTML)
);
await picker.screenshot({ path: testInfo.outputPath('assignment-details.png') });
const saved = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click();
await saved;
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
expect(JSON.stringify(requests)).toContain('"destCityId":2');
expect(JSON.stringify(requests)).not.toContain('description');
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toContainText(
`${longName}】【허창】으로 발령`
);
});
}
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
+123 -35
View File
@@ -38,6 +38,7 @@ const readGeneralPanelImages = async (panel: Locator) =>
label: image.getAttribute('aria-label'),
width: rect.width,
height: rect.height,
src: image instanceof HTMLImageElement ? image.src : null,
backgroundImage: style.backgroundImage,
backgroundSize: style.backgroundSize,
pointerEvents: style.pointerEvents,
@@ -74,6 +75,8 @@ type FixtureState = {
dieOnPrestartAttempts?: number;
dieOnPrestartInputs?: Array<Record<string, unknown>>;
generalMeQueries?: number;
generalMeFailure?: boolean;
canChangeIcon?: boolean;
generalLogQueries?: number;
ensurePrestartQueries?: number;
ensurePrestartFailure?: 'TIMEOUT' | 'INTERNAL_SERVER_ERROR';
@@ -284,7 +287,7 @@ const myGeneral = (state: FixtureState) => ({
},
penalties: {},
iconChoices: state.iconChoices ?? [],
canChangeIcon: true,
canChangeIcon: state.canChangeIcon ?? true,
iconChangeAvailableAt: null,
});
@@ -466,6 +469,15 @@ const install = async (page: Page, state: FixtureState) => {
}
if (operation === 'general.me') {
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
if (state.generalMeFailure) {
return {
error: {
message: '아이콘 조회 실패',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path: operation },
},
};
}
return response(myGeneral(state));
}
if (operation === 'general.ensureDieOnPrestartStatus') {
@@ -517,9 +529,7 @@ const install = async (page: Page, state: FixtureState) => {
},
meta: {
turntime: '2026-01-01T00:00:00.000Z',
lastTurnTime: state.gameStarted
? '2026-03-01T00:00:00.000Z'
: '2026-01-01T00:00:00.000Z',
lastTurnTime: state.gameStarted ? '2026-03-01T00:00:00.000Z' : '2026-01-01T00:00:00.000Z',
opentime: state.buildNationCandidateEnabled
? '2026-02-01T00:00:00.000Z'
: '2025-12-01T00:00:00.000Z',
@@ -1125,9 +1135,7 @@ test('커맨드 실행 결과의 성공·실패·일반 색상을 메인 기록
]) {
await page.setViewportSize(viewport);
await page.goto('');
const colors = await inspectResultColors(
`${viewport.selector} [data-record-bucket="general"] .record-line`
);
const colors = await inspectResultColors(`${viewport.selector} [data-record-bucket="general"] .record-line`);
await persistParityArtifact(page, `core-main-command-result-colors-${viewport.name}`, colors);
}
});
@@ -1358,7 +1366,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
{ width: 64, height: 64 },
{ width: 64, height: 64 },
]);
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(myPageImages[0]?.src).toContain('/icons/default.jpg');
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.general-table')).toContainText('병종보병');
await expect(page.locator('.general-table')).toContainText('삭턴6 턴');
@@ -1407,12 +1415,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
for (const checkbox of [
warAutomation,
promotionAutomation,
financeAutomation,
capitalAutomation,
]) {
for (const checkbox of [warAutomation, promotionAutomation, financeAutomation, capitalAutomation]) {
await expect(checkbox).not.toBeChecked();
await checkbox.check();
}
@@ -1718,14 +1721,14 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오
}
});
test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => {
test('환경 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-settings');
await waitForVisualAssets(page);
await expect(page.locator('.title-row')).toContainText('화 면 설 정');
await expect(page.locator('.title-row')).toContainText('환 경 설 정');
await expect(
page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.')
).toHaveCount(0);
@@ -1772,7 +1775,7 @@ test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정
await persistParityArtifact(page, 'core-interface-settings-desktop', desktop);
});
test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({
test('환경 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({
page,
}, testInfo) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
@@ -1847,7 +1850,7 @@ test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼
.toEqual(defaultOrder);
});
test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
test('환경 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile touch contract');
@@ -1943,36 +1946,121 @@ for (const [label, failure] of [
});
}
test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => {
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
]) {
test(`환경 설정에서 사람 장수의 등록 전콘을 골라 변경한다 ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
const state: FixtureState = {
permission: 'member',
myset: 1,
settingMutations: [],
accessPages: [],
iconChoices: [
{
id: iconId,
picture: 'mine.png',
imageServer: 1,
createdAt: '2026-08-01T00:00:00.000Z',
},
],
adjustIconInputs: [],
};
await install(page, state);
await page.goto('my-page');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect(page.locator('.general-icon-action')).toHaveCount(0);
const prestartQueries = state.ensurePrestartQueries;
await page.route('**/mine.png', async (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#306090"/></svg>',
})
);
await page.goto('my-settings');
await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible();
await expect(page.locator('.selected-general-icon img')).toHaveCSS('width', '64px');
await expect(page.locator('.selected-general-icon img')).toHaveCSS('height', '64px');
await page.locator('.general-icon-choice input').check();
await expect(page.locator('.general-icon-choice input')).toBeChecked();
await page.getByRole('button', { name: '아이콘 변경' }).focus();
await expect(page.getByRole('button', { name: '아이콘 변경' })).toBeFocused();
await page.getByRole('button', { name: '아이콘 변경' }).hover();
await waitForVisualAssets(page);
const geometry = await page.locator('#interface-settings').evaluate((element) => {
const rect = element.getBoundingClientRect();
const icon = element.querySelector<HTMLImageElement>('.selected-general-icon img')!;
return {
url: location.href,
width: rect.width,
scrollWidth: document.documentElement.scrollWidth,
icon: {
width: icon.width,
height: icon.height,
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
objectFit: getComputedStyle(icon).objectFit,
},
columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns,
html: element.outerHTML,
};
});
expect(geometry.width).toBe(viewport.width);
expect(geometry.scrollWidth).toBe(viewport.width);
expect(geometry.icon).toMatchObject({ width: 64, height: 64, objectFit: 'cover' });
expect(geometry.icon.naturalWidth).toBeGreaterThan(0);
await persistParityArtifact(page, `core-preferences-icon-${viewport.name}`, geometry);
page.once('dialog', async (dialog) => dialog.dismiss());
await page.getByRole('button', { name: '아이콘 변경' }).click();
expect(state.adjustIconInputs).toHaveLength(0);
page.once('dialog', async (dialog) => dialog.accept());
await page.getByRole('button', { name: '아이콘 변경' }).click();
await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1);
expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId });
await expect.poll(() => state.generalMeQueries).toBe(3);
expect(state.ensurePrestartQueries).toBe(prestartQueries);
expect(state.settingMutations).toHaveLength(0);
await page.reload();
await expect(page.locator('.general-icon-action')).toBeVisible();
expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
}
test('환경 설정의 아이콘 조회 오류는 화면 설정을 막지 않고 재시도할 수 있다', async ({ page }) => {
const state: FixtureState = {
permission: 'member',
myset: 1,
settingMutations: [],
accessPages: [],
generalMeFailure: true,
iconChoices: [
{
id: iconId,
id: '3f804277-584f-4f44-b39c-9ecf40d1ed31',
picture: 'mine.png',
imageServer: 1,
createdAt: '2026-08-01T00:00:00.000Z',
},
],
adjustIconInputs: [],
};
await install(page, state);
await page.goto('my-page');
await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible();
await expect(page.locator('.selected-general-icon img')).toHaveCSS('width', '64px');
await expect(page.locator('.selected-general-icon img')).toHaveCSS('height', '64px');
await page.locator('.general-icon-choice input').check();
page.once('dialog', async (dialog) => dialog.accept());
await page.getByRole('button', { name: '아이콘 변경' }).click();
await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1);
expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId });
expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
await page.goto('my-settings');
await expect(page.getByRole('alert')).toContainText('아이콘 조회 실패');
await page.getByRole('radio', { name: '500px' }).check();
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px');
state.generalMeFailure = false;
await page.getByRole('button', { name: '다시 불러오기' }).click();
await expect(page.locator('.general-icon-action')).toBeVisible();
state.canChangeIcon = false;
await page.reload();
await expect.poll(() => state.generalMeQueries).toBe(3);
await expect(page.locator('.general-icon-action')).toHaveCount(0);
await expect(page.locator('#custom_css')).toBeVisible();
expect(state.ensurePrestartQueries ?? 0).toBe(0);
expect(state.settingMutations).toHaveLength(0);
});
test('내 정보 아이템 파기 확인은 이름의 받침에 맞는 조사를 쓴다', async ({ page }) => {
@@ -2370,7 +2458,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
]);
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(battleImages[0]?.src).toContain('/icons/default.jpg');
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
+162 -4
View File
@@ -2,6 +2,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { buildEquipmentTradeItemOptions } from '../../game-api/src/turns/commandInput.js';
const response = (data: unknown) => ({ result: { data } });
const runtimeNavigation = JSON.parse(
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
@@ -44,6 +46,7 @@ type NavigationFixture = {
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null;
cityDefence?: number;
cityState?: number;
@@ -634,6 +637,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
clockMode: state.clockMode ?? 'realtime',
clockRunning: state.clockRunning ?? true,
clockStartsAt: state.clockStartsAt ?? null,
clockRecovery: state.clockRecovery ?? null,
turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning,
scenarioTitle: state.scenarioTitle ?? '',
autorunUser: state.autorunUser ?? null,
@@ -766,6 +770,17 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
autorunLimit: state.autorunLimit ?? null,
});
}
if (operation === 'turns.reserved.setGeneralBulk' && state.equipmentItemOptions) {
const input = operationInput(route, index) as {
entries: Array<{ turnList: number[]; action: string; args: Record<string, unknown> }>;
};
for (const entry of input.entries) {
for (const turnIndex of entry.turnList) {
state.reservedTurns![turnIndex] = { index: turnIndex, action: entry.action, args: entry.args };
}
}
return response({ ok: true, revision: 1, turns: state.reservedTurns, autorunLimit: null });
}
if (operation === 'messages.getRecent') {
return response(state.messages ?? emptyMessages(state.permission));
}
@@ -1396,7 +1411,7 @@ test('the private-message notice moves a mobile reader to the private section',
.poll(() =>
page.locator('.PrivateTalk > .stickyAnchor').evaluate((element) => element.getBoundingClientRect().top)
)
.toBeLessThan(80);
.toBeLessThan(400);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500);
});
@@ -2917,8 +2932,12 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await expect(cityBars).toHaveCount(8);
await expect(statBars).toHaveCount(3);
await expect(experienceBar).toHaveCount(1);
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
await expect(page.locator('[data-main-target="general"] [data-general-information-panel]')).toHaveCount(1);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
await expect(page.locator('[data-main-target="general"] .troop-label')).toHaveText('부대');
await expect(page.locator('[data-main-target="general"] .penalty-label')).toHaveText('벌점');
await expect(page.locator('[data-main-target="general"] [data-general-battle-summary]')).toHaveCount(0);
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
await expect(nationCard.locator('.head')).toHaveCount(17);
@@ -3289,6 +3308,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
await expect(page.locator('[data-main-target="general"] .troop-label')).toHaveText('부대');
await expect(page.locator('[data-main-target="general"] .penalty-label')).toHaveText('벌점');
await expect(page.locator('[data-main-target="general"] [data-general-battle-summary]')).toHaveCount(0);
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(
@@ -4796,10 +4819,11 @@ for (const viewport of [
await picker.getByRole('button', { name: '건국', exact: true }).click();
await picker.getByLabel('국가명').fill('초안보존국');
await picker.getByLabel('국기 색상').selectOption('15');
await picker.getByLabel('국기 색상').click();
await picker.getByRole('option', { name: '색상 16', exact: true }).click();
await refreshActivityAndCommands();
await expect(picker.getByLabel('국가명')).toHaveValue('초안보존국');
await expect(picker.getByLabel('국기 색상')).toHaveValue('15');
await expect(picker.getByLabel('국기 색상')).toContainText('색상 16');
await expect(picker.getByLabel('국기 색상')).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await picker.screenshot({ path: test.info().outputPath(`command-draft-${viewport.name}.png`) });
@@ -5052,6 +5076,80 @@ for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
] as const) {
test(`reserves owned unique equipment sales and preserves the slot brief on ${viewport.name}`, async ({ page }) => {
const items = buildEquipmentTradeItemOptions({
configConst: {},
itemModules: [
{
key: 'owned_unique',
slot: 'item',
name: '옥벽(보물)',
info: '보유 유니크',
cost: 10001,
reqSecu: 0,
buyable: false,
},
],
currentSecurity: 0,
generalGold: 0,
ownedItems: { horse: null, weapon: null, book: null, item: 'owned_unique' },
});
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
draftCommandTable: true,
equipmentItemOptions: items.item.map((option) => ({ ...option, value: String(option.value) })),
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
};
await installFixture(page, state);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await waitForMain(page);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('item');
const equipment = picker.getByLabel('장비', { exact: true });
await equipment.click();
await equipment.press('Escape');
await equipment.selectOption({ label: '옥벽(보물) 판매' });
await expect(equipment).toHaveValue('None');
await expect(equipment.locator('option')).toHaveText(['옥벽(보물) 판매']);
await expect(picker).toContainText('소유 물품 판매 · 판매가 5,000금');
await equipment.focus();
await expect(equipment).toBeFocused();
await page.evaluate(() => document.fonts.ready);
const geometry = await equipment.evaluate((element) => {
const style = getComputedStyle(element);
return {
rect: element.getBoundingClientRect().toJSON(),
font: style.font,
color: style.color,
background: style.backgroundColor,
outline: style.outline,
html: element.outerHTML,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.documentWidth).toBeLessThanOrEqual(viewport.width);
await writeFile(test.info().outputPath(`owned-sale-${viewport.name}.json`), JSON.stringify(geometry, null, 2));
await picker.screenshot({ path: test.info().outputPath(`owned-sale-${viewport.name}.png`) });
const request = page.waitForRequest((request) => request.url().includes('turns.reserved.setGeneralBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click();
const payload = (await request).postDataJSON();
expect(JSON.stringify(payload)).toContain('"itemCode":"None"');
expect(JSON.stringify(payload)).toContain('"itemType":"item"');
await expect(page.locator('.action-column [title="【도구】를 판매."]')).toHaveText('【도구】를 판매.');
await page.reload();
await expect(page.locator('.action-column [title="【도구】를 판매."]')).toHaveText('【도구】를 판매.');
await page.screenshot({ path: test.info().outputPath(`owned-sale-brief-${viewport.name}.png`) });
});
test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
@@ -5982,3 +6080,63 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
})
.toBe(1);
});
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => {
const start = new Date('2026-09-06T07:59:50Z');
await page.clock.install({ time: start });
await page.setViewportSize(viewport);
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 1,
npcMode: 1,
generalMeCalls: 0,
operations: [],
serverTime: '2026-09-06T07:59:40Z',
serverWallTime: start.toISOString(),
clockMode: 'realtime',
clockRunning: true,
turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-06T04:00:00Z', endsAt: '2026-09-06T08:00:00Z' },
};
await installFixture(page, state);
await page.goto('./');
await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 });
await page.evaluate(() => document.fonts.ready);
const status = page.locator('.execution-status:visible');
await expect(status).toContainText('복구 2배속');
const root = process.env.TURN_RECOVERY_ARTIFACT_DIR;
const measure = () =>
status.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
font: getComputedStyle(element).font,
lineHeight: getComputedStyle(element).lineHeight,
text: element.textContent,
}));
const before = await measure();
expect(before.scrollWidth).toBeLessThanOrEqual(before.clientWidth + 1);
if (root) {
await mkdir(root, { recursive: true });
await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true });
}
await page.clock.runFor(20_000);
await expect(status).not.toContainText('복구 2배속');
await expect(status).toContainText('17:00');
const after = await measure();
expect(after.scrollWidth).toBeLessThanOrEqual(after.clientWidth + 1);
if (root) {
await page.screenshot({ path: resolve(root, `normal-${viewport.width}.png`), fullPage: true });
await writeFile(
resolve(root, `geometry-${viewport.width}.json`),
JSON.stringify({ viewport, before, after }, null, 2)
);
}
});
}
@@ -134,6 +134,7 @@ const npcColorSecretGenerals = npcColorStates.map((npcState) => ({
name: `색상장수${npcState}`,
npcState,
injury: 0,
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 70, strength: 60, intelligence: 50 },
leadershipBonus: 0,
experienceLevel: 9,
@@ -205,8 +206,9 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false
id: 1,
name: '테스트장수',
npcState: 0,
injury: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
injury: 30,
baseStats: { leadership: 100, strength: 80, intelligence: 60 },
stats: { leadership: 70, strength: 56, intelligence: 42 },
leadershipBonus: 0,
experienceLevel: 9,
troopId: 0,
@@ -236,6 +238,7 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false
name: '부유장수',
npcState: 0,
injury: 0,
baseStats: { leadership: 60, strength: 50, intelligence: 40 },
stats: { leadership: 60, strength: 50, intelligence: 40 },
leadershipBonus: 0,
experienceLevel: 8,
@@ -644,6 +647,12 @@ test('secret office renders five Ref-style command briefs and the forbidden erro
'5 : 휴식',
]);
await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여');
const woundedName = page.locator('[data-directory-tooltip="secret-injury-name-1"]');
const woundedLeadership = page.locator('[data-directory-tooltip="secret-injury-leadership-1"]');
await expect(woundedName.locator('[data-general-name]')).toHaveCSS('color', 'rgb(255, 165, 0)');
await expect(woundedLeadership.locator('span').first()).toHaveCSS('color', 'rgb(255, 165, 0)');
await woundedLeadership.hover();
await expect(woundedLeadership.getByRole('tooltip')).toContainText('부상 30% · 중상 · 원래 통솔 100 → 적용 70');
const geometry = await page
.locator('#secret-general-list .turns')
.first()
+10 -2
View File
@@ -17,7 +17,7 @@ type FixtureState = {
const candidates = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
name: `빙의후보${index + 1}`,
name: index === 0 ? '아주긴빙의후보장수이름' : `빙의후보${index + 1}`,
nation: { id: 0, name: '재야', color: '#aaaaaa' },
stats: {
leadership: 40 + index,
@@ -336,15 +336,23 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
return {
sectionWidth: sectionRect.width,
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
nameHeights: cards.map(
(card) => card.querySelector<HTMLElement>('.npc-card-name')!.getBoundingClientRect().height
),
nameWhiteSpace: getComputedStyle(cards[0]!.querySelector<HTMLElement>('.npc-card-name')!).whiteSpace,
longNameFontSize: getComputedStyle(cards[0]!.querySelector<HTMLElement>('.npc-card-name')!).fontSize,
imageWidth: image?.getBoundingClientRect().width,
imageHeight: image?.getBoundingClientRect().height,
imageNaturalWidth: image?.naturalWidth,
imageNaturalHeight: image?.naturalHeight,
};
});
expect(geometry).toEqual({
expect(geometry).toMatchObject({
sectionWidth: 1000,
cardWidths: [125, 125, 125, 125, 125],
nameHeights: [25, 25, 25, 25, 25],
nameWhiteSpace: 'nowrap',
longNameFontSize: '12px',
imageWidth: 64,
imageHeight: 64,
imageNaturalWidth: 64,
@@ -24,9 +24,9 @@ const crewTypes = computed(() => props.info.groups.flatMap((group) => group.valu
const selectedCrewType = computed(
() => crewTypes.value.find((crewType) => crewType.id === selectedCrewTypeId.value) ?? crewTypes.value[0] ?? null
);
const valid = computed(
() => Boolean(selectedCrewType.value?.available) && Number.isFinite(amount.value) && amount.value >= 1
);
// .
// // .
const valid = computed(() => Boolean(selectedCrewType.value) && Number.isFinite(amount.value) && amount.value >= 1);
const estimatedGold = computed(() =>
selectedCrewType.value ? Math.ceil(amount.value * selectedCrewType.value.baseCost * goldCoefficient.value) : 0
);
@@ -135,7 +135,7 @@ watch(
type="button"
class="crew-name"
:class="availabilityClass(selectedCrewType)"
:title="selectedCrewType.available ? '현재 선택 가능' : '현재 선택 불가'"
:title="selectedCrewType.available ? '현재 실행 가능' : '현재 실행 불가 · 예약 가능'"
>
{{ selectedCrewType.name }}<small>{{ selectedCrewType.available ? '가능' : '불가' }}</small>
</button>
@@ -189,7 +189,7 @@ watch(
:class="{ selected: crewType.id === selectedCrewTypeId }"
role="button"
tabindex="0"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '선택 불가'}`"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '현재 실행 불가, 예약 가능'}`"
@click="selectCrewType(crewType)"
@keydown.enter="selectCrewType(crewType)"
>
@@ -256,7 +256,7 @@ watch(
</label>
</span>
<span class="crew-action" @click.stop>
<button type="button" :disabled="!crewType.available" @click="submit(crewType)">
<button type="button" @click="submit(crewType)">
{{ commandName }}
</button>
</span>
@@ -59,7 +59,6 @@ const numberText = (value: unknown, grouped = false): string => {
};
const wrap = (value: string): string => `${value}`;
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
@@ -134,7 +133,11 @@ export const formatReservedCommandBrief = (
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_징병' || action === 'che_모병') {
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
const crewType =
optionLabel(input?.crewTypes ?? [], args.crewType) ??
input?.recruitment?.groups
.flatMap((group) => group.values)
.find((entry) => entry.id === args.crewType)?.name;
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_숙련전환') {
@@ -146,7 +149,7 @@ export const formatReservedCommandBrief = (
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
if (args.itemCode === 'None') {
const itemTypeName = ITEM_TYPE_NAMES[itemType];
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
if (itemTypeName) return `${wrap(itemTypeName)}${JosaUtil.pick(itemTypeName, '을')} 판매.`;
}
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
if (itemName) {
@@ -53,6 +53,7 @@ export type CommandInputField = {
required: boolean;
min?: number;
max?: number;
legacyWidthMax?: number;
step?: number;
defaultValue?: string | number | boolean;
constValue?: string | number;
@@ -12,6 +12,7 @@ import {
} from '../command/commandArgumentDraft';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
import { getNpcColor } from '../../utils/npcColor';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import type {
CommandInputContext,
CommandInputField,
@@ -309,6 +310,14 @@ const setNumberPreset = (field: CommandInputField, rawValue: string, tupleIndex?
const effectiveMin = (field: CommandInputField): number | undefined => amountPreset.value?.min ?? field.min;
const effectiveMax = (field: CommandInputField): number | undefined => amountPreset.value?.max ?? field.max;
const effectiveStep = (field: CommandInputField): number | undefined => amountPreset.value?.step ?? field.step;
const textFieldError = (field: CommandInputField): string => {
const value = values[field.key];
if (field.kind !== 'text' || typeof value !== 'string') return '';
if (field.legacyWidthMax !== undefined && getLegacyStringWidth(value.trim()) > field.legacyWidthMax) {
return `${field.label}은 전각 ${Math.floor(field.legacyWidthMax / 2)}자 또는 반각 ${field.legacyWidthMax}자 이하여야 합니다.`;
}
return '';
};
const OPTION_CARD_COMMANDS = new Set([
'che_물자원조',
@@ -334,7 +343,8 @@ const isValid = computed(() =>
return (
(!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
(field.max === undefined || length <= field.max) &&
!textFieldError(field)
);
}
if (field.kind === 'number') {
@@ -429,8 +439,18 @@ watch(
:value="String(values[field.key] ?? '')"
:minlength="field.min"
:maxlength="field.max"
:aria-invalid="Boolean(textFieldError(field))"
:aria-describedby="textFieldError(field) ? `command-arg-${field.key}-error` : undefined"
@input="values[field.key] = ($event.target as HTMLInputElement).value"
/>
<small
v-if="field.kind === 'text' && textFieldError(field)"
:id="`command-arg-${field.key}-error`"
class="argument-error"
role="alert"
>
{{ textFieldError(field) }}
</small>
<div v-else-if="field.kind === 'number'" class="number-options">
<input
:id="`command-arg-${field.key}`"
@@ -524,6 +544,7 @@ watch(
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
"
class="option-detail"
:class="{ 'assignment-detail': commandKey === 'che_발령' && field.optionSource === 'generals' }"
>
<span
v-if="selectedOptionFor(field)?.color"
@@ -536,6 +557,7 @@ watch(
<div
v-if="showOptionCards(field)"
class="target-option-list"
:class="{ 'assignment-target-list': commandKey === 'che_발령' && field.optionSource === 'generals' }"
:data-testid="field.optionSource === 'nations' ? 'nation-target-list' : 'general-target-list'"
>
<button
@@ -547,6 +569,7 @@ watch(
selected: option.value === values[field.key],
unavailable: option.availableNow === false,
}"
:aria-pressed="option.value === values[field.key]"
@click="setSelectValue(field, String(option.value))"
>
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
@@ -647,6 +670,12 @@ watch(
align-items: center;
}
.argument-error {
grid-column: 2;
margin: -2px 6px 5px 0;
color: #ff9a9a;
}
.option-detail {
grid-column: 2;
display: flex;
@@ -725,6 +754,42 @@ watch(
line-height: 1.35;
}
.assignment-target-list {
max-height: 320px;
}
.assignment-target-list .target-option {
grid-template-columns: minmax(0, 1fr) max-content;
}
.assignment-target-list .target-state {
grid-column: 2;
}
.assignment-target-list .target-option small {
font-size: inherit;
line-height: 1.45;
}
.assignment-detail,
.assignment-target-list .target-option {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.assignment-detail > span,
.assignment-target-list .target-option small {
min-width: 0;
white-space: pre-line;
}
.assignment-target-list .target-option > strong {
grid-column: 1;
min-width: 0;
white-space: normal;
}
.option-color {
width: 18px;
height: 18px;
@@ -29,6 +29,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
@@ -60,11 +61,7 @@ const labelMap = computed(() => {
});
const firstReservedMonth = computed(
() =>
(props.currentYear ?? 0) * 12 +
(props.currentMonth ?? 1) -
1 +
(props.general?.nextTurnMonthOffset ?? 0)
() => (props.currentYear ?? 0) * 12 + (props.currentMonth ?? 1) - 1 + (props.general?.nextTurnMonthOffset ?? 0)
);
const rows = computed<ReservedCommandRow[]>(() => {
@@ -119,7 +116,7 @@ const updateServerClock = () => {
currentServerTime.value = '--:--:--';
return;
}
const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample);
const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample);
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
@@ -127,15 +124,30 @@ const updateServerClock = () => {
updateServerClock,
untilStartMs > 0
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
: 1_000 - projectedTime.getMilliseconds()
: (1_000 - projectedTime.getMilliseconds()) / rate
);
}
};
watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt });
() =>
[
props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true }
@@ -9,6 +9,7 @@ import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgres
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
import { generalInjuryPresentation } from '../../utils/generalInjury';
interface GeneralStats {
leadership: number;
@@ -175,14 +176,7 @@ const crewTypeIconBackground = computed(() => {
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff0000' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ffa500' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const injuryInfo = computed(() => generalInjuryPresentation(props.general?.injury ?? 0));
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
@@ -18,6 +18,7 @@ const props = withDefaults(
general: GeneralInformationPanelData | null;
summary: GeneralBattleSummaryData | null;
loading: boolean;
showDetails?: boolean;
nationColor?: string | null;
defenceText?: string | null;
killTurn?: number | null;
@@ -26,6 +27,7 @@ const props = withDefaults(
penaltyText?: string | number | null;
}>(),
{
showDetails: true,
nationColor: '#173d27',
defenceText: null,
killTurn: null,
@@ -48,7 +50,7 @@ const props = withDefaults(
:troop-text="props.troopText"
:penalty-text="props.penaltyText"
>
<template v-if="props.general" #details>
<template v-if="props.general && props.showDetails" #details>
<GeneralBattleSummary v-if="props.summary" :summary="props.summary" show-win-rate />
<LegacyGeneralProgress :general="props.general" :show-primary="false" />
</template>
@@ -16,6 +16,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null;
status: {
onlineUserCount: number;
@@ -34,6 +35,7 @@ const props = defineProps<{
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const currentServerTime = ref('기록 없음');
const hasServerClock = ref(false);
const recovering = ref(false);
const turnEngineStopped = computed(() => props.turnEngineRunning === false);
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean');
const serverClockTitle = computed(() => {
@@ -57,6 +59,7 @@ const updateServerClock = () => {
const now = Date.now();
const projection = projectServerClock(serverClockSample, now);
recovering.value = projection.rate === 2;
currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime',
fallback: '기록 없음',
@@ -67,16 +70,37 @@ const updateServerClock = () => {
const nextDelays: number[] = [];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs;
nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time));
nextDelays.push(
untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time) / projection.rate
);
}
for (const boundary of [serverClockSample.recoveryStartDelayMs, serverClockSample.recoveryEndDelayMs]) {
if (boundary !== undefined && boundary > projection.clientElapsedMs)
nextDelays.push(boundary - projection.clientElapsedMs);
}
if (nextDelays.length === 0) return;
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
};
watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt });
() =>
[
props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true }
@@ -100,7 +124,7 @@ onUnmounted(() => {
}"
:title="serverClockTitle"
>
현재 시각: {{ currentServerTime }}
현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span>
</div>
<div class="status-row tournament-status">
<RouterLink to="/tournament">
@@ -55,6 +55,7 @@ const destination = computed<MessageTarget>(
);
const invalid = computed(() => props.message.option?.invalid === true);
const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
@@ -134,7 +135,12 @@ onBeforeUnmount(() => {
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:class="[
'msg-plate',
`msg-plate-${message.msgType}`,
`msg-plate-${nationDirection}`,
{ 'msg-plate-permission-redacted': permissionRedacted },
]"
:data-id="message.id"
>
<div class="msg-icon">
@@ -262,13 +268,19 @@ onBeforeUnmount(() => {
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
<div
:class="[
'msg-content',
invalid ? 'msg-invalid' : permissionRedacted ? 'msg-permission-redacted' : 'msg-valid',
]"
>
<strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong>
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
<div v-if="hasAction" class="message-response">
<div v-if="hasAction && !invalid" class="message-response">
<button
class="prompt-yes"
class="prompt-yes legacy-button legacy-button--primary"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(true)"
@@ -276,7 +288,7 @@ onBeforeUnmount(() => {
수락
</button>
<button
class="prompt-no"
class="prompt-no legacy-button legacy-button--danger"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(false)"
@@ -411,20 +423,41 @@ button.msg-target {
color: rgba(255, 255, 255, 0.5);
}
.msg-plate-permission-redacted {
outline: 1px dashed #d7b86c;
background: #3b3427;
}
.msg-plate-permission-redacted .general-icon {
filter: grayscale(1);
opacity: 0.55;
}
.msg-permission-redacted {
color: rgba(255, 244, 214, 0.72);
font-style: italic;
}
.permission-redacted-label {
display: inline-block;
margin-right: 6px;
border: 1px solid #d7b86c;
padding: 1px 4px;
color: #ffe0a0;
font-style: normal;
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 0;
gap: 4px;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
.message-response .legacy-button {
min-width: 42px;
border: 1px outset buttonborder;
background: buttonface;
padding: 1px 6px;
color: buttontext;
padding: 2px 8px;
font-size: 12.5px;
cursor: pointer;
}
@@ -243,7 +243,7 @@ export const nationNavigation: MainNavigationEntry[] = [
},
],
},
{ kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' },
{ kind: 'link', id: 'my-settings', label: '환경 설정', to: '/my-settings', access: 'always' },
];
export const buildNationNavigation = (
@@ -0,0 +1,9 @@
export type GeneralInjuryPresentation = { text: string; color: string };
export const generalInjuryPresentation = (injury: number): GeneralInjuryPresentation => {
if (injury > 60) return { text: '위독', color: '#ff0000' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ffa500' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
};
@@ -4,6 +4,7 @@ export type ServerClockProjectionInput = {
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
};
export type SampledServerClock = {
@@ -11,6 +12,8 @@ export type SampledServerClock = {
sampledClientTimeMs: number;
clockMode: 'realtime' | 'manual';
startDelayMs: number | null;
recoveryStartDelayMs?: number;
recoveryEndDelayMs?: number;
};
const parseInstant = (value?: string | null): number | null => {
@@ -40,11 +43,20 @@ export const sampleServerClock = (
: null;
}
const wallSample = parseInstant(input.serverWallTime);
const recoveryStart = parseInstant(input.clockRecovery?.startsAt);
const recoveryEnd = parseInstant(input.clockRecovery?.endsAt);
return {
serverTimeMs,
sampledClientTimeMs,
clockMode: input.clockMode ?? 'realtime',
startDelayMs,
...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart
? {
recoveryStartDelayMs: recoveryStart - wallSample,
recoveryEndDelayMs: recoveryEnd - wallSample,
}
: {}),
};
};
@@ -55,9 +67,25 @@ export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Da
? 0
: Math.max(0, clientElapsedMs - sample.startDelayMs);
const accelerationMs =
sample.startDelayMs === null || sample.clockMode === 'manual'
? 0
: Math.max(
0,
Math.min(clientElapsedMs, sample.recoveryEndDelayMs ?? 0) -
Math.max(0, sample.recoveryStartDelayMs ?? 0)
);
const rate =
sample.startDelayMs !== null &&
sample.clockMode !== 'manual' &&
clientElapsedMs >= (sample.recoveryStartDelayMs ?? Infinity) &&
clientElapsedMs < (sample.recoveryEndDelayMs ?? -Infinity)
? 2
: 1;
return {
clientElapsedMs,
time: new Date(sample.serverTimeMs + elapsedGameMs),
rate,
time: new Date(sample.serverTimeMs + elapsedGameMs + accelerationMs),
};
};
+16 -2
View File
@@ -1052,7 +1052,13 @@ onUnmounted(() => {
</div>
<form class="npc-card-holder" @submit.prevent>
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<h4 class="npc-card-name">{{ npc.name }}</h4>
<h4
class="npc-card-name"
:class="{ 'npc-card-name--long': npc.name.length >= 9 }"
:title="npc.name"
>
{{ npc.name }}
</h4>
<h4>
<img
class="npc-card-image"
@@ -1723,10 +1729,18 @@ onUnmounted(() => {
}
.npc-card-name {
min-height: 25px;
box-sizing: border-box;
height: 25px;
overflow: hidden;
border: 1px solid rgba(201, 164, 90, 0.3);
font-size: 1rem;
line-height: 23px;
text-overflow: ellipsis;
white-space: nowrap;
}
.npc-card-name--long {
font-size: 0.75rem;
}
.npc-card-image {
+58 -9
View File
@@ -8,7 +8,7 @@ import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import MapViewer from '../components/main/MapViewer.vue';
import CommandListPanel from '../components/main/CommandListPanel.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import MessagePanel from '../components/main/MessagePanel.vue';
@@ -100,6 +100,43 @@ const nationAccess = computed(() => ({
nationLevel: nation.value?.level ?? 0,
}));
const nationColor = computed(() => nation.value?.color ?? '#000000');
const generalPanel = computed(() => {
const current = general.value;
if (!current) return null;
return {
...current,
progression: {
experienceLevel: current.progression?.experienceLevel ?? 0,
dedicationLevel: current.progression?.dedicationLevel ?? 0,
dedicationText: current.progression?.dedicationText ?? '-',
statExperience: current.progression?.statExperience ?? {
leadership: 0,
strength: 0,
intelligence: 0,
},
statUpgradeLimit: current.progression?.statUpgradeLimit ?? 30,
dex: current.progression?.dex ?? [0, 0, 0, 0, 0],
},
};
});
const generalSummary = computed(() =>
general.value
? {
available: true,
experience: general.value.experience,
dedicationText: general.value.progression?.dedicationText,
bill: general.value.bill,
warnum: general.value.records?.battles,
wins: general.value.records?.wins,
losses: general.value.records?.losses,
strategies: general.value.records?.strategies,
serviceYears: general.value.records?.serviceYears,
killCrew: general.value.records?.killedCrew,
deathCrew: general.value.records?.lostCrew,
recentWar: general.value.recentWar,
}
: null
);
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const profileLabels: Record<string, string> = {
che: '체',
@@ -183,10 +220,7 @@ const shiftGeneralTurns = (amount: number) => {
void dashboard.shiftGeneralTurns(amount);
};
const reserveGeneralTurns = async (
entries: CommandPatternEntry[],
complete?: (success: boolean) => void
) => {
const reserveGeneralTurns = async (entries: CommandPatternEntry[], complete?: (success: boolean) => void) => {
const success = await dashboard.setGeneralTurns(entries);
complete?.(success);
};
@@ -277,6 +311,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:turn-engine-running="lobbyInfo?.turnEngineRunning"
/>
</div>
@@ -322,7 +357,7 @@ watch(
:command-table="commandTable"
:loading="loading"
:reserved-general-turns="reservedGeneralTurns"
:general="general"
:general="generalPanel"
:current-year="lobbyInfo?.year"
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
@@ -331,6 +366,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@@ -371,7 +407,13 @@ watch(
data-mobile-panel-id="general"
>
<PanelCard title="장수 스탯" hide-header aria-label="장수 정보" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
<GeneralInformationPanel
:general="generalPanel"
:summary="generalSummary"
:show-details="false"
:loading="loading"
:nation-color="nation?.color"
/>
</PanelCard>
</div>
@@ -498,7 +540,7 @@ watch(
:command-table="commandTable"
:loading="loading"
:reserved-general-turns="reservedGeneralTurns"
:general="general"
:general="generalPanel"
:current-year="lobbyInfo?.year"
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
@@ -507,6 +549,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@@ -528,7 +571,13 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" hide-header aria-label="장수 정보" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
<GeneralInformationPanel
:general="generalPanel"
:summary="generalSummary"
:show-details="false"
:loading="loading"
:nation-color="nation?.color"
/>
</PanelCard>
<MainNationMenu
class="nation-menu-middle"
+1 -92
View File
@@ -6,7 +6,6 @@ import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
@@ -46,7 +45,6 @@ const dieOnPrestartStatusLoading = ref(false);
const dieOnPrestartStatusError = ref<string | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const selectedIconId = ref('');
const session = useSessionStore();
const readPendingDieOnPrestartId = (): string => {
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
@@ -169,8 +167,6 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
},
]
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
const showUserRulerAutomation = computed(() => showAutoNationTurn.value && (data.value?.general.npcState ?? 2) < 2);
@@ -259,10 +255,6 @@ const loadPage = async (resetImmediateActionIds = true) => {
use_auto_nation_finance: general.settings.use_auto_nation_finance,
use_auto_nation_capital: general.settings.use_auto_nation_capital,
});
selectedIconId.value =
iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ??
iconChoices.value[0]?.id ??
'';
}
await Promise.all(logTypes.map((type) => loadLog(type)));
if (resetImmediateActionIds) {
@@ -275,20 +267,6 @@ const loadPage = async (resetImmediateActionIds = true) => {
}
};
const changeGeneralIcon = async () => {
if (!selectedIconId.value) return;
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
try {
await trpc.general.adjustIcon.mutate({
iconId: selectedIconId.value,
clientRequestId: crypto.randomUUID(),
});
await loadPage();
} catch (cause) {
showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`);
}
};
const saveSettings = async () => {
if (!canSave.value) return;
try {
@@ -447,12 +425,7 @@ onMounted(() => {
>
<legend>사용자 군주 자동 업무</legend>
<label>
<input
v-model="form.use_auto_nation_war"
type="checkbox"
:true-value="1"
:false-value="0"
/>
<input v-model="form.use_auto_nation_war" type="checkbox" :true-value="1" :false-value="0" />
자동 선전포고
</label>
<label>
@@ -526,41 +499,6 @@ onMounted(() => {
휴가 신청
</button>
</div>
<div v-if="data?.canChangeIcon && iconChoices.length" class="action-line general-icon-action">
전용 아이콘 변경 (24시간에 1)<br />
<span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl(icon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
<button
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
type="button"
@click="changeGeneralIcon"
>
아이콘 변경
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
<button
@@ -877,35 +815,6 @@ button:disabled {
min-height: 32px;
margin-top: 8px;
}
.general-icon-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin: 6px 0;
}
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 var(--sammo-general-icon-size);
width: var(--sammo-general-icon-size);
height: var(--sammo-general-icon-size);
object-fit: cover;
}
.general-icon-choice {
display: flex;
align-items: center;
gap: 2px;
}
@media (max-width: 991px) {
.legacy-page {
width: 100%;
+133 -3
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback';
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
@@ -18,7 +21,52 @@ import {
type ScreenMode,
} from '../utils/screenModeViewport';
const { success: showSuccessToast } = useGameFeedback();
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const data = ref<MyGeneralResponse | null>(null);
const selectedIconId = ref('');
const iconLoading = ref(false);
const iconSaving = ref(false);
const iconError = ref<string | null>(null);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
const loadIcons = async () => {
iconLoading.value = true;
iconError.value = null;
try {
const general = await trpc.general.me.query();
data.value = general;
selectedIconId.value =
iconChoices.value.find((icon) => icon.picture === general?.general.picture)?.id ??
iconChoices.value[0]?.id ??
'';
} catch (cause) {
iconError.value = errorText(cause);
} finally {
iconLoading.value = false;
}
};
const changeGeneralIcon = async () => {
if (!selectedIconId.value || iconSaving.value) return;
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
iconSaving.value = true;
try {
await trpc.general.adjustIcon.mutate({
iconId: selectedIconId.value,
clientRequestId: crypto.randomUUID(),
});
showSuccessToast('전용 아이콘을 변경했습니다.');
await loadIcons();
} catch (cause) {
showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`);
} finally {
iconSaving.value = false;
}
};
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
@@ -67,6 +115,7 @@ watch(customCss, (text) => {
});
onMounted(() => {
void loadIcons();
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
});
@@ -82,7 +131,7 @@ onBeforeUnmount(() => {
<template>
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
<div class="title-row">
<span> </span>
<span> </span>
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
</div>
@@ -125,6 +174,55 @@ onBeforeUnmount(() => {
<p class="css-hint">변경 사항은 기기에 자동 저장됩니다.</p>
</div>
</article>
<article
v-if="iconLoading || iconError || (data?.canChangeIcon && iconChoices.length)"
class="settings-column"
>
<h2 class="section-title">전용 아이콘</h2>
<p v-if="iconLoading" class="settings-content" role="status">아이콘 목록을 불러오는 중입니다.</p>
<div v-else-if="iconError" class="settings-content" role="alert">
<p>{{ iconError }}</p>
<button class="legacy-button legacy-button--secondary" type="button" @click="loadIcons">
다시 불러오기
</button>
</div>
<div v-else-if="data?.canChangeIcon && iconChoices.length" class="settings-content general-icon-action">
전용 아이콘 변경 (24시간에 1)<br />
<span v-if="data.iconChangeAvailableAt" class="css-hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl(icon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
<button
class="legacy-button legacy-button--primary"
type="button"
:disabled="iconSaving || iconLoading"
@click="changeGeneralIcon"
>
아이콘 변경
</button>
</div>
</article>
</section>
<footer class="legacy-credit">
@@ -413,6 +511,38 @@ onBeforeUnmount(() => {
.mobile-layout-dialog__actions .mobile-layout-apply {
font-weight: 700;
}
.general-icon-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin: 6px 0;
}
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 var(--sammo-general-icon-size);
width: var(--sammo-general-icon-size);
height: var(--sammo-general-icon-size);
object-fit: cover;
}
.general-icon-choice {
display: flex;
align-items: center;
gap: 2px;
}
.general-icon-action {
text-align: center;
}
@media (max-width: 939.98px) {
.settings-grid {
grid-template-columns: 1fr;
@@ -4,7 +4,9 @@ import { computed, onMounted, ref } from 'vue';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
import LegacySortControls from '../components/ui/LegacySortControls.vue';
import DirectoryTooltip from '../components/directory/DirectoryTooltip.vue';
import { getNpcColor } from '../utils/npcColor';
import { generalInjuryPresentation } from '../utils/generalInjury';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type ReservedCommand = Result['generals'][number]['reservedCommands'][number];
@@ -48,6 +50,18 @@ const generals = computed(() =>
const closeWindow = () => window.close();
const displayName = (general: { name: string; npcState: number }) =>
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `${general.name}` : general.name;
const injuryInfo = (injury: number) => generalInjuryPresentation(injury);
const injuryDescription = (general: Result['generals'][number]): string =>
general.injury > 0 ? `부상 ${general.injury}% · ${injuryInfo(general.injury).text}` : '';
const statInjuryDescription = (
general: Result['generals'][number],
label: string,
original: number,
effective: number
): string =>
general.injury > 0
? `부상 ${general.injury}% · ${injuryInfo(general.injury).text} · 원래 ${label} ${original} → 적용 ${effective}`
: '';
const commandBrief = (command: ReservedCommand): string =>
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
const updateSelectedSort = (value: number): void => {
@@ -229,15 +243,72 @@ onMounted(load);
:data-npc-state="general.npcState"
>
<td>
<span data-general-name :style="{ color: getNpcColor(general.npcState) }">{{
displayName(general)
}}</span
<DirectoryTooltip
:title="`부상 · ${injuryInfo(general.injury).text}`"
:description="injuryDescription(general)"
:test-id="`secret-injury-name-${general.id}`"
>
<span
data-general-name
:style="{
color:
general.injury > 0
? injuryInfo(general.injury).color
: getNpcColor(general.npcState),
}"
>{{ displayName(general) }}</span
> </DirectoryTooltip
><br />Lv {{ general.experienceLevel }}
</td>
<td>
{{ general.stats.leadership
}}<span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span
>{{ general.stats.strength }}{{ general.stats.intelligence }}
<DirectoryTooltip
title="통솔 부상"
:description="
statInjuryDescription(
general,
'통솔',
general.baseStats.leadership,
general.stats.leadership
)
"
:test-id="`secret-injury-leadership-${general.id}`"
>
<span :style="{ color: injuryInfo(general.injury).color }">{{
general.stats.leadership
}}</span
><span v-if="general.leadershipBonus" class="bonus"
>+{{ general.leadershipBonus }}</span
>
</DirectoryTooltip>
<DirectoryTooltip
title="무력 부상"
:description="
statInjuryDescription(
general,
'무력',
general.baseStats.strength,
general.stats.strength
)
"
:test-id="`secret-injury-strength-${general.id}`"
><span :style="{ color: injuryInfo(general.injury).color }">{{
general.stats.strength
}}</span></DirectoryTooltip
><DirectoryTooltip
title="지력 부상"
:description="
statInjuryDescription(
general,
'지력',
general.baseStats.intelligence,
general.stats.intelligence
)
"
:test-id="`secret-injury-intelligence-${general.id}`"
><span :style="{ color: injuryInfo(general.injury).color }">{{
general.stats.intelligence
}}</span></DirectoryTooltip
>
</td>
<td>{{ general.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
@@ -100,7 +100,37 @@ const table: CommandTable = {
{ value: 'che_명마_01_노기', label: '노기(+1)' },
],
},
recruitment: null,
recruitment: {
techLevel: 1,
leadership: 70,
fullLeadership: 70,
currentCrewTypeId: 1100,
currentCrewTypeName: '보병',
crew: 0,
gold: 1000,
groups: [
{
armType: 0,
armName: '보병',
values: [
{
id: 1200,
armType: 0,
name: '정예병',
available: false,
special: true,
attack: 10,
defence: 10,
speed: 10,
avoid: 10,
baseCost: 10,
baseRice: 10,
info: [],
},
],
},
],
},
},
};
@@ -112,6 +142,10 @@ void test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인
formatReservedCommandBrief('general', 'che_모병', { crewType: 1100, amount: 2400 }, table),
'【보병】 2400명 모병'
);
assert.equal(
formatReservedCommandBrief('general', 'che_모병', { crewType: 1200, amount: 2500 }, table),
'【정예병】 2500명 모병'
);
});
void test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => {
@@ -131,7 +165,10 @@ void test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', (
['che_증여', { destGeneralId: 8, amount: 200, isGold: true }, '【손권】에게 금 200을 증여'],
['che_징병', { crewType: 1100, amount: 3200 }, '【보병】 3200명 징병'],
['che_숙련전환', { srcArmType: 0, destArmType: 1 }, '【보병】숙련을 【궁병】숙련으로 전환'],
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매'],
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매.'],
['che_장비매매', { itemType: 'weapon', itemCode: 'None' }, '【무기】를 판매.'],
['che_장비매매', { itemType: 'book', itemCode: 'None' }, '【서적】을 판매.'],
['che_장비매매', { itemType: 'item', itemCode: 'None' }, '【도구】를 판매.'],
['che_장비매매', { itemType: 'horse', itemCode: 'che_명마_01_노기' }, '【노기(+1)】를 구입'],
];
for (const [action, args, expected] of cases) {
@@ -49,3 +49,22 @@ void test('holds a preopen clock until its wall-clock start delay passes', () =>
void test('rejects an invalid server clock sample', () => {
assert.equal(sampleServerClock({ serverTime: 'not-a-time' }, 10_000), null);
});
void test('a single browser sample accelerates only inside the recovery window and rejoins normal time', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-06T00:20:00Z',
serverWallTime: '2026-09-06T04:20:00Z',
clockRunning: true,
clockRecovery: { startsAt: '2026-09-06T05:00:00Z', endsAt: '2026-09-06T09:00:00Z' },
},
0
);
assert.ok(sample);
const minute = 60_000;
assert.equal(projectServerClock(sample, 40 * minute).time.toISOString(), '2026-09-06T01:00:00.000Z');
assert.equal(projectServerClock(sample, 100 * minute).time.toISOString(), '2026-09-06T03:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).time.toISOString(), '2026-09-06T09:00:00.000Z');
assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).rate, 1);
});
+28
View File
@@ -2285,6 +2285,22 @@ export const adminRouter = router({
message: 'preopenAt and openAt are required for RESERVED status.',
});
}
const current = await ctx.profiles.getProfile(input.profileName);
if (!current) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
if (current.currentScenario !== null && current.status !== input.status) {
const clockAction =
input.status === 'RUNNING'
? 'RESUME'
: current.status === 'RUNNING' && (input.status === 'PAUSED' || input.status === 'STOPPED')
? 'SUSPEND'
: null;
if (clockAction)
await ctx.orchestrator.transitionProfileClock(
input.profileName,
clockAction,
'operator setStatus'
);
}
const result = await ctx.profiles.updateStatus(input.profileName, input.status, {
preopenAt: input.preopenAt,
openAt: input.openAt,
@@ -2696,6 +2712,18 @@ export const adminRouter = router({
});
}
if (input.action === 'ACCELERATE' || input.action === 'DELAY') {
const [settings] = (await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
if (!settings || input.durationMinutes! % settings.turnTermMinutes !== 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: settings
? `일정 이동은 현재 턴 길이(${settings.turnTermMinutes}분)의 정수 배로 입력해 주세요.`
: '현재 턴 길이를 확인할 수 없습니다.',
});
}
}
if (
input.action === 'ACCELERATE' ||
input.action === 'DELAY' ||
@@ -1,6 +1,7 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
@@ -1237,7 +1238,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!profile || profile.currentScenario === null) {
throw new Error(`Profile clock is unavailable: ${profileName}`);
}
const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) });
const { connectorFactory, supportsTurnRecovery } = await this.resolveProfileClockAdapter(profile);
const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) });
const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await postgres.connect();
await redis.connect();
@@ -1265,6 +1267,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
});
if (!world) throw new Error(`Profile has no world_state: ${profileName}`);
if (['PREOPEN', 'MANUAL', 'COMPLETED'].includes(world.clockPhase)) {
return { phase: world.clockPhase, revision: clockRevisionAsNumber(world.clockRevision) };
}
if (action === 'SUSPEND') {
let suspension = await postgres.prisma.clockSuspension.findFirst({
@@ -1280,6 +1285,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
db: postgres.prisma,
suspensionId: `gateway-maintenance-${suffix}`,
source: 'MAINTENANCE',
// 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다.
// 완전한 12턴은 정수 이동하고 잔여 지연은 복구 구간에서 두 배속으로 실행한다.
policy: supportsTurnRecovery ? 'RECOVER_TURNS' : 'PRESERVE_SCHEDULE',
authority,
});
suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({
@@ -1323,7 +1331,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) };
}
if (world.clockPhase === 'SUSPENDED') {
await reconcileClockSuspension({ db: postgres.prisma, suspensionId: suspension.id, authority });
await reconcileClockSuspension({
db: postgres.prisma,
suspensionId: suspension.id,
authority,
upgradeMaintenancePolicy: supportsTurnRecovery,
});
} else if (world.clockPhase !== 'RECONCILING') {
throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`);
}
@@ -1350,7 +1363,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.promoteProfileOpeningOverride(profile);
return;
}
const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) });
const { connectorFactory } = await this.resolveProfileClockAdapter(profile);
const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) });
const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await postgres.connect();
await redis.connect();
@@ -1692,6 +1706,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new Error(`Profile status ${profile.status} cannot be started by an operator.`);
}
await this.transitionProfileClock(profile.profileName, 'RESUME', operation.reason ?? 'operator START');
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
const updated = await updateOperationProfile(
{
@@ -1732,6 +1747,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`);
}
await this.transitionProfileClock(profile.profileName, 'SUSPEND', operation.reason ?? 'operator STOP');
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
await updateOperationProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
@@ -1976,14 +1992,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
'settlement',
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
);
const result = await this.cancelGame({
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
});
const result = await this.cancelGame(
{
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
},
this.cancelGame === defaultCancelGame
? (await this.resolveProfileClockAdapter(profile)).connectorFactory
: undefined
);
cancellationCommitted = true;
await assertLease();
await updateClaimedProfile({
@@ -2490,11 +2511,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`);
}
await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.');
// 실제 seed가 정한 경계를 공개 시간표와 scheduler에도 사용한다.
const openingConnector = createGamePostgresConnector({ url: seedInfo.databaseUrl });
let effectiveOpenAt = openAt;
try {
await openingConnector.connect();
const clock = await openingConnector.prisma.worldState.findFirstOrThrow({
select: { clockMode: true, clockWallAnchor: true },
});
if (clock.clockMode === 'realtime' && clock.clockWallAnchor) effectiveOpenAt = clock.clockWallAnchor;
} finally {
await openingConnector.disconnect();
}
await this.clearTournamentRuntimeState(profile.profileName);
await assertLease?.();
const completedAt = this.now().toISOString();
const now = this.now();
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, effectiveOpenAt);
const publishedProfile = await updateClaimedProfile(
{
currentScenario: String(scenarioId),
@@ -2505,8 +2539,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
buildLastUsedAt: completedAt,
buildCompletedAt: completedAt,
buildError: null,
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
preopenAt: preopenAt
? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null,
...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}),
},
@@ -2520,8 +2558,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
}
return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
preopenAt: preopenAt
? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null,
});
}
@@ -2799,6 +2841,25 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
private async resolveProfileClockAdapter(profile: GatewayProfileRecord): Promise<{
connectorFactory: typeof createGamePostgresConnector;
supportsTurnRecovery: boolean;
}> {
// Gateway와 profile은 독립 배포된다. 이전 profile에는 당시 Prisma 모델과
// 기존 즉시 따라잡기 정책을 사용하고, 새 profile만 복구 창을 저장한다.
const profileWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
const manifest = await readReleaseManifest(profileWorkspace);
const supportsTurnRecovery = manifest.gameSchemaHead >= '20260906090000_add_turn_recovery_window';
const connectorFactory = supportsTurnRecovery
? createGamePostgresConnector
: (
(await import(pathToFileURL(path.join(profileWorkspace, 'packages/infra/dist/index.js')).href)) as {
createGamePostgresConnector: typeof createGamePostgresConnector;
}
).createGamePostgresConnector;
return { connectorFactory, supportsTurnRecovery };
}
private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string {
return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url;
}
@@ -316,7 +316,13 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
where:
candidate.status === 'QUEUED'
? { id: candidate.id, status: 'QUEUED' }
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil },
: {
id: candidate.id,
status: 'RUNNING',
// 마이크로초 lease를 Date로 왕복한 값과 equality CAS하면
// 만료된 행도 선점하지 못한다. 갱신 race는 만료 조건으로 막는다.
leaseUntil: candidate.leaseUntil ? { lt: now } : null,
},
data: {
status: 'RUNNING',
startedAt: candidate.startedAt ?? now,
@@ -802,7 +802,13 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
where:
candidate.status === 'QUEUED'
? { id: candidate.id, status: 'QUEUED' }
: { id: candidate.id, status: 'RUNNING', leaseUntil: candidate.leaseUntil },
: {
id: candidate.id,
status: 'RUNNING',
// DB microseconds는 JS Date로 읽을 때 잘린다. timestamp
// 동등 비교 대신 갱신되지 않은 만료 lease인지 DB에서 재검사한다.
leaseUntil: candidate.leaseUntil ? { lt: now } : null,
},
data: {
status: 'RUNNING',
startedAt: candidate.startedAt ?? now,
@@ -65,6 +65,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P
tickSeconds: request.tickSeconds,
gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime',
now: new Date(request.now),
wallNow: new Date(),
installOptions: request.installOptions
? {
...request.installOptions,

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