Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3443ec7f5 | ||
|
|
63da0921cb | ||
|
|
6dcc914f94 | ||
|
|
d3b2bcd155 | ||
|
|
ce7d15eba9 | ||
|
|
8488bf1aae | ||
|
|
71d7abcda4 | ||
|
|
332bd9a3dd | ||
|
|
3437cd99f4 | ||
|
|
1d53e6f226 | ||
|
|
0157ff6a6b | ||
|
|
5d220de739 | ||
|
|
07897d9765 | ||
|
|
01fdb51b6e | ||
|
|
746bbe7e7d | ||
|
|
f009c50df5 | ||
|
|
38e516de6d |
@@ -1,40 +1,29 @@
|
||||
# core2026 작업 지침
|
||||
|
||||
## 적용 범위와 목표
|
||||
## 적용 범위와 문서 역할
|
||||
|
||||
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운
|
||||
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
|
||||
지침 순으로 적용합니다.
|
||||
이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
|
||||
AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
|
||||
보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
|
||||
|
||||
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
|
||||
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
|
||||
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
|
||||
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
|
||||
상위 지침의 상대 경로는 `sam_rebuild/`, 이 파일의 경로는 `core2026/` 기준입니다.
|
||||
독립 checkout에서 상위 작업공간 문서를 참조할 수 없으면 실제 작업공간 위치를
|
||||
확인하고, 확인하지 못한 규칙이나 Ref 근거를 읽었다고 보고하지 않습니다.
|
||||
|
||||
## 현재 기준과 저장소 경계
|
||||
| 작업 | 상세 기준 |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 구조·파일 책임 | [README](README.md#저장소-구성), [개발자 핸드북](docs/developer/index.md) |
|
||||
| Ref 비교·계측·분류 | [상위 호환성 계약](../AGENTS.md#호환성-계약), [매핑](../docs/ref-core2026-mapping.md) |
|
||||
| 테스트 준비·실행·오류 복구 | [테스트 정책](docs/testing-policy.md) |
|
||||
| CSS·실제 Chromium 비교 | [CSS 구조](docs/frontend-css-architecture.md), [호환 검증](docs/frontend-legacy-parity.md) |
|
||||
| DB 이관 | [이관 절차](tools/legacy-db-migration/README.md) |
|
||||
| 배포 대상 선택 | [환경 라우팅](../docs/docker-environment-routing.md) |
|
||||
| prefix·빌드 변수 | [Caddy 계약](docs/e2e-caddy-routing.md) |
|
||||
| Gateway·profile 릴리스 | [릴리스 운영](docs/release-operations.md) |
|
||||
|
||||
- `core2026/`은 제품 구현 저장소입니다. gateway/game frontend·API, game engine,
|
||||
공통 package, Prisma와 검증 도구가 실제로 존재합니다.
|
||||
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`입니다.
|
||||
`devel`은 변경하지 않는 기준선이고 비교 fixture·계측은 `ng_compare`에만
|
||||
두어 주세요.
|
||||
- 아키텍처 매핑과 보고서는 별도 상위 저장소의
|
||||
`../docs/ref-core2026-mapping.md`, `../report/`에 있습니다. 두 Git 경계의
|
||||
commit과 상태를 혼동하지 말아 주세요.
|
||||
- 이미지의 운영 소유자는 외부 Caddy의 `/image/*`입니다. 제품 저장소에 무단
|
||||
복제하거나 앱 경로로 rewrite하지 말아 주세요.
|
||||
|
||||
작업 시작과 종료에 최소한 다음을 확인해 주세요.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git remote -v
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
상위 매핑/보고서를 바꾸면 상위 저장소에서도 같은 검사를 별도로 수행해 주세요.
|
||||
dirty 파일은 사용자의 변경일 수 있으므로 관련 없는 수정, 정리, stage,
|
||||
commit 또는 삭제를 하지 말아 주세요.
|
||||
PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
|
||||
매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
|
||||
각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
|
||||
|
||||
## 완료 상태를 해석하는 법
|
||||
|
||||
@@ -55,87 +44,19 @@ commit 또는 삭제를 하지 말아 주세요.
|
||||
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
|
||||
코드와 commit으로 다시 확인해 주세요.
|
||||
|
||||
## 실제 구조와 책임
|
||||
|
||||
- `app/gateway-frontend`: 가입·로그인, 로비, 계정, 관리자 운영 UI
|
||||
- `app/gateway-api`: 계정·세션·profile 정책, operation queue와 PM2
|
||||
orchestration
|
||||
- `app/game-frontend`: profile별 게임 SPA와 ref 호환 UI
|
||||
- `app/game-api`: tRPC/SSE, 조회·입력 API와 battle/auction/tournament worker
|
||||
- `app/game-engine`: turn daemon, scheduler, 월간 lifecycle와 DB flush
|
||||
- `packages/common`: 타입, 직렬화, RNG와 공통 유틸리티
|
||||
- `packages/logic`: 전투·명령·월간 action과 typed action module 도메인 로직
|
||||
- `packages/infra`: gateway/game Prisma schema, migration과 client
|
||||
- `tools/integration-tests`: PostgreSQL/Redis 및 ref↔core 차등
|
||||
- `tools/frontend-legacy-parity`: 실제 Chromium 비교
|
||||
- `tools/legacy-db-migration`: 장기보존 데이터 CLI 이관
|
||||
|
||||
`tools/build-scripts/build-server.mjs`는 현재 profile resource 복사
|
||||
placeholder입니다. 이를 완성된 배포 bundle이나 검증된 profile build로 설명하지
|
||||
않습니다. 운영 build/reset/open은 gateway operation, commit별 worktree와
|
||||
orchestrator 실행 경로를 조사해 주세요.
|
||||
## 도메인 조립과 비교
|
||||
|
||||
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
|
||||
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
|
||||
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`의
|
||||
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
|
||||
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언해
|
||||
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md`에
|
||||
있습니다.
|
||||
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
|
||||
상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
|
||||
|
||||
## 레거시 매핑과 비교
|
||||
|
||||
기능을 변경하기 전에 다음을 end-to-end로 연결해 주세요.
|
||||
|
||||
1. ref entry point와 호출 순서
|
||||
2. PHP domain class, SQL read/write, template/CSS/JS와 사용자 출력
|
||||
3. core service/router/action/loader/flush와 frontend 호출부
|
||||
4. session/auth, validation, transaction, 비동기 경계와 오류 복구
|
||||
5. RNG 생성·소비와 persistence 순서
|
||||
|
||||
`../docs/ref-core2026-mapping.md`에는 1:1 대응을 억지로 만들지 말고 1:N/N:1
|
||||
소유권과 변환 지점을 기록해 주세요. `확인`, `부분 확인`, `가설`, `미구현`,
|
||||
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
|
||||
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
|
||||
|
||||
Ref 비교 전에 대상 계약도 구분해 주세요.
|
||||
|
||||
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
|
||||
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
|
||||
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
|
||||
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
|
||||
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
|
||||
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
|
||||
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
|
||||
|
||||
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
|
||||
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
|
||||
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
|
||||
|
||||
ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
|
||||
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
|
||||
- 기존 `ng_compare` 이력과 분기 기준을 조사하고 reset/overwrite하지 말아 주세요.
|
||||
- 계측은 최소·deterministic·가능하면 read-only로 두고 test 환경 guard를
|
||||
사용해 주세요.
|
||||
- 계측 유무가 결과, RNG 소비, DB mutation과 출력 순서를 바꾸지 않는지
|
||||
확인해 주세요.
|
||||
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
|
||||
|
||||
## 계승 계약의 호환성 우선순위
|
||||
|
||||
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
|
||||
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
|
||||
3. API 요청·응답·오류, session과 인증
|
||||
4. 문구, 로그, 화면 흐름과 룩앤필
|
||||
5. 내부 구현 세부사항
|
||||
|
||||
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
|
||||
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
|
||||
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
|
||||
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
|
||||
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
|
||||
이름만으로 완화하지 않습니다.
|
||||
차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
|
||||
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
|
||||
되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
|
||||
따릅니다.
|
||||
|
||||
## 인증과 권한
|
||||
|
||||
@@ -182,18 +103,18 @@ API request
|
||||
|
||||
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
|
||||
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
|
||||
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가
|
||||
추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
|
||||
소비하는 RNG call을 생략하지 말아 주세요.
|
||||
|
||||
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
|
||||
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기
|
||||
side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
|
||||
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
|
||||
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
|
||||
|
||||
## 프론트엔드와 룩앤필
|
||||
|
||||
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는
|
||||
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
||||
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
|
||||
UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
|
||||
따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
|
||||
|
||||
- gateway `/gateway/`, game `/che/`와 `/hwe/` prefix에서 direct navigation,
|
||||
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
|
||||
@@ -210,20 +131,10 @@ layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
||||
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
||||
일괄 disable하지 말아 주세요.
|
||||
|
||||
UI를 변경하면 실제 Chromium을 사용해 주세요.
|
||||
|
||||
- ref와 core에 같은 Chromium, viewport, device scale, zoom, locale, font,
|
||||
image와 로그인/test data를 사용해 주세요.
|
||||
- 전체·영역 screenshot과 `getBoundingClientRect()`,
|
||||
`getComputedStyle()`을 수집해 주세요.
|
||||
- `hover()`, focus, pointer down/up, checked/selected/disabled, dropdown,
|
||||
modal과 transition 상태를 실제 interaction으로 만들어 주세요.
|
||||
- pixel diff mask는 시간·난수 등 불가피한 영역만 최소화하고 이유를 기록해 주세요.
|
||||
- auth redirect만 확인하고 visual parity라고 주장하지 말아 주세요.
|
||||
- 민감정보가 보이는 artifact는 저장하거나 report에 넣지 말아 주세요.
|
||||
|
||||
Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
|
||||
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
|
||||
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
|
||||
`docs/frontend-legacy-parity.md`를 따라 주세요.
|
||||
`docs/frontend-legacy-parity.md`를 따릅니다.
|
||||
|
||||
## DB와 레거시 데이터 이관
|
||||
|
||||
@@ -263,94 +174,29 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
|
||||
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
|
||||
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
|
||||
|
||||
현재 외부 호스트 계약은 `0.0.0.0` bind와 `/gateway/`, `/che/`, `/hwe/`
|
||||
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지
|
||||
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요.
|
||||
|
||||
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다.
|
||||
|
||||
```sh
|
||||
VITE_APP_BASE_PATH=/gateway \
|
||||
VITE_GATEWAY_API_URL=/gateway/api/trpc \
|
||||
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
|
||||
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
|
||||
pnpm --filter @sammo-ts/gateway-frontend build
|
||||
```
|
||||
|
||||
profile별 정확한 포트와 game frontend/API 변수는
|
||||
`docs/e2e-caddy-routing.md`에서 확인하고 현재 인프라와 대조해 주세요.
|
||||
컨테이너 서비스는 필요한 listener를 `0.0.0.0`에 bind합니다. 로컬 E2E의
|
||||
`/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
|
||||
대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
|
||||
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
|
||||
HTTPS 검증과 구분합니다.
|
||||
|
||||
## 개발과 검증 절차
|
||||
|
||||
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요.
|
||||
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요.
|
||||
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다.
|
||||
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요.
|
||||
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
|
||||
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
|
||||
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
|
||||
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
|
||||
상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
|
||||
검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
|
||||
명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
|
||||
[테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
|
||||
|
||||
기본 정적 검사는 실제 루트 script를 사용해 주세요.
|
||||
|
||||
```sh
|
||||
CI=1 pnpm typecheck
|
||||
pnpm check:architecture
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
루트 `pnpm test`와 `pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
|
||||
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
|
||||
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
|
||||
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
|
||||
|
||||
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
|
||||
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
|
||||
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
|
||||
adapter로 분리합니다.
|
||||
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
|
||||
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
|
||||
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
|
||||
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
|
||||
`packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
|
||||
- Vitest filter에 불필요한 `--`를 넣지 않습니다.
|
||||
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
|
||||
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요.
|
||||
해당 Playwright script 또는 legacy parity suite를 사용해 주세요.
|
||||
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use`
|
||||
(nvm 환경은 `nvm use`) 후
|
||||
`pnpm test:bootstrap`을 실행해 install, Prisma client와
|
||||
common → logic → infra → game-engine → API 산출물을 준비해 주세요. 이미
|
||||
install된 checkout은 `pnpm test:prepare`를 사용합니다. Playwright
|
||||
`webServer` 실패를 이 준비가 끝나기 전 제품 회귀로 분류하지 말아 주세요.
|
||||
- E2E port가 다른 worktree와 겹치면 설정 파일을 임시 수정하지 말고
|
||||
`PLAYWRIGHT_FRONTEND_PORT`, `PLAYWRIGHT_GATEWAY_FRONTEND_PORT` 또는
|
||||
`FRONTEND_PARITY_*_PORT`를 지정합니다. 같은 game frontend `dist`를 쓰는
|
||||
profile production run은 직렬로 실행합니다.
|
||||
- 전체 lint/test의 기존 실패가 있으면 targeted 결과와 baseline 재현 결과를
|
||||
구분하고, 관련 없는 기대값을 완화해 숨기지 말아 주세요.
|
||||
|
||||
외부 서비스 없는 기본 integration:
|
||||
|
||||
```sh
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
전용 PostgreSQL/Redis를 준비한 조건부 전체 경계:
|
||||
|
||||
```sh
|
||||
pnpm test:integration:conditional
|
||||
```
|
||||
|
||||
ref 명령과 UI entry point:
|
||||
|
||||
```sh
|
||||
pnpm check:legacy:general
|
||||
pnpm check:legacy:nation
|
||||
pnpm test:e2e:frontend-legacy
|
||||
```
|
||||
|
||||
각 command가 요구하는 ref checkout, Docker, DB URL, secret과 fixture는 관련
|
||||
docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report나 제안 문서만 보고
|
||||
실행하지 말아 주세요.
|
||||
- frontend package의 `test` placeholder는 UI 검증이 아닙니다. 해당 Playwright
|
||||
script 또는 legacy parity suite를 실행합니다.
|
||||
- ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
|
||||
오래된 report만 보고 명령을 가정하지 않습니다.
|
||||
- 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
|
||||
|
||||
## 코드 스타일
|
||||
|
||||
@@ -374,42 +220,22 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
|
||||
|
||||
## 문서와 보고서
|
||||
|
||||
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요.
|
||||
변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
|
||||
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
|
||||
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
|
||||
|
||||
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계
|
||||
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약
|
||||
- `docs/*`: core2026 내부 구현·운영 상세
|
||||
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
|
||||
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
|
||||
|
||||
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
|
||||
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
|
||||
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
|
||||
보고서의 필수 내용과 저장소별 commit 기록은
|
||||
[상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
|
||||
|
||||
## Git, worktree와 commit
|
||||
|
||||
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다.
|
||||
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에
|
||||
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요.
|
||||
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요.
|
||||
- ref 비교 변경과 core 제품 변경, 상위 mapping/report 변경은 각 Git
|
||||
저장소에서 별도 commit해 주세요.
|
||||
- 생성물, `.env`, DB volume, log, coverage, screenshot, test-results와
|
||||
secret을 commit하지 말아 주세요.
|
||||
- worktree 정리는 clean status와
|
||||
`git merge-base --is-ancestor HEAD <baseline>`을 모두 확인한 뒤 non-force
|
||||
제거해 주세요. 이름, 나이 또는 uncommitted 여부만으로 삭제하지 말아 주세요.
|
||||
- 사용자가 commit을 요청하지 않은 일반 작업은 diff와 권장 commit 경계를
|
||||
준비하되 임의로 commit하지 말아 주세요.
|
||||
[상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
|
||||
[기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
|
||||
충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
|
||||
않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
|
||||
|
||||
## Qwen 보조 분석
|
||||
|
||||
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만
|
||||
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을
|
||||
보내지 말아 주세요.
|
||||
|
||||
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
|
||||
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
|
||||
- 응답 budget은 128–4096 token 범위로 제한해 주세요.
|
||||
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
|
||||
로컬 분석을 계속해 주세요.
|
||||
Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
|
||||
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
|
||||
worktree 정리가 요청 범위라면 clean status와
|
||||
`git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
|
||||
이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 삼국지 모의전투 HiDCHe core2026
|
||||
|
||||
`core2026`은 `../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm
|
||||
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작은 ref 구현과 실제 실행
|
||||
결과를 기준으로 검증합니다.
|
||||
`core2026`은 PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
|
||||
발전하는 TypeScript pnpm 모노레포입니다. 계승 영역은 Ref의 동작·상태·권한과
|
||||
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
||||
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
||||
|
||||
## 저장소 구성
|
||||
|
||||
@@ -252,9 +253,11 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
|
||||
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
|
||||
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
|
||||
|
||||
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한
|
||||
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE,
|
||||
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다.
|
||||
활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
|
||||
`/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
|
||||
[환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
|
||||
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
|
||||
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
|
||||
|
||||
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
|
||||
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
|
||||
|
||||
@@ -70,6 +70,9 @@ type WorldClockFields =
|
||||
| 'clockTick'
|
||||
| 'clockMode'
|
||||
| 'clockWallAnchor'
|
||||
| 'clockRecoveryStartTick'
|
||||
| 'clockRecoveryEndTick'
|
||||
| 'clockRecoveryStartWallAt'
|
||||
| 'lastTurnTick'
|
||||
| 'clockPhase'
|
||||
| 'clockRevision'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
readModelOutboxPayloadToDiplomacyMailboxes,
|
||||
readModelOutboxPayloadToChanges,
|
||||
readModelOutboxPayloadToMessageMailboxes,
|
||||
type ReadModelDomain,
|
||||
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
||||
'access.general',
|
||||
'dashboard.global',
|
||||
'messages.mailbox',
|
||||
'messages.diplomacyMailbox',
|
||||
'tournament',
|
||||
'betting',
|
||||
]);
|
||||
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
||||
this.db,
|
||||
async (payload) => {
|
||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||
if (mailboxes.length > 0) {
|
||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
||||
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
|
||||
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
|
||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
|
||||
}
|
||||
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||
const changes = readModelOutboxPayloadToChanges(payload);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
|
||||
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
||||
const seen = new Set<string>();
|
||||
return identities.filter((identity) => {
|
||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
|
||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
|
||||
const changes = eventChanges(event);
|
||||
if (!changes) return false;
|
||||
const generalId = identity.generalId;
|
||||
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
|
||||
return [
|
||||
changes.generalIds,
|
||||
changes.mapGeneralIds ?? changes.generalIds,
|
||||
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
|
||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||
);
|
||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
||||
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||
const mailboxes =
|
||||
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
|
||||
const diplomacyMailboxes =
|
||||
event.type === 'messageCreated'
|
||||
? event.msgType === 'diplomacy'
|
||||
? [event.mailbox]
|
||||
: []
|
||||
: (event.diplomacyMailboxes ?? []);
|
||||
return viewers.some(
|
||||
(identity) =>
|
||||
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
|
||||
(identity.canReadDiplomacy &&
|
||||
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||
)
|
||||
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
|
||||
export const publishRealtimeMessageChanges = async (
|
||||
redis: RedisConnector['client'],
|
||||
profileName: string,
|
||||
mailboxes: readonly number[]
|
||||
mailboxes: readonly number[],
|
||||
diplomacyMailboxes: readonly number[] = []
|
||||
): Promise<void> => {
|
||||
if (mailboxes.length === 0) return;
|
||||
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
|
||||
await publishRealtimeEvent(redis, profileName, {
|
||||
type: 'messagesChanged',
|
||||
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||
...(diplomacyMailboxes.length > 0
|
||||
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
|
||||
await sendMessage(store, { ...draft, msgType: 'national' });
|
||||
}
|
||||
|
||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
|
||||
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||
};
|
||||
|
||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||
|
||||
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
|
||||
clockMode: gameTime.mode ?? 'realtime',
|
||||
clockRunning: gameTime.running,
|
||||
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||
clockRecovery: gameTime.recovery ?? null,
|
||||
turnEngineRunning,
|
||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||
npcMode: worldState.config.npcMode ?? 0,
|
||||
|
||||
@@ -49,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
|
||||
return {
|
||||
...message,
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { ...(message.option ?? {}), permissionRedacted: true },
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -76,9 +77,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||
return value === true || value === 1 || value === '1';
|
||||
};
|
||||
|
||||
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
|
||||
const markMessageMailboxes = (
|
||||
ctx: Pick<GameApiContext, 'changeJournal'>,
|
||||
mailboxes: Iterable<number>,
|
||||
msgType?: MessageType
|
||||
): void => {
|
||||
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
|
||||
for (const mailbox of mailboxes) {
|
||||
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
||||
ctx.changeJournal?.mark(domain, mailbox);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,7 +321,11 @@ export const messagesRouter = router({
|
||||
message.msgType === 'national'
|
||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||
: null;
|
||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||
markMessageMailboxes(
|
||||
ctx,
|
||||
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
|
||||
message.msgType
|
||||
);
|
||||
return { ok: true, deletedIds };
|
||||
}),
|
||||
respond: engineAuthedProcedure
|
||||
@@ -333,6 +343,9 @@ export const messagesRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||
}
|
||||
const action = message.payload.option?.action;
|
||||
if (message.payload.option?.invalid === true) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
||||
}
|
||||
if (action === 'scout' || action === 'raiseInvader') {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
@@ -620,7 +633,7 @@ export const messagesRouter = router({
|
||||
: msgType === 'private'
|
||||
? general.id
|
||||
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
|
||||
|
||||
return { msgType, msgId: result.receiverId };
|
||||
}),
|
||||
|
||||
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
injury: general.injury,
|
||||
baseStats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
stats: {
|
||||
leadership: woundedStat(general.leadership, general.injury),
|
||||
strength: woundedStat(general.strength, general.injury),
|
||||
|
||||
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
|
||||
};
|
||||
|
||||
const resolveCommandEnv = (
|
||||
config: Record<string, unknown>
|
||||
config: Record<string, unknown>,
|
||||
worldMeta: Record<string, unknown>
|
||||
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
||||
const constValues = asRecord(config.const ?? config.consts);
|
||||
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
return {
|
||||
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
develCost: resolveNumberFromKeys(
|
||||
worldMeta,
|
||||
['develcost', 'develCost', 'develrate'],
|
||||
configuredDevelCost
|
||||
),
|
||||
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
||||
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
||||
};
|
||||
@@ -409,7 +415,7 @@ export const npcRouter = router({
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const stat = resolveScenarioStat(config);
|
||||
const env = resolveCommandEnv(config);
|
||||
const env = resolveCommandEnv(config, worldMeta);
|
||||
const unitSetName = resolveUnitSetName(config, 'che');
|
||||
const nationTech = readNumber(nation.tech, 0);
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
shouldReloadRealtimeViewerIdentity,
|
||||
toPublicRealtimeEvent,
|
||||
} from './realtime/publicEvent.js';
|
||||
import { resolveNationPermission } from './router/nation/shared.js';
|
||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
||||
@@ -313,11 +314,24 @@ export const createGameApiServer = async () => {
|
||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||
const general = await postgres.prisma.general.findFirst({
|
||||
where: { userId: auth.user.id, npcState: 0 },
|
||||
select: { id: true, cityId: true, nationId: true },
|
||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||
});
|
||||
return general
|
||||
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
|
||||
: { generalId: null, cityId: null, nationId: null };
|
||||
if (!general) {
|
||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||
}
|
||||
const nation =
|
||||
general.nationId > 0
|
||||
? await postgres.prisma.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
generalId: general.id,
|
||||
cityId: general.cityId,
|
||||
nationId: general.nationId,
|
||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||
};
|
||||
};
|
||||
let viewerIdentity = await loadViewerIdentity();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
GameClock,
|
||||
readTurnRecovery,
|
||||
inferClockPhase,
|
||||
parseGameClockPhase,
|
||||
type GameClockMode,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { readTurnRuntimeReady } from '@sammo-ts/infra';
|
||||
|
||||
export interface CurrentGameTime {
|
||||
now: Date;
|
||||
@@ -17,6 +19,8 @@ export interface CurrentGameTime {
|
||||
revision?: number | null;
|
||||
deadlineGeneration?: number | null;
|
||||
running: boolean;
|
||||
runtimeReady?: boolean;
|
||||
recovery?: { startsAt: string; endsAt: string } | null;
|
||||
startsAt: Date | null;
|
||||
dateToTick(date: Date): number | null;
|
||||
}
|
||||
@@ -43,6 +47,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
clockTick: true,
|
||||
clockMode: true,
|
||||
clockWallAnchor: true,
|
||||
clockRecoveryStartTick: true,
|
||||
clockRecoveryEndTick: true,
|
||||
clockRecoveryStartWallAt: true,
|
||||
tickSeconds: true,
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
@@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
tick: storedTick,
|
||||
mode,
|
||||
wallAnchor: state.clockWallAnchor,
|
||||
recovery: readTurnRecovery(state),
|
||||
turnSeconds: state.tickSeconds,
|
||||
phase,
|
||||
revision,
|
||||
});
|
||||
const tick = clock.nowTick(wallNow);
|
||||
const running = phase === 'RUNNING' && mode === 'realtime';
|
||||
const runtimeReady =
|
||||
phase !== 'RUNNING' ||
|
||||
mode !== 'realtime' ||
|
||||
state.clockRecoveryStartTick === undefined ||
|
||||
(await readTurnRuntimeReady(db, state.clockRevision));
|
||||
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
|
||||
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
|
||||
return {
|
||||
now: clock.tickToDate(tick),
|
||||
wallNow,
|
||||
@@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
||||
revision,
|
||||
deadlineGeneration,
|
||||
running,
|
||||
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
|
||||
runtimeReady,
|
||||
recovery:
|
||||
clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow
|
||||
? {
|
||||
startsAt: clock.recovery.startWallAt.toISOString(),
|
||||
endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(),
|
||||
}
|
||||
: null,
|
||||
startsAt:
|
||||
phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null,
|
||||
dateToTick: (date) => clock.dateToTick(date),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ const ensureRedisClockFence = async (
|
||||
allowedPhases: readonly GameClockPhase[]
|
||||
): Promise<ActiveRedisClockFence | null> => {
|
||||
if (
|
||||
gameTime.runtimeReady === false ||
|
||||
!gameTime.phase ||
|
||||
!allowedPhases.includes(gameTime.phase) ||
|
||||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
|
||||
@@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async (
|
||||
profileName: string,
|
||||
gameTime: CurrentGameTime
|
||||
): Promise<ActiveRedisClockFence | null> => {
|
||||
if (!gameTime.running) return null;
|
||||
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
|
||||
};
|
||||
|
||||
|
||||
@@ -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자 이하여야 합니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -277,15 +277,33 @@ describe('tournament router permissions and mutations', () => {
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set('sammo:che:default:tournament:participants', '[]');
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
|
||||
);
|
||||
const context = buildContext({
|
||||
redis,
|
||||
transport,
|
||||
generals: [general],
|
||||
userId: 'user-1',
|
||||
develCost: 200,
|
||||
requestId: 'http:tournament-join',
|
||||
});
|
||||
const outerApiTransaction = vi.fn(async () => {
|
||||
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
|
||||
});
|
||||
Object.assign(context.db, { $transaction: outerApiTransaction });
|
||||
const caller = appRouter.createCaller(context);
|
||||
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||
reason: 'tournamentJoin',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
|
||||
const snapshot = await caller.tournament.getSnapshot();
|
||||
expect(snapshot.participants).toHaveLength(1);
|
||||
|
||||
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
|
||||
const fixture = buildContext({
|
||||
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
|
||||
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
|
||||
result: null,
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).troop.getList();
|
||||
|
||||
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
|
||||
});
|
||||
|
||||
it('creates a troop only for the general owned by the authenticated user', async () => {
|
||||
const { context, requestCommand } = buildContext({
|
||||
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -72,11 +72,13 @@ export interface TurnStateStore {
|
||||
phase?: GameClockPhase;
|
||||
revision?: number;
|
||||
deadlineGeneration?: number;
|
||||
startsAt?: Date;
|
||||
}>;
|
||||
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
|
||||
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
||||
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||
projectGameDeadline?(gameTime: Date): Promise<Date>;
|
||||
}
|
||||
|
||||
export interface TurnDaemonControlQueue {
|
||||
|
||||
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
|
||||
return { ...resultFromPersisted(created), alreadyApplied: false };
|
||||
};
|
||||
|
||||
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => {
|
||||
export const cancelGame = async (
|
||||
request: GameCancellationRequest,
|
||||
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
|
||||
): Promise<GameCancellationResult> => {
|
||||
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
|
||||
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
|
||||
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
|
||||
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
|
||||
earnedPoint: 0,
|
||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: request.databaseUrl });
|
||||
const connector = connectorFactory({ url: request.databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
return await connector.prisma.$transaction(
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
|
||||
generalPoolOptions?: GeneralPoolLoaderOptions;
|
||||
resetTables?: boolean;
|
||||
now?: Date;
|
||||
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
|
||||
wallNow?: Date;
|
||||
tickSeconds?: number;
|
||||
gameClockMode?: GameClockMode;
|
||||
installOptions?: ScenarioInstallOptions;
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -70,7 +70,12 @@ import {
|
||||
} from './selectPoolService.js';
|
||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
import {
|
||||
buildPrestartDeleteAfter,
|
||||
formatPrestartDeleteAfter,
|
||||
readPrestartDeleteAfter,
|
||||
hasStartedForPrestartActions,
|
||||
} from './prestartDeletion.js';
|
||||
import { respondToActionableMessage } from './actionableMessageResponse.js';
|
||||
import { executeInheritanceAction } from './inheritanceActionService.js';
|
||||
import { applyNpcPolicyMutation } from './npcPolicyMutation.js';
|
||||
@@ -344,8 +349,14 @@ async function handleJoinCreateGeneral(
|
||||
throw new Error('Join world state is missing.');
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
||||
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||
const turnScheduleAt = ctx.world.gameTickToDate(
|
||||
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
|
||||
);
|
||||
try {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
@@ -463,7 +474,10 @@ async function handleSelectPoolCreate(
|
||||
throw new Error('selectPoolCreate requires an authoritative daemon processing game tick.');
|
||||
}
|
||||
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
|
||||
const turnScheduleAt = acceptedAt;
|
||||
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
|
||||
const turnScheduleAt = ctx.world.gameTickToDate(
|
||||
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
|
||||
);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -1607,8 +1621,7 @@ async function handleEnsureDieOnPrestartStatus(
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const worldState = ctx.world.getState();
|
||||
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
|
||||
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
|
||||
if (hasStartedForPrestartActions(worldState) || general.nationId !== 0) {
|
||||
return {
|
||||
type: 'ensureDieOnPrestartStatus',
|
||||
generalId: command.generalId,
|
||||
@@ -1643,8 +1656,7 @@ async function handleDieOnPrestart(
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const worldState = world.getState();
|
||||
const opentime = worldState.meta.opentime as string | undefined;
|
||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
||||
if (hasStartedForPrestartActions(worldState)) {
|
||||
return {
|
||||
type: 'dieOnPrestart',
|
||||
ok: false,
|
||||
@@ -1709,8 +1721,7 @@ async function handleBuildNationCandidate(
|
||||
await assertImmediateGeneralActionActor(ctx, command, general);
|
||||
|
||||
const worldState = world.getState();
|
||||
const opentime = worldState.meta.opentime as string | undefined;
|
||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
||||
if (hasStartedForPrestartActions(worldState)) {
|
||||
return {
|
||||
type: 'buildNationCandidate',
|
||||
ok: false,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
GameClock,
|
||||
readTurnRecovery,
|
||||
asRecord,
|
||||
inferClockPhase,
|
||||
isRecord,
|
||||
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
worldState.clockWallAnchor !== null &&
|
||||
worldState.lastTurnTick !== null;
|
||||
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
|
||||
const clockPhase = hasPersistedClock
|
||||
? parseGameClockPhase(worldState.clockPhase)
|
||||
: inferClockPhase(clockMode);
|
||||
const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
|
||||
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
|
||||
const deadlineGeneration = toSafeTick(
|
||||
worldState.deadlineGeneration,
|
||||
'world_state.deadline_generation'
|
||||
);
|
||||
const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
|
||||
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
|
||||
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
|
||||
const bootstrapClock = new GameClock({
|
||||
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
tick: 0,
|
||||
mode: clockMode,
|
||||
wallAnchor: clockWallAnchor,
|
||||
recovery: readTurnRecovery(worldState),
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
phase: clockPhase,
|
||||
revision: clockRevision,
|
||||
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
|
||||
mode: clockMode,
|
||||
wallAnchor: clockWallAnchor,
|
||||
recovery: readTurnRecovery(worldState),
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
phase: clockPhase,
|
||||
revision: clockRevision,
|
||||
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
clockTick: gameClock.tick,
|
||||
clockMode,
|
||||
clockWallAnchor: gameClock.wallAnchor,
|
||||
clockRecovery: gameClock.recovery,
|
||||
lastTurnTick,
|
||||
clockPhase,
|
||||
clockRevision,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
|
||||
import {
|
||||
buildPrestartDeleteAfter,
|
||||
formatPrestartDeleteAfter,
|
||||
hasStartedForPrestartActions,
|
||||
} from '../src/turn/prestartDeletion.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
@@ -59,6 +63,8 @@ const buildFixture = (options: {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
|
||||
lastTurnTick:
|
||||
options.lastTurnTime && options.lastTurnTime > new Date('2026-08-01T00:00:00.000Z') ? 36_000_000 : 0,
|
||||
meta: { opentime: '2026-08-01T00:00:00.000Z' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
@@ -118,6 +124,21 @@ const buildFixture = (options: {
|
||||
};
|
||||
|
||||
describe('pre-start general deletion', () => {
|
||||
it('uses the opening phase and executed tick despite shifted or ancient display projections', () => {
|
||||
const state = { lastTurnTime: new Date('2099-01-01T00:00:00Z'), meta: { opentime: '2026-01-01T00:00:00Z' } };
|
||||
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'PREOPEN', lastTurnTick: 0 })).toBe(false);
|
||||
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'RUNNING', lastTurnTick: 0 })).toBe(false);
|
||||
expect(
|
||||
hasStartedForPrestartActions({
|
||||
...state,
|
||||
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||
clockPhase: 'RUNNING',
|
||||
lastTurnTick: 1,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasStartedForPrestartActions({ ...state, clockPhase: 'COMPLETED', lastTurnTick: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
|
||||
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { City, MapDefinition } from '@sammo-ts/logic';
|
||||
|
||||
import { LARGE_TEST_MAP } from './largeTestMap.js';
|
||||
|
||||
const COMPACT_CITY_IDS = new Set([1, 2, 3, 4, 5]);
|
||||
|
||||
export const COMPACT_NPC_TEST_MAP: MapDefinition = {
|
||||
id: 'compact_npc_test_map',
|
||||
name: 'NPC 장기 시뮬레이션용 소형 맵',
|
||||
cities: LARGE_TEST_MAP.cities
|
||||
.filter((city) => COMPACT_CITY_IDS.has(city.id))
|
||||
.map((city) => ({
|
||||
...city,
|
||||
connections: city.connections.filter((cityId) => COMPACT_CITY_IDS.has(cityId)),
|
||||
})),
|
||||
defaults: { ...LARGE_TEST_MAP.defaults },
|
||||
};
|
||||
|
||||
export const buildCompactNpcTestCities = (): City[] =>
|
||||
COMPACT_NPC_TEST_MAP.cities.map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
nationId: 0,
|
||||
level: city.level,
|
||||
state: 0,
|
||||
population: city.initial.population,
|
||||
populationMax: city.max.population,
|
||||
agriculture: city.initial.agriculture,
|
||||
agricultureMax: city.max.agriculture,
|
||||
commerce: city.initial.commerce,
|
||||
commerceMax: city.max.commerce,
|
||||
security: city.initial.security,
|
||||
securityMax: city.max.security,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: city.initial.defence,
|
||||
defenceMax: city.max.defence,
|
||||
wall: city.initial.wall,
|
||||
wallMax: city.max.wall,
|
||||
meta: { trust: 95 },
|
||||
}));
|
||||
@@ -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');
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"><{{ message.time }}></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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user