Author SHA1 Message Date
Hide_D 91b2980e89 보급 차단 시점 계약을 회귀 테스트로 고정한다
출병 점령 그 순간에는 보급이 즉시 끊기지 않고(che_출병/aftermath는 점령
도시와 긴급천도 신수도에만 supply=1을 쓴다), 차단(切断)은 월 경계의
UpdateCitySupply(pre_month)에서 일어난다는 Ref 계승 계약을 테스트로
고정한다. 월 경계 연감 스냅샷은 Ref #230(d17b2518) 이후 UpdateCitySupply
뒤에 찍히므로 끝난 달의 연감 지도에는 이미 고립이 반영된다. 10월중 점령 ->
11월 경계 차단 -> 10월 연감 반영 순서를 검증한다.
2026-09-03 02:48:39 +00:00
Hide_D 1a2c7ba775 접속 집계 교착으로 인한 턴 정지를 방지 2026-09-03 01:58:45 +00:00
Hide_D 97a8b1daef 가오픈 장수 턴의 오픈 전 적체 방지 2026-09-03 00:36:56 +00:00
Hide_D b1984669d4 공백지 출병 시 재야 장수의 수비 참여를 차단
defenderGenerals 선정이 nationId === defenderCity.nationId만 비교해
공백지(nationId 0)와 재야 장수(nationId 0)가 0 === 0으로 같은 세력으로
판정되었다. nationId 0는 실제 국가가 아니라 무소속을 뜻하므로
general.nationId !== 0 조건을 추가해 war/aftermath.ts의 수비국 장수
조건과 같은 의미로 맞춘다. 공백지 공격/공성/점령과 경로 탐색의 공백지
허용은 그대로 두고, 병력·훈련·사기를 갖춘 재야 장수는 수비하지 않고
국가 소속 수비 장수는 계속 수비함을 회귀 테스트로 고정한다.
2026-09-02 14:59:00 +00:00
Hide_D 77299ffe41 랜덤 임관 기수의 등용 실행을 차단 2026-09-01 23:39:14 +00:00
Hide_D 89a33a8349 턴 실패 뒤 중복 휴식 로그를 제거한다 2026-09-01 13:24:37 +00:00
Hide_D 76da41a00b fix(test): 사령턴 병종 fixture 계약을 보완 2026-09-01 10:52:03 +00:00
Hide_D 63e29743d9 fix(game-ui): 명장일람 유니크 아이템 설명 복구 2026-09-01 05:55:27 +00:00
Hide_D 426e1127ef fix: 사령턴 이후 자율행동 상태를 갱신한다 2026-09-01 05:35:46 +00:00
Hide_D 9c82eb0cf5 fix: 빙의 전용 화면과 가오픈 갱신을 보정한다 2026-09-01 02:38:02 +00:00
Hide_D dd84e6d6b3 내무부 모바일 정렬과 스크롤을 바로잡음 2026-08-31 23:34:33 +00:00
Hide_D 8e0e7291de 내무부 500px 레이아웃을 바로잡음 2026-08-31 18:50:13 +00:00
Hide_D 50a443d94b 인사부 후보 정렬과 추방 확인 개선 2026-08-31 18:42:33 +00:00
Hide_D bf13dabc01 독립 페이지 하단 버튼 크기를 통일 2026-08-31 14:55:52 +00:00
Hide_D 7080ae5504 징병 모병 병종 활성화 조건을 표시 2026-08-31 14:29:40 +00:00
Hide_D 96f6d85cf4 개선: 토너먼트 베팅 입력 가독성을 높임 2026-08-31 13:54:29 +00:00
Hide_D 3ef86448d7 fix: 커맨드 실패 결과 색상 복구 2026-08-31 11:43:56 +00:00
Hide_D bd826b1ff8 수정: 인사 명령에서 유저장을 먼저 정렬 2026-08-31 11:40:16 +00:00
Hide_D 0bc4be9a13 fix:장수 생성 순서 버튼 변경 2026-08-31 11:29:54 +00:00
Hide_D 898d756036 다중 턴 명령 쿨타임을 불가로 표시 2026-08-31 08:35:46 +00:00
Hide_D 29014e9c2d 도시정보 적국 NPC 소속을 표시 2026-08-31 05:07:47 +00:00
Hide_D 99a477b47e fix(tooling): lint 검사를 저메모리 하이브리드로 전환 2026-08-31 04:58:57 +00:00
Hide_D 8d88603305 fix(tooling): ESLint 메모리 사용량을 제한 2026-08-31 04:25:17 +00:00
Hide_D d4e83661ca fix(game-ui): 인사 명령 장수 선택 정보를 정리 2026-08-31 02:49:36 +00:00
Hide_D 70a4980a31 fix(game-ui): 증축 감축을 즉시 예약 2026-08-31 00:44:43 +00:00
Hide_D f3eaab1fa9 예약 커맨드 비용 표시 복원 2026-08-30 23:35:23 +00:00
Hide_D 71b9449903 지도 도시 포인터 대상을 아이콘으로 한정 2026-08-30 22:30:07 +00:00
Hide_D 6162e56583 fix(game-ui): 로비 새 탭 이동을 지원한다 2026-08-30 17:22:19 +00:00
Hide_D fa7898b6a2 fix(game-ui): 징병 모병 최종 자금을 표시한다 2026-08-30 17:15:40 +00:00
Hide_D c769d915a6 fix(game-ui): 메인 지도 연도 제한 안내를 복원한다 2026-08-30 12:57:48 +00:00
Hide_D 4e4dff55a1 fix: 세력일람 NPC 기호 중복 표시 수정 2026-08-30 12:40:54 +00:00
Hide_D dd66da14f4 장수 목록 기본 정렬 순서를 통일 2026-08-30 12:19:17 +00:00
Hide_D c56610b179 test: 현재 실패한 월간 경계 검증을 교정한다
Ref의 반복 월 경계 처리와 Core의 장기 backlog 재개 순서를 반영하고, 제거된 시나리오 catalog 검사에서 남은 미사용 선언을 정리한다.
2026-08-30 07:50:39 +00:00
Hide_D 24e57ee94c fix: 불가침 제의 기한을 20년으로 제한 2026-08-30 07:24:33 +00:00
Hide_D d68209879b 카카오 OTP 만료 시각을 한국 시간으로 표시 2026-08-30 01:37:58 +00:00
Hide_D 7377aa62ed 사령부 턴 시각을 UTC+9로 표시 2026-08-30 01:35:32 +00:00
Hide_D 11fca1ed8b 사령부 지도 입력창 너비를 통일 2026-08-30 01:27:06 +00:00
Hide_D 254d321e8b fix(game-ui): 세력일람 병력을 추정 정보로 교체 2026-08-29 14:43:52 +00:00
Hide_D 52a75ca9b7 CHE 아이템 상태 동결로 인한 턴 정지 수정 2026-08-29 08:56:40 +00:00
Hide_D 23678df9de fix(game-ui): CR 테마에 맞춰 도시 배경 크기 조정 2026-08-29 04:36:49 +00:00
Hide_D 95d5246d4b fix(game-ui): 턴 이동 버튼 너비를 채움 2026-08-29 04:23:52 +00:00
Hide_D 342994f77b 빌드와 테스트 간단 출력을 기본으로 전환 2026-08-29 04:18:43 +00:00
Hide_D 71ee529d35 fix(game-ui): 천통국 베팅 금액 초안을 유지 2026-08-29 03:31:00 +00:00
Hide_D 20a2812d65 feat(game-ui): 세력일람에 총 병사를 표시 2026-08-29 03:30:55 +00:00
Hide_D 26952fcdc7 fix(game-ui): 메인 턴과 장수 상태 표시를 보완 2026-08-29 03:30:50 +00:00
Hide_D fa9fe71539 feat: 예약 명령 처리 로직 개선 및 완료 콜백 추가 2026-08-28 15:55:39 +00:00
Hide_D e6873f813a fix: 내 정보&설정에서 설정 저장 로직을 개선하고 불필요한 속성 제거 2026-08-28 14:18:06 +00:00
Hide_D b251120e45 fix(test): monthlyCatalogCoverage 테스트에서 불필요한 시나리오 리소스 검증 제거 2026-08-28 14:14:56 +00:00
Hide_D eb755757ec fix: 자동 외교 설정을 제거하고 관련된 모든 부분을 업데이트 2026-08-28 13:58:18 +00:00
Hide_D ddbfa0afa9 fix(game-ui): iPhone Safari 확인 조작을 안정화 2026-08-28 12:25:57 +00:00
Hide_D b45fe5ba39 Merge branch 'fix/general-turn-repeat-12-20260828' 2026-08-28 11:21:38 +00:00
Hide_D 59f0a051d8 fix: 메인 턴 반복 범위를 12턴으로 복원한다 2026-08-28 11:20:16 +00:00
Hide_D 8ad10e6b2c docs: 출시 후 Ref 비교 기준을 개념 계약으로 조정 2026-08-28 07:07:06 +00:00
Hide_D c741ef4be5 fix: 메인 모바일 겹침과 전용 아이콘 배경을 바로잡는다 2026-08-28 06:56:44 +00:00
Hide_D f43a1db136 fix: 자동 참모 교체 시 기존 참모를 해임한다 2026-08-28 06:20:13 +00:00
Hide_D ab0f1b9cd5 fix(game-ui): 턴 입력기 모드를 서버별로 기억한다 2026-08-28 01:12:34 +00:00
Hide_D ca3e028603 merge: 유산 설명 줄바꿈 복원을 main에 반영 2026-08-27 18:19:20 +00:00
Hide_D 212c13bf16 fix: 유산 설명의 줄바꿈을 복원
전투 특기와 유니크 경매 설명의 레거시 br 표기를 안전한 줄 단위 DOM으로 변환한다. Chromium 회귀로 실제 줄바꿈과 리터럴 태그 비노출을 검증한다.
2026-08-27 18:17:15 +00:00
Hide_D 4b06330753 fix(game-ui): 모바일 자율 행동을 한 줄로 표시한다 2026-08-27 18:14:32 +00:00
Hide_D 6a60882005 merge: 메인 자율행동 표시를 main에 반영한다 2026-08-27 16:38:02 +00:00
Hide_D 9e5e580134 feat: 메인에 자율행동 설정을 표시한다
Ref와 Gateway의 옵션 순서와 유효 기간 문구를 따라 8열 마지막 셀에 상태를 더한다.\n활성 상태의 hover와 keyboard focus에서 상세 tooltip을 보이고, 비활성 상태는 표준으로 표시한다.
2026-08-27 16:37:47 +00:00
Hide_D 68870be5e5 merge: 모바일 국가색 선택기를 통합한다 2026-08-27 15:44:28 +00:00
Hide_D 7c07742690 fix(game-ui): 모바일 국가색 선택기를 추가한다
건국과 국기 변경의 색상 입력을 앱 내부 listbox로 렌더링해 모바일 OS 선택기에서도 항목별 국가색을 직접 확인할 수 있게 한다. 키보드 탐색과 닫기 동작, 대비색, 터치 행 크기와 예약 payload를 Chromium 회귀로 고정한다.
2026-08-27 15:43:01 +00:00
184 changed files with 5901 additions and 920 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "off"
},
"plugins": ["typescript"],
"options": {
"typeAware": true,
"respectEslintDisableDirectives": true
},
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": {
"arguments": false,
"attributes": false
}
}
]
},
"ignorePatterns": [
"**/dist/**",
"**/node_modules/**",
"**/*.d.ts",
"**/*.js",
"**/*.mjs",
"**/*.cjs",
"**/*.vue",
"legacy/**",
"packages/infra/prisma/client/**",
"**/generated/**",
".pnpm-store/**"
]
}
+33 -9
View File
@@ -6,9 +6,10 @@
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간 `AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
지침 순으로 적용합니다. 지침 순으로 적용합니다.
목표는 `../ref/sam`의 PHP 서비스를 TypeScript 런타임으로 호환 이관하는 `../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
것입니다. 내부 구조를 더 깔끔하게 만드는 일보다 기존 결과, 상태 전이, 권한, 기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
화면과 운영 경계를 보존하는 일이 우선입니다. 이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
## 현재 기준과 저장소 경계 ## 현재 기준과 저장소 경계
@@ -41,7 +42,9 @@ commit 또는 삭제를 하지 말아 주세요.
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고 판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
판정하지 말아 주세요. 판정하지 말아 주세요.
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요. - 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
확정하지 않습니다.
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는 - green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
운영 장애 복구를 자동으로 증명하지 않습니다. 운영 장애 복구를 자동으로 증명하지 않습니다.
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다. - 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
@@ -95,6 +98,20 @@ priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준 `의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요. commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
Ref 비교 전에 대상 계약도 구분해 주세요.
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
ref 계측이 필요하면 다음을 지켜 주세요. ref 계측이 필요하면 다음을 지켜 주세요.
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요. - `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
@@ -105,7 +122,7 @@ ref 계측이 필요하면 다음을 지켜 주세요.
확인해 주세요. 확인해 주세요.
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요. - ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
## 호환성 우선순위 ## 계승 계약의 호환성 우선순위
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서 1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한 2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
@@ -113,10 +130,12 @@ ref 계측이 필요하면 다음을 지켜 주세요.
4. 문구, 로그, 화면 흐름과 룩앤필 4. 문구, 로그, 화면 흐름과 룩앤필
5. 내부 구현 세부사항 5. 내부 구현 세부사항
레거시의 이상해 보이는 동작도 계약일 수 있습니다. 보안 또는 데이터 손상 위험이 레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
아니면 우선 같은 동작을 재현하고 개선 제안은 분리해 주세요. 허용하는 차이는 위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
사용자 경험, 저장 상태와 후속 턴 결과에 영향이 없다는 근거를 mapping/report에 차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
남겨 주세요. 상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
이름만으로 완화하지 않습니다.
## 인증과 권한 ## 인증과 권한
@@ -282,6 +301,11 @@ pnpm test
pnpm build pnpm build
``` ```
루트 `pnpm test``pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요. - 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해 - package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra 주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
+11
View File
@@ -175,6 +175,17 @@ pnpm build
pnpm test:integration pnpm test:integration
``` ```
`pnpm lint`는 ESLint의 일반 TypeScript/Vue 검사, Oxlint의 type-aware TypeScript
Promise 검사, ESLint의 type-aware Vue Promise 검사를 메모리 경계에 맞게 나누어
실행합니다. cache를 제외한 전체 검증은 `pnpm lint:uncached`, package 하나만
검사할 때는 기존처럼 `pnpm --filter <package-name> lint`를 사용합니다.
루트 `test``build`는 Codex와 일반 개발 검증에서 성공 로그를 줄이는 간단 출력이
기본입니다. 테스트별 진행과 전체 Turbo task 로그가 필요한 실패 진단에는
`pnpm test-verbose`, `pnpm build-verbose`를 사용합니다. Gateway 서버 빌드 워커는
관리자 operation 화면에 진행 상황을 제공하기 위해 별도의 streaming 상세 출력
정책을 계속 사용합니다.
PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다. PostgreSQL·Redis 조건부 suite는 격리된 서비스를 준비한 뒤 실행합니다.
```sh ```sh
+2 -2
View File
@@ -20,8 +20,8 @@
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js", "worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js",
"worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-api",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-api",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json" "typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
}, },
+8 -9
View File
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
return started && !ended; return started && !ended;
}; };
const zGeneralSettings = z.object({ const zGeneralSettings = z.strictObject({
tnmt: z.number().int().optional(), tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(), defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(), use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
use_auto_nation_war: z.number().int().min(0).max(1).optional(), use_auto_nation_war: z.number().int().min(0).max(1).optional(),
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(), use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
use_auto_nation_finance: z.number().int().min(0).max(1).optional(), use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
@@ -244,7 +243,6 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다. // Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다. // 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0), use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0), use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0), use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
troopLeaderFirstTurn, troopLeaderFirstTurn,
accessLog, accessLog,
rankRows, rankRows,
gameTime,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true }, select: { type: true, value: true },
}), }),
loadCurrentGameTime(ctx.db),
]); ]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
parsedLastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
loadCurrentGameTime(ctx.db, now), loadCurrentGameTime(ctx.db, now),
]); ]);
// vote_poll timestamps are logical game-wall values. During PREOPEN the // vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock is held at the future open anchor, so comparing them with // logical clock advances through negative ticks before the opening anchor,
// JavaScript wall time inside a timestamp-without-time-zone predicate can // so comparing them with JavaScript wall time inside a timestamp-without-time-zone
// hide an otherwise active poll. Resolve the same tick-first clock contract // predicate can hide an otherwise active poll. Resolve the same tick-first
// used by voting instead; hasVoted only affects the notice, not activity. // clock contract used by voting; hasVoted only affects the notice, not activity.
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null; const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId); const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
+3 -1
View File
@@ -302,6 +302,7 @@ export const joinRouter = router({
} }
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
const configConst = asRecord(config.const); const configConst = asRecord(config.const);
const availableSpecialWar = asStringArray(configConst.availableSpecialWar); const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
@@ -348,7 +349,8 @@ export const joinRouter = router({
return { return {
rules: { rules: {
stat: resolveJoinStat(worldState), stat: resolveJoinStat(worldState),
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0, allowDirectCreation: (blockGeneralCreate & 1) === 0,
allowCustomName: (blockGeneralCreate & 2) === 0,
}, },
user: { user: {
id: ctx.auth?.user.id ?? '', id: ctx.auth?.user.id ?? '',
+1
View File
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
: null, : null,
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
npcPossessionEnabled: worldState.config.npcMode === 1, npcPossessionEnabled: worldState.config.npcMode === 1,
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '', scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
myGeneral, myGeneral,
+4 -12
View File
@@ -23,6 +23,7 @@ import {
resolveRefreshScoreText, resolveRefreshScoreText,
resolveRemainingMinutes, resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js'; } from '../../services/generalBasicCardProjection.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -72,7 +73,7 @@ export const troopRouter = router({
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
} }
const [nation, troops, generals, cities, worldState] = await Promise.all([ const [nation, troops, generals, cities, worldState, gameTime] = await Promise.all([
ctx.db.nation.findUnique({ ctx.db.nation.findUnique({
where: { id: me.nationId }, where: { id: me.nationId },
select: { id: true, name: true, color: true, level: true, meta: true }, select: { id: true, name: true, color: true, level: true, meta: true },
@@ -121,6 +122,7 @@ export const troopRouter = router({
select: { id: true, name: true }, select: { id: true, name: true },
}), }),
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }), ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
loadCurrentGameTime(ctx.db),
]); ]);
if (!nation) { if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
@@ -214,15 +216,6 @@ export const troopRouter = router({
const traitName = (code: string, names: Map<string, { name: string }>): string => const traitName = (code: string, names: Map<string, { name: string }>): string =>
names.get(code)?.name ?? sanitizeInternalDisplayCode(code); names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code); const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
const worldMeta = asRecord(worldState?.meta);
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
const lastExecuted =
rawLastExecuted instanceof Date
? rawLastExecuted
: typeof rawLastExecuted === 'string'
? new Date(rawLastExecuted)
: null;
const mappedTroops = troops const mappedTroops = troops
.map((troop) => { .map((troop) => {
const leader = generalMap.get(troop.troopLeaderId); const leader = generalMap.get(troop.troopLeaderId);
@@ -348,8 +341,7 @@ export const troopRouter = router({
killTurn: readNumber(meta.killturn ?? meta.killTurn), killTurn: readNumber(meta.killturn ?? meta.killTurn),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
lastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
troopId: general.troopId, troopId: general.troopId,
troop: { troop: {
+1
View File
@@ -446,6 +446,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
nationGenerals, nationGenerals,
realNationCount: nations.filter((entry) => entry.id > 0).length, realNationCount: nations.filter((entry) => entry.id > 0).length,
inputOptions, inputOptions,
generalActionModules: moduleBundle.general,
}); });
}; };
+87 -1
View File
@@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js';
const zDirectorySort = z.number().int().min(1).max(15).default(9); const zDirectorySort = z.number().int().min(1).max(15).default(9);
const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
const readNumber = (value: unknown, fallback = 0): number => const readNumber = (value: unknown, fallback = 0): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback; typeof value === 'number' && Number.isFinite(value) ? value : fallback;
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
return left < right ? -1 : 1; return left < right ? -1 : 1;
}; };
const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => {
if (leadership < 40) {
return strength + intel < 40 ? '무능' : '무지';
}
const maxStat = Math.max(leadership, strength, intel);
const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership);
if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) {
return '만능';
}
if (strength >= 60 && intel < strength * 0.8) {
return '무';
}
if (intel >= 60 && strength < intel * 0.8) {
return '지';
}
if (leadership >= 60 && strength + intel < leadership) {
return '통';
}
return '평범';
};
const resolveExperienceLevel = (experience: number, maxLevel: number): number => { const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)); const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
return Math.max(0, Math.min(level, maxLevel)); return Math.max(0, Math.min(level, maxLevel));
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
const [nations, generals, cities] = await Promise.all([ const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
ctx.db.nation.findMany({ ctx.db.nation.findMany({
select: { select: {
id: true, id: true,
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
npcState: true, npcState: true,
nationId: true, nationId: true,
cityId: true, cityId: true,
leadership: true,
strength: true,
intel: true,
dedication: true, dedication: true,
officerLevel: true, officerLevel: true,
meta: true, meta: true,
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
select: { id: true, name: true, nationId: true }, select: { id: true, name: true, nationId: true },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}), }),
ctx.db.generalAccessLog.findMany({
select: { generalId: true, refreshScoreTotal: true },
}),
ctx.db.worldState.findFirst({
select: { tickSeconds: true, meta: true },
}),
]); ]);
const directoryNations = nations.some((nation) => nation.id === 0) const directoryNations = nations.some((nation) => nation.id === 0)
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
} }
const citiesByNation = new Map<number, typeof cities>(); const citiesByNation = new Map<number, typeof cities>();
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const)); const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
const accessScoreByGeneralId = new Map(
accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const)
);
const worldMeta = asRecord(worldState?.meta);
const autorunUser = asRecord(worldMeta.autorun_user);
const worldKillturn = readNumber(worldMeta.killturn);
const turnMinutes = readNumber(worldState?.tickSeconds) / 60;
const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0;
const activeKillturnThreshold = worldKillturn - autorunLimitTurns;
for (const city of cities) { for (const city of cities) {
const list = citiesByNation.get(city.nationId) ?? []; const list = citiesByNation.get(city.nationId) ?? [];
list.push(city); list.push(city);
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
false false
), ),
})); }));
const analyzedGenerals = nationGenerals.map((general) => {
const killturn = readMetaNumber(general.meta, 'killturn');
const inactive = general.npcState < 2 && killturn < activeKillturnThreshold;
const accessScore = accessScoreByGeneralId.get(general.id) ?? 0;
return {
id: general.id,
name: general.name,
npcState: general.npcState,
leadership: general.leadership,
type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel),
inactive,
accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal',
killturn,
} as const;
});
const combatGenerals = analyzedGenerals.filter(
(general) => general.type !== '무능' && general.type !== '무지'
);
const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive);
const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5);
const generalGroups = directoryGeneralTypes
.map((type) => ({
type,
label: `${type}`,
generals: analyzedGenerals
.filter((general) => general.type === type)
.sort((left, right) => {
const leftScore = accessScoreByGeneralId.get(left.id) ?? 0;
const rightScore = accessScoreByGeneralId.get(right.id) ?? 0;
return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id;
})
.map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general),
}))
.filter((group) => group.generals.length > 0);
return { return {
id: nation.id, id: nation.id,
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null, rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length), generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
cityCount: nationCities.length, cityCount: nationCities.length,
forceEstimate: {
totalGeneralCount: analyzedGenerals.length,
userCombatGeneralCount: userCombatGenerals.length,
userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
npcCombatGeneralCount: npcCombatGenerals.length,
npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length,
},
generalGroups,
officers, officers,
ambassadorNames: secretPermissions ambassadorNames: secretPermissions
.filter(({ permission }) => permission === 4) .filter(({ permission }) => permission === 4)
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
}; };
export const flushDeferredGeneralAccessBatch = async ( export const flushDeferredGeneralAccessBatch = async (
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>, db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
batchId: string, batchId: string,
entries: readonly DeferredGeneralAccessEntry[] entries: readonly DeferredGeneralAccessEntry[]
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => { ): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
await markBatchProcessed(db, batchId); await markBatchProcessed(db, batchId);
return { states: [], refreshLimit: 0 }; return { states: [], refreshLimit: 0 };
} }
if (!db.$transaction) {
throw new Error('Deferred general access persistence requires transaction support.');
}
const worldState = await db.worldState.findFirst({ const worldState = await db.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { select: {
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
})) }))
); );
const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1; const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1;
const states = await db.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql` const states = await db.$transaction(async (transaction) => {
// The turn flush and synchronous access path must enter through the same
// schema-scoped lock before either traffic or access rows are mutated.
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
return transaction.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
WITH inserted_batch AS ( WITH inserted_batch AS (
INSERT INTO "general_access_batch" ("id") INSERT INTO "general_access_batch" ("id")
VALUES (${batchId}) VALUES (${batchId})
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
END, END,
resolved."weight" resolved."weight"
FROM resolved FROM resolved
ORDER BY resolved."general_id"
ON CONFLICT ("general_id") DO UPDATE SET ON CONFLICT ("general_id") DO UPDATE SET
"user_id" = EXCLUDED."user_id", "user_id" = EXCLUDED."user_id",
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"), "last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
@@ -395,6 +403,7 @@ export const flushDeferredGeneralAccessBatch = async (
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id" LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
ORDER BY actor."id" ORDER BY actor."id"
`); `);
});
return { return {
states, states,
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit), refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
+2 -1
View File
@@ -1,5 +1,5 @@
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js'; import type { GameApiContext } from '../context.js';
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
throw new Error('Traffic access persistence requires transaction support.'); throw new Error('Traffic access persistence requires transaction support.');
} }
await db.$transaction(async (transaction) => { await db.$transaction(async (transaction) => {
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
const periodKey = input.year * 12 + input.month - 1; const periodKey = input.year * 12 + input.month - 1;
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>( const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
GamePrisma.sql` GamePrisma.sql`
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
export const resolveRemainingMinutes = ( export const resolveRemainingMinutes = (
turnTime: Date, turnTime: Date,
lastExecuted: Date | null, currentGameTime: Date | null
turnTermSeconds: number
): number | null => { ): number | null => {
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null; if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
let nextTurnMillis = turnTime.getTime(); return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
if (nextTurnMillis < lastExecuted.getTime()) {
nextTurnMillis += turnTermSeconds * 1_000;
}
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
}; };
export interface NextTurnMonthOffsetInput { export interface NextTurnMonthOffsetInput {
+1
View File
@@ -80,6 +80,7 @@ export interface TurnCommandInputField {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue; constValue?: TurnCommandOptionValue;
options?: TurnCommandOption[]; options?: TurnCommandOption[];
optionSource?: TurnCommandOptionSource; optionSource?: TurnCommandOptionSource;
+145 -11
View File
@@ -18,7 +18,7 @@ import type {
TriggerValue, TriggerValue,
UnitSetDefinition, UnitSetDefinition,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic'; import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
@@ -26,6 +26,7 @@ import { getTechAbility, getTechLevel, isCrewTypeAvailable } from '@sammo-ts/log
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
import { import {
buildTurnCommandInputFields, buildTurnCommandInputFields,
loadTurnCommandSpecs, loadTurnCommandSpecs,
@@ -40,6 +41,7 @@ export interface TurnCommandAvailability {
key: string; key: string;
name: string; name: string;
turnDurationText?: string; turnDurationText?: string;
costText?: string;
reqArg: boolean; reqArg: boolean;
possible: boolean; possible: boolean;
status: AvailabilityStatus; status: AvailabilityStatus;
@@ -326,9 +328,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
const config = asRecord(worldState.config); const config = asRecord(worldState.config);
const constValues = asRecord(config.const); const constValues = asRecord(config.const);
const meta = asRecord(worldState.meta);
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
return { return {
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), // daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0), trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1), trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
@@ -554,7 +559,10 @@ export const buildRecruitmentCommandInfo = (options: {
avoid: crewType.avoid, avoid: crewType.avoid,
baseCost: displayCost.gold, baseCost: displayCost.gold,
baseRice: displayCost.rice, baseRice: displayCost.rice,
info: [...crewType.info], info: [
...crewType.info,
...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean),
],
}; };
}), }),
})); }));
@@ -646,6 +654,40 @@ const evaluateDefinition = (
return evaluateAvailability(constraints, ctx, view, reqArg); return evaluateAvailability(constraints, ctx, view, reqArg);
}; };
const readNextAvailableTurn = (meta: Readonly<Record<string, unknown>>, actionName: string): number | null => {
const raw = meta[`next_execute_${actionName}`];
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string') {
const parsed = Number(raw);
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
};
const evaluateCooldown = (
definition: GeneralActionDefinition,
scope: 'general' | 'nation',
ctx: ConstraintContext,
view: StateView,
currentYearMonth: number
): AvailabilityCore | null => {
const owner =
scope === 'general'
? (view.get({ kind: 'general', id: ctx.actorId }) as General | null)
: ctx.nationId === undefined
? null
: (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null);
if (!owner) return null;
const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name);
if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null;
return {
possible: false,
status: 'blocked',
reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`,
};
};
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore => const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs; AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
@@ -656,18 +698,33 @@ const FOUNDING_COMMAND_KEYS = new Set(['che_건국', 'cr_건국', 'che_무작위
const buildEntries = ( const buildEntries = (
env: CommandEnv, env: CommandEnv,
specs: TurnCommandSpec[], specs: TurnCommandSpec[],
options: { foundingAvailable?: boolean } = {} options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
): CommandEntry[] => { ): CommandEntry[] => {
const entries: CommandEntry[] = []; const entries: CommandEntry[] = [];
for (const spec of specs) { for (const spec of specs) {
const definition = spec.createDefinition(env); const definition = spec.createDefinition(env);
const inputFields = buildTurnCommandInputFields(spec).map((field) => {
if (spec.key !== 'che_불가침제의') return field;
if (field.key === 'year' && options.currentYear !== undefined) {
return {
...field,
min: options.currentYear + 1,
max: resolveNonAggressionMaxEndYear(options.currentYear),
defaultValue: options.currentYear + 1,
};
}
if (field.key === 'month' && options.currentMonth !== undefined) {
return { ...field, defaultValue: options.currentMonth };
}
return field;
});
const entry: CommandEntry = { const entry: CommandEntry = {
category: spec.category, category: spec.category,
definition, definition,
reqArg: spec.reqArg, reqArg: spec.reqArg,
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {}, availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
inputFields: buildTurnCommandInputFields(spec), inputFields,
}; };
if (spec.key === 'che_포상') { if (spec.key === 'che_포상') {
@@ -711,23 +768,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined; return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined;
}; };
const formatCost = (gold: number | undefined, rice: number | undefined): string | undefined => {
const parts = [
gold && gold > 0 ? `${gold.toLocaleString('ko-KR')}` : null,
rice && rice > 0 ? `${rice.toLocaleString('ko-KR')}` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? parts.join(' · ') : undefined;
};
const getCommandCostText = (
entry: CommandEntry,
ctx: ConstraintContext,
view: StateView,
env: CommandEnv
): string | undefined => {
const hint = entry.definition.getCostHint?.(ctx, view);
if (hint?.formula) return hint.formula;
const hintedCost = hint ? formatCost(hint.gold, hint.rice) : undefined;
if (hintedCost) return hintedCost;
const develCost = env.develCost;
const general = view.get({ kind: 'general', id: ctx.actorId }) as General | null;
switch (entry.definition.key) {
case 'che_단련':
case 'che_숙련전환':
return formatCost(develCost, develCost);
case 'che_첩보':
return formatCost(develCost * 3, develCost * 3);
case 'che_이동':
case 'che_인재탐색':
return formatCost(develCost, 0);
case 'che_강행':
return formatCost(develCost * 5, 0);
case 'che_사기진작':
return formatCost(Math.round((general?.crew ?? 0) / 100), 0);
case 'che_출병':
return formatCost(0, Math.round((general?.crew ?? 0) / 100));
case 'che_천도':
return `금·쌀 ${(develCost * 5).toLocaleString('ko-KR')} × 2^거리`;
case 'cr_인구이동':
return `금·쌀 ${develCost.toLocaleString('ko-KR')} × 인구[만]`;
case 'che_증축': {
const cost = develCost * 500 + 60_000;
return formatCost(cost, cost);
}
case 'che_감축': {
const recovery = develCost * 500 + 30_000;
return `${recovery.toLocaleString('ko-KR')} · 쌀 ${recovery.toLocaleString('ko-KR')} 회수`;
}
default:
return undefined;
}
};
const buildGroups = ( const buildGroups = (
entries: CommandEntry[], entries: CommandEntry[],
ctx: ConstraintContext, ctx: ConstraintContext,
view: StateView, view: StateView,
includeTurnDuration = false includeTurnDuration = false,
env: CommandEnv,
scope: 'general' | 'nation',
currentYearMonth: number
): TurnCommandGroup[] => { ): TurnCommandGroup[] => {
const groups = new Map<string, TurnCommandAvailability[]>(); const groups = new Map<string, TurnCommandAvailability[]>();
for (const entry of entries) { for (const entry of entries) {
const availability = entry.evaluate const baseAvailability = entry.evaluate
? entry.evaluate(ctx, view) ? entry.evaluate(ctx, view)
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs); : evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
const availability =
baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown'
? baseAvailability
: (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability);
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined; const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
const costText = getCommandCostText(entry, ctx, view, env);
const value: TurnCommandAvailability = { const value: TurnCommandAvailability = {
key: entry.definition.key, key: entry.definition.key,
name: entry.definition.name, name: entry.definition.name,
...(turnDurationText ? { turnDurationText } : {}), ...(turnDurationText ? { turnDurationText } : {}),
...(costText ? { costText } : {}),
reqArg: entry.reqArg, reqArg: entry.reqArg,
inputFields: entry.inputFields, inputFields: entry.inputFields,
...availability, ...availability,
@@ -785,6 +904,7 @@ export const buildTurnCommandTable = async (options: {
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */ /** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
realNationCount?: number; realNationCount?: number;
inputOptions?: TurnCommandInputOptions; inputOptions?: TurnCommandInputOptions;
generalActionModules?: TurnCommandEnv['generalActionModules'];
}): Promise<TurnCommandTable> => { }): Promise<TurnCommandTable> => {
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다. // 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
const general = mapGeneralRow(options.general); const general = mapGeneralRow(options.general);
@@ -803,7 +923,10 @@ export const buildTurnCommandTable = async (options: {
mode: 'precheck', mode: 'precheck',
}; };
const env = buildCommandEnv(options.worldState); const env = {
...buildCommandEnv(options.worldState),
...(options.generalActionModules ? { generalActionModules: options.generalActionModules } : {}),
};
const scenarioConst = asRecord(options.worldState.config).const; const scenarioConst = asRecord(options.worldState.config).const;
const { const {
general: generalSpecs, general: generalSpecs,
@@ -817,19 +940,30 @@ export const buildTurnCommandTable = async (options: {
? undefined ? undefined
: options.realNationCount < resolveMaxNation(options.worldState), : options.realNationCount < resolveMaxNation(options.worldState),
}); });
const nationEntries = buildEntries(env, nationSpecs); const nationEntries = buildEntries(env, nationSpecs, {
currentYear: options.worldState.currentYear,
currentMonth: options.worldState.currentMonth,
});
const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1;
return { return {
general: buildGroups( general: buildGroups(
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS), projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
ctx, ctx,
view view,
false,
env,
'general',
currentYearMonth
), ),
nation: buildGroups( nation: buildGroups(
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
ctx, ctx,
view, view,
true true,
env,
'nation',
currentYearMonth
), ),
inputOptions: options.inputOptions ?? { inputOptions: options.inputOptions ?? {
cities: [], cities: [],
+14 -5
View File
@@ -56,17 +56,28 @@ export const buildRefGeneralTargetOptions = (options: {
}): RefGeneralTargetOptions => { }): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => { const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined; const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
const cityName = options.cityNames.get(entry.cityId) ?? '재야';
const troopLabel = entry.troopId
? `${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
: '부대 없음';
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id); const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
const isTroopExit = action === 'che_부대탈퇴지시'; const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => {
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})();
const details = [ const details = [
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`, entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`, entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
entry.troopId action === 'che_발령' || action === 'che_포상' || action === 'che_몰수'
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}` ? null
: entry.troopId
? `탑승 부대 ${troopLabel}`
: '탑승 부대 없음', : '탑승 부대 없음',
].filter((value): value is string => Boolean(value)); ].filter((value): value is string => Boolean(value));
if (isTroopExit) { if (isTroopExit) {
@@ -74,9 +85,7 @@ export const buildRefGeneralTargetOptions = (options: {
} }
return { return {
value: entry.id, value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${ label,
options.cityNames.get(entry.cityId) ?? '재야'
})`,
description: details.join(' · '), description: details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }), ...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }), ...(entry.gold === undefined ? {} : { gold: entry.gold }),
+167 -5
View File
@@ -102,6 +102,9 @@ const buildNation = (): NationRow =>
}) as unknown as NationRow; }) as unknown as NationRow;
describe('buildTurnCommandTable', () => { describe('buildTurnCommandTable', () => {
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
it('projects the general and chief reserved-turn categories and command order from Ref', async () => { it('projects the general and chief reserved-turn categories and command order from Ref', async () => {
const table = await buildTurnCommandTable({ const table = await buildTurnCommandTable({
worldState: buildWorldState(), worldState: buildWorldState(),
@@ -211,6 +214,29 @@ describe('buildTurnCommandTable', () => {
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual( expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
expect.objectContaining({ turnDurationText: expect.any(String) }) expect.objectContaining({ turnDurationText: expect.any(String) })
); );
const generalCosts = Object.fromEntries(
table.general.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
);
expect(generalCosts).toMatchObject({
che_농지개간: '금 100',
che_기술연구: '금 100',
che_정착장려: '쌀 200',
che_주민선정: '쌀 200',
che_화계: '금 500 · 쌀 500',
che_첩보: '금 300 · 쌀 300',
che_사기진작: '금 1',
che_출병: '쌀 1',
che_이동: '금 100',
che_강행: '금 500',
});
const nationCosts = Object.fromEntries(
table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText]))
);
expect(nationCosts).toMatchObject({
che_천도: '금·쌀 500 × 2^거리',
che_증축: '금 110,000 · 쌀 110,000',
che_감축: '금 80,000 · 쌀 80,000 회수',
});
}); });
it('projects scenario-specific command categories instead of the default profile', async () => { it('projects scenario-specific command categories instead of the default profile', async () => {
@@ -239,6 +265,13 @@ describe('buildTurnCommandTable', () => {
'che_물자조달', 'che_물자조달',
'cr_맹훈련', 'cr_맹훈련',
]); ]);
expect(
Object.fromEntries(table.nation.flatMap(({ values }) => values.map(({ key, costText }) => [key, costText])))
).toMatchObject({
cr_인구이동: '금·쌀 100 × 인구[만]',
event_대검병연구: '금 50,000 · 쌀 50,000',
event_화륜차연구: '금 100,000 · 쌀 100,000',
});
expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']); expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']);
expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([ expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([
'휴식', '휴식',
@@ -256,6 +289,103 @@ describe('buildTurnCommandTable', () => {
}); });
}); });
it('blocks both speciality resets while their cooldown remains and opens them at the boundary month', async () => {
const cooldownGeneral = {
...buildGeneral(),
specialCode: 'che_상재',
special2Code: 'che_신산',
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 36,
'next_execute_전투 특기 초기화': '37',
},
} as GeneralRow;
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: cooldownGeneral,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
expect(findCommand(table, 'che_내정특기초기화')).toMatchObject({
possible: true,
status: 'available',
});
expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: {
...buildGeneral(),
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 37,
'next_execute_전투 특기 초기화': 37,
},
} as GeneralRow,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) {
expect(findCommand(table, key)).toMatchObject({
possible: false,
status: 'blocked',
reason: '특기가 없습니다.',
});
}
});
it('projects a multi-turn nation command cooldown before target input', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: {
...buildNation(),
meta: { next_execute_피장파장: 37 },
} as NationRow,
nationGenerals: null,
});
expect(findCommand(table, 'che_피장파장')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('projects costs from the current world develcost used by command execution', async () => {
const worldState = buildWorldState();
(worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120;
const table = await buildTurnCommandTable({
worldState,
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const costs = Object.fromEntries(
[...table.general, ...table.nation].flatMap(({ values }) =>
values.map(({ key, costText }) => [key, costText])
)
);
expect(costs).toMatchObject({
che_농지개간: '금 120',
che_화계: '금 600 · 쌀 600',
che_천도: '금·쌀 600 × 2^거리',
che_증축: '금 120,000 · 쌀 120,000',
});
});
it('projects the real 904/905/910/912 world command profiles into the API table', async () => { it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
const buildScenarioTable = async (scenarioId: number) => { const buildScenarioTable = async (scenarioId: number) => {
const scenario = await loadScenarioDefinitionById(scenarioId); const scenario = await loadScenarioDefinitionById(scenarioId);
@@ -337,6 +467,27 @@ describe('buildTurnCommandTable', () => {
} }
}); });
it('projects the Ref twenty-year non-aggression end-year window', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const proposal = table.nation
.flatMap((group) => group.values)
.find((command) => command.key === 'che_불가침제의');
expect(proposal?.inputFields.find((field) => field.key === 'year')).toMatchObject({
kind: 'number',
min: 4,
max: 23,
defaultValue: 4,
});
expect(proposal?.inputFields.find((field) => field.key === 'month')).toMatchObject({ defaultValue: 1 });
});
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
buildTurnCommandTable({ buildTurnCommandTable({
@@ -444,15 +595,17 @@ describe('buildTurnCommandTable', () => {
nationGenerals: null, nationGenerals: null,
}); });
const appointment = table.general for (const commandKey of ['che_임관', 'che_등용']) {
const command = table.general
.flatMap((group) => group.values) .flatMap((group) => group.values)
.find((command) => command.key === 'che_임관'); .find((candidate) => candidate.key === commandKey);
expect(appointment).toMatchObject({ expect(command).toMatchObject({
possible: false, possible: false,
status: 'blocked', status: 'blocked',
reason: '랜덤 임관만 가능합니다', reason: '랜덤 임관만 가능합니다',
}); });
}
}); });
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => { it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
@@ -525,7 +678,11 @@ describe('buildTurnCommandTable', () => {
magicCoef: 0, magicCoef: 0,
cost: 12, cost: 12,
rice: 10, rice: 10,
requirements: [{ type: 'ReqTech', tech: 2000 }], requirements: [
{ type: 'ReqTech', tech: 2000 },
{ type: 'ReqRegions', regions: ['중원'] },
{ type: 'ReqCities', cities: ['TestCity'] },
],
attackCoef: {}, attackCoef: {},
defenceCoef: {}, defenceCoef: {},
info: ['강력하지만 기술이 필요합니다.'], info: ['강력하지만 기술이 필요합니다.'],
@@ -583,7 +740,12 @@ describe('buildTurnCommandTable', () => {
special: true, special: true,
attack: 175, attack: 175,
defence: 225, defence: 225,
info: ['강력하지만 기술이 필요합니다.'], info: [
'강력하지만 기술이 필요합니다.',
'기술력 2000 이상 필요',
'중원 지역 소유시 가능',
'TestCity 소유시 가능',
],
}); });
}); });
}); });
+13 -1
View File
@@ -87,7 +87,19 @@ describe('Ref command general targets', () => {
troopId: 3, troopId: 3,
description: expect.stringContaining('탑승 부대 청룡대'), description: expect.stringContaining('탑승 부대 청룡대'),
}); });
expect(detailed.generalTargets.che_포상?.[0]?.description).toContain('금 5,000 · 쌀 4,000 · 병력 1,000'); expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (부대 없음 · 업)',
'부대원 (청룡대 · 업)',
'부대장 (청룡대 (부대장) · 업)',
]);
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
}); });
}); });
@@ -238,6 +238,7 @@ integration('generic general creation through the durable turn daemon', () => {
it('commits one complete ref-shaped general and survives a daemon reload', async () => { it('commits one complete ref-shaped general and survives a daemon reload', async () => {
const auth = buildAuth(userId, '생성사용자', 4242); const auth = buildAuth(userId, '생성사용자', 4242);
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig(); const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
expect(config.rules.allowDirectCreation).toBe(true);
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔'); expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999'); expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
@@ -114,6 +114,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
}, },
}), }),
}, },
$transaction: db.$transaction.bind(db),
$queryRaw: db.$queryRaw.bind(db), $queryRaw: db.$queryRaw.bind(db),
$executeRaw: db.$executeRaw.bind(db), $executeRaw: db.$executeRaw.bind(db),
}; };
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1), $executeRaw: vi.fn(async () => 1),
}; };
Object.assign(db, {
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
});
await expect( await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [ flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] }); ).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"'); expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset'); expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"'); expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"'); expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
expect(statement.sql).toContain('INSERT INTO "general_access_log"'); expect(statement.sql).toContain('INSERT INTO "general_access_log"');
expect(statement.sql).toContain('ORDER BY resolved."general_id"');
expect(statement.sql).not.toContain('read_model_revision'); expect(statement.sql).not.toContain('read_model_revision');
expect(statement.sql).not.toContain('read_model_outbox'); expect(statement.sql).not.toContain('read_model_outbox');
}); });
+94 -1
View File
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
leadership: 70, leadership: 70,
strength: 60, strength: 60,
intel: 50, intel: 50,
crew: 100,
experience: 1_000, experience: 1_000,
meta: { killturn: 3 }, meta: { killturn: 3 },
penalty: {}, penalty: {},
@@ -124,6 +125,9 @@ const globalGenerals = [
name: '군주', name: '군주',
dedication: 900, dedication: 900,
officerLevel: 12, officerLevel: 12,
leadership: 80,
strength: 80,
intel: 80,
meta: { killturn: 4, owner_name: '통일유저' }, meta: { killturn: 4, owner_name: '통일유저' },
experience: 10_000, experience: 10_000,
}), }),
@@ -132,6 +136,9 @@ const globalGenerals = [
name: '외교관', name: '외교관',
dedication: 800, dedication: 800,
officerLevel: 1, officerLevel: 1,
leadership: 70,
strength: 80,
intel: 40,
meta: { killturn: 2, permission: 'ambassador' }, meta: { killturn: 2, permission: 'ambassador' },
experience: 5_000, experience: 5_000,
}), }),
@@ -140,6 +147,9 @@ const globalGenerals = [
name: '제재외교관', name: '제재외교관',
dedication: 700, dedication: 700,
officerLevel: 1, officerLevel: 1,
leadership: 70,
strength: 40,
intel: 80,
meta: { killturn: 1, permission: 'ambassador' }, meta: { killturn: 1, permission: 'ambassador' },
penalty: { noTopSecret: true }, penalty: { noTopSecret: true },
experience: 2_000, experience: 2_000,
@@ -149,6 +159,9 @@ const globalGenerals = [
name: '조언자', name: '조언자',
dedication: 600, dedication: 600,
officerLevel: 1, officerLevel: 1,
leadership: 30,
strength: 70,
intel: 70,
meta: { killturn: 5, permission: 'auditor' }, meta: { killturn: 5, permission: 'auditor' },
experience: 1_000, experience: 1_000,
}), }),
@@ -161,6 +174,8 @@ const globalGenerals = [
dedication: 500, dedication: 500,
officerLevel: 12, officerLevel: 12,
leadership: 80, leadership: 80,
strength: 70,
intel: 65,
meta: { killturn: 0 }, meta: { killturn: 0 },
experience: 20_000, experience: 20_000,
}), }),
@@ -221,7 +236,12 @@ const createContext = (
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
config: { const: { maxLevel: 100, maxDedLevel: 30 } }, config: { const: { maxLevel: 100, maxDedLevel: 30 } },
meta: { isUnited: options.isUnited ?? 0 }, meta: {
isUnited: options.isUnited ?? 0,
killturn: 4,
autorun_user: { limit_minutes: 60 },
},
tickSeconds: 3_600,
})), })),
}, },
}; };
@@ -279,6 +299,20 @@ describe('legacy global nation/general directories', () => {
); );
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
for (const result of results) { for (const result of results) {
const serializedNationRows = JSON.stringify(result.nations);
for (const privateField of [
'totalCrew',
'crew',
'leadership',
'strength',
'intel',
'killturn',
'refreshScoreTotal',
'meta',
'penalty',
]) {
expect(serializedNationRows).not.toContain(`"${privateField}"`);
}
const serializedGeneralRows = JSON.stringify(result.generals); const serializedGeneralRows = JSON.stringify(result.generals);
for (const privateField of [ for (const privateField of [
'userId', 'userId',
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
id: 1, id: 1,
generalCount: 4, generalCount: 4,
cityCount: 1, cityCount: 1,
forceEstimate: {
totalGeneralCount: 4,
userCombatGeneralCount: 1,
userTroops: 8_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 2,
},
ambassadorNames: ['군주', '외교관'], ambassadorNames: ['군주', '외교관'],
auditorCount: 1, auditorCount: 1,
}); });
expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([
['만능장', ['군주']],
['무장', ['외교관']],
['지장', ['제재외교관']],
['무지장', ['조언자']],
]);
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']); expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
expect(result[1]?.officers[0]).toMatchObject({ expect(result[1]?.officers[0]).toMatchObject({
officerLevel: 12, officerLevel: 12,
general: { id: 10, name: '군주' }, general: { id: 10, name: '군주' },
}); });
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 }); expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
expect(JSON.stringify(result)).not.toContain('totalCrew');
});
it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => {
const categoryStats = [
['만능장', 80, 80, 80],
['통장', 70, 20, 20],
['무장', 70, 80, 40],
['지장', 70, 40, 80],
['평범장', 60, 50, 50],
['무지장', 30, 50, 50],
['무능장', 30, 10, 10],
] as const;
const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) =>
directoryGeneral({
id: 101 + index,
name,
nationId: 1,
leadership,
strength,
intel,
meta: { killturn: 4 },
})
);
const [nation] = await appRouter
.createCaller(
createContext({
directory: {
nations: [nationRows[0]!],
generals: categoryGenerals,
cities: [{ id: 1, name: '허창', nationId: 1 }],
},
}).context
)
.world.getNationDirectory();
expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name));
expect(nation?.forceEstimate).toEqual({
totalGeneralCount: 7,
userCombatGeneralCount: 5,
userTroops: 35_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 0,
});
}); });
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => { it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
+9 -9
View File
@@ -7,8 +7,8 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
({ ({
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'), clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
clockTick: 36_000_000n, clockTick: 0n,
clockMode: mode, clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600, tickSeconds: 600,
@@ -17,14 +17,14 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
}) as unknown as DatabaseClient; }) as unknown as DatabaseClient;
describe('current game time projection', () => { describe('current game time projection', () => {
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => { it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase(); const db = buildDatabase();
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z')); const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
expect(preopen).toMatchObject({ expect(preopen).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'), now: new Date('2026-08-21T10:30:00.000Z'),
wallNow: new Date('2026-08-21T10:30:00.000Z'), wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: 36_000_000, tick: -108_000_000,
mode: 'realtime', mode: 'realtime',
running: false, running: false,
startsAt: new Date('2026-08-21T11:00:00.000Z'), startsAt: new Date('2026-08-21T11:00:00.000Z'),
@@ -32,8 +32,8 @@ describe('current game time projection', () => {
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z')); const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
expect(opened).toMatchObject({ expect(opened).toMatchObject({
now: new Date('2026-08-21T10:00:05.000Z'), now: new Date('2026-08-21T11:00:05.000Z'),
tick: 36_300_000, tick: 300_000,
running: true, running: true,
startsAt: null, startsAt: null,
}); });
@@ -43,8 +43,8 @@ describe('current game time projection', () => {
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z')); const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
expect(result).toMatchObject({ expect(result).toMatchObject({
now: new Date('2026-08-21T10:00:00.000Z'), now: new Date('2026-08-21T11:00:00.000Z'),
tick: 36_000_000, tick: 0,
running: false, running: false,
startsAt: null, startsAt: null,
}); });
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
}); });
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(executeRaw).toHaveBeenCalledTimes(2); expect(executeRaw).toHaveBeenCalledTimes(3);
const lockStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(lockStatement.sql).toContain('pg_advisory_xact_lock');
expect(lockStatement.values).toContain('general-access:persistence');
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(periodStatement.sql).toContain('INSERT INTO traffic_period'); expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
expect(periodStatement.values).toContain(now); expect(periodStatement.values).toContain(now);
expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const memberStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general'); expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING'); expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
expect(memberStatement.sql).toContain('SET online = traffic_period.online'); expect(memberStatement.sql).toContain('SET online = traffic_period.online');
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] }; const accessStatement = executeRaw.mock.calls[2]![0] as { sql: string; values: unknown[] };
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
expect(accessStatement.values).toContain(7); expect(accessStatement.values).toContain(7);
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true); await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0); expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now); expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(now);
}); });
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => { it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
expect(resolveRefreshScoreText(12_800)).toBe('헐...'); expect(resolveRefreshScoreText(12_800)).toBe('헐...');
}); });
it('matches the Ref remaining-minute calculation and one-turn rollover', () => { it('matches the Ref remaining-minute calculation against the current game clock', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z'); const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7); expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); expect(resolveRemainingMinutes(new Date('2026-08-13T00:19:00.000Z'), currentGameTime)).toBe(0);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:20:00.000Z'), null)).toBeNull();
}); });
it('moves the first reserved month only after the general turn bucket has passed', () => { it('moves the first reserved month only after the general turn bucket has passed', () => {
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
gameClockNow?: Date;
rankRows?: Array<{ generalId?: number; type: string; value: number }>; rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
@@ -164,6 +165,10 @@ const createContext = (options: {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
config: { const: { upgradeLimit: 20 } }, config: { const: { upgradeLimit: 20 } },
clockBaseTime: options.gameClockNow ?? null,
clockTick: options.gameClockNow ? 0n : null,
clockMode: 'manual',
clockWallAnchor: options.gameClockNow ?? null,
})), })),
}, },
logEntry: { logEntry: {
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
troopLeaderAction: '휴식', troopLeaderAction: '휴식',
refreshScore: 3, refreshScore: 3,
refreshScoreTotal: 1_141, refreshScoreTotal: 1_141,
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
}); });
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
retirementYear: 70, retirementYear: 70,
defenceTrain: 80, defenceTrain: 80,
killTurn: 6, killTurn: 6,
remainingMinutes: null, remainingMinutes: 7,
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' }, troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
refreshScore: { current: 3, total: 1_141, text: '열심' }, refreshScore: { current: 3, total: 1_141, text: '열심' },
}, },
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
defence_train: 80, defence_train: 80,
use_treatment: 21, use_treatment: 21,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0, use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
expect(requestCommand).not.toHaveBeenCalled(); expect(requestCommand).not.toHaveBeenCalled();
}); });
it('rejects the removed automatic diplomacy setting before dispatching it to ENGINE', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
await expect(
appRouter.createCaller(fixture.context).general.setMySetting({
use_auto_nation_diplomacy: 1,
} as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(requestCommand).not.toHaveBeenCalled();
});
it('sends settings directly to ENGINE without creating an API input event', async () => { it('sends settings directly to ENGINE without creating an API input event', async () => {
const transaction = vi.fn(async () => { const transaction = vi.fn(async () => {
throw new Error('API transaction must not run'); throw new Error('API transaction must not run');
+21 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DatabaseClient, GameApiContext } from '../src/context.js'; import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
@@ -47,6 +47,10 @@ const buildContext = (
}) as unknown as GameApiContext; }) as unknown as GameApiContext;
describe('lobby season state', () => { describe('lobby season state', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
const result = await appRouter const result = await appRouter
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited })) .createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
@@ -96,15 +100,18 @@ describe('lobby season state', () => {
expect(result.turnEngineRunning).toBe(false); expect(result.turnEngineRunning).toBe(false);
}); });
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => { it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
const wallAnchor = new Date('2099-08-21T11:00:00.000Z'); const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
const wallNow = new Date('2099-08-21T10:30:00.000Z');
vi.useFakeTimers();
vi.setSystemTime(wallNow);
const result = await appRouter const result = await appRouter
.createCaller( .createCaller(
buildContext( buildContext(
{}, {},
{ {
baseTime: new Date('2026-08-21T09:00:00.000Z'), baseTime: wallAnchor,
tick: 36_000_000n, tick: 0n,
mode: 'realtime', mode: 'realtime',
wallAnchor, wallAnchor,
} }
@@ -112,13 +119,22 @@ describe('lobby season state', () => {
) )
.lobby.info(); .lobby.info();
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z'); expect(result.serverTime).toBe(wallNow.toISOString());
expect(result.clockMode).toBe('realtime'); expect(result.clockMode).toBe('realtime');
expect(result.clockRunning).toBe(false); expect(result.clockRunning).toBe(false);
expect(result.clockStartsAt).toBe(wallAnchor.toISOString()); expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime()); expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
}); });
it('reports direct creation and possession as independent acquisition modes', async () => {
const result = await appRouter
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
.lobby.info();
expect(result.directGeneralCreationEnabled).toBe(false);
expect(result.npcPossessionEnabled).toBe(true);
});
it('preserves zero as the first official game index', async () => { it('preserves zero as the first official game index', async () => {
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info(); const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
@@ -44,8 +44,8 @@ const buildContext = (
tickSeconds: 3600, tickSeconds: 3600,
...(options.preopenClock ...(options.preopenClock
? { ? {
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'), clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
clockTick: -180_000_000n, clockTick: 0n,
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
} }
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
}); });
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => { it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z')); vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
const context = buildContext({ hasVoted: true, preopenClock: true }); const context = buildContext({ hasVoted: true, preopenClock: true });
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({ await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
@@ -1,13 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
buildNpcSelectionTokenSeed,
createTurnDaemonRuntime,
seedScenarioToDatabase,
type TurnDaemonRuntime,
} from '@sammo-ts/game-engine';
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector, createGamePostgresConnector,
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
const delayedUserId = 'npc-possession-integration-delayed'; const delayedUserId = 'npc-possession-integration-delayed';
const cleanupUserId = 'npc-possession-integration-cleanup'; const cleanupUserId = 'npc-possession-integration-cleanup';
const raceUserId = 'npc-possession-integration-race'; const raceUserId = 'npc-possession-integration-race';
const preopenUserId = 'npc-possession-integration-preopen';
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
const assertDedicatedDatabase = (rawUrl: string): void => { const assertDedicatedDatabase = (rawUrl: string): void => {
@@ -93,6 +88,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705); const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706); const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707); const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => { const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
const redisClient = { const redisClient = {
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await db.inputEvent.deleteMany(); await db.inputEvent.deleteMany();
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
await db.npcSelectionToken.deleteMany(); await db.npcSelectionToken.deleteMany();
await db.worldState.updateMany({
data: {
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date(),
},
});
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
await db.general.createMany({ await db.general.createMany({
data: Array.from({ length: 24 }, (_, index) => ({ data: Array.from({ length: 24 }, (_, index) => ({
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
await closeDb?.(); await closeDb?.();
}, 30_000); }, 30_000);
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => { it('reserves at most five type-2 NPCs and preserves Ref refresh/keep timing', async () => {
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig(); const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
expect(config.npcPossession).toEqual({ enabled: true }); expect(config.npcPossession).toEqual({ enabled: true });
const worldState = await db.worldState.findFirstOrThrow();
const acceptedGameTick = Number(worldState.clockTick);
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
throw new Error('NPC possession integration hidden seed is missing');
}
const selectable = await db.general.findMany({
where: { userId: null, npcState: 2 },
orderBy: { id: 'asc' },
select: { id: true, leadership: true, strength: true, intel: true },
});
const weights = Object.fromEntries(
selectable.map((candidate) => [
String(candidate.id),
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
])
);
const rng = new RandUtil(
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
);
const expectedCandidateIds = new Set<number>();
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
}
const [first, concurrentSameOwner] = await Promise.all([ const [first, concurrentSameOwner] = await Promise.all([
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}), appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
@@ -240,9 +217,6 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(first.candidates.length).toBeGreaterThan(0); expect(first.candidates.length).toBeGreaterThan(0);
expect(first.candidates.length).toBeLessThanOrEqual(5); expect(first.candidates.length).toBeLessThanOrEqual(5);
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length); expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
[...expectedCandidateIds].sort((left, right) => left - right)
);
expect(first.pickMoreSeconds).toBe(0); expect(first.pickMoreSeconds).toBe(0);
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true); expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
const rows = await db.general.findMany({ const rows = await db.general.findMany({
@@ -281,6 +255,49 @@ integration('mode 1 NPC possession through token reservation and the durable dae
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false); expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
}, 30_000); }, 30_000);
it('refreshes possession candidates while the realtime game tick is negative before opening', async () => {
await stopRuntime('preopen candidate clock boundary');
const worldState = await db.worldState.findFirstOrThrow();
const originalClock = {
clockBaseTime: worldState.clockBaseTime,
clockTick: worldState.clockTick,
clockMode: worldState.clockMode,
clockWallAnchor: worldState.clockWallAnchor,
};
const openAt = new Date('2099-08-01T00:00:00.000Z');
try {
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
await db.worldState.update({
where: { id: worldState.id },
data: {
clockBaseTime: openAt,
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: openAt,
},
});
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-07-31T23:59:30.000Z'));
const first = await appRouter
.createCaller(buildContext('npc-possession-preopen-first', preopenAuth))
.join.listPossessCandidates({});
expect(first.pickMoreSeconds).toBe(0);
vi.advanceTimersByTime(30_000);
const refreshed = await appRouter
.createCaller(buildContext('npc-possession-preopen-refresh', preopenAuth))
.join.listPossessCandidates({ refresh: true, keepIds: [] });
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
await db.worldState.update({ where: { id: worldState.id }, data: originalClock });
await startRuntime('npc-possession-integration-daemon-resumed');
}
}, 30_000);
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => { it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
const reservation = await appRouter const reservation = await appRouter
.createCaller(buildContext('npc-possession-token-current')) .createCaller(buildContext('npc-possession-token-current'))
+36
View File
@@ -1418,6 +1418,20 @@ describe('appRouter', () => {
message: expect.stringContaining('랜덤 임관만 가능합니다'), message: expect.stringContaining('랜덤 임관만 가능합니다'),
}); });
expect(randomOnlyWrites).toHaveLength(0); expect(randomOnlyWrites).toHaveLength(0);
await expect(
randomOnlyCaller.turns.reserved.setGeneral({
generalId: general.id,
turnIndex: 0,
action: 'che_등용',
args: { destGeneralId: 2 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('랜덤 임관만 가능합니다'),
});
expect(randomOnlyWrites).toHaveLength(0);
}); });
it('checks nation treaty-term reservation permission but not full diplomacy state', async () => { it('checks nation treaty-term reservation permission but not full diplomacy state', async () => {
@@ -1462,6 +1476,28 @@ describe('appRouter', () => {
}) })
).resolves.toMatchObject({ ok: true }); ).resolves.toMatchObject({ ok: true });
expect(validTermWrites).toHaveLength(1); expect(validTermWrites).toHaveLength(1);
const excessiveTermWrites: unknown[] = [];
const excessiveTermCaller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
general,
nationTurnWrites: excessiveTermWrites,
})
);
await expect(
excessiveTermCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_불가침제의',
args: { destNationId: 2, year: 211, month: 1 },
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: expect.stringContaining('기한은 210년 이하'),
});
expect(excessiveTermWrites).toHaveLength(0);
}); });
it('validates every bulk reservation permission before writing any turn', async () => { it('validates every bulk reservation permission before writing any turn', async () => {
+1
View File
@@ -8,5 +8,6 @@ export default defineConfig({
environment: 'node', environment: 'node',
globals: true, globals: true,
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
reporters: [process.env.SAMMO_TEST_REPORTER === 'verbose' ? 'default' : 'minimal'],
}, },
}); });
+2 -2
View File
@@ -103,8 +103,8 @@
"build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .", "build": "tsdown -c ./tsdown.config.ts && node ../../tools/check-package-exports.mjs .",
"dev": "tsdown -c ./tsdown.config.ts --watch", "dev": "tsdown -c ./tsdown.config.ts --watch",
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js", "start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-engine",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-engine",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs", "profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs",
"profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs", "profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs",
@@ -488,6 +488,23 @@ export class GeneralAI {
return message; return message;
} }
syncGeneralAfterNationAction(general: TurnGeneral): void {
const meta = { ...general.meta } as Record<string, unknown>;
for (const key of [
'fullLeadership',
'fullStrength',
'fullIntelligence',
'effectiveLeadership',
'effectiveStrength',
'effectiveIntelligence',
]) {
if (Object.prototype.hasOwnProperty.call(this.general.meta, key)) {
meta[key] = this.general.meta[key];
}
}
this.general = { ...general, meta: meta as TurnGeneral['meta'] };
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance(); this.updateInstance();
if (!this.worldRef) { if (!this.worldRef) {
@@ -1198,6 +1215,13 @@ export class GeneralAI {
) { ) {
continue; continue;
} }
const oldChief = this.chiefGenerals[11];
if (oldChief && oldChief.id !== candidate.id) {
oldChief.officerLevel = 1;
oldChief.meta = { ...oldChief.meta, officer_city: 0 };
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 ? undefined : 'ambassador';
candidate.officerLevel = 11; candidate.officerLevel = 11;
candidate.meta = { candidate.meta = {
@@ -1,6 +1,6 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js'; import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js';
import { resolveDiplomacyMessageValidUntilTick } from '@sammo-ts/logic'; import { resolveDiplomacyMessageValidUntilTick, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic';
import { isNeighbor } from '@sammo-ts/logic/world/distance.js'; import { isNeighbor } from '@sammo-ts/logic/world/distance.js';
import { resolveNationIncome } from './helpers.js'; import { resolveNationIncome } from './helpers.js';
@@ -99,7 +99,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
return null; return null;
} }
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth)); const maxEndMonth = joinYearMonth(resolveNonAggressionMaxEndYear(ai.world.currentYear), 12);
const [targetYear, targetMonth] = parseYearMonth(Math.min(Math.floor(yearMonth + diplomatMonth), maxEndMonth));
const result = ai.buildNationCandidate( const result = ai.buildNationCandidate(
'che_불가침제의', 'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth }, { destNationId, year: targetYear, month: targetMonth },
+1 -3
View File
@@ -61,10 +61,9 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
NPC전방발령: true, NPC전방발령: true,
}; };
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital'; export type UserRulerAutomationFeature = 'war' | 'promotion' | 'finance' | 'capital';
const USER_RULER_AUTOMATION_META_KEY = { const USER_RULER_AUTOMATION_META_KEY = {
diplomacy: 'use_auto_nation_diplomacy',
war: 'use_auto_nation_war', war: 'use_auto_nation_war',
promotion: 'use_auto_nation_promotion', promotion: 'use_auto_nation_promotion',
finance: 'use_auto_nation_finance', finance: 'use_auto_nation_finance',
@@ -72,7 +71,6 @@ const USER_RULER_AUTOMATION_META_KEY = {
} as const satisfies Record<UserRulerAutomationFeature, string>; } as const satisfies Record<UserRulerAutomationFeature, string>;
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = { const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
: 'diplomacy',
: 'war', : 'war',
: 'capital', : 'capital',
}; };
+1 -2
View File
@@ -142,12 +142,11 @@ const zSetMySetting = z.object({
type: z.literal('setMySetting'), type: z.literal('setMySetting'),
userId: z.string().min(1), userId: z.string().min(1),
generalId: zFiniteNumber, generalId: zFiniteNumber,
settings: z.object({ settings: z.strictObject({
tnmt: z.number().int().optional(), tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(), defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(), use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: z.number().int().optional(),
use_auto_nation_war: z.number().int().optional(), use_auto_nation_war: z.number().int().optional(),
use_auto_nation_promotion: z.number().int().optional(), use_auto_nation_promotion: z.number().int().optional(),
use_auto_nation_finance: z.number().int().optional(), use_auto_nation_finance: z.number().int().optional(),
+15
View File
@@ -1,6 +1,7 @@
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
createGamePostgresConnector, createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma, GamePrisma,
writeReadModelChangeJournal, writeReadModelChangeJournal,
enqueuePrivateMessageWebPush, enqueuePrivateMessageWebPush,
@@ -1322,6 +1323,20 @@ export const createDatabaseTurnHooks = async (
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle'); const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle'); const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
const writesGeneralAccess =
accessScoreResetGeneralIds.length > 0 ||
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
if (writesGeneralAccess) {
// API access writers acquire this before traffic/access rows.
// Match that order before lifecycle and monthly score writes.
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
}
await persistInheritancePointAdjustments(beforeLifecycleAdjustments); await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs); await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents( await persistGeneralLifecycleEvents(
+10
View File
@@ -656,6 +656,16 @@ export class InMemoryTurnWorld {
return this.getGameClock().now(wallNow); return this.getGameClock().now(wallNow);
} }
getRunnableGameNow(wallNow: Date): Date {
const clock = this.getGameClock();
// PREOPEN still needs negative game ticks for cooldowns, but executable
// turn schedules must not precede the wall-clock opening boundary.
if (clock.mode === 'realtime' && wallNow.getTime() < clock.wallAnchor.getTime()) {
return clock.now(clock.wallAnchor);
}
return clock.now(wallNow);
}
dateToGameTick(date: Date): number { dateToGameTick(date: Date): number {
return this.getGameClock().dateToTick(date); return this.getGameClock().dateToTick(date);
} }
@@ -383,7 +383,9 @@ export const resolveJoinTurnTime = (
} }
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000); let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
if (turnTime.getTime() <= acceptedAt.getTime()) { if (turnTime.getTime() <= acceptedAt.getTime()) {
turnTime = new Date(turnTime.getTime() + tickSeconds * 1000); const termMs = tickSeconds * 1000;
const missedTerms = Math.floor((acceptedAt.getTime() - turnTime.getTime()) / termMs) + 1;
turnTime = new Date(turnTime.getTime() + missedTerms * termMs);
} }
return turnTime; return turnTime;
}; };
@@ -523,9 +525,11 @@ export const createGeneralFromJoin = async (options: {
worldState: WorldStateRow; worldState: WorldStateRow;
input: JoinCreateGeneralInput; input: JoinCreateGeneralInput;
acceptedAt: Date; acceptedAt: Date;
turnScheduleAt?: Date;
operationalAcceptedAt: Date; operationalAcceptedAt: Date;
}): Promise<{ ok: true; generalId: number }> => { }): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options; const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
const turnScheduleAt = options.turnScheduleAt ?? acceptedAt;
await lockJoinMutation(db, input.userId); await lockJoinMutation(db, input.userId);
await assertGeneralIdSnapshotMatches(db, world); await assertGeneralIdSnapshotMatches(db, world);
@@ -707,7 +711,7 @@ export const createGeneralFromJoin = async (options: {
const turnTime = resolveJoinTurnTime( const turnTime = resolveJoinTurnTime(
rng, rng,
worldState, worldState,
acceptedAt, turnScheduleAt,
world.getState().lastTurnTime, world.getState().lastTurnTime,
input.inheritTurntimeZone input.inheritTurntimeZone
); );
@@ -28,6 +28,7 @@ import {
addOccupiedUniqueItemKeys, addOccupiedUniqueItemKeys,
buildGenericUniqueSeed, buildGenericUniqueSeed,
countOccupiedUniqueItems, countOccupiedUniqueItems,
cloneItemInventory,
createItemModuleRegistry, createItemModuleRegistry,
loadItemModules, loadItemModules,
resolveUniqueConfig, resolveUniqueConfig,
@@ -267,12 +268,13 @@ export const applyLegacyGeneralProgression = (
const resolveConstraintEnv = ( const resolveConstraintEnv = (
world: TurnWorldState, world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined, scenarioMeta: ScenarioMeta | undefined,
env: TurnCommandEnv env: TurnCommandEnv,
worldConfig: Record<string, unknown>
): Record<string, unknown> => { ): Record<string, unknown> => {
const worldMeta = asRecord(world.meta); const worldMeta = asRecord(world.meta);
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
const joinModeRaw = worldMeta.join_mode ?? worldMeta.joinMode; const joinModeRaw = worldConfig.join_mode ?? worldConfig.joinMode ?? worldMeta.join_mode ?? worldMeta.joinMode;
const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full'; const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full';
const killturnRaw = worldMeta.killturn; const killturnRaw = worldMeta.killturn;
const killturn = const killturn =
@@ -317,6 +319,7 @@ const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number)
const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
...general, ...general,
...(general.itemInventory ? { itemInventory: cloneItemInventory(general.itemInventory) } : {}),
...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}), ...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}),
stats: { ...general.stats }, stats: { ...general.stats },
role: { role: {
@@ -1035,7 +1038,12 @@ export const createReservedTurnHandler = async (options: {
const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null; const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null;
const worldView = worldOverlay?.view ?? worldRef; const worldView = worldOverlay?.view ?? worldRef;
const baseConstraintEnv = { const baseConstraintEnv = {
...resolveConstraintEnv(context.world, options.scenarioMeta, env), ...resolveConstraintEnv(
context.world,
options.scenarioMeta,
env,
options.getWorld()?.getWorldConfig() ?? asRecord(options.scenarioConfig)
),
...(options.map ? { map: options.map } : {}), ...(options.map ? { map: options.map } : {}),
...(options.unitSet ? { unitSet: options.unitSet } : {}), ...(options.unitSet ? { unitSet: options.unitSet } : {}),
}; };
@@ -1401,6 +1409,10 @@ export const createReservedTurnHandler = async (options: {
progressionLogs progressionLogs
); );
} }
// A failed requested command already emitted its specific failure above.
// The internal rest definition only preserves the existing turn-result
// lifecycle; it must not add a second "아무것도 실행하지 않았습니다" log.
if (!usedFallback) {
logs.push( logs.push(
...orderLegacyCommandLogs( ...orderLegacyCommandLogs(
actionKey, actionKey,
@@ -1409,6 +1421,7 @@ export const createReservedTurnHandler = async (options: {
resolution.postProgressionLogs resolution.postProgressionLogs
) )
); );
}
if ( if (
!resolution.alternative && !resolution.alternative &&
kind === 'nation' && kind === 'nation' &&
@@ -1992,6 +2005,10 @@ export const createReservedTurnHandler = async (options: {
// The following general command therefore observes and adds to // The following general command therefore observes and adds to
// those fractions before the turn's final persistence boundary. // those fractions before the turn's final persistence boundary.
worldOverlay?.syncGeneral(currentGeneral); worldOverlay?.syncGeneral(currentGeneral);
// Ref keeps one mutable General object across nation/general AI
// selection. Core uses immutable patches, so carry the executed
// nation action back into the shared AI without resetting its RNG.
sharedAi?.syncGeneralAfterNationAction(currentGeneral);
if ( if (
worldView && worldView &&
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))
@@ -2511,7 +2528,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
const state = options.world.getState(); const state = options.world.getState();
const constraintEnv = { const constraintEnv = {
...resolveConstraintEnv(state, options.scenarioMeta, env), ...resolveConstraintEnv(state, options.scenarioMeta, env, options.world.getWorldConfig()),
...(options.map ? { map: options.map } : {}), ...(options.map ? { map: options.map } : {}),
...(options.world.getUnitSet() ? { unitSet: options.world.getUnitSet() } : {}), ...(options.world.getUnitSet() ? { unitSet: options.world.getUnitSet() } : {}),
cities: options.world.listCities(), cities: options.world.listCities(),
+11 -3
View File
@@ -651,11 +651,18 @@ const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => {
return now; return now;
}; };
const buildInitialTurnTime = (rng: RandUtil, worldState: WorldStateRow, now: Date): Date => { export const buildInitialTurnTime = (
rng: Pick<RandUtil, 'nextRangeInt'>,
worldState: WorldStateRow,
now: Date,
notBefore: Date = now
): Date => {
const termSeconds = resolveTurnTermMinutes(worldState) * 60; const termSeconds = resolveTurnTermMinutes(worldState) * 60;
const seconds = rng.nextRangeInt(0, termSeconds - 1); const seconds = rng.nextRangeInt(0, termSeconds - 1);
const microseconds = rng.nextRangeInt(0, 999_999); const microseconds = rng.nextRangeInt(0, 999_999);
return new Date(resolveTurnTimeBase(worldState, now).getTime() + seconds * 1000 + microseconds / 1000); const base = resolveTurnTimeBase(worldState, now);
const safeBase = base.getTime() < notBefore.getTime() ? notBefore : base;
return new Date(safeBase.getTime() + seconds * 1000 + microseconds / 1000);
}; };
const appendSelectionLogs = async (options: { const appendSelectionLogs = async (options: {
@@ -702,6 +709,7 @@ export const createGeneralFromSelectionPool = async (options: {
uniqueName: string; uniqueName: string;
personality: string; personality: string;
now?: Date; now?: Date;
turnScheduleAt?: Date;
operationalAcceptedAt: Date; operationalAcceptedAt: Date;
seedOwnerIdentity?: string | number; seedOwnerIdentity?: string | number;
ownerPicture?: string; ownerPicture?: string;
@@ -761,7 +769,7 @@ export const createGeneralFromSelectionPool = async (options: {
); );
} }
const city = rng.choice(cities); const city = rng.choice(cities);
const turnTime = buildInitialTurnTime(rng, worldState, now); const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
const age = 20; const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age); const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date( const nextChangeAt = new Date(
@@ -340,6 +340,7 @@ async function handleJoinCreateGeneral(
} }
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt); const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
try { try {
return { return {
type: 'joinCreateGeneral', type: 'joinCreateGeneral',
@@ -377,6 +378,7 @@ async function handleJoinCreateGeneral(
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
}, },
acceptedAt, acceptedAt,
turnScheduleAt,
operationalAcceptedAt, operationalAcceptedAt,
})), })),
}; };
@@ -455,6 +457,7 @@ async function handleSelectPoolCreate(
: command.acceptedGameAt !== undefined : command.acceptedGameAt !== undefined
? new Date(command.acceptedGameAt) ? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt); : ctx.world.getGameNow(operationalAcceptedAt);
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
try { try {
return { return {
type: 'selectPoolCreate', type: 'selectPoolCreate',
@@ -471,6 +474,7 @@ async function handleSelectPoolCreate(
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}), ...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
now: acceptedAt, now: acceptedAt,
turnScheduleAt,
operationalAcceptedAt, operationalAcceptedAt,
})), })),
}; };
@@ -1896,7 +1900,6 @@ async function handleSetMySetting(
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn === 0 ? 0 : 1; nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn === 0 ? 0 : 1;
} }
for (const key of [ for (const key of [
'use_auto_nation_diplomacy',
'use_auto_nation_war', 'use_auto_nation_war',
'use_auto_nation_promotion', 'use_auto_nation_promotion',
'use_auto_nation_finance', 'use_auto_nation_finance',
@@ -98,6 +98,23 @@ describe('authenticated actor-bound command registry and execution', () => {
).toBeNull(); ).toBeNull();
}); });
it('rejects the removed automatic diplomacy setting at the daemon boundary', () => {
expect(
normalizeTurnDaemonCommand({
requestId: 'set-my-setting:removed-diplomacy',
sentAt: '2026-08-28T00:00:00.000Z',
command: {
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: {
use_auto_nation_diplomacy: 1,
},
} as unknown as TurnDaemonCommand,
})
).toBeNull();
});
it('rejects every actor-bound queue payload that omits userId', () => { it('rejects every actor-bound queue payload that omits userId', () => {
for (const command of buildActorBoundCommands()) { for (const command of buildActorBoundCommands()) {
const { const {
@@ -568,6 +568,53 @@ describe('legacy NPC user-chief promotion parity', () => {
}); });
}); });
it.each([
{ label: 'inactive user', npcState: 0, killturn: 70, permission: 'auditor' },
{ label: 'NPC', npcState: 3, killturn: 100, permission: undefined },
])('demotes the existing $label advisor before forcing an active user into the seat', (oldChiefInput) => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 0,
meta: { killturn: 100, belong: 2, use_auto_nation_promotion: 1 },
});
const oldChief = makePromotionGeneral({
id: 2,
name: '기존참모',
officerLevel: 11,
npcState: oldChiefInput.npcState,
meta: {
killturn: oldChiefInput.killturn,
belong: 2,
...(oldChiefInput.permission ? { permission: oldChiefInput.permission } : {}),
},
});
const candidate = makePromotionGeneral({
id: 3,
name: '신규참모',
npcState: 0,
stats: { leadership: 80, strength: 40, intelligence: 40 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, oldChief, candidate],
userGenerals: oldChief.npcState < 2 ? [oldChief, candidate] : [candidate],
chiefGenerals: [ruler, oldChief],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches()).toEqual({
generals: [
{ generalId: oldChief.id, officerLevel: 1, officerCity: 0 },
{ generalId: candidate.id, officerLevel: 11, officerCity: 0, permission: 'ambassador' },
],
nationMeta: expect.objectContaining({ chief_set: 1 << 11 }),
});
expect(oldChief).toMatchObject({ officerLevel: 1, meta: expect.objectContaining({ officer_city: 0 }) });
});
it('waits for belong 3 before forcing a user over a stronger NPC under an established NPC ruler', () => { it('waits for belong 3 before forcing a user over a stronger NPC under an established NPC ruler', () => {
const run = (belong: number) => { const run = (belong: number) => {
const ruler = makePromotionGeneral({ const ruler = makePromotionGeneral({
@@ -779,7 +826,7 @@ describe('legacy NPC user-chief promotion parity', () => {
expect(run(3, 0, 1)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]); expect(run(3, 0, 1)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
}); });
it('keeps user-ruler duties individually disabled until each setting is enabled', () => { it('keeps non-aggression proposals NPC-only while user-ruler duties remain opt-in', () => {
const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, npcState: 0, meta: { killturn: 0 } }); const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, npcState: 0, meta: { killturn: 0 } });
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false); expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false); expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
@@ -792,18 +839,21 @@ describe('legacy NPC user-chief promotion parity', () => {
use_auto_nation_capital: 1, use_auto_nation_capital: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
}; };
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true); // 기존 DB에 제거된 플래그가 남아 있어도 사용자 군주에게는 다시 활성화되지 않는다.
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false); expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true); expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true);
expect(canUseRulerAutomation(ruler, 'finance')).toBe(true); expect(canUseRulerAutomation(ruler, 'finance')).toBe(true);
ruler.meta = { ruler.meta = {
...ruler.meta, ...ruler.meta,
use_auto_nation_diplomacy: 0,
use_auto_nation_war: 1, use_auto_nation_war: 1,
}; };
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false); expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true); expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true);
const npcRuler = makePromotionGeneral({ id: 2, officerLevel: 12, npcState: 2, meta: { killturn: 0 } });
expect(canUseAutomatedNationAction(npcRuler, '불가침제의')).toBe(true);
}); });
it('honors the existing automatic nation-turn master switch for user chiefs only', () => { it('honors the existing automatic nation-turn master switch for user chiefs only', () => {
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { LogFormat } from '@sammo-ts/logic'; import {
LogFormat,
equipNewItem,
getEquippedItemInstance,
loadActionModuleBundle,
} from '@sammo-ts/logic';
import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js';
@@ -247,6 +252,29 @@ describe('legacy general-turn execution contract', () => {
}); });
}); });
it('keeps multi-use item state mutable across consecutive general turns', async () => {
const general = makeGeneral({ injury: 30 });
equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 });
const actionModules = await loadActionModuleBundle();
const commandEnv = buildCommandEnv(makeSnapshot(general).scenarioConfig);
commandEnv.generalActionModules = actionModules.general;
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
schedule,
map,
commandEnv,
});
await harness.runOneTick();
expect(getEquippedItemInstance(harness.world.getGeneralById(1)!, 'item')?.state.charges).toBe(2);
harness.world.updateGeneral(1, { injury: 30 });
await harness.runOneTick();
expect(getEquippedItemInstance(harness.world.getGeneralById(1)!, 'item')?.state.charges).toBe(1);
});
it('fails closed instead of silently resting on an unknown queued command', async () => { it('fails closed instead of silently resting on an unknown queued command', async () => {
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()), snapshot: makeSnapshot(makeGeneral()),
@@ -401,6 +429,38 @@ describe('legacy general-turn execution contract', () => {
expect(updated.meta.prev_types_special2).toEqual(['che_격노']); expect(updated.meta.prev_types_special2).toEqual(['che_격노']);
}); });
it('rejects a speciality reset cooldown before accumulating its first preparation turn', async () => {
const general = makeGeneral({
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_격노',
items: { horse: null, weapon: null, book: null, item: null },
},
meta: { killturn: 24, 'next_execute_전투 특기 초기화': 2460 },
});
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
schedule,
map,
collectLogs: true,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} };
await harness.runOneTick();
const updated = harness.world.getGeneralById(1)!;
expect(updated.role.specialWar).toBe('che_격노');
expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 });
expect(harness.getCollectedLogs()).toContainEqual(
expect.objectContaining({ text: expect.stringContaining('60턴 더 기다려야 합니다') })
);
expect(harness.getCollectedLogs()).not.toContainEqual(
expect.objectContaining({ text: expect.stringContaining('새로운 적성을 찾는 중') })
);
});
it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => { it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => {
const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 }); const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 });
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
@@ -75,6 +75,7 @@ export type TurnTestHarnessOptions = {
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']; onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled']; onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory']; commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
commandEnv?: Parameters<typeof createReservedTurnHandler>[0]['commandEnv'];
wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler; wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler;
extraCalendarHandlers?: TurnCalendarHandler[]; extraCalendarHandlers?: TurnCalendarHandler[];
collectLogs?: boolean; collectLogs?: boolean;
@@ -115,6 +116,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
onActionResolved: options.onActionResolved, onActionResolved: options.onActionResolved,
onActionProfiled: options.onActionProfiled, onActionProfiled: options.onActionProfiled,
commandRngFactory: options.commandRngFactory, commandRngFactory: options.commandRngFactory,
commandEnv: options.commandEnv,
}); });
const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler; const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler;
@@ -80,6 +80,27 @@ describe('generic join deterministic contracts', () => {
]); ]);
}); });
it('moves an inherited turn phase past the runnable opening boundary', () => {
const rng = {
nextRangeInt(min: number) {
return min;
},
};
const openingGameAt = new Date('2026-07-30T10:00:00.000Z');
const preopenCursor = new Date(openingGameAt.getTime() - 30 * 60_000);
const turnTime = resolveJoinTurnTime(
rng,
{ tickSeconds: 60 } as Parameters<typeof resolveJoinTurnTime>[1],
openingGameAt,
preopenCursor,
0
);
expect(turnTime.getTime()).toBeGreaterThan(openingGameAt.getTime());
expect((turnTime.getTime() - preopenCursor.getTime()) % 60_000).toBe(0);
});
it('uses the HiDCHe product name without the legacy PHP runtime label', () => { it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'); expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP'); expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
@@ -4,7 +4,6 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
import { MONTHLY_EVENT_ACTION_CATALOG, type MonthlyEventActionName } from '../src/turn/monthlyEventHandler.js'; import { MONTHLY_EVENT_ACTION_CATALOG, type MonthlyEventActionName } from '../src/turn/monthlyEventHandler.js';
import { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js'; import { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js';
@@ -136,7 +135,6 @@ const segments = [
}, },
] as const satisfies readonly CatalogSegment[]; ] as const satisfies readonly CatalogSegment[];
const KNOWN_MISSING_SCENARIO_RESOURCES = [] as const;
const KNOWN_MISSING_MONTHLY_ACTIONS = [] as const; const KNOWN_MISSING_MONTHLY_ACTIONS = [] as const;
const listBasenames = async (directory: string, pattern: RegExp): Promise<string[]> => const listBasenames = async (directory: string, pattern: RegExp): Promise<string[]> =>
@@ -161,18 +159,6 @@ describe('monthly event catalog coverage', () => {
expect([...covered].sort()).toEqual([...MONTHLY_EVENT_ACTION_CATALOG].sort()); expect([...covered].sort()).toEqual([...MONTHLY_EVENT_ACTION_CATALOG].sort());
}); });
refSourceIt('keeps the Core and Ref scenario resource catalogs complete', async () => {
const refScenarioDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'scenario');
const coreScenarioDirectory = path.dirname(resolveScenarioDefaultsPath());
const [refScenarios, coreScenarios] = await Promise.all([
listBasenames(refScenarioDirectory, /^scenario_\d+\.json$/),
listBasenames(coreScenarioDirectory, /^scenario_\d+\.json$/),
]);
expect(difference(refScenarios, coreScenarios)).toEqual([...KNOWN_MISSING_SCENARIO_RESOURCES]);
expect(difference(coreScenarios, refScenarios)).toEqual([]);
});
refSourceIt('keeps the Core and Ref monthly action catalogs complete', async () => { refSourceIt('keeps the Core and Ref monthly action catalogs complete', async () => {
const refActionDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'sammo', 'Event', 'Action'); const refActionDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'sammo', 'Event', 'Action');
const refActions = (await listBasenames(refActionDirectory, /\.php$/)).map((fileName) => const refActions = (await listBasenames(refActionDirectory, /\.php$/)).map((fileName) =>
@@ -161,6 +161,7 @@ const buildImmediateActionWorld = (options: {
availableInstantRetreat?: boolean; availableInstantRetreat?: boolean;
lastTurnTime?: Date; lastTurnTime?: Date;
scenarioConst?: Record<string, unknown>; scenarioConst?: Record<string, unknown>;
worldConfig?: Record<string, unknown>;
}) => { }) => {
const state: TurnWorldState = { const state: TurnWorldState = {
id: 1, id: 1,
@@ -209,6 +210,7 @@ const buildImmediateActionWorld = (options: {
}, },
}, },
scenarioMeta, scenarioMeta,
worldConfig: options.worldConfig,
map: options.map, map: options.map,
}; };
const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const world = new InMemoryTurnWorld(state, snapshot, { schedule });
@@ -237,7 +239,6 @@ describe('my information world commands', () => {
defence_train: 94, defence_train: 94,
use_treatment: 200, use_treatment: 200,
use_auto_nation_turn: 0, use_auto_nation_turn: 0,
use_auto_nation_diplomacy: 1,
use_auto_nation_war: 1, use_auto_nation_war: 1,
use_auto_nation_promotion: 1, use_auto_nation_promotion: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
@@ -254,7 +255,6 @@ describe('my information world commands', () => {
defence_train: 999, defence_train: 999,
use_treatment: 100, use_treatment: 100,
use_auto_nation_turn: 0, use_auto_nation_turn: 0,
use_auto_nation_diplomacy: 1,
use_auto_nation_war: 1, use_auto_nation_war: 1,
use_auto_nation_promotion: 1, use_auto_nation_promotion: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
@@ -696,6 +696,71 @@ describe('my information world commands', () => {
]); ]);
}); });
it('rejects recruitment-letter acceptance in a random-appointment-only world', async () => {
const recipient = buildGeneral({
id: 8,
userId: 'user-8',
name: '재야장수',
nationId: 0,
cityId: 1,
officerLevel: 0,
});
const recruiter = buildGeneral({
id: 9,
userId: 'user-9',
name: '등용장수',
nationId: 2,
cityId: 2,
});
const map = {
id: 'test',
name: 'test',
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
};
const fixture = buildImmediateActionWorld({
general: recipient,
additionalGenerals: [recruiter],
cities: [
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
] as TurnWorldSnapshot['cities'],
nations: [
{
id: 2,
name: '등용국',
color: '#222222',
typeCode: 'che_중립',
level: 1,
capitalCityId: 2,
chiefGeneralId: recruiter.id,
gold: 0,
rice: 0,
power: 0,
meta: { gennum: 1 },
},
] as TurnWorldSnapshot['nations'],
map,
worldConfig: { joinMode: 'onlyRandom' },
});
const executor = await createImmediateGeneralActionExecutor({
world: fixture.world,
reservedTurns: fixture.reservedTurns,
scenarioMeta: fixture.scenarioMeta,
map,
});
await expect(
executor.execute({
actionKey: 'che_등용수락',
generalId: recipient.id,
rng: new RandUtil(new LiteHashDRBG('reject-random-only-recruitment-letter')),
args: { destNationId: 2, destGeneralId: recruiter.id },
})
).resolves.toEqual({ ok: false, reason: '랜덤 임관만 가능합니다 등용수락 실패.' });
expect(fixture.world.getGeneralById(recipient.id)?.nationId).toBe(0);
expect(fixture.world.peekDirtyState().logs).toHaveLength(0);
});
it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => { it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => {
const deletedRecruiterId = 99; const deletedRecruiterId = 99;
const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } }; const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } };
@@ -144,6 +144,7 @@ describe('NPC 원조 기반 불가침 제의 lifecycle', () => {
.peekDirtyState() .peekDirtyState()
.messages.filter((message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression'); .messages.filter((message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression');
expect(firstMessages).toHaveLength(1); expect(firstMessages).toHaveLength(1);
expect(firstMessages[0]?.option).toMatchObject({ year: 210, month: 12 });
const tryEntry = (world.getNationById(1)!.meta.resp_assist_try as Record<string, number[]>).n2!; const tryEntry = (world.getNationById(1)!.meta.resp_assist_try as Record<string, number[]>).n2!;
expect(tryEntry).toHaveLength(3); expect(tryEntry).toHaveLength(3);
const validUntilTick = tryEntry[2]!; const validUntilTick = tryEntry[2]!;
@@ -0,0 +1,259 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
const turnTime = new Date('0190-01-01T00:00:00Z');
const TEST_MAP = {
id: 'nation_general_turn_order',
name: '사령 장수턴 순서 맵',
cities: [
{
id: 1,
name: '아군성',
level: 1,
region: 1,
position: { x: 10, y: 10 },
connections: [2],
max: {
population: 20_000,
agriculture: 2_000,
commerce: 2_000,
security: 2_000,
defence: 1_000,
wall: 1_000,
},
initial: {
population: 10_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 500,
wall: 500,
},
},
{
id: 2,
name: '적군성',
level: 1,
region: 2,
position: { x: 20, y: 10 },
connections: [1],
max: {
population: 20_000,
agriculture: 2_000,
commerce: 2_000,
security: 2_000,
defence: 1_000,
wall: 1_000,
},
initial: {
population: 10_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 500,
wall: 500,
},
},
],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as const;
const UNIT_SET: UnitSetDefinition = {
id: 'nation_general_turn_order_unit',
name: '사령 장수턴 순서 병종',
defaultCrewTypeId: 1100,
crewTypes: [
{
id: 1100,
armType: 1,
name: '보병',
attack: 10,
defence: 10,
speed: 10,
avoid: 0,
magicCoef: 0,
cost: 10,
rice: 1,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
iActionList: null,
initSkillTrigger: null,
phaseSkillTrigger: null,
},
],
};
const createGeneral = (id: number, nationId: number, cityId: number, officerLevel: number): TurnGeneral => ({
id,
name: `NPC_${id}`,
nationId,
cityId,
troopId: 0,
stats: { leadership: 75, strength: 70, intelligence: 40 },
turnTime: id === 1 ? turnTime : new Date('0191-01-01T00:00:00Z'),
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 800 },
officerLevel,
experience: 0,
dedication: 0,
injury: 0,
gold: 2_000,
rice: 2_000,
crew: 2_000,
crewTypeId: 1100,
train: 80,
atmos: 80,
age: 30,
npcState: 2,
});
describe('NPC 사령턴과 장수턴 실행 순서', () => {
it('필사즉생 실행 결과를 반영한 뒤 같은 장수의 자율 출병을 선택한다', async () => {
const generals = [createGeneral(1, 1, 1, 12), createGeneral(2, 2, 2, 12)];
const snapshot: TurnWorldSnapshot = {
generals,
cities: [
{
id: 1,
name: '아군성',
nationId: 1,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 3,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
meta: { trust: 100 },
},
{
id: 2,
name: '적군성',
nationId: 2,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 3,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
meta: { trust: 100 },
},
],
nations: [
{
id: 1,
name: '아군',
color: '#aa0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 50_000,
rice: 50_000,
power: 0,
level: 1,
typeCode: 'npc',
meta: {
strategic_cmd_limit: 0,
turn_last_12: { command: '필사즉생', term: 2 },
npc_general_policy: { priority: ['출병', '전투준비'] },
},
},
{
id: 2,
name: '적군',
color: '#0000aa',
capitalCityId: 2,
chiefGeneralId: 2,
gold: 50_000,
rice: 50_000,
power: 0,
level: 1,
typeCode: 'npc',
meta: {},
},
],
troops: [],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 0, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: 0, term: 0, dead: 0, meta: {} },
],
events: [],
initialEvents: [],
map: TEST_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
openingPartYear: 3,
develCost: 10,
baseGold: 1_000,
baseRice: 1_000,
maxResourceActionAmount: 10_000,
minAvailableRecruitPop: 0,
},
environment: { mapName: TEST_MAP.id, unitSet: UNIT_SET.id },
},
scenarioMeta: { startYear: 180 } as any,
unitSet: UNIT_SET,
};
const state: TurnWorldState = {
id: 1,
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: turnTime,
meta: { seed: 1, initYear: 180, initMonth: 1 },
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const resolved: Array<{ kind: string; generalId: number; actionKey: string }> = [];
const { reservedTurnStore, runOneTick, world } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: TEST_MAP as any,
onActionResolved: ({ kind, generalId, actionKey }) => resolved.push({ kind, generalId, actionKey }),
});
reservedTurnStore.getNationTurns(1, 12)[0] = { action: 'che_필사즉생', args: {} };
await runOneTick();
expect(
resolved.filter((entry) => entry.generalId === 1).map(({ kind, actionKey }) => [kind, actionKey])
).toEqual([
['nation', 'che_필사즉생'],
['general', 'che_출병'],
]);
expect(world.getGeneralById(1)!.train).toBeGreaterThanOrEqual(100);
expect(world.getGeneralById(1)!.atmos).toBeGreaterThanOrEqual(100);
});
});
@@ -342,7 +342,7 @@ describe('Reserved Turn Execution Integration', () => {
// Turns should indeed be empty/default now. // Turns should indeed be empty/default now.
}); });
it('should fall back to rest when args are invalid', async () => { it('should log invalid argument failures without appending rest', async () => {
const invalidSnapshot: TurnWorldSnapshot = { const invalidSnapshot: TurnWorldSnapshot = {
generals: [ generals: [
{ {
@@ -503,10 +503,13 @@ describe('Reserved Turn Execution Integration', () => {
finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth }) finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth })
) )
).not.toContain(null); ).not.toContain(null);
expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(false);
}); });
it('should fall back to rest when constraints fail', async () => { it.each([
{ label: 'a directly entered turn', npcState: 0 },
{ label: 'an NPC autonomous turn', npcState: 2 },
])('should log a full-agriculture failure once for $label', async ({ npcState }) => {
const invalidSnapshot: TurnWorldSnapshot = { const invalidSnapshot: TurnWorldSnapshot = {
generals: [ generals: [
{ {
@@ -536,7 +539,7 @@ describe('Reserved Turn Execution Integration', () => {
train: 0, train: 0,
atmos: 0, atmos: 0,
age: 30, age: 30,
npcState: 0, npcState,
} as TurnGeneral, } as TurnGeneral,
], ],
cities: [ cities: [
@@ -545,7 +548,7 @@ describe('Reserved Turn Execution Integration', () => {
name: 'City_1', name: 'City_1',
nationId: 1, nationId: 1,
viewName: 'City_1', viewName: 'City_1',
agriculture: 100, agriculture: 2000,
agricultureMax: 2000, agricultureMax: 2000,
commerce: 100, commerce: 100,
commerceMax: 2000, commerceMax: 2000,
@@ -629,7 +632,7 @@ describe('Reserved Turn Execution Integration', () => {
meta: {}, meta: {},
}; };
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } }]; const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} }];
const mockPrisma = createMockPrisma(invalidRows); const mockPrisma = createMockPrisma(invalidRows);
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
@@ -639,6 +642,12 @@ describe('Reserved Turn Execution Integration', () => {
await reservedTurnStore.loadAll(); await reservedTurnStore.loadAll();
const wrapper = { world: null as InMemoryTurnWorld | null }; const wrapper = { world: null as InMemoryTurnWorld | null };
const outcomes: Array<{
requestedAction: string;
actionKey: string;
usedFallback: boolean;
completed?: boolean;
}> = [];
const handler = await createReservedTurnHandler({ const handler = await createReservedTurnHandler({
reservedTurns: reservedTurnStore, reservedTurns: reservedTurnStore,
scenarioConfig: invalidSnapshot.scenarioConfig, scenarioConfig: invalidSnapshot.scenarioConfig,
@@ -646,6 +655,11 @@ describe('Reserved Turn Execution Integration', () => {
map: MINIMAL_MAP, map: MINIMAL_MAP,
unitSet: invalidSnapshot.unitSet, unitSet: invalidSnapshot.unitSet,
getWorld: () => wrapper.world, getWorld: () => wrapper.world,
onActionResolved: (outcome) => {
if (outcome.kind === 'general') {
outcomes.push(outcome);
}
},
}); });
const world = new InMemoryTurnWorld(invalidState, invalidSnapshot, { const world = new InMemoryTurnWorld(invalidState, invalidSnapshot, {
@@ -662,12 +676,19 @@ describe('Reserved Turn Execution Integration', () => {
}); });
const dirty = world.consumeDirtyState(); const dirty = world.consumeDirtyState();
expect(world.getGeneralById(1)!.cityId).toBe(1); expect(world.getCityById(1)!.agriculture).toBe(2000);
const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.')); const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.'));
expect(denyLog?.text).toContain('이동 실패.'); expect(denyLog?.text).toContain('농지 개간 실패.');
expect(denyLog?.meta?.constraintName).toBe('notSameDestCity'); expect(denyLog?.meta?.constraintName).toBe('remainCityCapacity');
expect(denyLog?.generalId).toBe(1); expect(denyLog?.generalId).toBe(1);
expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(false);
expect(outcomes).toContainEqual(
expect.objectContaining({
requestedAction: 'che_농지개간',
actionKey: '휴식',
usedFallback: true,
})
);
}); });
describe('Uprising and Founding Execution', () => { describe('Uprising and Founding Execution', () => {
@@ -174,6 +174,26 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt); expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
}); });
it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => {
const gameBase = new Date('2026-07-30T10:00:00.000Z');
const openAt = new Date('2026-09-02T23:30:00.000Z');
const world = buildWorld({
clockBaseTime: gameBase,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: openAt,
lastTurnTick: 0,
});
const preopenAt = new Date('2026-09-02T23:03:00.000Z');
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
new Date(gameBase.getTime() + 60_000)
);
});
it.each([ it.each([
[5, 6], [5, 6],
[10, 3], [10, 3],
@@ -4,7 +4,11 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js'; import {
buildInitialTurnTime,
reserveSelectionPool,
resolveSelectionPoolUserIcon,
} from '../src/turn/selectPoolService.js';
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
interface TestPoolRow { interface TestPoolRow {
@@ -28,6 +32,30 @@ interface PoolWhere {
const acceptedAt = new Date('0200-05-01T00:00:00.000Z'); const acceptedAt = new Date('0200-05-01T00:00:00.000Z');
describe('selection-pool initial turn scheduling', () => {
it('does not inherit a turntime base from before the opening boundary', () => {
const openingGameAt = new Date('0200-01-01T00:00:00.000Z');
const preopenGameAt = new Date(openingGameAt.getTime() - 30 * 60_000);
const rng = {
nextRangeInt(min: number) {
return min;
},
};
const turnTime = buildInitialTurnTime(
rng,
{
tickSeconds: 300,
meta: { turntime: preopenGameAt.toISOString() },
} as unknown as Parameters<typeof buildInitialTurnTime>[1],
preopenGameAt,
openingGameAt
);
expect(turnTime.getTime()).toBeGreaterThanOrEqual(openingGameAt.getTime());
});
});
const buildRows = (): TestPoolRow[] => const buildRows = (): TestPoolRow[] =>
Array.from({ length: 29 }, (_, index) => { Array.from({ length: 29 }, (_, index) => {
const uniqueName = `P${String(index + 1).padStart(2, '0')}`; const uniqueName = `P${String(index + 1).padStart(2, '0')}`;
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import type { City, MapDefinition, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js';
import { createUpdateCitySupplyHandler } from '../src/turn/monthlyCitySupplyAction.js';
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js';
import { createYearbookHandler } from '../src/turn/yearbookHandler.js';
import type { PendingYearbookSnapshot, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
// Ref 계승 계약 (devel TurnExecutionHelper.php 실행 순서):
// runEventHandler(PreMonth) -> UpdateCitySupply (보급 재계산/고립)
// preUpdateMonthly() -> LogHistory (방금 끝난 달의 연감 스냅샷)
// turnDate() -> 월 advance
// 즉 월중 출병 점령은 보급을 즉시 끊지 않고(che_출병/aftermath는 점령 도시와
// 천도 신수도에만 supply=1을 쓴다), 보급切断은 다음 달 시작 경계에서 일어난다.
// 단 경계 스냅샷은 Ref #230(d17b2518) 이후 UpdateCitySupply "뒤"에 찍히므로
// 끝난 달(10월)의 연감 지도에는 이미 끊긴 보급선이 반영된다. Core의
// composeCalendarHandlers 순서(monthlyEventHandler -> yearbookHandler)가 이
// 계약을 보존하는지를 고정한다.
const map: MapDefinition = {
id: 'supply-cut-order-test',
name: 'supply-cut-order-test',
cities: [
{
id: 10,
name: '공격기지',
level: 2,
region: 1,
position: { x: 0, y: 0 },
connections: [20],
max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 },
initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 },
},
{
id: 20,
name: '중간 보급로',
level: 2,
region: 1,
position: { x: 1, y: 0 },
connections: [10, 21, 22],
max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 },
initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 },
},
{
id: 21,
name: '수도',
level: 2,
region: 1,
position: { x: 2, y: 0 },
connections: [20],
max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 },
initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 },
},
{
id: 22,
name: '외곽 도시',
level: 2,
region: 1,
position: { x: 3, y: 0 },
connections: [20],
max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 },
initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 },
},
],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
const buildCity = (id: number, nationId: number): City => ({
id,
name: map.cities.find((city) => city.id === id)?.name ?? `도시${id}`,
nationId,
level: 2,
state: 0,
population: 1_000,
populationMax: 2_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
supplyState: 1,
frontState: 0,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
conflict: {},
meta: { trust: 50, trade: 100, officer_set: 0, term: 0 },
});
const buildNation = (id: number, capitalCityId: number): Nation => ({
id,
name: `국가${id}`,
color: '#000000',
capitalCityId,
chiefGeneralId: null,
gold: 1_000,
rice: 1_000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: {},
});
type MapCityCompact = [number, number, number, number, number, number];
const buildWorld = (): { world: InMemoryTurnWorld; snapshots: () => PendingYearbookSnapshot[] } => {
const state: TurnWorldState = {
id: 1,
currentYear: 191,
currentMonth: 10,
tickSeconds: 600,
lastTurnTime: new Date('0191-10-01T00:00:00.000Z'),
meta: {},
};
const actions = new Map<string, MonthlyEventActionHandler>();
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: map.id, unitSet: 'default' },
},
map,
generals: [],
cities: [buildCity(10, 2), buildCity(20, 1), buildCity(21, 1), buildCity(22, 1)],
nations: [buildNation(1, 21), buildNation(2, 10)],
troops: [],
diplomacy: [],
events: [
{
id: 1,
targetCode: 'pre_month',
priority: 9_000,
condition: true,
action: [['UpdateCitySupply']],
meta: {},
},
],
initialEvents: [],
};
let world: InMemoryTurnWorld | null = null;
// 프로덕션 turnDaemon과 같은 구성 순서: monthly event(pre_month) -> yearbook.
const calendarHandler = composeCalendarHandlers(
createMonthlyEventHandler({ getWorld: () => world, startYear: 180, actions }),
createYearbookHandler({ profileName: 'test', getWorld: () => world }).handler
);
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler,
});
actions.set('UpdateCitySupply', createUpdateCitySupplyHandler({ getWorld: () => world!, map }));
return {
world: world!,
snapshots: () => world!.peekDirtyState().pendingYearbookSnapshots,
};
};
describe('10월중 보급로 도시 점령 -> 11월 경계切断 -> 10월 연감 스냅샷 순서', () => {
it('keeps supply alive during October and cuts it exactly at the month boundary', async () => {
const { world } = buildWorld();
// che_출병의 aftermath effect가 반영된 10월중 상태: 도시 20이 국가2에
// 점령되고 Ref처럼 supplyState=1만 쓴다. 나머지 도시는 무변경.
world.updateCity(20, { nationId: 2, supplyState: 1 });
// 10월 진행중에는 고립될 외곽 도시도 아직 보급 상태다(라이브 지도 기준).
expect(world.getCityById(22)?.supplyState).toBe(1);
expect(world.getCityById(21)?.supplyState).toBe(1);
await world.advanceMonth(new Date('0191-11-01T00:00:00.000Z'));
//切断은 정확히 월 경계에서 일어난다.
expect(world.getCityById(22)?.supplyState).toBe(0);
expect(world.getCityById(21)?.supplyState).toBe(1);
expect(world.getCityById(20)?.supplyState).toBe(1);
expect(world.getCityById(10)?.supplyState).toBe(1);
});
it('archives the finished month yearbook map after the boundary supply recalculation (Ref #230 order)', async () => {
const { world, snapshots } = buildWorld();
world.updateCity(20, { nationId: 2, supplyState: 1 });
expect(snapshots()).toEqual([]);
await world.advanceMonth(new Date('0191-11-01T00:00:00.000Z'));
const pending = snapshots();
expect(pending).toHaveLength(1);
const october = pending[0]!;
expect(october.year).toBe(191);
expect(october.month).toBe(10);
const cityList = (october.map as { cityList: MapCityCompact[] }).cityList;
const supplyFlagById = new Map(cityList.map((entry) => [entry[0], entry[5]]));
// Ref #230 이후 LogHistory는 UpdateCitySupply 뒤에 실행되므로 끝난 10월의
// 연감 지도는 이미 고립을 반영한다. 이 순서가 Core의 계약이다.
expect(supplyFlagById.get(22)).toBe(0);
expect(supplyFlagById.get(21)).toBe(1);
expect(supplyFlagById.get(20)).toBe(1);
expect(supplyFlagById.get(10)).toBe(1);
});
});
@@ -234,14 +234,19 @@ describe('HWE-shaped unification invader resume', () => {
loadArchivedNationMaxId: async () => 57, loadArchivedNationMaxId: async () => 57,
}); });
const processor = new InMemoryTurnProcessor(world); const processor = new InMemoryTurnProcessor(world);
const clock = new ManualClock(acceptedAt.getTime());
const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => { const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => {
const result = await processor.run(...args); const result = await processor.run(...args);
queue.enqueue({ type: 'shutdown', reason: 'one resumed monthly boundary verified' }); if (world.getState().currentMonth === 4) {
queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' });
} else {
clock.advanceMs(2_000);
}
return result; return result;
}); });
const lifecycle = new TurnDaemonLifecycle( const lifecycle = new TurnDaemonLifecycle(
{ {
clock: new ManualClock(acceptedAt.getTime()), clock,
controlQueue: queue, controlQueue: queue,
getNextTickTime: (value) => addMinutes(value, 1), getNextTickTime: (value) => addMinutes(value, 1),
stateStore: new InMemoryTurnStateStore(world), stateStore: new InMemoryTurnStateStore(world),
@@ -270,7 +275,11 @@ describe('HWE-shaped unification invader resume', () => {
}); });
expect(world.listNations().filter((entry) => entry.name.startsWith('ⓞ'))).toHaveLength(1); expect(world.listNations().filter((entry) => entry.name.startsWith('ⓞ'))).toHaveLength(1);
expect(world.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10); expect(world.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10);
expect(run).toHaveBeenCalledOnce(); expect(run).toHaveBeenCalledTimes(2);
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-19T21:15:00.000Z'); 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');
}); });
}); });
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
globals: true, globals: true,
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
maxWorkers: 4, maxWorkers: 4,
reporters: [process.env.SAMMO_TEST_REPORTER === 'verbose' ? 'default' : 'minimal'],
testTimeout: 10_000, testTimeout: 10_000,
}, },
}); });
+9
View File
@@ -0,0 +1,9 @@
import { expect, type Page } from '@playwright/test';
export const acceptAppConfirmation = async (page: Page, message: string): Promise<void> => {
const dialog = page.getByTestId('game-notice-dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(message);
await dialog.getByRole('button', { name: '확인', exact: true }).click();
await expect(dialog).toBeHidden();
};
+3
View File
@@ -270,6 +270,8 @@ test('resource auction preserves the legacy desktop structure, geometry, and int
return { return {
container: { x: containerRect.x, width: containerRect.width }, container: { x: containerRect.x, width: containerRect.width },
topBar: box('.top-back-bar'), topBar: box('.top-back-bar'),
topClose: box('.top-back-bar .close-button'),
bottomClose: box('.bottom-bar .close-button'),
row: { width: rowRect.width, height: rowRect.height }, row: { width: rowRect.width, height: rowRect.height },
cells, cells,
button: { button: {
@@ -282,6 +284,7 @@ test('resource auction preserves the legacy desktop structure, geometry, and int
}); });
expect(geometry.container).toEqual({ x: 0, width: 1000 }); expect(geometry.container).toEqual({ x: 0, width: 1000 });
expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 }); expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 });
expect(geometry.bottomClose).toMatchObject({ width: geometry.topClose.width, height: geometry.topClose.height });
expect(geometry.row).toEqual({ width: 1000, height: 22 }); expect(geometry.row).toEqual({ width: 1000, height: 22 });
expect(geometry.cells[0]).toBeCloseTo(66.66, 1); expect(geometry.cells[0]).toBeCloseTo(66.66, 1);
expect(geometry.cells[1]).toBeCloseTo(133.34, 1); expect(geometry.cells[1]).toBeCloseTo(133.34, 1);
+8 -2
View File
@@ -294,6 +294,7 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
return { return {
container: rect('#container'), container: rect('#container'),
topBar: rect('.top-back-bar'), topBar: rect('.top-back-bar'),
topButton: rect('.top-back-bar .back-button'),
titleLabel: rect('.form-label'), titleLabel: rect('.form-label'),
submitArticle: rect('#submitArticle'), submitArticle: rect('#submitArticle'),
articleAuthor: rect('.article-header .author-name'), articleAuthor: rect('.article-header .author-name'),
@@ -319,7 +320,7 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
}; };
}); });
expect(geometry.container).toMatchObject({ width: 1000, height: 397.3125 }); expect(geometry.container).toMatchObject({ width: 1000, height: 393.8125 });
expect(geometry.topBar.height).toBe(32); expect(geometry.topBar.height).toBe(32);
expect(geometry.titleLabel.width).toBeCloseTo(83.33, 1); expect(geometry.titleLabel.width).toBeCloseTo(83.33, 1);
expect(geometry.titleLabel).toMatchObject({ y: 64.1875, height: 22.1875 }); expect(geometry.titleLabel).toMatchObject({ y: 64.1875, height: 22.1875 });
@@ -333,7 +334,12 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
expect(geometry.commentAuthor.width).toBe(120); expect(geometry.commentAuthor.width).toBe(120);
expect(geometry.commentAuthor).toMatchObject({ y: 271.25, height: 20.1875 }); expect(geometry.commentAuthor).toMatchObject({ y: 271.25, height: 20.1875 });
expect(geometry.submitComment).toMatchObject({ y: 292.4375, height: 29.375 }); expect(geometry.submitComment).toMatchObject({ y: 292.4375, height: 29.375 });
expect(geometry.bottomButton).toMatchObject({ x: 0, y: 361.8125, width: 71, height: 35.5 }); expect(geometry.bottomButton).toMatchObject({
x: 0,
y: 361.8125,
width: geometry.topButton.width,
height: geometry.topButton.height,
});
expect(geometry.font.family).toContain('Pretendard'); expect(geometry.font.family).toContain('Pretendard');
expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' }); expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' });
expect(geometry.whiteSpace).toBe('pre'); expect(geometry.whiteSpace).toBe('pre');
+441 -63
View File
@@ -128,86 +128,89 @@ const inputOptions = {
che_포상: [ che_포상: [
{ {
value: 1, value: 1,
label: '장수 (아국 · 업)', label: '장수 (업)',
gold: 5000, gold: 5000,
rice: 400, rice: 400,
crew: 500, crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음', description: '금 5,000 · 쌀 400 · 병력 500',
}, },
{ {
value: 2, value: 2,
label: '관우 (아국 · 업)', label: '관우 (업)',
gold: 100, gold: 100,
rice: 4000, rice: 4000,
crew: 1200, crew: 1200,
troopId: 2, troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)', description: '금 100 · 쌀 4,000 · 병력 1,200',
}, },
{ {
value: 3, value: 3,
label: '여포NPC (아국 · 업)', label: '여포NPC (업)',
npcState: 2, npcState: 2,
gold: 3000, gold: 3000,
rice: 500, rice: 500,
crew: 1500, crew: 1500,
troopId: 2, troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대', description: '금 3,000 · 쌀 500 · 병력 1,500',
}, },
], ],
che_몰수: [ che_몰수: [
{ {
value: 1, value: 1,
label: '장수 (아국 · 업)', label: '장수 (업)',
gold: 5000, gold: 5000,
rice: 400, rice: 400,
crew: 500, crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음', description: '금 5,000 · 쌀 400 · 병력 500',
}, },
{ {
value: 2, value: 2,
label: '관우 (아국 · 업)', label: '관우 (업)',
gold: 100, gold: 100,
rice: 4000, rice: 4000,
crew: 1200, crew: 1200,
troopId: 2, troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)', description: '금 100 · 쌀 4,000 · 병력 1,200',
}, },
{ {
value: 3, value: 3,
label: '여포NPC (아국 · 업)', label: '여포NPC (업)',
npcState: 2,
gold: 3000, gold: 3000,
rice: 500, rice: 500,
crew: 1500, crew: 1500,
troopId: 2, troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대', description: '금 3,000 · 쌀 500 · 병력 1,500',
}, },
], ],
che_발령: [ che_발령: [
{ {
value: 1, value: 1,
label: '장수 (아국 · 업)', label: '장수 (부대 없음 · 업)',
crew: 500, crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음', description: '금 5,000 · 쌀 400 · 병력 500',
}, },
{ {
value: 2, value: 2,
label: '관우 (아국 · 업)', label: '관우 (청룡대 (부대장) · 업)',
crew: 1200, crew: 1200,
troopId: 2, troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)', description: '금 100 · 쌀 4,000 · 병력 1,200',
}, },
{ {
value: 3, value: 3,
label: '여포NPC (아국 · 업)', label: '여포NPC (청룡대 · 업)',
npcState: 2,
crew: 1500, crew: 1500,
troopId: 2, troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대', description: '금 3,000 · 쌀 500 · 병력 1,500',
}, },
], ],
che_부대탈퇴지시: [ che_부대탈퇴지시: [
{ {
value: 3, value: 3,
label: '여포NPC (아국 · 업)', label: '여포NPC (아국 · 업)',
npcState: 2,
availableNow: true, availableNow: true,
crew: 1500, crew: 1500,
troopId: 2, troopId: 2,
@@ -274,7 +277,12 @@ const inputOptions = {
avoid: 20, avoid: 20,
baseCost: 13.8, baseCost: 13.8,
baseRice: 11.5, baseRice: 11.5,
info: ['강력하지만 기술이 필요합니다.'], info: [
'강력하지만 기술이 필요합니다.',
'기술력 2000 이상 필요',
'중원 지역 소유시 가능',
'저 소유시 가능',
],
}, },
], ],
}, },
@@ -320,10 +328,11 @@ const buildGeneralCommand = (key: string, name: string) => ({
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' }, { key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
], ],
}); });
const buildSimpleCommand = (key: string, name: string, turnDurationText?: string) => ({ const buildSimpleCommand = (key: string, name: string, turnDurationText?: string, costText?: string) => ({
key, key,
name, name,
...(turnDurationText ? { turnDurationText } : {}), ...(turnDurationText ? { turnDurationText } : {}),
...(costText ? { costText } : {}),
reqArg: false, reqArg: false,
possible: true, possible: true,
status: 'available', status: 'available',
@@ -504,7 +513,32 @@ const commandTable = {
}, },
], ],
}, },
buildNationCommand('che_불가침제의', '불가침 제의'), {
...buildNationCommand('che_불가침제의', '불가침 제의'),
inputFields: [
...buildNationCommand('che_불가침제의', '불가침 제의').inputFields,
{
key: 'year',
label: '기간(년)',
kind: 'number',
required: true,
min: 191,
max: 210,
step: 1,
defaultValue: 191,
},
{
key: 'month',
label: '기간(월)',
kind: 'number',
required: true,
min: 1,
max: 12,
step: 1,
defaultValue: 1,
},
],
},
buildNationCommand('che_선전포고', '선전포고'), buildNationCommand('che_선전포고', '선전포고'),
buildNationCommand('che_종전제의', '종전 제의'), buildNationCommand('che_종전제의', '종전 제의'),
buildNationCommand('che_불가침파기제의', '불가침 파기 제의'), buildNationCommand('che_불가침파기제의', '불가침 파기 제의'),
@@ -637,9 +671,9 @@ const refChiefCommandTable = {
category: '특수', category: '특수',
values: [ values: [
buildSimpleCommand('che_초토화', '초토화', '3턴'), buildSimpleCommand('che_초토화', '초토화', '3턴'),
buildSimpleCommand('che_천도', '천도', '1+거리×2턴'), buildSimpleCommand('che_천도', '천도', '1+거리×2턴', '금·쌀 500 × 2^거리'),
buildSimpleCommand('che_증축', '증축', '6턴'), buildSimpleCommand('che_증축', '증축', '6턴', '금 110,000 · 쌀 110,000'),
buildSimpleCommand('che_감축', '감축', '6턴'), buildSimpleCommand('che_감축', '감축', '6턴', '금 80,000 · 쌀 80,000 회수'),
], ],
}, },
{ {
@@ -751,7 +785,7 @@ const chiefCenter = {
officerLevel, officerLevel,
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`, name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
npcState: officerLevel === 8 ? 2 : 0, npcState: officerLevel === 8 ? 2 : 0,
turnTime: null, turnTime: '2026-08-30T01:46:47.590Z',
revision: 0, revision: 0,
turns: turns:
officerLevel === 12 officerLevel === 12
@@ -763,8 +797,17 @@ const chiefCenter = {
})), })),
}; };
const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => { const install = async (
page: Page,
rejectGeneral = false,
commandTableResponse: unknown = commandTable,
generalId = 1
) => {
const requests: unknown[] = []; const requests: unknown[] = [];
const currentGeneralContext = {
...generalContext,
general: { ...generalContext.general, id: generalId },
};
const generalTurns = turns(30); const generalTurns = turns(30);
const nationTurns = turns(12); const nationTurns = turns(12);
let generalRevision = 0; let generalRevision = 0;
@@ -811,7 +854,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
? { ? {
kind: 'snapshot', kind: 'snapshot',
revision: 'AAAAAAAAAAAAAAAAAAAAAA', revision: 'AAAAAAAAAAAAAAAAAAAAAA',
data: generalContext, data: currentGeneralContext,
} }
: { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' }, : { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' },
commandTable: initial commandTable: initial
@@ -830,7 +873,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
: { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' }, : { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' },
}); });
} }
if (name === 'general.me') return response(generalContext); if (name === 'general.me') return response(currentGeneralContext);
if (name === 'world.getMapLayout') if (name === 'world.getMapLayout')
return response({ return response({
mapName: 'che', mapName: 'che',
@@ -844,7 +887,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
if (name === 'auth.status') return response({ ok: true }); if (name === 'auth.status') return response({ ok: true });
if (name === 'lobby.info') if (name === 'lobby.info')
return response({ return response({
myGeneral: { id: 1, name: '장수' }, myGeneral: { id: generalId, name: '장수' },
year: 200, year: 200,
month: 1, month: 1,
turnTerm: 10, turnTerm: 10,
@@ -937,6 +980,52 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
return requests; return requests;
}; };
test('offers 12 repeat turns and six shift turns for general turns while keeping the chief range', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const generalEditor = page.locator('[data-command-scope="general"]:visible');
const generalRepeat = generalEditor.locator('.control-pad > details').filter({ hasText: '반복' });
await generalRepeat.locator('summary').click();
await expect(generalRepeat.locator('.menu-items > button')).toHaveText(
Array.from({ length: 12 }, (_, index) => `${index + 1}`)
);
const generalPull = generalEditor.locator('.bottom-shift-menu').first();
await generalPull.locator('summary').click();
await expect(generalPull.locator('.menu-items > button')).toHaveText(
Array.from({ length: 6 }, (_, index) => `${index + 1}`)
);
await page.setViewportSize({ width: 500, height: 900 });
const mobileGeneralEditor = page.locator('[data-command-scope="general"]:visible');
const mobileGeneralRepeat = mobileGeneralEditor.locator('.control-pad > details').filter({ hasText: '반복' });
await mobileGeneralRepeat.locator('summary').click();
const mobileRepeatItems = mobileGeneralRepeat.locator('.menu-items');
await expect(mobileRepeatItems).toBeVisible();
await expect(mobileRepeatItems.locator(':scope > button')).toHaveText(
Array.from({ length: 12 }, (_, index) => `${index + 1}`)
);
const mobileGeometry = await mobileRepeatItems.evaluate((element) => ({
right: element.getBoundingClientRect().right,
height: element.getBoundingClientRect().height,
viewportWidth: document.documentElement.clientWidth,
overflow: element.scrollWidth - element.clientWidth,
}));
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
expect(mobileGeometry.height).toBeGreaterThan(0);
expect(mobileGeometry.overflow).toBeLessThanOrEqual(0);
await page.screenshot({ path: test.info().outputPath('general-repeat-12-mobile-500.png'), fullPage: true });
await page.goto('/che/chief-center');
const chiefEditor = page.locator('[data-command-scope="nation"]:visible').first();
const chiefRepeat = chiefEditor.locator('.control-pad > details').filter({ hasText: '반복' });
await chiefRepeat.locator('summary').click();
await expect(chiefRepeat.locator('.menu-items > button')).toHaveText(
Array.from({ length: 6 }, (_, index) => `${index + 1}`)
);
});
test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => { test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -1003,7 +1092,9 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
}); });
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => { test('defaults founding to a Ref-selectable nation trait and opens colored options inside the app', async ({
page,
}) => {
const foundingColors = [ const foundingColors = [
{ value: 0, label: '색상 1', color: '#FF0000' }, { value: 0, label: '색상 1', color: '#FF0000' },
{ value: 15, label: '색상 16', color: '#6495ED' }, { value: 15, label: '색상 16', color: '#6495ED' },
@@ -1044,7 +1135,7 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
nation: [], nation: [],
inputOptions: { ...inputOptions, colors: foundingColors }, inputOptions: { ...inputOptions, colors: foundingColors },
}; };
await install(page, false, foundingCommandTable); const requests = await install(page, false, foundingCommandTable);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
@@ -1060,18 +1151,30 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
await expect(nationType).toBeFocused(); await expect(nationType).toBeFocused();
const colorType = picker.getByLabel('국기 색상'); const colorType = picker.getByLabel('국기 색상');
const color16 = colorType.locator('option[value="15"]'); await expect(colorType).toHaveRole('button');
await colorType.click();
const colorList = picker.getByRole('listbox');
const color16 = colorList.getByRole('option', { name: '색상 16' });
await expect(color16).toHaveText('색상 16'); await expect(color16).toHaveText('색상 16');
await expect(color16).toHaveCSS('background-color', 'rgb(100, 149, 237)'); await expect(color16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(color16).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(color16).toHaveCSS('color', 'rgb(255, 255, 255)');
await colorType.selectOption('15'); await color16.hover();
await expect(colorType).toHaveValue('15'); await color16.focus();
await expect(color16).toBeFocused();
await color16.press('Escape');
await expect(colorList).toBeHidden();
await expect(colorType).toBeFocused();
await colorType.click();
await color16.click();
await expect(colorType).toContainText('색상 16');
await expect(colorType).toHaveCSS('background-color', 'rgb(100, 149, 237)'); await expect(colorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(colorType).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(colorType).toHaveCSS('color', 'rgb(255, 255, 255)');
await colorType.selectOption('16'); await colorType.press('ArrowDown');
await page.getByRole('option', { name: '색상 17' }).press('Enter');
await expect(colorType).toHaveCSS('background-color', 'rgb(127, 255, 212)'); await expect(colorType).toHaveCSS('background-color', 'rgb(127, 255, 212)');
await expect(colorType).toHaveCSS('color', 'rgb(0, 0, 0)'); await expect(colorType).toHaveCSS('color', 'rgb(0, 0, 0)');
await colorType.selectOption('15'); await colorType.press('ArrowUp');
await page.getByRole('option', { name: '색상 16' }).press('Enter');
await picker.screenshot({ path: test.info().outputPath('founding-colored-option-desktop-1200.png') }); await picker.screenshot({ path: test.info().outputPath('founding-colored-option-desktop-1200.png') });
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -1079,8 +1182,27 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
const mobilePicker = page.getByTestId('command-picker'); const mobilePicker = page.getByTestId('command-picker');
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click(); await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click(); await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
await mobilePicker.getByLabel('국가명').fill('신국');
const mobileColorType = mobilePicker.getByLabel('국기 색상'); const mobileColorType = mobilePicker.getByLabel('국기 색상');
await mobileColorType.selectOption('15'); await mobileColorType.click();
const mobileColorList = mobilePicker.getByRole('listbox');
const mobileColor16 = mobileColorList.getByRole('option', { name: '색상 16' });
await expect(mobileColor16).toBeVisible();
await expect(mobileColor16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(mobileColor16).toHaveCSS('color', 'rgb(255, 255, 255)');
const mobileOptionRect = await mobileColor16.evaluate((element) => element.getBoundingClientRect().toJSON());
expect(mobileOptionRect.height).toBeGreaterThanOrEqual(44);
const mobileListGeometry = await mobileColorList.evaluate((element) => ({
bottom: element.getBoundingClientRect().bottom,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
const mobilePickerBottom = await mobilePicker.evaluate((element) => element.getBoundingClientRect().bottom);
expect(mobileListGeometry.bottom).toBeLessThanOrEqual(mobilePickerBottom);
expect(mobileListGeometry.scrollHeight).toBeGreaterThanOrEqual(mobileListGeometry.clientHeight);
await mobilePicker.screenshot({ path: test.info().outputPath('founding-color-options-open-mobile-500.png') });
await mobileColor16.click();
await expect(mobileColorType).toContainText('색상 16');
await expect(mobileColorType).toHaveCSS('background-color', 'rgb(100, 149, 237)'); await expect(mobileColorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(mobileColorType).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(mobileColorType).toHaveCSS('color', 'rgb(255, 255, 255)');
const colorSelectRect = await mobileColorType.evaluate((element) => element.getBoundingClientRect().toJSON()); const colorSelectRect = await mobileColorType.evaluate((element) => element.getBoundingClientRect().toJSON());
@@ -1088,9 +1210,12 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
expect(colorSelectRect.right).toBeLessThanOrEqual(500); expect(colorSelectRect.right).toBeLessThanOrEqual(500);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') }); await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') });
await mobilePicker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toContainText('신국');
await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15');
}); });
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { test('shows speciality reset cooldowns and reserves special user commands from the picker', async ({ page }) => {
const specialCommandTable = { const specialCommandTable = {
general: [ general: [
{ {
@@ -1105,6 +1230,24 @@ test('reserves force move, retirement, and resignation from the user command pic
reason: '나이가 60세 이상이어야 합니다.', reason: '나이가 60세 이상이어야 합니다.',
inputFields: [], inputFields: [],
}, },
{
key: 'che_내정특기초기화',
name: '내정 특기 초기화',
reqArg: false,
possible: false,
status: 'blocked',
reason: '12턴 더 기다려야 합니다',
inputFields: [],
},
{
key: 'che_전투특기초기화',
name: '전투 특기 초기화',
reqArg: false,
possible: false,
status: 'blocked',
reason: '24턴 더 기다려야 합니다',
inputFields: [],
},
], ],
}, },
{ {
@@ -1156,6 +1299,12 @@ test('reserves force move, retirement, and resignation from the user command pic
const retirement = picker.getByRole('button', { name: '은퇴', exact: true }); const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
await expect(retirement).toHaveClass(/blocked/); await expect(retirement).toHaveClass(/blocked/);
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.'); await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
const domesticReset = picker.getByRole('button', { name: '내정 특기 초기화', exact: true });
const warReset = picker.getByRole('button', { name: '전투 특기 초기화', exact: true });
await expect(domesticReset).toHaveClass(/blocked/);
await expect(domesticReset).toHaveAttribute('title', '12턴 더 기다려야 합니다');
await expect(warReset).toHaveClass(/blocked/);
await expect(warReset).toHaveAttribute('title', '24턴 더 기다려야 합니다');
await retirement.hover(); await retirement.hover();
await retirement.focus(); await retirement.focus();
await expect(retirement).toBeFocused(); await expect(retirement).toBeFocused();
@@ -1198,6 +1347,8 @@ test('reserves force move, retirement, and resignation from the user command pic
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
await picker.getByRole('button', { name: '개인', exact: true }).click(); await picker.getByRole('button', { name: '개인', exact: true }).click();
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible(); await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
await expect(picker.getByRole('button', { name: '내정 특기 초기화', exact: true })).toHaveClass(/blocked/);
await expect(picker.getByRole('button', { name: '전투 특기 초기화', exact: true })).toHaveClass(/blocked/);
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') }); await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
}); });
@@ -1226,9 +1377,9 @@ test('shows every Ref chief command in the exact category and command order', as
await picker.getByRole('button', { name: '특수', exact: true }).click(); await picker.getByRole('button', { name: '특수', exact: true }).click();
await expect(picker.locator('.command-grid .command-item')).toHaveText([ await expect(picker.locator('.command-grid .command-item')).toHaveText([
'초토화 /3턴', '초토화 /3턴',
'천도 /1+거리×2턴', '천도 /1+거리×2턴금·쌀 500 × 2^거리',
'증축 /6턴', '증축 /6턴금 110,000 · 쌀 110,000',
'감축 /6턴', '감축 /6턴금 80,000 · 쌀 80,000 회수',
]); ]);
const durationGeometry = await picker.locator('.command-grid .command-item').evaluateAll((buttons) => const durationGeometry = await picker.locator('.command-grid .command-item').evaluateAll((buttons) =>
buttons.map((button) => ({ buttons.map((button) => ({
@@ -1238,6 +1389,7 @@ test('shows every Ref chief command in the exact category and command order', as
})) }))
); );
expect(durationGeometry.every(({ height, overflow }) => height >= 35 && overflow <= 0)).toBe(true); expect(durationGeometry.every(({ height, overflow }) => height >= 35 && overflow <= 0)).toBe(true);
await picker.screenshot({ path: test.info().outputPath('chief-command-costs-desktop-1200.png') });
await picker.getByRole('button', { name: '기타', exact: true }).click(); await picker.getByRole('button', { name: '기타', exact: true }).click();
const rename = picker.getByRole('button', { name: '국호변경', exact: true }); const rename = picker.getByRole('button', { name: '국호변경', exact: true });
await rename.hover(); await rename.hover();
@@ -1260,13 +1412,14 @@ test('shows every Ref chief command in the exact category and command order', as
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
await mobilePicker.getByRole('button', { name: '특수', exact: true }).click(); await mobilePicker.getByRole('button', { name: '특수', exact: true }).click();
const mobileExpand = mobilePicker.getByRole('button', { name: '증축 /6턴', exact: true }); const mobileExpand = mobilePicker.getByRole('button', { name: '증축 /6턴 금 110,000 · 쌀 110,000', exact: true });
await expect(mobileExpand).toBeVisible(); await expect(mobileExpand).toBeVisible();
await mobileExpand.hover(); await mobileExpand.hover();
await mobileExpand.focus(); await mobileExpand.focus();
await expect(mobileExpand).toBeFocused(); await expect(mobileExpand).toBeFocused();
await mobileExpand.dispatchEvent('pointerdown'); await mobileExpand.dispatchEvent('pointerdown');
await expect(mobileExpand.locator('.command-duration')).toHaveText('/6턴'); await expect(mobileExpand.locator('.command-duration')).toHaveText('/6턴');
await expect(mobileExpand.locator('.command-cost')).toHaveText('금 110,000 · 쌀 110,000');
await mobileExpand.dispatchEvent('pointerup'); await mobileExpand.dispatchEvent('pointerup');
expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') }); await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
@@ -1340,6 +1493,53 @@ test('keeps general and chief command categories after input and across page rel
}); });
}); });
test('keeps the general turn editor mode across general recreation within one server profile', async ({
page,
context,
}) => {
await install(page, false, commandTable, 1);
await page.addInitScript(() => {
localStorage.setItem('core2026:general:1:editMode', '1');
});
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const profileModeKey = 'core2026:profile:che:general-turn-editor:editMode';
const firstEditor = page.locator('[data-command-scope="general"]');
await expect(firstEditor.getByRole('button', { name: '일반 모드', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), profileModeKey)).toBe('1');
await page.evaluate(() => localStorage.removeItem('core2026:general:1:editMode'));
const recreatedPage = await context.newPage();
await install(recreatedPage, false, commandTable, 2);
await recreatedPage.setViewportSize({ width: 500, height: 900 });
await recreatedPage.goto('/');
const recreatedEditor = recreatedPage.locator('[data-command-scope="general"]');
await expect(recreatedEditor.getByRole('button', { name: '일반 모드', exact: true })).toBeVisible();
await expect
.poll(() => recreatedPage.evaluate(() => localStorage.getItem('core2026:general:2:editMode')))
.toBeNull();
await recreatedEditor.screenshot({
path: test.info().outputPath('general-advanced-mode-after-recreation-mobile-500.png'),
});
await recreatedEditor.getByRole('button', { name: '일반 모드', exact: true }).click();
await expect(recreatedEditor.getByRole('button', { name: '고급 모드', exact: true })).toBeVisible();
await expect.poll(() => recreatedPage.evaluate((key) => localStorage.getItem(key), profileModeKey)).toBe('0');
const nextGeneralPage = await context.newPage();
await install(nextGeneralPage, false, commandTable, 3);
await nextGeneralPage.goto('/');
await expect(
nextGeneralPage.locator('[data-command-scope="general"]').getByRole('button', {
name: '고급 모드',
exact: true,
})
).toBeVisible();
await nextGeneralPage.close();
await recreatedPage.close();
});
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => { test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -1569,7 +1769,7 @@ test('enters general and nation command arguments and sends exact values', async
expect(castle.renderedWidth / castle.naturalWidth).toBeCloseTo(castle.renderedHeight / castle.naturalHeight, 2); expect(castle.renderedWidth / castle.naturalWidth).toBeCloseTo(castle.renderedHeight / castle.naturalHeight, 2);
} }
const layerStyles = await commandMap.evaluate((element) => ({ const layerStyles = await commandMap.evaluate((element) => ({
background: getComputedStyle(element.querySelector<HTMLElement>('.map-bglayer1')!).backgroundImage, background: element.querySelector<HTMLImageElement>('.map-bglayer1 .map-background-image')?.src ?? '',
road: getComputedStyle(element.querySelector<HTMLElement>('.map-bgroad')!).backgroundImage, road: getComputedStyle(element.querySelector<HTMLElement>('.map-bgroad')!).backgroundImage,
})); }));
expect(layerStyles.background).toContain('/game/map/che/bg_spring.jpg'); expect(layerStyles.background).toContain('/game/map/che/bg_spring.jpg');
@@ -1710,6 +1910,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true }); const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true });
await expect(unavailable).toBeVisible(); await expect(unavailable).toBeVisible();
await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)'); await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)');
await expect(unavailable.locator('.crew-info')).toHaveText(
'강력하지만 기술이 필요합니다.기술력 2000 이상 필요중원 지역 소유시 가능저 소유시 가능'
);
const desktopGeometry = await form.evaluate(async (element) => { const desktopGeometry = await form.evaluate(async (element) => {
const row = element.querySelector('.crew-row'); const row = element.querySelector('.crew-row');
@@ -1745,7 +1948,12 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
expect(desktopGeometry.bodyOverflow).toBe('hidden'); expect(desktopGeometry.bodyOverflow).toBe('hidden');
const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true }); const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true });
const estimatedGold = infantry.getByRole('status', { name: '보병 예상 자금' });
await expect(estimatedGold).toHaveText('673금');
await infantry.locator('input[type=number]').fill('100');
await expect(estimatedGold).toHaveText('1,035금');
await infantry.getByRole('button', { name: '절반', exact: true }).click(); await infantry.getByRole('button', { name: '절반', exact: true }).click();
await expect(estimatedGold).toHaveText('363금');
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') }); await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
@@ -2020,9 +2228,79 @@ test('touch command maps select city and nation on the first tap without changin
} }
}); });
test('limits non-aggression end years to the Ref twenty-year window on desktop and mobile', async ({
browser,
}, testInfo) => {
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
const context = await browser.newContext({ viewport });
const page = await context.newPage();
try {
await install(page);
await page.goto('/che/chief-center');
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 yearInput = form.locator('#command-arg-year');
await expect(yearInput).toHaveAttribute('min', '191');
await expect(yearInput).toHaveAttribute('max', '210');
await expect(yearInput).toHaveValue('191');
const submit = picker.getByRole('button', { name: '입력', exact: true });
await yearInput.fill('211');
await expect(submit).toBeDisabled();
await yearInput.fill('210');
await expect(submit).toBeEnabled();
await picker.screenshot({ path: testInfo.outputPath(`non-aggression-year-limit-${viewport.name}.png`) });
} finally {
await context.close();
}
}
});
test('reserves argument-free expansion and reduction immediately on desktop and mobile', async ({
browser,
}, testInfo) => {
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
const context = await browser.newContext({ viewport });
const page = await context.newPage();
try {
const requests = await install(page);
await page.goto('/che/chief-center');
const editor = page.locator('[data-command-scope="nation"]');
for (const [turn, action] of ['증축', '감축'].entries()) {
await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click();
const picker = editor.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?특수$/ }).click();
await picker.getByRole('button', { name: new RegExp(action) }).click();
await expect(picker).toBeHidden();
await expect(editor.locator('.action-column > div').nth(turn)).toContainText(action);
}
const serialized = JSON.stringify(requests);
expect(serialized).toContain('"action":"che_증축","args":{}');
expect(serialized).toContain('"action":"che_감축","args":{}');
await page.screenshot({ path: testInfo.outputPath(`chief-capital-commands-${viewport.name}.png`) });
} finally {
await context.close();
}
}
});
test('shows a map and target details for every city or nation argument chief command except assignment', async ({ test('shows a map and target details for every city or nation argument chief command except assignment', async ({
page, page,
}) => { }) => {
test.setTimeout(60_000);
await install(page); await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
const cases = [ const cases = [
@@ -2244,22 +2522,21 @@ test('offers Ref amount presets and rich, command-specific general lists', async
await expect(amount).toHaveValue('1375'); await expect(amount).toHaveValue('1375');
let generalList = form.getByTestId('general-target-list'); let generalList = form.getByTestId('general-target-list');
await expect(generalList.locator('.target-option strong')).toHaveText([ await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
'관우 (아국 · 업)', '관우 (업)',
'여포NPC (아국 · 업)', '장수 (업)',
'장수 (아국 · 업)', '여포NPC (업)',
]); ]);
await expect(generalList.locator('.target-option strong')).toHaveText(['관우 (업)', '장수 (업)', '여포NPC (업)']);
await expect(generalList.locator('.target-option').filter({ hasText: '여포NPC' }).locator('strong')).toHaveCSS( await expect(generalList.locator('.target-option').filter({ hasText: '여포NPC' }).locator('strong')).toHaveCSS(
'color', 'color',
'rgb(0, 255, 255)' 'rgb(0, 255, 255)'
); );
await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)'); await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200');
await expect(generalList).not.toContainText('아국');
await expect(generalList).not.toContainText('탑승 부대');
await form.getByRole('button', { name: '쌀', exact: true }).click(); await form.getByRole('button', { name: '쌀', exact: true }).click();
await expect(generalList.locator('.target-option strong')).toHaveText([ await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
'장수 (아국 · 업)',
'여포NPC (아국 · 업)',
'관우 (아국 · 업)',
]);
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click(); await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk')); const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
@@ -2272,19 +2549,34 @@ test('offers Ref amount presets and rich, command-specific general lists', async
await picker.getByRole('button', { name: /몰수/ }).click(); await picker.getByRole('button', { name: /몰수/ }).click();
form = picker.getByTestId('command-argument-form'); form = picker.getByTestId('command-argument-form');
generalList = form.getByTestId('general-target-list'); generalList = form.getByTestId('general-target-list');
await expect(generalList.locator('.target-option strong')).toHaveText([ await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
'장수 (아국 · 업)', '장수 (업)',
'여포NPC (아국 · 업)', '관우 (업)',
'관우 (아국 · 업)', '여포NPC (업)',
]); ]);
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
await expect(generalList).not.toContainText('아국');
await expect(generalList).not.toContainText('탑승 부대');
await page.goto('/che/chief-center'); await page.goto('/che/chief-center');
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker'); picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /발령/ }).click(); await picker.getByRole('button', { name: /발령/ }).click();
form = picker.getByTestId('command-argument-form'); form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('general-target-list')).toContainText('병력 1,200 · 탑승 부대 청룡대 (부대장)'); 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 (청룡대 · 업)',
]);
await expect(generalList).not.toContainText('아국');
await expect(generalList).not.toContainText('탑승 부대');
await picker.screenshot({ path: test.info().outputPath('chief-assignment-option-format-desktop.png') });
await page.goto('/che/chief-center'); await page.goto('/che/chief-center');
await page.getByRole('button', { name: '4턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
@@ -2293,9 +2585,13 @@ test('offers Ref amount presets and rich, command-specific general lists', async
await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click(); await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click();
form = picker.getByTestId('command-argument-form'); form = picker.getByTestId('command-argument-form');
generalList = form.getByTestId('general-target-list'); generalList = form.getByTestId('general-target-list');
await expect(generalList.locator('.target-option strong').first()).toHaveText('여포NPC (아국 · 업)'); await expect(generalList.locator('.target-option strong')).toHaveText([
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('우선 대상'); '장수 (아국 · 업)',
await expect(generalList.locator('.target-option').nth(1).locator('.target-state')).toHaveText('현재 불가'); '관우 (아국 · 업)',
'여포NPC (아국 · 업)',
]);
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('현재 불가');
await expect(generalList.locator('.target-option').nth(2).locator('.target-state')).toHaveText('우선 대상');
await page.goto('/che/chief-center'); await page.goto('/che/chief-center');
await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click();
@@ -2319,6 +2615,34 @@ test('offers Ref amount presets and rich, command-specific general lists', async
expect(serialized).toContain('"destGeneralId":3'); expect(serialized).toContain('"destGeneralId":3');
expect(serialized).toContain('"amountList":[20000,1370]'); expect(serialized).toContain('"amountList":[20000,1370]');
await page.screenshot({ path: test.info().outputPath('chief-ref-guidance-controls.png'), fullPage: true }); await page.screenshot({ path: test.info().outputPath('chief-ref-guidance-controls.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 900 });
const mobilePersonnelCases = [
{ turn: 5, command: '포상', labels: ['관우 (업)', '장수 (업)', '여포NPC (업)'] },
{ turn: 6, command: '몰수', labels: ['장수 (업)', '관우 (업)', '여포NPC (업)'] },
{
turn: 7,
command: '발령',
labels: ['장수 (부대 없음 · 업)', '관우 (청룡대 (부대장) · 업)', '여포NPC (청룡대 · 업)'],
},
];
for (const entry of mobilePersonnelCases) {
await page.goto('/che/chief-center');
await page.getByRole('button', { name: `${entry.turn + 1}턴 명령 입력`, exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: new RegExp(entry.command) }).click();
form = picker.getByTestId('command-argument-form');
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText(entry.labels);
await expect(form.getByTestId('general-target-list')).not.toContainText('아국');
}
const mobileGeometry = await picker.evaluate((element) => ({
pickerWidth: element.getBoundingClientRect().width,
pickerOverflow: element.scrollWidth - element.clientWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
expect(mobileGeometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0 });
await page.screenshot({ path: test.info().outputPath('chief-personnel-option-format-mobile.png'), fullPage: true });
}); });
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => { test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
@@ -2385,10 +2709,15 @@ test('keeps the entered command visible and reports a server validation error',
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
await page.getByTestId('command-argument-form').locator('select').selectOption('2'); await page.getByTestId('command-argument-form').locator('select').selectOption('2');
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); const submit = page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true });
await submit.click();
await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.'); await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.');
await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
await expect(submit).toBeEnabled();
await expect(page.getByTestId('command-picker').getByRole('button', { name: '저장 중', exact: true })).toHaveCount(
0
);
}); });
test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page, context }) => { test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page, context }) => {
@@ -2464,6 +2793,24 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
const mobileEditor = mobilePage.locator('[data-command-scope="general"]'); const mobileEditor = mobilePage.locator('[data-command-scope="general"]');
const mobileFirstRow = mobileEditor.locator('.action-column > div').first(); const mobileFirstRow = mobileEditor.locator('.action-column > div').first();
await expect(mobileFirstRow).toContainText('휴식(자율 행동)'); await expect(mobileFirstRow).toContainText('휴식(자율 행동)');
const mobileRestGeometry = await mobileFirstRow.evaluate((element) => {
const label = element.querySelector<HTMLElement>('span');
const autonomous = element.querySelector<HTMLElement>('small');
if (!label || !autonomous) throw new Error('mobile autonomous rest labels are missing');
const labelRect = label.getBoundingClientRect();
const autonomousRect = autonomous.getBoundingClientRect();
return {
display: getComputedStyle(element).display,
labelCenterY: labelRect.top + labelRect.height / 2,
autonomousCenterY: autonomousRect.top + autonomousRect.height / 2,
inlineGap: autonomousRect.left - labelRect.right,
rowOverflow: element.scrollWidth - element.clientWidth,
};
});
expect(mobileRestGeometry.display).toBe('flex');
expect(mobileRestGeometry.labelCenterY).toBeCloseTo(mobileRestGeometry.autonomousCenterY, 0);
expect(Math.abs(mobileRestGeometry.inlineGap)).toBeLessThanOrEqual(1);
expect(mobileRestGeometry.rowOverflow).toBeLessThanOrEqual(0);
await expect(mobileFirstRow).toHaveAttribute('data-autorun-tooltip', / : 200 3 · .*/u); await expect(mobileFirstRow).toHaveAttribute('data-autorun-tooltip', / : 200 3 · .*/u);
await mobileFirstRow.focus(); await mobileFirstRow.focus();
await expect await expect
@@ -2625,6 +2972,15 @@ test('physical mobile touch drag selects general and nation turns in advanced mo
} }
}); });
test('renders chief turn rows in the fixed UTC+9 server timezone', async ({ page }) => {
await install(page);
await page.goto('chief-center');
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await expect(page.locator('.chief-grid-row').first().locator('.row-time').nth(0)).toHaveText('10:46');
await expect(page.locator('.chief-grid-row').first().locator('.row-time').nth(1)).toHaveText('10:56');
});
test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => {
const requests = await install(page); const requests = await install(page);
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
@@ -2706,3 +3062,25 @@ test('keeps the shared main and chief shell geometry and interaction states', as
})); }));
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 }); expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
}); });
test('keeps the chief footer return button the same size as the top return button', async ({ page }) => {
await install(page);
const measure = () =>
page.locator('.chief-page').evaluate((element) => {
const top = element.querySelector<HTMLElement>('.chief-top .chief-nav')!.getBoundingClientRect();
const bottom = element.querySelector<HTMLElement>('.chief-footer .chief-nav')!.getBoundingClientRect();
return {
top: { width: top.width, height: top.height },
bottom: { width: bottom.width, height: bottom.height },
};
});
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
const desktop = await measure();
expect(desktop.bottom).toEqual(desktop.top);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await measure();
expect(mobile.bottom).toEqual(mobile.top);
});
+58 -7
View File
@@ -19,6 +19,21 @@ const nationDirectory = [
power: 2000, power: 2000,
capitalCityId: 2, capitalCityId: 2,
generalCount: 1, generalCount: 1,
forceEstimate: {
totalGeneralCount: 1,
userCombatGeneralCount: 1,
userTroops: 8_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 0,
},
generalGroups: [
{
type: '만능',
label: '만능장',
generals: [{ id: 20, name: '유비', npcState: 0, inactive: false, accessGrade: 'normal' }],
},
],
cityCount: 1, cityCount: 1,
officers: Array.from({ length: 8 }, (_, index) => ({ officers: Array.from({ length: 8 }, (_, index) => ({
officerLevel: 12 - index, officerLevel: 12 - index,
@@ -38,6 +53,26 @@ const nationDirectory = [
power: 1000, power: 1000,
capitalCityId: 1, capitalCityId: 1,
generalCount: 2, generalCount: 2,
forceEstimate: {
totalGeneralCount: 2,
userCombatGeneralCount: 1,
userTroops: 9_000,
npcCombatGeneralCount: 0,
npcTroops: 0,
inactiveGeneralCount: 1,
},
generalGroups: [
{
type: '무',
label: '무장',
generals: [{ id: 10, name: '조조', npcState: 0, inactive: false, accessGrade: 'high' }],
},
{
type: '지',
label: '지장',
generals: [{ id: 11, name: '순욱', npcState: 1, inactive: true, accessGrade: 'medium' }],
},
],
cityCount: 1, cityCount: 1,
officers: Array.from({ length: 8 }, (_, index) => ({ officers: Array.from({ length: 8 }, (_, index) => ({
officerLevel: 12 - index, officerLevel: 12 - index,
@@ -60,6 +95,15 @@ const nationDirectory = [
power: 0, power: 0,
capitalCityId: 0, capitalCityId: 0,
generalCount: 1, generalCount: 1,
forceEstimate: {
totalGeneralCount: 1,
userCombatGeneralCount: 0,
userTroops: 0,
npcCombatGeneralCount: 1,
npcTroops: 7_000,
inactiveGeneralCount: 0,
},
generalGroups: [],
cityCount: 1, cityCount: 1,
officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })), officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })),
ambassadorNames: [], ambassadorNames: [],
@@ -348,10 +392,9 @@ test('a level-zero nation shows the ruler current city even without territory th
await expect(table.locator('.roaming-city')).toHaveCSS('color', 'rgb(255, 255, 0)'); await expect(table.locator('.roaming-city')).toHaveCSS('color', 'rgb(255, 255, 0)');
}); });
test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ( test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ({
{ page }, page,
testInfo }, testInfo) => {
) => {
const requestedPortraits = new Set<string>(); const requestedPortraits = new Set<string>();
page.on('request', (request) => { page.on('request', (request) => {
if (request.url().includes('/performance/general-')) { if (request.url().includes('/performance/general-')) {
@@ -697,6 +740,16 @@ test('nation directory reuses only the public general-directory row on hover and
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0); await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0);
await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4); await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4);
const nationAnalysis = page.locator('[data-nation-id="1"] .force-analysis');
await expect(nationAnalysis).not.toContainText('총 병사');
await expect(nationAnalysis).toContainText('무장(1): 조조');
await expect(nationAnalysis).toContainText('지장(1): ⓝ순욱');
await expect(nationAnalysis).toContainText('예상 병력 약 9,000명');
await expect(nationAnalysis).toContainText('삭턴장(1)');
await expect(page.locator('[data-general-preview-trigger="11"]')).toHaveCSS(
'text-decoration-line',
'line-through'
);
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength( expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(
viewport.name === 'desktop' ? 0 : 1 viewport.name === 'desktop' ? 0 : 1
); );
@@ -860,9 +913,7 @@ test('nation and general directories rearrange for mobile and keep the tapped pr
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2); await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText( await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText('81');
'81'
);
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6'); await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]'); const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
await mobileInjury.focus(); await mobileInjury.focus();
+105 -24
View File
@@ -58,9 +58,9 @@ const city = {
}; };
const castleFixtures = [ const castleFixtures = [
{ id: 1, level: 8, layoutLevel: 1, x: 100, y: 100, width: 32, height: 24 }, { id: 1, level: 8, layoutLevel: 1, x: 100, y: 100, width: 32, height: 24 },
{ id: 2, level: 1, layoutLevel: 8, x: 200, y: 100, width: 16, height: 15 }, { id: 2, name: '진류', level: 6, layoutLevel: 6, x: 365, y: 185, width: 26, height: 18 },
{ id: 3, level: 2, layoutLevel: 8, x: 300, y: 100, width: 20, height: 14 }, { id: 3, name: '관도', level: 2, layoutLevel: 2, x: 340, y: 165, width: 20, height: 14 },
{ id: 4, level: 3, layoutLevel: 8, x: 400, y: 100, width: 14, height: 14 }, { id: 4, name: '정도', level: 2, layoutLevel: 2, x: 400, y: 210, width: 20, height: 14 },
{ id: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 }, { id: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 },
{ id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 }, { id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 },
{ id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 }, { id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 },
@@ -419,7 +419,8 @@ const install = async (
: [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }], : [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }],
}, },
...(denseCurrentCity ...(denseCurrentCity
? Array.from({ length: 12 }, (_, index) => ({ ? [
...Array.from({ length: 12 }, (_, index) => ({
id: index + 2, id: index + 2,
name: `NPC장수이름이긴${index + 1}`, name: `NPC장수이름이긴${index + 1}`,
npcState: 2, npcState: 2,
@@ -440,7 +441,30 @@ const install = async (
train: 90, train: 90,
atmos: 90, atmos: 90,
turns: [], turns: [],
})) })),
{
id: 14,
name: '적국NPC',
npcState: 2,
picture: null,
imageServer: 0,
nationId: 2,
nationName: '적국',
leadership: 60,
strength: 60,
intelligence: 60,
injury: 0,
officerLevel: 1,
leadershipBonus: 0,
defenceTrain: null,
crewTypeId: null,
crewTypeName: null,
crew: 500,
train: null,
atmos: null,
turns: [],
},
]
: []), : []),
], ],
forceSummary: { forceSummary: {
@@ -551,6 +575,63 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await desktopCity.hover(); await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
const cityHitTargets = await page.locator('.map-area .city-base').evaluateAll((cities) =>
cities.map((city) => {
const icon = city.querySelector<HTMLElement>('.city-icon');
const background = city.querySelector<HTMLElement>('.city-bg');
if (!icon || !background) throw new Error('Missing city icon or background');
const cityRect = city.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
return {
city: { left: cityRect.left, top: cityRect.top, width: cityRect.width, height: cityRect.height },
icon: { left: iconRect.left, top: iconRect.top, width: iconRect.width, height: iconRect.height },
backgroundPointerEvents: getComputedStyle(background).pointerEvents,
};
})
);
for (const target of cityHitTargets) {
expect(target.city).toEqual(target.icon);
expect(target.backgroundPointerEvents).toBe('none');
}
const firstCityBackgroundOnlyPoint = await desktopCity.evaluate((city) => {
const iconRect = city.querySelector<HTMLElement>('.city-icon')!.getBoundingClientRect();
const backgroundRect = city.querySelector<HTMLElement>('.city-bg')!.getBoundingClientRect();
return {
x: backgroundRect.left + 2,
y: backgroundRect.top + 2,
icon: { left: iconRect.left, top: iconRect.top, right: iconRect.right, bottom: iconRect.bottom },
};
});
expect(firstCityBackgroundOnlyPoint.x).toBeLessThan(firstCityBackgroundOnlyPoint.icon.left);
expect(firstCityBackgroundOnlyPoint.y).toBeLessThan(firstCityBackgroundOnlyPoint.icon.top);
await page.mouse.move(firstCityBackgroundOnlyPoint.x, firstCityBackgroundOnlyPoint.y);
await expect(page.locator('.map-tooltip')).toHaveCount(0);
await page.mouse.click(firstCityBackgroundOnlyPoint.x, firstCityBackgroundOnlyPoint.y);
await expect(page).toHaveURL(/\/global-info$/u);
const mapRect = await page.locator('.map-area').evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, top: rect.top };
});
for (const gap of [
{ x: 351, y: 174, between: '관도-진류' },
{ x: 383, y: 198, between: '진류-정도' },
]) {
await page.mouse.move(mapRect.left + gap.x, mapRect.top + gap.y);
await expect(page.locator('.map-tooltip'), `${gap.between} 아이콘 사이 hover`).toHaveCount(0);
await page.mouse.click(mapRect.left + gap.x, mapRect.top + gap.y);
await expect(page, `${gap.between} 아이콘 사이 click`).toHaveURL(/\/global-info$/u);
}
const jinliu = page.getByRole('link', { name: '진류', exact: true });
await jinliu.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|중】진류');
await jinliu.click();
await expect(page).toHaveURL(/\/current-city\?cityId=2$/u);
await page.goBack();
await expect(page).toHaveURL(/\/global-info$/u);
for (const cityName of ['남만', '교지', '남해', '대']) { for (const cityName of ['남만', '교지', '남해', '대']) {
await page.getByRole('link', { name: cityName, exact: true }).hover(); await page.getByRole('link', { name: cityName, exact: true }).hover();
await expect(page.locator('.map-tooltip')).toBeVisible(); await expect(page.locator('.map-tooltip')).toBeVisible();
@@ -698,7 +779,7 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await mobileCities.nth(1).tap(); await mobileCities.nth(1).tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u); await expect(mobilePage).toHaveURL(/\/global-info$/u);
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|수】성2'); await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|중】진류');
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true }); await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true });
await mobileCities.nth(1).tap(); await mobileCities.nth(1).tap();
@@ -742,24 +823,19 @@ test('global-info renders the ref nation summary columns beside the map', async
const titleTextBackground = await mapTitle.evaluate((element) => getComputedStyle(element).backgroundImage); const titleTextBackground = await mapTitle.evaluate((element) => getComputedStyle(element).backgroundImage);
expect(titleTextBackground).toContain('ad.gif'); expect(titleTextBackground).toContain('ad.gif');
expect(titleTextBackground).toContain('spring.gif'); expect(titleTextBackground).toContain('spring.gif');
await mapTitle.hover();
const titleTooltip = page.locator('.map-title-tooltip'); const titleTooltip = page.locator('.map-title-tooltip');
await expect(titleTooltip).toBeVisible(); await expect(titleTooltip).toHaveCount(0);
await expect(titleTooltip).toContainText('기술등급 제한 : 5등급 (205년 해제)');
const titleGeometry = await page.locator('.map-top').evaluate((element) => { const titleGeometry = await page.locator('.map-top').evaluate((element) => {
const band = element.getBoundingClientRect(); const band = element.getBoundingClientRect();
const title = element.querySelector('.map-title')?.getBoundingClientRect(); const title = element.querySelector('.map-title')?.getBoundingClientRect();
const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect();
return { return {
band: { width: band.width, height: band.height }, band: { width: band.width, height: band.height },
title: title ? { width: title.width, height: title.height } : null, title: title ? { width: title.width, height: title.height } : null,
tooltip: tooltip ? { width: tooltip.width, height: tooltip.height } : null,
}; };
}); });
expect(titleGeometry).toEqual({ expect(titleGeometry).toEqual({
band: { width: 700, height: 20 }, band: { width: 700, height: 20 },
title: { width: 160, height: 20 }, title: { width: 160, height: 20 },
tooltip: { width: 220, height: 28 },
}); });
if (artifactRoot) { if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true }); await mkdir(artifactRoot, { recursive: true });
@@ -938,19 +1014,14 @@ test('global-info renders the ref nation summary columns beside the map', async
expect(mobileGeometry.map?.width).toBe(500); expect(mobileGeometry.map?.width).toBe(500);
expect(mobileGeometry.summary?.width).toBe(500); expect(mobileGeometry.summary?.width).toBe(500);
expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom); expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom);
await mapTitle.hover();
await expect(titleTooltip).toBeVisible();
const mobileTitleGeometry = await page.locator('.map-top').evaluate((element) => { const mobileTitleGeometry = await page.locator('.map-top').evaluate((element) => {
const band = element.getBoundingClientRect(); const band = element.getBoundingClientRect();
const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect();
return { return {
band: { width: band.width, height: band.height }, band: { width: band.width, height: band.height },
tooltip: tooltip ? { x: tooltip.x, width: tooltip.width } : null,
documentWidth: document.documentElement.scrollWidth, documentWidth: document.documentElement.scrollWidth,
}; };
}); });
expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 }); expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 });
expect(mobileTitleGeometry.tooltip?.width).toBe(220);
expect(mobileTitleGeometry.documentWidth).toBe(500); expect(mobileTitleGeometry.documentWidth).toBe(500);
await hoveredCastle.hover(); await hoveredCastle.hover();
await expect(cityTooltip).toBeVisible(); await expect(cityTooltip).toBeVisible();
@@ -964,17 +1035,17 @@ test('global-info renders the ref nation summary columns beside the map', async
} }
}); });
test('map title keeps the ref early-game restriction boundary and color', async ({ page }) => { test('non-main map title keeps the ref early-game color without the main-only restriction tooltip', async ({
page,
}) => {
await install(page, 'member', 100, 2, { ...map, startYear: 198 }); await install(page, 'member', 100, 2, { ...map, startYear: 198 });
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'global-info'); await go(page, 'global-info');
const mapTitle = page.locator('.map-title'); const mapTitle = page.locator('.map-title');
await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)'); await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)');
await mapTitle.hover(); await expect(mapTitle).not.toHaveAttribute('tabindex');
await expect(page.locator('.map-title-tooltip')).toHaveText( await expect(page.locator('.map-title-tooltip')).toHaveCount(0);
'초반제한 기간 : 0년 12개월 (201년)기술등급 제한 : 1등급 (203년 해제)'
);
}); });
test('global-info diplomacy height follows the active nation count', async ({ page }) => { test('global-info diplomacy height follows the active nation count', async ({ page }) => {
@@ -1040,6 +1111,13 @@ test('current-city hides values and general rows for a wandering user', async ({
await expect(page.locator('.generals')).toHaveCount(0); await expect(page.locator('.generals')).toHaveCount(0);
}); });
test('current-city keeps only the top and footer return buttons', async ({ page }) => {
await install(page, 'admin');
await go(page, 'current-city');
await expect(page.locator('.city-page .back-link')).toHaveCount(2);
await expect(page.locator('.city-page .back-row')).toHaveCount(0);
});
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
await install(page, 'admin'); await install(page, 'admin');
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
@@ -1067,8 +1145,8 @@ test('current-city exposes own general details to a member and admin fixture', a
}; };
}); });
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 }); expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
expect(legacyGeometry.stats).toEqual({ x: 100, y: 165.375, width: 1000, height: 106.9375 }); expect(legacyGeometry.stats).toEqual({ x: 100, y: 130.875, width: 1000, height: 106.9375 });
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 290.3125, width: 1024 }); expect(legacyGeometry.generals).toMatchObject({ x: 100, y: 255.8125, width: 1000 });
expect(legacyGeometry.titleAlign).toBe('start'); expect(legacyGeometry.titleAlign).toBe('start');
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 }); expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
if (artifactRoot) { if (artifactRoot) {
@@ -1166,6 +1244,7 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
const rows = page.locator('.generals tbody tr'); const rows = page.locator('.generals tbody tr');
const reservedTurns = rows.nth(0).locator('.turns'); const reservedTurns = rows.nth(0).locator('.turns');
const npcTurns = rows.nth(1).locator('.turns'); const npcTurns = rows.nth(1).locator('.turns');
const enemyNpcTurns = rows.nth(13).locator('.turns');
await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병'); await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병');
await expect(reservedTurns).toContainText('2 : 【업】에 화계실행'); await expect(reservedTurns).toContainText('2 : 【업】에 화계실행');
await expect(reservedTurns.locator('.turn-line').nth(0)).toHaveAttribute('title', '【보병】 300명 징병'); await expect(reservedTurns.locator('.turn-line').nth(0)).toHaveAttribute('title', '【보병】 300명 징병');
@@ -1173,6 +1252,8 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
await expect(reservedTurns).toHaveClass(/turns--reserved/); await expect(reservedTurns).toHaveClass(/turns--reserved/);
await expect(npcTurns).toHaveText('NPC 장수'); await expect(npcTurns).toHaveText('NPC 장수');
await expect(npcTurns).not.toHaveClass(/turns--reserved/); await expect(npcTurns).not.toHaveClass(/turns--reserved/);
await expect(enemyNpcTurns).toHaveText('【적국】 장수');
await expect(enemyNpcTurns).not.toHaveClass(/turns--reserved/);
const turnFontSizes = await Promise.all([ const turnFontSizes = await Promise.all([
reservedTurns.evaluate((element) => getComputedStyle(element).fontSize), reservedTurns.evaluate((element) => getComputedStyle(element).fontSize),
+92 -6
View File
@@ -276,7 +276,6 @@ const myGeneral = (state: FixtureState) => ({
defence_train: 80, defence_train: 80,
use_treatment: 21, use_treatment: 21,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0, use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
@@ -1077,6 +1076,62 @@ test('개인턴·수뇌턴 실패 사유를 메인 개인 기록에 표시한다
await persistParityArtifact(page, 'core-main-turn-failure-personal-records-mobile', mobile); await persistParityArtifact(page, 'core-main-turn-failure-personal-records-mobile', mobile);
}); });
test('커맨드 실행 결과의 성공·실패·일반 색상을 메인 기록에 구분한다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
recentRecords: {
global: [],
general: [
{
id: 19103,
text: "<C>●</>1월:상업 투자를 <span class='ev_failed'>실패</span>하여 <C>10</> 상승했습니다.",
},
{
id: 19102,
text: '<C>●</>1월:상업 투자를 <S>성공</>하여 <C>30</> 상승했습니다.',
},
{
id: 19101,
text: '<C>●</>1월:상업 투자를 하여 <C>20</> 상승했습니다.',
},
],
history: [],
},
};
await install(page, state);
const inspectResultColors = async (selector: string) => {
const lines = page.locator(selector);
await expect(lines).toHaveCount(3);
const failure = lines.nth(0).locator('.ev_failed');
const success = lines.nth(1).locator('span', { hasText: '성공' });
const normal = lines.nth(2);
await expect(failure).toHaveCSS('color', 'rgb(255, 0, 255)');
await expect(success).toHaveCSS('color', 'rgb(135, 206, 235)');
await expect(normal).toHaveCSS('color', 'rgb(255, 255, 255)');
return {
failure: await failure.evaluate((element) => getComputedStyle(element).color),
success: await success.evaluate((element) => getComputedStyle(element).color),
normal: await normal.evaluate((element) => getComputedStyle(element).color),
};
};
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900, selector: '.record-zone' },
{ name: 'mobile', width: 500, height: 900, selector: '.record-zone-mobile' },
]) {
await page.setViewportSize(viewport);
await page.goto('');
const colors = await inspectResultColors(
`${viewport.selector} [data-record-bucket="general"] .record-line`
);
await persistParityArtifact(page, `core-main-command-result-colors-${viewport.name}`, colors);
}
});
test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => { test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => {
const seedText = '(전투시드: 0123456789abcdef)'; const seedText = '(전투시드: 0123456789abcdef)';
const logText = const logText =
@@ -1347,13 +1402,12 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
]); ]);
const rulerAutomation = page.locator('.ruler-automation-settings'); const rulerAutomation = page.locator('.ruler-automation-settings');
await expect(rulerAutomation).toBeVisible(); await expect(rulerAutomation).toBeVisible();
const diplomacyAutomation = page.getByRole('checkbox', { name: '자동 외교 (불가침 제의)' }); await expect(page.getByRole('checkbox', { name: '자동 외교 (불가침 제의)' })).toHaveCount(0);
const warAutomation = page.getByRole('checkbox', { name: '자동 선전포고' }); const warAutomation = page.getByRole('checkbox', { name: '자동 선전포고' });
const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' }); const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' }); const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' }); const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
for (const checkbox of [ for (const checkbox of [
diplomacyAutomation,
warAutomation, warAutomation,
promotionAutomation, promotionAutomation,
financeAutomation, financeAutomation,
@@ -1420,8 +1474,8 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await page.locator('#set_my_setting').click(); await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(1); await expect.poll(() => state.settingMutations.length).toBe(1);
expect(state.settingMutations[0]).not.toHaveProperty('generalId'); expect(state.settingMutations[0]).not.toHaveProperty('generalId');
expect(state.settingMutations[0]).not.toHaveProperty('myset');
expect(state.settingMutations[0]).toMatchObject({ expect(state.settingMutations[0]).toMatchObject({
use_auto_nation_diplomacy: 1,
use_auto_nation_war: 1, use_auto_nation_war: 1,
use_auto_nation_promotion: 1, use_auto_nation_promotion: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
@@ -1953,7 +2007,11 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
accessPages: [], accessPages: [],
createGeneralInputs: [], createGeneralInputs: [],
joinConfig: { joinConfig: {
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true }, rules: {
stat: { total: 150, min: 30, max: 70 },
allowDirectCreation: true,
allowCustomName: true,
},
user: { user: {
id: 'user-1', id: 'user-1',
displayName: '생성장수', displayName: '생성장수',
@@ -2002,7 +2060,11 @@ test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청
accessPages: [], accessPages: [],
createGeneralInputs: [], createGeneralInputs: [],
joinConfig: { joinConfig: {
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true }, rules: {
stat: { total: 150, min: 30, max: 70 },
allowDirectCreation: true,
allowCustomName: true,
},
user: { user: {
id: 'user-1', id: 'user-1',
displayName: '생성장수', displayName: '생성장수',
@@ -2400,3 +2462,27 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await page.reload(); await page.reload();
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
}); });
test('감찰부 하단 창 닫기 버튼은 상단 버튼과 같은 크기를 유지한다', async ({ page }) => {
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, head);
const measure = () =>
page.locator('.battle-page').evaluate((element) => {
const top = element.querySelector<HTMLElement>('.battle-top .battle-nav')!.getBoundingClientRect();
const bottom = element.querySelector<HTMLElement>('.battle-footer .battle-nav')!.getBoundingClientRect();
return {
top: { width: top.width, height: top.height },
bottom: { width: bottom.width, height: bottom.height },
};
});
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('battle-center');
await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible();
const desktop = await measure();
expect(desktop.bottom).toEqual(desktop.top);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await measure();
expect(mobile.bottom).toEqual(mobile.top);
});
+46 -19
View File
@@ -41,6 +41,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
return response({ return response({
rules: { rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 }, stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowDirectCreation: true,
allowCustomName: true, allowCustomName: true,
}, },
user: { user: {
@@ -109,14 +110,14 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
state.generalRequests += 1; state.generalRequests += 1;
return response([ return response([
{ {
id: 1, id: 3,
name: '유비', name: '황건장수',
npcState: 0, npcState: 2,
nationId: 1, nationId: 0,
nationName: '', nationName: '무주',
leadership: 72, leadership: 55,
strength: 67, strength: 65,
intelligence: 76, intelligence: 45,
}, },
{ {
id: 2, id: 2,
@@ -129,14 +130,14 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
intelligence: 80, intelligence: 80,
}, },
{ {
id: 3, id: 1,
name: '황건장수', name: '유비',
npcState: 2, npcState: 0,
nationId: 0, nationId: 1,
nationName: '무주', nationName: '',
leadership: 55, leadership: 72,
strength: 65, strength: 67,
intelligence: 45, intelligence: 76,
}, },
]); ]);
} }
@@ -179,7 +180,19 @@ test('prioritizes core general fields and keeps context and inheritance progress
await expect(page.getByLabel('성격')).toBeVisible(); await expect(page.getByLabel('성격')).toBeVisible();
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55'); await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55');
const statActions = page.getByRole('group', { name: '능력치 빠른 설정' }); const statActions = page.getByRole('group', { name: '능력치 빠른 설정' });
await expect(statActions.getByRole('button')).toHaveText(['랜덤형', '통솔무력형', '통솔지력형', '무력지력형']); await expect(statActions.getByRole('button')).toHaveText(['통솔무력형', '통솔지력형', '무력지력형', '랜덤형']);
const desktopStatButtonBoxes = await statActions.getByRole('button').evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
})
);
expect(desktopStatButtonBoxes).toHaveLength(4);
expect(desktopStatButtonBoxes[0]?.top).toBe(desktopStatButtonBoxes[1]?.top);
expect(desktopStatButtonBoxes[2]?.top).toBe(desktopStatButtonBoxes[3]?.top);
expect(desktopStatButtonBoxes[2]?.top).toBeGreaterThan(desktopStatButtonBoxes[0]?.top ?? 0);
expect(desktopStatButtonBoxes[0]?.left).toBe(desktopStatButtonBoxes[2]?.left);
expect(desktopStatButtonBoxes[1]?.left).toBe(desktopStatButtonBoxes[3]?.left);
const randomButton = statActions.getByRole('button', { name: '랜덤형', exact: true }); const randomButton = statActions.getByRole('button', { name: '랜덤형', exact: true });
const defaultButtonStyle = await randomButton.evaluate((element) => { const defaultButtonStyle = await randomButton.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -336,7 +349,11 @@ test('prioritizes core general fields and keeps context and inheritance progress
const generalTab = page.getByRole('tab', { name: '장수 목록' }); const generalTab = page.getByRole('tab', { name: '장수 목록' });
await generalTab.click(); await generalTab.click();
await expect(page.locator('.context-general-table tbody tr')).toHaveCount(3); const generalRows = page.locator('.context-general-table tbody tr');
await expect(generalRows).toHaveCount(3);
await expect(generalRows.nth(0)).toContainText('유비');
await expect(generalRows.nth(1)).toContainText('조조');
await expect(generalRows.nth(2)).toContainText('황건장수');
expect(state.generalRequests).toBe(1); expect(state.generalRequests).toBe(1);
await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉'); await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉');
await expect(page.locator('.context-general-table tbody tr')).toHaveCount(1); await expect(page.locator('.context-general-table tbody tr')).toHaveCount(1);
@@ -380,12 +397,22 @@ test('keeps the primary creation flow readable without horizontal overflow on mo
.evaluateAll((buttons) => .evaluateAll((buttons) =>
buttons.map((button) => { buttons.map((button) => {
const rect = button.getBoundingClientRect(); const rect = button.getBoundingClientRect();
return { width: rect.width, height: rect.height }; return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
}) })
); );
expect(mobileStatButtons).toHaveLength(4); expect(mobileStatButtons).toHaveLength(4);
expect(mobileStatButtons.every(({ width, height }) => width >= 160 && height === 40)).toBe(true); expect(mobileStatButtons.every(({ width, height }) => width >= 160 && height === 40)).toBe(true);
expect(mobileStatButtons[0]?.top).toBe(mobileStatButtons[1]?.top);
expect(mobileStatButtons[2]?.top).toBe(mobileStatButtons[3]?.top);
expect(mobileStatButtons[2]?.top).toBeGreaterThan(mobileStatButtons[0]?.top ?? 0);
expect(mobileStatButtons[0]?.left).toBe(mobileStatButtons[2]?.left);
expect(mobileStatButtons[1]?.left).toBe(mobileStatButtons[3]?.left);
await expect(page.locator('.advanced-options')).not.toHaveAttribute('open'); await expect(page.locator('.advanced-options')).not.toHaveAttribute('open');
await page.getByRole('tab', { name: '장수 목록' }).click();
const mobileGeneralRows = page.locator('.context-general-table tbody tr');
await expect(mobileGeneralRows.nth(0)).toContainText('유비');
await expect(mobileGeneralRows.nth(1)).toContainText('조조');
await expect(mobileGeneralRows.nth(2)).toContainText('황건장수');
await page.getByRole('tab', { name: '임관 권유' }).focus(); await page.getByRole('tab', { name: '임관 권유' }).focus();
await expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused(); await expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true });
+373 -9
View File
@@ -35,6 +35,8 @@ type NavigationFixture = {
generalMeCalls: number; generalMeCalls: number;
operations: string[]; operations: string[];
generalName?: string; generalName?: string;
generalPicture?: string | null;
generalImageServer?: number;
generalTurnTime?: string; generalTurnTime?: string;
nextTurnMonthOffset?: 0 | 1; nextTurnMonthOffset?: 0 | 1;
serverTime?: string; serverTime?: string;
@@ -78,6 +80,7 @@ type NavigationFixture = {
messages?: unknown; messages?: unknown;
messageContacts?: unknown; messageContacts?: unknown;
autorunLimit?: number | null; autorunLimit?: number | null;
autorunUser?: { limitMinutes: number; options: string[] } | null;
dashboardResponses?: Array<{ dashboardResponses?: Array<{
bytes: number; bytes: number;
contextKind: string | null; contextKind: string | null;
@@ -445,8 +448,8 @@ const generalContext = (state: NavigationFixture) => ({
officerCityName: state.officerLevel >= 2 && state.officerLevel <= 4 ? '업' : null, officerCityName: state.officerLevel >= 2 && state.officerLevel <= 4 ? '업' : null,
generalType: '용장', generalType: '용장',
leadershipBonus: state.officerLevel === 12 ? state.nationLevel * 2 : 0, leadershipBonus: state.officerLevel === 12 ? state.nationLevel * 2 : 0,
picture: null, picture: state.generalPicture ?? null,
imageServer: 0, imageServer: state.generalImageServer ?? 0,
stats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1_000, gold: 1_000,
rice: 2_000, rice: 2_000,
@@ -633,6 +636,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
clockStartsAt: state.clockStartsAt ?? null, clockStartsAt: state.clockStartsAt ?? null,
turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning, turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning,
scenarioTitle: state.scenarioTitle ?? '', scenarioTitle: state.scenarioTitle ?? '',
autorunUser: state.autorunUser ?? null,
}); });
} }
if (operation === 'dashboard.getContextBundleDelta') { if (operation === 'dashboard.getContextBundleDelta') {
@@ -1233,6 +1237,32 @@ test('keeps the active survey title after voting without reopening the new-surve
await expect(page.locator('.survey-notice')).toHaveCount(0); await expect(page.locator('.survey-notice')).toHaveCount(0);
}); });
test('keeps the survey footer close button the same size as the top close button', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(page, state);
await page.goto(`${basePath}/survey`);
for (const width of [1000, 500]) {
await page.setViewportSize({ width, height: 900 });
const geometry = await page.locator('.pageVote').evaluate((element) => {
const top = element.querySelector<HTMLElement>('.back_bar .back_btn')!.getBoundingClientRect();
const bottom = element.querySelector<HTMLElement>('.bottom_bar .back_btn')!.getBoundingClientRect();
return {
top: { width: top.width, height: top.height },
bottom: { width: bottom.width, height: bottom.height },
};
});
expect(geometry.bottom).toEqual(geometry.top);
}
});
test('notifies only for a new incoming private message and marks it read from the notice', async ({ test('notifies only for a new incoming private message and marks it read from the notice', async ({
page, page,
}, testInfo) => { }, testInfo) => {
@@ -1370,6 +1400,141 @@ test('the private-message notice moves a mobile reader to the private section',
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500);
}); });
test('main info fills the eighth slot with Ref-compatible autorun status and an accessible detail tooltip', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
autorunUser: {
limitMinutes: 1_440,
options: ['chief', 'battle', 'train', 'recruit', 'recruit_high', 'warp', 'develop'],
},
generalMeCalls: 0,
operations: [],
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const gameInfo = page.locator('.legacy-game-info');
const status = page.locator('[data-main-autorun-status]');
const tooltip = page.getByRole('tooltip', {
name: '내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효',
});
await expect(gameInfo.locator(':scope > *')).toHaveCount(8);
await expect(status).toHaveText(/ : /u);
await expect(status).toHaveAttribute('tabindex', '0');
await expect(tooltip).toBeHidden();
const desktopBefore = await gameInfo.boundingBox();
await status.hover();
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText('내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효');
expect(await status.evaluate((element) => getComputedStyle(element).overflow)).toBe('visible');
expect(await gameInfo.boundingBox()).toEqual(desktopBefore);
await page.screenshot({ path: testInfo.outputPath('main-autorun-hover-desktop-1200.png'), fullPage: false });
await page.setViewportSize({ width: 500, height: 900 });
await status.focus();
await expect(tooltip).toBeVisible();
expect(await status.evaluate((element) => getComputedStyle(element).overflow)).toBe('visible');
const mobileTooltip = await tooltip.boundingBox();
expect(mobileTooltip?.x).toBeGreaterThanOrEqual(16);
expect(mobileTooltip ? mobileTooltip.x + mobileTooltip.width : Infinity).toBeLessThanOrEqual(484);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(500);
await page.screenshot({ path: testInfo.outputPath('main-autorun-focus-mobile-500.png'), fullPage: false });
state.autorunUser = null;
await page.reload();
await waitForMain(page);
await expect(status).toHaveText('기타 설정: 표준');
await expect(status).not.toHaveAttribute('tabindex', '0');
await expect(page.getByRole('tooltip')).toHaveCount(0);
});
test('main user icon keeps transparent pixels clear and only falls back after an image error', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalPicture: 'transparent-main.png',
generalImageServer: 1,
generalMeCalls: 0,
operations: [],
};
await page.route('**/icons/transparent-main.png', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X1N6WQAAAABJRU5ErkJggg==',
'base64'
),
});
});
await installFixture(page, state);
await page.setViewportSize({ width: 500, height: 900 });
await waitForMain(page);
const icon = page.locator('[data-main-target="general"] .general-icon');
await expect(icon).toHaveJSProperty('tagName', 'IMG');
await expect(icon).toHaveAttribute('src', /\/icons\/transparent-main\.png$/u);
await expect(icon).toHaveCSS('background-image', 'none');
await expect(icon).toHaveCSS('object-fit', 'contain');
await icon.screenshot({ path: testInfo.outputPath('transparent-main-user-icon.png') });
});
test('mobile top menu popup stays above the eight-cell game information strip', async ({ page }, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(page, state);
await page.setViewportSize({ width: 500, height: 900 });
await waitForMain(page);
const topMenu = page.locator('[data-menu-position="top"]');
const gameInfoButton = topMenu.locator('[data-menu-id="game-info"]');
await gameInfoButton.click();
const popup = topMenu.locator('#global-menu-game-info');
await expect(popup).toBeVisible();
const stacking = await popup.evaluate((element) => {
const menu = element.closest<HTMLElement>('.main-global-menu');
const gameInfo = document.querySelector<HTMLElement>('.legacy-game-info');
if (!menu || !gameInfo) throw new Error('mobile top menu stacking targets are missing');
const popupRect = element.getBoundingClientRect();
const gameInfoRect = gameInfo.getBoundingClientRect();
const left = Math.max(popupRect.left, gameInfoRect.left);
const right = Math.min(popupRect.right, gameInfoRect.right);
const top = Math.max(popupRect.top, gameInfoRect.top);
const bottom = Math.min(popupRect.bottom, gameInfoRect.bottom);
if (right <= left || bottom <= top) throw new Error('mobile popup does not overlap the game information strip');
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
return {
menuZIndex: Number(getComputedStyle(menu).zIndex),
gameInfoZIndex: Number(getComputedStyle(gameInfo).zIndex),
hitInsidePopup: hit === element || element.contains(hit),
};
});
expect(stacking.menuZIndex).toBeGreaterThan(stacking.gameInfoZIndex);
expect(stacking.hitInsidePopup).toBe(true);
await page.screenshot({ path: testInfo.outputPath('mobile-top-menu-game-info-stacking.png') });
});
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({
page, page,
}, testInfo) => { }, testInfo) => {
@@ -1701,7 +1866,11 @@ test('tournament split main action follows recruitment, betting, finals, and tou
const main = nationMenu.locator('[data-navigation-id="tournament"]'); const main = nationMenu.locator('[data-navigation-id="tournament"]');
await expect(main).toHaveText(lifecycle.label); await expect(main).toHaveText(lifecycle.label);
await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`); await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`);
if (lifecycle.stage === 1 || lifecycle.stage === 6) {
await expect(main).toHaveClass(/highlight/); await expect(main).toHaveClass(/highlight/);
} else {
await expect(main).not.toHaveClass(/highlight/);
}
const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]');
await toggle.click(); await toggle.click();
@@ -1712,9 +1881,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou
if (lifecycle.stage === 6) { if (lifecycle.stage === 6) {
await expect(tournamentItem).not.toHaveClass(/highlight/); await expect(tournamentItem).not.toHaveClass(/highlight/);
await expect(bettingItem).toHaveClass(/highlight/); await expect(bettingItem).toHaveClass(/highlight/);
} else { } else if (lifecycle.stage === 1) {
await expect(tournamentItem).toHaveClass(/highlight/); await expect(tournamentItem).toHaveClass(/highlight/);
await expect(bettingItem).not.toHaveClass(/highlight/); await expect(bettingItem).not.toHaveClass(/highlight/);
} else {
await expect(tournamentItem).not.toHaveClass(/highlight/);
await expect(bettingItem).not.toHaveClass(/highlight/);
} }
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
@@ -1726,9 +1898,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou
if (lifecycle.stage === 6) { if (lifecycle.stage === 6) {
await expect(mobileTournament).not.toHaveClass(/highlight/); await expect(mobileTournament).not.toHaveClass(/highlight/);
await expect(mobileBetting).toHaveClass(/highlight/); await expect(mobileBetting).toHaveClass(/highlight/);
} else { } else if (lifecycle.stage === 1) {
await expect(mobileTournament).toHaveClass(/highlight/); await expect(mobileTournament).toHaveClass(/highlight/);
await expect(mobileBetting).not.toHaveClass(/highlight/); await expect(mobileBetting).not.toHaveClass(/highlight/);
} else {
await expect(mobileTournament).not.toHaveClass(/highlight/);
await expect(mobileBetting).not.toHaveClass(/highlight/);
} }
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
} }
@@ -1968,6 +2143,84 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`);
}); });
test('mobile message content owns its full height and leaves the diplomacy action clear of the bottom bar', async ({
page,
}, testInfo) => {
const target = (generalId: number, generalName: string) => ({
generalId,
generalName,
nationId: 1,
nationName: '위',
color: '#008000',
icon: '',
});
const entries = (type: 'public' | 'national' | 'private' | 'diplomacy', startId: number) =>
Array.from({ length: 12 }, (_, index) => ({
id: startId + index,
text: `${type} 모바일 높이 검증 메시지 ${index + 1}`,
time: '2026-08-12 12:00:00',
msgType: type,
src: target(21, '보낸장수'),
dest: type === 'public' ? null : target(7, '메뉴검증장수'),
option: {},
}));
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
messages: {
...emptyMessages(2),
public: entries('public', 100),
national: entries('national', 200),
private: entries('private', 300),
diplomacy: entries('diplomacy', 400),
},
};
await installFixture(page, state);
await page.setViewportSize({ width: 500, height: 900 });
await waitForMain(page);
const panel = page.locator('.mobile-message-panel');
const diplomacyLoadOlder = panel.locator('.DiplomacyTalk .load-older');
await expect(diplomacyLoadOlder).toBeVisible();
const flowGeometry = await panel.evaluate((element) => {
const repeated = document.querySelector<HTMLElement>('[data-menu-position="bottom"]');
const privateTalk = element.querySelector<HTMLElement>('.PrivateTalk');
const actions = element.querySelector<HTMLElement>('.DiplomacyTalk .Actions');
if (!repeated || !privateTalk || !actions) throw new Error('mobile message flow targets are missing');
return {
panel: element.getBoundingClientRect().toJSON(),
panelClientHeight: element.clientHeight,
panelScrollHeight: element.scrollHeight,
privateTalk: privateTalk.getBoundingClientRect().toJSON(),
actionsColumns: getComputedStyle(actions).gridTemplateColumns,
repeatedMenu: repeated.getBoundingClientRect().toJSON(),
};
});
expect(flowGeometry.panelClientHeight).toBeGreaterThanOrEqual(flowGeometry.panelScrollHeight);
expect(flowGeometry.repeatedMenu.top).toBeGreaterThanOrEqual(flowGeometry.panel.bottom - 0.5);
expect(flowGeometry.repeatedMenu.top).toBeGreaterThanOrEqual(flowGeometry.privateTalk.bottom - 0.5);
expect(flowGeometry.actionsColumns.split(' ')).toHaveLength(2);
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
const bottomClearance = await diplomacyLoadOlder.evaluate((button) => {
const bottomBar = document.querySelector<HTMLElement>('.main-mobile-bottom');
const spacer = document.querySelector<HTMLElement>('.main-mobile-bottom-spacer');
if (!bottomBar || !spacer) throw new Error('mobile bottom clearance targets are missing');
return {
clearance: bottomBar.getBoundingClientRect().top - button.getBoundingClientRect().bottom,
spacerHeight: spacer.getBoundingClientRect().height,
};
});
expect(bottomClearance.clearance).toBeGreaterThanOrEqual(12);
expect(bottomClearance.spacerHeight).toBeGreaterThanOrEqual(61);
await page.screenshot({ path: testInfo.outputPath('mobile-diplomacy-bottom-clearance.png') });
});
test('first reserved month crosses December only after the general turn has passed', async ({ page }, testInfo) => { test('first reserved month crosses December only after the general turn has passed', async ({ page }, testInfo) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 0, officerLevel: 0,
@@ -2106,6 +2359,12 @@ test('main general card uses local turn time and command clock tracks corrected
await expect(generalCard).toContainText('7분 남음'); await expect(generalCard).toContainText('7분 남음');
await expect(generalCard).toContainText('백마대'); await expect(generalCard).toContainText('백마대');
await expect(generalCard).toContainText('보통 120점(3)'); await expect(generalCard).toContainText('보통 120점(3)');
const leadershipProgress = generalCard.locator('[data-rich-tooltip="stat-leadership"]');
await leadershipProgress.hover();
const statTooltip = page.locator('.tippy-box[data-theme~="sammo-rich"]');
await expect(statTooltip).toBeVisible();
await expect(statTooltip).toContainText('통솔 성장');
await expect(statTooltip).toContainText('5 / 20');
const desktopGeometry = await title.evaluate((element) => { const desktopGeometry = await title.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -3182,6 +3441,84 @@ test('main layout and map use the same mobile/desktop boundary', async ({ page }
await page.screenshot({ path: testInfo.outputPath('screen-mode-mobile-939.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('screen-mode-mobile-939.png'), fullPage: true });
}); });
test('main map year exposes the Ref restriction and technology limit on desktop and mobile', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 6,
npcMode: 1,
currentYear: 182,
currentMonth: 1,
generalMeCalls: 0,
operations: [],
validMapImages: true,
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const assertTitleTooltip = async (layout: 'desktop' | 'mobile') => {
const mapPanel = page.locator(`.layout-${layout} [data-main-target="map"]`);
const title = mapPanel.locator('.map-title');
const tooltip = mapPanel.getByRole('tooltip');
await expect(title).toHaveText(/182 1/u);
await expect(title).toHaveCSS('color', 'rgb(255, 255, 0)');
await expect(title).toHaveAttribute('tabindex', '0');
await expect(tooltip).toBeHidden();
await title.hover();
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText('초반제한 기간 : 0년 12개월 (183년)기술등급 제한 : 1등급 (185년 해제)');
const geometry = await mapPanel.evaluate((panel) => {
const panelRect = panel.getBoundingClientRect();
const titleRect = panel.querySelector('.map-title')?.getBoundingClientRect();
const tooltipRect = panel.querySelector('.map-title-tooltip')?.getBoundingClientRect();
const titleStyle = titleRect ? getComputedStyle(panel.querySelector('.map-title')!) : null;
return {
panelOverflow: getComputedStyle(panel).overflow,
title: titleRect ? { top: titleRect.top, width: titleRect.width, height: titleRect.height } : null,
tooltip: tooltipRect
? { top: tooltipRect.top, bottom: tooltipRect.bottom, width: tooltipRect.width }
: null,
textDecorationLine: titleStyle?.textDecorationLine ?? null,
viewportWidth: document.documentElement.scrollWidth,
panelLeft: panelRect.left,
panelRight: panelRect.right,
};
});
expect(geometry.panelOverflow).toBe('visible');
expect(geometry.title?.width).toBe(160);
expect(geometry.title?.height).toBe(20);
expect(geometry.tooltip?.width).toBe(220);
expect(geometry.tooltip?.bottom).toBeLessThanOrEqual(geometry.title?.top ?? 0);
expect(geometry.textDecorationLine).toBe('none');
return geometry;
};
const desktopGeometry = await assertTitleTooltip('desktop');
await testInfo.attach('main-map-year-tooltip-desktop.png', {
body: await page.screenshot({ fullPage: false }),
contentType: 'image/png',
});
await page.setViewportSize({ width: 500, height: 900 });
await expect(page.locator('.layout-mobile')).toBeVisible();
const mobileGeometry = await assertTitleTooltip('mobile');
expect(mobileGeometry.viewportWidth).toBe(500);
await page.locator('.layout-mobile [data-main-target="map"] .map-title').focus();
await expect(page.locator('.layout-mobile [data-main-target="map"] .map-title-tooltip')).toBeVisible();
await testInfo.attach('main-map-year-tooltip-mobile.png', {
body: await page.screenshot({ fullPage: false }),
contentType: 'image/png',
});
await testInfo.attach('main-map-year-tooltip-geometry.json', {
body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`),
contentType: 'application/json',
});
});
test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => { test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 5, officerLevel: 5,
@@ -3937,7 +4274,7 @@ test('all main Lumen button families share the rounded pressed geometry', async
page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }), page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }),
{ radius: '5.25px 0px 0px 5.25px' }, { radius: '5.25px 0px 0px 5.25px' },
], ],
['로비로', page.locator('.main-turn-controls').getByRole('button', { name: '로비로' })], ['로비로', page.locator('.main-turn-controls').getByRole('link', { name: '로비로' })],
]; ];
const measure = (control: Locator) => const measure = (control: Locator) =>
@@ -3947,6 +4284,8 @@ test('all main Lumen button families share the rounded pressed geometry', async
return { return {
top: rect.top, top: rect.top,
bottom: rect.bottom, bottom: rect.bottom,
width: rect.width,
parentWidth: element.parentElement?.getBoundingClientRect().width ?? 0,
height: rect.height, height: rect.height,
marginTop: style.marginTop, marginTop: style.marginTop,
borderTop: style.borderTopWidth, borderTop: style.borderTopWidth,
@@ -3979,6 +4318,9 @@ test('all main Lumen button families share the rounded pressed geometry', async
radius: expectedGeometry?.radius ?? '5.25px', radius: expectedGeometry?.radius ?? '5.25px',
filter: 'none', filter: 'none',
}); });
if (label === '당기기' || label === '미루기') {
expect(base.width, `${label} fills its menu column`).toBeCloseTo(base.parentWidth, 2);
}
await control.focus(); await control.focus();
await expect(control, `${label} keyboard focus`).toBeFocused(); await expect(control, `${label} keyboard focus`).toBeFocused();
@@ -4022,6 +4364,18 @@ test('all main Lumen button families share the rounded pressed geometry', async
await page.mouse.up(); await page.mouse.up();
} }
const generalPullMenu = page.locator('[data-main-target="commands"] .bottom-shift-menu').first();
await generalPullMenu.locator('summary').click();
await expect(generalPullMenu.locator('.menu-items > button')).toHaveText([
'1턴',
'2턴',
'3턴',
'4턴',
'5턴',
'6턴',
]);
await generalPullMenu.locator('summary').click();
state.permission = 0; state.permission = 0;
await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click(); await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click();
const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]'); const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]');
@@ -4203,7 +4557,7 @@ test('mobile main Lumen button families keep the same state geometry without ove
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }), page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: / /u }), page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: / /u }),
page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: '갱 신' }), page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: '갱 신' }),
page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: '로비로' }), page.locator('.layout-mobile .main-turn-controls').getByRole('link', { name: '로비로' }),
]; ];
for (const [index, control] of controls.entries()) { for (const [index, control] of controls.entries()) {
await expect(control).toBeVisible(); await expect(control).toBeVisible();
@@ -4243,7 +4597,7 @@ test('mobile main Lumen button families keep the same state geometry without ove
await persistArtifact(page, `${basePath.slice(1)}-mobile-main-lumen-button-families`); await persistArtifact(page, `${basePath.slice(1)}-mobile-main-lumen-button-families`);
}); });
test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => { test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page, context }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 5, officerLevel: 5,
permission: 2, permission: 2,
@@ -4349,10 +4703,20 @@ test('mobile single document refreshes once and preserves tokens on lobby return
await page.evaluate(() => { await page.evaluate(() => {
localStorage.setItem('sammo-session-token', 'session_navigation'); localStorage.setItem('sammo-session-token', 'session_navigation');
}); });
await page.route('**/gateway/', async (route) => { await context.route('**/gateway/', async (route) => {
await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>gateway</title>' }); await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>gateway</title>' });
}); });
await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('button', { name: '로비로' }).click()]); const lobbyLink = page.locator('.main-turn-controls__lobby');
await expect(lobbyLink).toHaveAttribute('href', /\/gateway\/$/);
const sourceUrl = page.url();
const popupPromise = context.waitForEvent('page');
await lobbyLink.click({ button: 'middle' });
const popup = await popupPromise;
await popup.waitForURL('**/gateway/');
expect(page.url()).toBe(sourceUrl);
await popup.close();
await Promise.all([page.waitForURL('**/gateway/'), lobbyLink.click()]);
expect( expect(
await page.evaluate(() => ({ await page.evaluate(() => ({
session: localStorage.getItem('sammo-session-token'), session: localStorage.getItem('sammo-session-token'),
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string) => ({
error: {
message: `Unhandled fixture operation: ${path}`,
code: -32000,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path },
},
});
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const bettingList = {
bettingList: {
1: {
id: 1,
type: 'nation',
name: '첫 번째 천통국 베팅',
finished: false,
selectCnt: 1,
isExclusive: true,
reqInheritancePoint: false,
openYearMonth: 2400,
closeYearMonth: 2411,
winner: null,
totalAmount: 0,
},
2: {
id: 2,
type: 'nation',
name: '두 번째 천통국 베팅',
finished: false,
selectCnt: 1,
isExclusive: true,
reqInheritancePoint: false,
openYearMonth: 2400,
closeYearMonth: 2411,
winner: null,
totalAmount: 0,
},
},
year: 200,
month: 1,
};
const bettingDetail = (id: number) => ({
bettingInfo: {
...bettingList.bettingList[id as 1 | 2],
candidates: [
{ title: '위', info: '조조' },
{ title: '촉', info: '유비' },
{ title: '오', info: '손권' },
],
},
bettingDetail: [],
myBetting: [],
remainPoint: 5_000,
year: 200,
month: 1,
});
const installFixture = async (page: Page) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_nation_betting_playwright');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
let detailId = 2;
await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '유비' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'betting.getList') return response(bettingList);
if (operation === 'betting.getDetail') {
detailId = detailId === 2 ? 1 : 2;
return response(bettingDetail(detailId));
}
if (operation === 'betting.bet') return response({ result: true });
return errorResponse(operation);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
test('keeps the nation-betting amount while changing candidates, submitting, and opening another bet', async ({
page,
}, testInfo) => {
await installFixture(page);
await page.setViewportSize({ width: 500, height: 844 });
await page.goto('nation-betting');
await page.getByRole('button', { name: / /u }).click();
const amount = page.getByRole('spinbutton', { name: '베팅 금액' });
await amount.fill('100');
await page.getByRole('button', { name: / /u }).click();
await expect(amount).toHaveValue('100');
await page.getByRole('button', { name: / /u }).click();
await expect(amount).toHaveValue('100');
await page.getByRole('button', { name: '베팅', exact: true }).click();
await expect(page.getByTestId('game-toast')).toContainText('베팅했습니다');
await expect(amount).toHaveValue('100');
await page.getByRole('button', { name: / /u }).click();
await expect(amount).toHaveValue('100');
await page.screenshot({ path: testInfo.outputPath('nation-betting-amount-retained.png'), fullPage: true });
});
+149 -28
View File
@@ -1,9 +1,10 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { expectLumenButtonStates } from './lumenButton.js'; import { expectLumenButtonStates } from './lumenButton.js';
import { acceptAppConfirmation } from './appConfirmation.js';
type Role = 'leader' | 'head' | 'member'; type Role = 'leader' | 'head' | 'member';
type FixtureState = { type FixtureState = {
@@ -14,6 +15,7 @@ type FixtureState = {
appointedGeneralId?: number; appointedGeneralId?: number;
appointedCityId?: number; appointedCityId?: number;
appointedOfficerLevel?: number; appointedOfficerLevel?: number;
kickedGeneralId?: number;
permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] }; permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] };
noticeMutationInput?: string; noticeMutationInput?: string;
scoutMutationInput?: string; scoutMutationInput?: string;
@@ -100,7 +102,7 @@ const personnelFixture = (state: FixtureState) => {
general(3, '하후돈', 4), general(3, '하후돈', 4),
general(4, '곽가', 3), general(4, '곽가', 3),
general(5, '정욱', 2), general(5, '정욱', 2),
general(6, '장료', 1), general(6, '장료', 1, { npcState: 2 }),
general(7, '허저', 1, { permission: 'ambassador' }), general(7, '허저', 1, { permission: 'ambassador' }),
general(8, '가후', 1, { permission: 'auditor' }), general(8, '가후', 1, { permission: 'auditor' }),
general(9, '전위', 1), general(9, '전위', 1),
@@ -255,7 +257,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
}; };
return response({ ok: true }); return response({ ok: true });
} }
if (operation === 'nation.kick') return response({ ok: true }); if (operation === 'nation.kick') {
state.kickedGeneralId = Number(jsonInput.destGeneralId ?? 0);
return response({ ok: true });
}
if (operation === 'nation.setRate') { if (operation === 'nation.setRate') {
if (state.failNextRate) { if (state.failNextRate) {
state.failNextRate = false; state.failNextRate = false;
@@ -394,11 +399,8 @@ test('leader can grant two ambassador and auditor permissions by click or touch
expect(ambassadorGeometry.width).toBeGreaterThanOrEqual(100); expect(ambassadorGeometry.width).toBeGreaterThanOrEqual(100);
await screenshot(page, 'core-personnel-mobile-permission-picker-open.png'); await screenshot(page, 'core-personnel-mobile-permission-picker-open.png');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('외교권자를 변경할까요?');
await dialog.accept();
});
await page.getByRole('button', { name: '외교권자 임명 반영' }).click(); await page.getByRole('button', { name: '외교권자 임명 반영' }).click();
await acceptAppConfirmation(page, '외교권자를 변경할까요?');
await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible();
await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: true, targetGeneralIds: [7, 6] }); await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: true, targetGeneralIds: [7, 6] });
@@ -409,11 +411,8 @@ test('leader can grant two ambassador and auditor permissions by click or touch
await expect(auditorOptions.getByRole('option', { name: '허저' })).toHaveCount(0); await expect(auditorOptions.getByRole('option', { name: '허저' })).toHaveCount(0);
await auditorOptions.getByRole('option', { name: '장료' }).click(); await auditorOptions.getByRole('option', { name: '장료' }).click();
await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명'); await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('조언자를 변경할까요?');
await dialog.accept();
});
await page.getByRole('button', { name: '조언자 임명 반영' }).click(); await page.getByRole('button', { name: '조언자 임명 반영' }).click();
await acceptAppConfirmation(page, '조언자를 변경할까요?');
await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible();
await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] }); await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
@@ -430,6 +429,16 @@ test('personnel selects an informed general and reports the JosaUtil-composed re
const picker = page.getByTestId('personnel-selection-dialog'); const picker = page.getByTestId('personnel-selection-dialog');
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible(); await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible();
await expect(picker.locator('.personnel-picker-card:not(.vacancy-card)')).toHaveText([
/가후/,
/곽가/,
/순욱/,
/전위/,
/정욱/,
/하후돈/,
/허저/,
/장료/,
]);
await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료'); await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료');
const candidate = picker.getByRole('button', { name: /장료/ }); const candidate = picker.getByRole('button', { name: /장료/ });
await expect(candidate).toContainText('허창 · 일반 장수'); await expect(candidate).toContainText('허창 · 일반 장수');
@@ -441,11 +450,8 @@ test('personnel selects an informed general and reports the JosaUtil-composed re
await candidate.focus(); await candidate.focus();
expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none');
await screenshot(page, 'core-personnel-desktop-general-picker.png'); await screenshot(page, 'core-personnel-desktop-general-picker.png');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('장료를 주부직에 임명하시겠습니까?');
await dialog.accept();
});
await candidate.click(); await candidate.click();
await acceptAppConfirmation(page, '장료를 주부직에 임명하시겠습니까?');
await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.');
expect(state.appointedGeneralId).toBe(6); expect(state.appointedGeneralId).toBe(6);
@@ -484,14 +490,7 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
expect(rowGeometry.officerWidth).toBeLessThan(141); expect(rowGeometry.officerWidth).toBeLessThan(141);
expect(rowGeometry.gradientCount).toBe(0); expect(rowGeometry.gradientCount).toBe(0);
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toBeVisible();
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
'장수 선택',
'하후돈 (70/70/70)',
'곽가 (70/70/70)',
'정욱 (70/70/70)',
'장료 (70/70/70)',
]);
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click(); await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog'); const picker = page.getByTestId('personnel-selection-dialog');
@@ -531,11 +530,8 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
await picker.getByRole('button', { name: /장료/ }).evaluate((button) => getComputedStyle(button).outlineStyle) await picker.getByRole('button', { name: /장료/ }).evaluate((button) => getComputedStyle(button).outlineStyle)
).not.toBe('none'); ).not.toBe('none');
await screenshot(page, 'core-personnel-mobile-city-picker.png'); await screenshot(page, 'core-personnel-mobile-city-picker.png');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('장료를 허창 태수직에 임명하시겠습니까?');
await dialog.accept();
});
await picker.getByRole('button', { name: /장료/ }).click(); await picker.getByRole('button', { name: /장료/ }).click();
await acceptAppConfirmation(page, '장료를 허창 태수직에 임명하시겠습니까?');
await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.');
expect(state.appointedGeneralId).toBe(6); expect(state.appointedGeneralId).toBe(6);
expect(state.appointedCityId).toBe(1); expect(state.appointedCityId).toBe(1);
@@ -572,12 +568,82 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
await screenshot(page, 'core-personnel-mobile-rows.png'); await screenshot(page, 'core-personnel-mobile-rows.png');
}); });
test('personnel chooses a kick target in the informed picker and double-confirms a user general', async ({ page }) => {
const state: FixtureState = { role: 'head', rate: 20 };
await installFixture(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await gotoOffice(page, 'nation/personnel');
const kickLabel = page.locator('.kick-label');
await expect(kickLabel).toHaveText('대상 장수');
expect(await kickLabel.evaluate((element) => getComputedStyle(element).whiteSpace)).toBe('nowrap');
await page.getByRole('button', { name: '추방 대상 선택하기' }).click();
const picker = page.getByTestId('personnel-selection-dialog');
await expect(picker.getByRole('heading', { name: '추방 대상 선택' })).toBeVisible();
await expect(picker.locator('.vacancy-card')).toHaveCount(0);
await expect(picker.locator('.personnel-picker-card')).toHaveText([
/가후/,
/곽가/,
/전위/,
/정욱/,
/하후돈/,
/장료/,
]);
await picker.getByRole('button', { name: /정욱/ }).click();
await expect(picker).toBeHidden();
await expect(page.getByRole('button', { name: '정욱', exact: true })).toBeVisible();
await page.getByRole('button', { name: '추방', exact: true }).click();
const confirmation = page.getByTestId('game-notice-dialog');
await expect(confirmation).toContainText('정욱을 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '확인', exact: true }).click();
await expect(confirmation).toContainText('정욱은 유저장입니다. 그래도 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '취소', exact: true }).click();
await expect(confirmation).toBeHidden();
expect(state.kickedGeneralId).toBeUndefined();
await page.getByRole('button', { name: '추방', exact: true }).click();
await expect(confirmation).toContainText('정욱을 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '확인', exact: true }).click();
await expect(confirmation).toContainText('정욱은 유저장입니다. 그래도 추방하시겠습니까?');
await confirmation.getByRole('button', { name: '확인', exact: true }).click();
await expect(confirmation).toBeHidden();
await expect(page.getByTestId('game-toast')).toContainText('정욱을 추방했습니다.');
expect(state.kickedGeneralId).toBe(5);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
await screenshot(page, 'core-personnel-mobile-kick-picker.png');
});
test('@ios-webkit iPhone touch appoints a city officer after the picker closes', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required');
const context = await browser.newContext({ ...devices['iPhone 15'], baseURL: configuredBaseUrl });
const page = await context.newPage();
const state: FixtureState = { role: 'head', rate: 20 };
try {
await installFixture(page, state);
await gotoOffice(page, 'nation/personnel');
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog');
await picker.getByRole('button', { name: /장료/ }).click();
await expect(picker).toBeHidden();
await acceptAppConfirmation(page, '장료를 허창 태수직에 임명하시겠습니까?');
await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.');
expect(state.appointedGeneralId).toBe(6);
expect(state.appointedCityId).toBe(1);
expect(state.appointedOfficerLevel).toBe(4);
} finally {
await context.close();
}
});
test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => { test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => {
await installFixture(page, { role: 'member', rate: 20 }); await installFixture(page, { role: 'member', rate: 20 });
await gotoOffice(page, 'nation/personnel'); await gotoOffice(page, 'nation/personnel');
await expect(page.getByRole('button', { name: /변경하기/ })).toHaveCount(0); await expect(page.getByRole('button', { name: /변경하기/ })).toHaveCount(0);
await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0); await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toHaveCount(0); await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '추방', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '추방', exact: true })).toHaveCount(0);
const auditorCell = page.locator('.city-officer-cell').filter({ hasText: '곽가' }); const auditorCell = page.locator('.city-officer-cell').filter({ hasText: '곽가' });
await expect(auditorCell).toContainText('10년 · 허창'); await expect(auditorCell).toContainText('10년 · 허창');
@@ -671,6 +737,61 @@ test('finance matches the reference 1000px and 500px computed DOM contracts', as
await screenshot(page, 'core-finance-mobile-head.png'); await screenshot(page, 'core-finance-mobile-head.png');
}); });
test('finance centers its 500px mobile document without a nested horizontal scroller', async ({ page }) => {
await installFixture(page, { role: 'head', rate: 20 });
await page.setViewportSize({ width: 800, height: 900 });
await gotoOffice(page, 'nation/finance');
await expect(page.getByText('외교관계')).toBeVisible();
const centeredMobile = await page.locator('#finance-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
const style = getComputedStyle(container);
const scrollingElement = document.scrollingElement!;
return {
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: scrollingElement.scrollWidth,
left: rect.left,
right: rect.right,
width: rect.width,
overflowX: style.overflowX,
overflowY: style.overflowY,
containerScrollLeft: container.scrollLeft,
};
});
expect(centeredMobile).toEqual({
viewportWidth: 800,
documentScrollWidth: 800,
left: 150,
right: 650,
width: 500,
overflowX: 'clip',
overflowY: 'visible',
containerScrollLeft: 0,
});
await page.locator('#finance-container').evaluate((container) => container.scrollTo({ left: 200 }));
expect(await page.locator('#finance-container').evaluate((container) => container.scrollLeft)).toBe(0);
await page.setViewportSize({ width: 500, height: 900 });
const exactMobile = await page.locator('#finance-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
return {
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.scrollingElement!.scrollWidth,
left: rect.left,
right: rect.right,
width: rect.width,
};
});
expect(exactMobile).toEqual({
viewportWidth: 500,
documentScrollWidth: 500,
left: 0,
right: 500,
width: 500,
});
await screenshot(page, 'core-finance-mobile-centered-500.png');
});
test('finance enforces edit permissions and preserves the old value across an API failure', async ({ page }) => { test('finance enforces edit permissions and preserves the old value across an API failure', async ({ page }) => {
const state: FixtureState = { role: 'head', rate: 20, failNextRate: true }; const state: FixtureState = { role: 'head', rate: 20, failNextRate: true };
await installFixture(page, state); await installFixture(page, state);
+43 -19
View File
@@ -1,15 +1,18 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { touchDrag } from './touchDrag.js'; import { touchDrag } from './touchDrag.js';
import { acceptAppConfirmation } from './appConfirmation.js';
type FixtureState = { type FixtureState = {
permissionLevel: number; permissionLevel: number;
failNextMutation?: boolean; failNextMutation?: boolean;
failLoad?: boolean; failLoad?: boolean;
mutations: string[]; mutations: string[];
nationPriorityInput?: string[];
currentNationPriority?: string[];
}; };
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -124,7 +127,7 @@ const policyFixture = (state: FixtureState) => ({
reqNPCDevelGold: 540, reqNPCDevelGold: 540,
}, },
defaultNationPriority: nationPriority, defaultNationPriority: nationPriority,
currentNationPriority: nationPriority, currentNationPriority: state.currentNationPriority ?? nationPriority,
availableNationPriorityItems: nationPriority, availableNationPriorityItems: nationPriority,
defaultGeneralActionPriority: generalPriority, defaultGeneralActionPriority: generalPriority,
currentGeneralActionPriority: generalPriority, currentGeneralActionPriority: generalPriority,
@@ -155,7 +158,14 @@ const installFixture = async (page: Page, state: FixtureState) => {
} }
await page.route(gameTrpcRoute, async (route) => { await page.route(gameTrpcRoute, async (route) => {
const operations = operationName(route).split(','); const operations = operationName(route).split(',');
const results = operations.map((operation) => { const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const requestBody = rawBody && typeof rawBody === 'object' ? (rawBody as Record<string, unknown>) : {};
const results = operations.map((operation, operationIndex) => {
const rawPayload = requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : {});
const payload = rawPayload && typeof rawPayload === 'object' ? (rawPayload as Record<string, unknown>) : {};
const jsonInput = Array.isArray(rawPayload)
? rawPayload
: (payload.json ?? (payload.input as { json?: unknown } | undefined)?.json);
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } }); if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } });
if (operation === 'join.getConfig') return response({}); if (operation === 'join.getConfig') return response({});
@@ -170,6 +180,9 @@ const installFixture = async (page: Page, state: FixtureState) => {
operation === 'npc.setGeneralPriority' operation === 'npc.setGeneralPriority'
) { ) {
state.mutations.push(operation); state.mutations.push(operation);
if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput)) {
state.nationPriorityInput = jsonInput.map(String);
}
if (state.failNextMutation) { if (state.failNextMutation) {
state.failNextMutation = false; state.failNextMutation = false;
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
@@ -280,8 +293,8 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf
).toBeVisible(); ).toBeVisible();
await goldInput.fill('12345'); await goldInput.fill('12345');
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
await acceptAppConfirmation(page, '저장할까요?');
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
'NPC 정책이 반영되었습니다.' 'NPC 정책이 반영되었습니다.'
); );
@@ -289,6 +302,29 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf
await screenshot(page, 'core-npc-policy-desktop.png'); await screenshot(page, 'core-npc-policy-desktop.png');
}); });
test('@ios-webkit iPhone touch saves the reordered NPC priority', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required');
const context = await browser.newContext({ ...devices['iPhone 15'], baseURL: configuredBaseUrl });
const page = await context.newPage();
const reorderedPriority = [nationPriority[1]!, nationPriority[0]!, ...nationPriority.slice(2)];
const state: FixtureState = { permissionLevel: 4, mutations: [], currentNationPriority: reorderedPriority };
try {
await installFixture(page, state);
await gotoPolicy(page);
const panel = page.locator('.priority-panel').first();
const activeList = panel.locator('.priority-column').nth(1).locator('.priority-list');
await expect(activeList.locator('.priority_info > span:nth-child(2)').first()).toHaveText('선전포고');
await panel.getByRole('button', { name: '설정' }).click();
await acceptAppConfirmation(page, '저장할까요?');
await expect(page.getByTestId('game-toast')).toContainText('NPC 정책이 반영되었습니다.');
expect(state.nationPriorityInput?.[0]).toBe('선전포고');
expect(state.nationPriorityInput).toContain('불가침제의');
} finally {
await context.close();
}
});
test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => { test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => {
await installFixture(page, { permissionLevel: 4, mutations: [] }); await installFixture(page, { permissionLevel: 4, mutations: [] });
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -346,19 +382,9 @@ test('physical mobile touch reorders NPC priority across active and inactive lis
const nationPanel = mobilePage.locator('.priority-panel').first(); const nationPanel = mobilePage.locator('.priority-panel').first();
const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list'); const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list');
const activeRows = activeList.locator('.priority-item'); const activeRows = activeList.locator('.priority-item');
await touchDrag( await touchDrag(mobilePage, activeRows.nth(0), activeRows.nth(3), { targetYRatio: 0.9 });
mobilePage,
activeRows.nth(0),
activeRows.nth(3),
{ targetYRatio: 0.9 }
);
await expect await expect
.poll(() => .poll(() => activeList.locator('.priority-item .priority_info > span:nth-child(2)').first().textContent())
activeList
.locator('.priority-item .priority_info > span:nth-child(2)')
.first()
.textContent()
)
.toBe('선전포고'); .toBe('선전포고');
const activeItem = activeList.getByText('불가침제의', { exact: true }); const activeItem = activeList.getByText('불가침제의', { exact: true });
@@ -366,9 +392,7 @@ test('physical mobile touch reorders NPC priority across active and inactive lis
await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header')); await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header'));
await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible(); await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible();
await expect( await expect(activeList.getByText('불가침제의', { exact: true })).toHaveCount(0);
activeList.getByText('불가침제의', { exact: true })
).toHaveCount(0);
await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true }); await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true });
} finally { } finally {
await context.close(); await context.close();
+87 -1
View File
@@ -141,9 +141,16 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
return response({ return response({
rules: { rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 }, stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowDirectCreation: false,
allowCustomName: true, allowCustomName: true,
}, },
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true }, user: {
id: 'npc-user',
displayName: '빙의사용자',
canCreateGeneral: true,
icons: [],
preferredPicture: null,
},
personalities: [{ key: 'Random', name: '???', info: '' }], personalities: [{ key: 'Random', name: '???', info: '' }],
warSpecials: [], warSpecials: [],
nations: [], nations: [],
@@ -225,6 +232,83 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
}); });
}; };
test('shows only possession and keeps every candidate reachable from the left on mobile', async ({
page,
}, testInfo) => {
const state: FixtureState = {
reservationCalls: 0,
reservationInputs: [],
rawBodies: [],
possessInputs: [],
hasGeneral: false,
injectTimeout: false,
};
await installFixture(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('join');
await expect(page.getByRole('heading', { name: '장수 빙의', exact: true, level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: '장수 생성', exact: true })).toHaveCount(0);
await expect(page.locator('.npc-card')).toHaveCount(5);
const candidateGeometry = await page.locator('.npc-possession-section').evaluate((section) => {
const sectionRect = section.getBoundingClientRect();
const holder = section.querySelector<HTMLElement>('.npc-card-holder')!;
const holderRect = holder.getBoundingClientRect();
const cards = [...holder.querySelectorAll<HTMLElement>('.npc-card')];
return {
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
sectionLeft: sectionRect.left,
sectionRight: sectionRect.right,
holderLeft: holderRect.left,
firstCardLeft: cards[0]!.getBoundingClientRect().left,
lastCardRight: cards.at(-1)!.getBoundingClientRect().right,
holderClientWidth: holder.clientWidth,
holderScrollWidth: holder.scrollWidth,
holderScrollLeft: holder.scrollLeft,
};
});
expect(candidateGeometry.viewportWidth).toBe(390);
expect(candidateGeometry.documentWidth).toBe(390);
expect(candidateGeometry.sectionLeft).toBeGreaterThanOrEqual(0);
expect(candidateGeometry.sectionRight).toBeLessThanOrEqual(390);
expect(candidateGeometry.firstCardLeft).toBeGreaterThanOrEqual(candidateGeometry.holderLeft);
expect(candidateGeometry.lastCardRight).toBeGreaterThan(candidateGeometry.sectionRight);
expect(candidateGeometry.holderScrollWidth).toBeGreaterThan(candidateGeometry.holderClientWidth);
expect(candidateGeometry.holderScrollLeft).toBe(0);
const refreshButton = page.locator('#btn-pick-more');
await expect(refreshButton).toBeEnabled();
const refreshStyle = await refreshButton.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderBottomWidth: style.borderBottomWidth,
opacity: style.opacity,
cursor: style.cursor,
};
});
expect(refreshStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
expect(refreshStyle).toMatchObject({ borderBottomWidth: '4px', opacity: '1', cursor: 'pointer' });
await page.screenshot({ path: testInfo.outputPath('npc-possession-mobile-active.png'), fullPage: true });
const dialogs: string[] = [];
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
await dialog.dismiss();
});
await page.locator('.npc-action').last().click();
expect(dialogs).toContain('빙의할까요? : 빙의후보5');
await expect
.poll(() => page.locator('.npc-card-holder').evaluate((holder) => (holder as HTMLElement).scrollLeft))
.toBeGreaterThan(0);
await refreshButton.click();
await expect.poll(() => state.reservationCalls).toBe(2);
expect(state.reservationInputs.at(-1)).toMatchObject({ refresh: true, keepIds: [] });
});
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({ test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
page, page,
}, testInfo) => { }, testInfo) => {
@@ -239,6 +323,8 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
await installFixture(page, state); await installFixture(page, state);
await page.setViewportSize({ width: 1024, height: 900 }); await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('join?tab=possess'); await page.goto('join?tab=possess');
await expect(page.getByRole('heading', { name: '장수 빙의', exact: true, level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: '장수 생성', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/); await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
await expect(page.locator('.npc-card')).toHaveCount(5); await expect(page.locator('.npc-card')).toHaveCount(5);
@@ -33,6 +33,7 @@ export default defineConfig({
'nationGeneralSecret.spec.ts', 'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts', 'npcPolicy.spec.ts',
'auction.spec.ts', 'auction.spec.ts',
'nationBetting.spec.ts',
'tournamentBracket.spec.ts', 'tournamentBracket.spec.ts',
'battleSimulator.spec.ts', 'battleSimulator.spec.ts',
'battleSimulatorRef.spec.ts', 'battleSimulatorRef.spec.ts',
+79 -53
View File
@@ -465,27 +465,7 @@ test('desktop bracket connects every real general slot to the next round', async
expect(controls.join).toEqual({ width: 72, height: 44 }); expect(controls.join).toEqual({ width: 72, height: 44 });
expect(controls.close).toEqual({ width: 88, height: 44 }); expect(controls.close).toEqual({ width: 88, height: 44 });
const firstSlot = page.locator('.desktop-bracket-name').first(); await expect(page.locator('.desktop-bracket .bracket-bet-summary')).toHaveCount(0);
const oddsContainment = await firstSlot.evaluate((slot) => {
const card = slot.getBoundingClientRect();
const stat = slot.querySelector<HTMLElement>('.bracket-core-stat')!.getBoundingClientRect();
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
return {
cardTop: card.top,
cardBottom: card.bottom,
statTop: stat.top,
statBottom: stat.bottom,
oddsTop: odds.top,
oddsBottom: odds.bottom,
cardHeight: card.height,
};
});
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(oddsContainment.statTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
expect(oddsContainment.statBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
const preliminaryGroups = page.locator('.preliminary-grid .tournament-group-card'); const preliminaryGroups = page.locator('.preliminary-grid .tournament-group-card');
await expect(preliminaryGroups).toHaveCount(8); await expect(preliminaryGroups).toHaveCount(8);
@@ -768,25 +748,7 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1); expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
expect(identity.nameTop).toBeLessThan(identity.iconBottom); expect(identity.nameTop).toBeLessThan(identity.iconBottom);
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop); expect(identity.nameBottom).toBeGreaterThan(identity.iconTop);
const firstMobileSlot = bracket.locator('.mobile-bracket-name').first(); await expect(bracket.locator('.mobile-bracket .bracket-bet-summary')).toHaveCount(0);
const mobileOddsContainment = await firstMobileSlot.evaluate((slot) => {
const card = slot.getBoundingClientRect();
const stat = slot.querySelector<HTMLElement>('.bracket-core-stat')!.getBoundingClientRect();
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
return {
cardTop: card.top,
cardBottom: card.bottom,
statTop: stat.top,
statBottom: stat.bottom,
oddsBottom: odds.bottom,
cardHeight: card.height,
};
});
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(mobileOddsContainment.statTop).toBeGreaterThanOrEqual(mobileOddsContainment.cardTop);
expect(mobileOddsContainment.statBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
await expect(firstMobileSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible(); await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
await page.getByRole('tab', { name: '二조' }).first().click(); await page.getByRole('tab', { name: '二조' }).first().click();
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
@@ -976,7 +938,7 @@ test('tournament and betting close only their script-opened popup window', async
} }
}); });
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => { test('mobile betting cards prioritize readable values and retain the amount draft', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
const { placedBets } = await installFixture(page, { tournamentStage: 6 }); const { placedBets } = await installFixture(page, { tournamentStage: 6 });
await page.goto('betting'); await page.goto('betting');
@@ -989,20 +951,70 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
const firstCard = page.locator('.mobile-bracket-name[data-general-id="1"]'); const firstCard = page.locator('.mobile-bracket-name[data-general-id="1"]');
const firstBetButton = page.getByRole('button', { name: '관우에게 베팅하기' }); const firstBetButton = page.getByRole('button', { name: '관우에게 베팅하기' });
const corner = await firstCard.evaluate((card) => { await expect(firstCard.locator('.general-identity-icon:visible')).toHaveCount(0);
const layout = await firstCard.evaluate((card) => {
const own = card.getBoundingClientRect(); const own = card.getBoundingClientRect();
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect(); const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
const fields = [
'.general-identity-name',
'.bracket-core-stat',
'.bracket-odds',
'.bracket-my-bet',
'.bracket-bet-button',
].map((selector) => card.querySelector<HTMLElement>(selector)!.getBoundingClientRect());
const overlaps = fields.flatMap((rect, index) =>
fields
.slice(index + 1)
.map(
(other) =>
Math.min(rect.right, other.right) > Math.max(rect.left, other.left) &&
Math.min(rect.bottom, other.bottom) > Math.max(rect.top, other.top)
)
);
return { return {
topOffset: button.top - own.top, topOffset: button.top - own.top,
rightOffset: own.right - button.right, rightOffset: own.right - button.right,
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom, contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
allFieldsContained: fields.every(
(rect) =>
rect.left >= own.left && rect.right <= own.right && rect.top >= own.top && rect.bottom <= own.bottom
),
overlaps,
cardHeight: own.height,
}; };
}); });
expect(corner.topOffset).toBeGreaterThanOrEqual(2); expect(layout.topOffset).toBeGreaterThanOrEqual(2);
expect(corner.topOffset).toBeLessThanOrEqual(4); expect(layout.topOffset).toBeLessThanOrEqual(6);
expect(corner.rightOffset).toBeGreaterThanOrEqual(2); expect(layout.rightOffset).toBeGreaterThanOrEqual(2);
expect(corner.rightOffset).toBeLessThanOrEqual(4); expect(layout.rightOffset).toBeLessThanOrEqual(6);
expect(corner.contained).toBe(true); expect(layout.contained).toBe(true);
expect(layout.allFieldsContained).toBe(true);
expect(layout.overlaps.every((overlap) => !overlap)).toBe(true);
expect(layout.cardHeight).toBeGreaterThanOrEqual(92);
const longNameMetrics = await page
.locator('[data-rich-tooltip="mobile-candidate-icon-16"] .general-identity-name')
.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
whiteSpace: getComputedStyle(element).whiteSpace,
overflow: getComputedStyle(element).overflow,
textOverflow: getComputedStyle(element).textOverflow,
}));
expect(longNameMetrics.scrollWidth).toBeLessThanOrEqual(longNameMetrics.clientWidth);
expect(longNameMetrics).toMatchObject({ whiteSpace: 'normal', overflow: 'visible', textOverflow: 'clip' });
const iconTrigger = firstCard.locator('[data-rich-tooltip="mobile-candidate-icon-1"]');
await iconTrigger.hover();
const iconTooltip = page.locator('.tippy-box[data-state="visible"]');
await expect(iconTooltip).toContainText('관우');
await expect(iconTooltip.locator('.general-identity-icon')).toBeVisible();
await page.keyboard.press('Escape');
const returnTrigger = firstCard.locator('[data-rich-tooltip="mobile-candidate-return-1"]');
await returnTrigger.hover();
await expect(page.locator('.tippy-box[data-state="visible"]')).toContainText(
'현재 배당 28.00 × 내 투자 금120 = 금3,360'
);
await firstBetButton.hover(); await firstBetButton.hover();
await expect(firstBetButton).toHaveCSS('filter', 'brightness(1.25)'); await expect(firstBetButton).toHaveCSS('filter', 'brightness(1.25)');
@@ -1035,19 +1047,25 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
await page.screenshot({ path: testInfo.outputPath('mobile-betting-dialog-viewport.png') }); await page.screenshot({ path: testInfo.outputPath('mobile-betting-dialog-viewport.png') });
await expect(dialog.getByText('배당 28.00')).toBeVisible(); await expect(dialog.getByText('배당 28.00')).toBeVisible();
await expect(dialog.getByText('예상 환수금 280')).toBeVisible(); await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
await dialog.getByLabel('베팅 금액').selectOption('50'); await dialog.getByLabel('베팅 금액').selectOption('100');
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible(); await expect(dialog.getByText('예상 환수금 2,800')).toBeVisible();
await persistScreenshot( await persistScreenshot(
page, page,
'tournament-betting-dialog-mobile', 'tournament-betting-dialog-mobile',
testInfo.outputPath('betting-dialog-mobile.webp') testInfo.outputPath('betting-dialog-mobile.webp')
); );
await dialog.getByRole('button', { name: '취소' }).click();
await page.getByRole('button', { name: '장료에게 베팅하기' }).click();
await expect(dialog.getByLabel('베팅 금액')).toHaveValue('100');
await dialog.getByRole('button', { name: '베팅 등록' }).click(); await dialog.getByRole('button', { name: '베팅 등록' }).click();
await expect(dialog).not.toBeVisible(); await expect(dialog).not.toBeVisible();
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
'베팅이 등록되었습니다.' '베팅이 등록되었습니다.'
); );
expect(placedBets).toEqual([{ targetId: 1, amount: 50 }]); expect(placedBets).toEqual([{ targetId: 2, amount: 100 }]);
await page.getByRole('button', { name: '조운에게 베팅하기' }).click();
await expect(dialog.getByLabel('베팅 금액')).toHaveValue('100');
await dialog.getByRole('button', { name: '취소' }).click();
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible(); await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
await expect(page.locator('.ranking-table:visible')).toHaveCount(1); await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
@@ -1081,7 +1099,7 @@ test('betting bracket shows intelligence for debate tournament candidates', asyn
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
}); });
test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({ test('desktop betting widens icon-free candidate cards while rankings keep dedicated icons', async ({
page, page,
}, testInfo) => { }, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 }); await page.setViewportSize({ width: 1365, height: 900 });
@@ -1091,8 +1109,16 @@ test('desktop betting presents icon-and-name cards and all four rankings without
await expect(page.locator('.candidate-table')).toHaveCount(0); await expect(page.locator('.candidate-table')).toHaveCount(0);
await expect(page.locator('.desktop-bracket .bracket-bet-button:visible')).toHaveCount(16); await expect(page.locator('.desktop-bracket .bracket-bet-button:visible')).toHaveCount(16);
await expect(page.locator('.ranking-table:visible')).toHaveCount(4); await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px'); const firstCandidate = page.locator('.desktop-bracket-name.betting-candidate[data-general-id="1"]');
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px'); await expect(firstCandidate.locator('.general-identity-icon:visible')).toHaveCount(0);
await expect(page.locator('.ranking-table:visible .general-identity-icon').first()).toHaveCSS('width', '64px');
await expect(page.locator('.ranking-table:visible .general-identity-icon').first()).toHaveCSS('height', '64px');
const candidateGeometry = await firstCandidate.evaluate((card) => {
const rect = card.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
expect(candidateGeometry.width).toBeGreaterThanOrEqual(210);
expect(candidateGeometry.height).toBeGreaterThanOrEqual(92);
const firstCardCorner = await page const firstCardCorner = await page
.locator('.desktop-bracket-name.betting-target[data-general-id="1"]') .locator('.desktop-bracket-name.betting-target[data-general-id="1"]')
.evaluate((card) => { .evaluate((card) => {
+41 -3
View File
@@ -1,8 +1,9 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { acceptAppConfirmation } from './appConfirmation.js';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [ const imageRoots = [
@@ -348,6 +349,11 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3); state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3);
return response({ ok: true }); return response({ ok: true });
} }
if (operation === 'troop.exit') {
state.me.troopId = 0;
state.troops = state.troops.filter((troop) => troop.id !== state.me.id);
return response({ ok: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`); return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
}); });
await fulfillJson(route, results); await fulfillJson(route, results);
@@ -555,19 +561,19 @@ test('shows API failure then creates a troop successfully', async ({ page }) =>
await expect(input).toBeHidden(); await expect(input).toBeHidden();
}); });
test('renames and kicks through confirm dialogs, then refreshes state', async ({ page }) => { test('renames and kicks through app confirmation dialogs, then refreshes state', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
me: { id: 1, troopId: 1 }, me: { id: 1, troopId: 1 },
permission: 4, permission: 4,
troops: baseTroops(), troops: baseTroops(),
}; };
await installApiFixture(page, state); await installApiFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await gotoTroop(page); await gotoTroop(page);
await page.getByRole('button', { name: '부대명 변경...' }).first().click(); await page.getByRole('button', { name: '부대명 변경...' }).first().click();
await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종'); await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종');
await page.getByRole('button', { name: '변경', exact: true }).click(); await page.getByRole('button', { name: '변경', exact: true }).click();
await acceptAppConfirmation(page, '백마대 부대의 이름을 백마의종으로 바꾸시겠습니까?');
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
'부대명을 변경했습니다.' '부대명을 변경했습니다.'
); );
@@ -576,12 +582,44 @@ test('renames and kicks through confirm dialogs, then refreshes state', async ({
await page.getByRole('button', { name: '부대원 추방...' }).click(); await page.getByRole('button', { name: '부대원 추방...' }).click();
await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3'); await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3');
await page.getByRole('button', { name: '추방', exact: true }).click(); await page.getByRole('button', { name: '추방', exact: true }).click();
await acceptAppConfirmation(page, '백마의종 부대에서 조운을 추방하시겠습니까?');
await expect( await expect(
page.locator('[data-testid="game-toast"][data-feedback-kind="success"]').filter({ hasText: '조운' }) page.locator('[data-testid="game-toast"][data-feedback-kind="success"]').filter({ hasText: '조운' })
).toContainText('조운을 추방했습니다.'); ).toContainText('조운을 추방했습니다.');
await expect(page.locator('.troopMembers').first()).not.toContainText('조운'); await expect(page.locator('.troopMembers').first()).not.toContainText('조운');
}); });
test('@ios-webkit iPhone touch renames and disbands a troop through app confirmations', async ({
browser,
}, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required');
const context = await browser.newContext({ ...devices['iPhone 15'], baseURL: configuredBaseUrl });
const page = await context.newPage();
const state: FixtureState = {
me: { id: 1, troopId: 1 },
permission: 4,
troops: baseTroops(),
};
try {
await installApiFixture(page, state);
await gotoTroop(page);
await page.getByRole('button', { name: '부대명 변경...' }).first().click();
await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종');
await page.getByRole('button', { name: '변경', exact: true }).click();
await acceptAppConfirmation(page, '백마대 부대의 이름을 백마의종으로 바꾸시겠습니까?');
await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible();
await page.getByRole('button', { name: '부대 해산' }).click();
await acceptAppConfirmation(page, '백마의종 부대를 해산하겠습니까?');
await expect(page.getByTestId('game-toast').filter({ hasText: '부대를 해산했습니다.' })).toBeVisible();
expect(state.me.troopId).toBe(0);
await expect(page.locator('[data-troop-id="1"]')).toHaveCount(0);
} finally {
await context.close();
}
});
test('does not render management controls for an unauthorized member', async ({ page }) => { test('does not render management controls for an unauthorized member', async ({ page }) => {
await installApiFixture(page, { await installApiFixture(page, {
me: { id: 3, troopId: 1 }, me: { id: 3, troopId: 1 },
+4 -2
View File
@@ -14,9 +14,11 @@
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:ios-webkit": "playwright test nationOffices.spec.ts troop.spec.ts npcPolicy.spec.ts --config e2e/playwright.config.mjs --browser=webkit --grep @ios-webkit",
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:nation-betting": "playwright test nationBetting.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:tournament-bracket": "playwright test tournamentBracket.spec.ts --config e2e/playwright.config.mjs", "test:e2e:tournament-bracket": "playwright test tournamentBracket.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
@@ -26,8 +28,8 @@
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"test:e2e:default-recruit-commands-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/defaultRecruitCommands.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:default-recruit-commands-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/defaultRecruitCommands.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"lint": "eslint .", "lint": "pnpm -w lint:target app/game-frontend",
"lint:fix": "eslint . --fix", "lint:fix": "pnpm -w lint:target:fix app/game-frontend",
"typecheck": "vue-tsc --noEmit" "typecheck": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+9
View File
@@ -121,6 +121,15 @@ input[inputmode='search'] {
font-size: 0; font-size: 0;
} }
/*
* Domestic command logs keep Ref's semantic failure marker instead of a
* legacy color tag. Give that shared marker the Core command-result failure
* color so it stays distinct from skyblue success and neutral output.
*/
.ev_failed {
color: magenta;
}
.small_war_log .war_type_attack { .small_war_log .war_type_attack {
color: cyan; color: cyan;
} }
@@ -9,6 +9,8 @@ import type {
ReservedCommandRow, ReservedCommandRow,
} from '../command/types'; } from '../command/types';
type ReservationCompletion = (success: boolean) => void;
const props = defineProps<{ const props = defineProps<{
officerLevelText: string; officerLevelText: string;
name: string | null; name: string | null;
@@ -26,10 +28,14 @@ const props = defineProps<{
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action }))); const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'reserve-bulk', entries: CommandPatternEntry[]): void; (event: 'reserve-bulk', entries: CommandPatternEntry[], complete?: ReservationCompletion): void;
(event: 'shift', amount: number): void; (event: 'shift', amount: number): void;
(event: 'repeat', amount: number): void; (event: 'repeat', amount: number): void;
}>(); }>();
const reserveBulk = (entries: CommandPatternEntry[], complete?: ReservationCompletion) => {
emit('reserve-bulk', entries, complete);
};
</script> </script>
<template> <template>
@@ -47,7 +53,7 @@ const emit = defineEmits<{
:current-time="props.rows[0]?.time" :current-time="props.rows[0]?.time"
:map-data="props.mapData" :map-data="props.mapData"
:map-layout="props.mapLayout" :map-layout="props.mapLayout"
@reserve-bulk="emit('reserve-bulk', $event)" @reserve-bulk="reserveBulk"
@shift="emit('shift', $event)" @shift="emit('shift', $event)"
@repeat="emit('repeat', $event)" @repeat="emit('repeat', $event)"
/> />
@@ -37,6 +37,10 @@ const filledAmount = (crewType: RecruitmentCrewType | null): number => {
} }
return Math.max(1, props.info.fullLeadership); return Math.max(1, props.info.fullLeadership);
}; };
const displayedAmount = (crewType: RecruitmentCrewType): number =>
crewType.id === selectedCrewTypeId.value ? amount.value : filledAmount(crewType);
const estimatedGoldFor = (crewType: RecruitmentCrewType): number =>
Math.ceil(displayedAmount(crewType) * crewType.baseCost * goldCoefficient.value);
const initialize = () => { const initialize = () => {
showUnavailable.value = Object.fromEntries(props.info.groups.map((group) => [group.armType, false])); showUnavailable.value = Object.fromEntries(props.info.groups.map((group) => [group.armType, false]));
@@ -246,6 +250,9 @@ watch(
" "
/> />
<span>00</span> <span>00</span>
<output :aria-label="`${crewType.name} 예상 자금`">
{{ estimatedGoldFor(crewType).toLocaleString() }}
</output>
</label> </label>
</span> </span>
<span class="crew-action" @click.stop> <span class="crew-action" @click.stop>
@@ -393,7 +400,7 @@ watch(
} }
.amount-panel label { .amount-panel label {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto minmax(76px, auto);
align-items: center; align-items: center;
} }
.amount-panel input { .amount-panel input {
@@ -402,6 +409,14 @@ watch(
box-sizing: border-box; box-sizing: border-box;
text-align: right; text-align: right;
} }
.amount-panel output {
box-sizing: border-box;
min-width: 0;
padding: 4px;
background: #ddd;
color: #303030;
text-align: right;
}
.crew-action button, .crew-action button,
.submit-recruit { .submit-recruit {
width: 100%; width: 100%;
@@ -452,12 +467,7 @@ watch(
grid-template-columns: auto minmax(0, 1fr) auto 85px; grid-template-columns: auto minmax(0, 1fr) auto 85px;
} }
.mobile-selected-panel output { .mobile-selected-panel output {
box-sizing: border-box;
min-width: 0; min-width: 0;
padding: 4px;
background: #ddd;
color: #303030;
text-align: right;
} }
.crew-grid { .crew-grid {
grid-template-areas: grid-template-areas:
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue'; import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue'; import CommandSelectForm from '../main/CommandSelectForm.vue';
import { commandArgumentPresentation } from './commandArgumentPresentation'; import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from './commandArgumentPresentation';
import DragSelect from './DragSelect.vue'; import DragSelect from './DragSelect.vue';
import RecruitmentCommandForm from './RecruitmentCommandForm.vue'; import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
import { import {
@@ -30,7 +30,9 @@ const props = withDefaults(
commandTable: CommandTable | null; commandTable: CommandTable | null;
loading: boolean; loading: boolean;
storageKey: string; storageKey: string;
editModeStorageKey?: string;
maxPushTurn?: number; maxPushTurn?: number;
maxShiftTurn?: number;
compact?: boolean; compact?: boolean;
mobile?: boolean; mobile?: boolean;
title?: string; title?: string;
@@ -41,7 +43,9 @@ const props = withDefaults(
autonomousUntil?: string | null; autonomousUntil?: string | null;
}>(), }>(),
{ {
editModeStorageKey: undefined,
maxPushTurn: 6, maxPushTurn: 6,
maxShiftTurn: 6,
compact: false, compact: false,
mobile: false, mobile: false,
title: '', title: '',
@@ -54,7 +58,7 @@ const props = withDefaults(
); );
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'reserve-bulk', entries: CommandPatternEntry[]): void; (event: 'reserve-bulk', entries: CommandPatternEntry[], complete?: (success: boolean) => void): void;
(event: 'shift', amount: number): void; (event: 'shift', amount: number): void;
(event: 'repeat', amount: number): void; (event: 'repeat', amount: number): void;
}>(); }>();
@@ -77,25 +81,27 @@ const commandArgs = ref<Record<string, unknown>>({});
const commandArgsValid = ref(false); const commandArgsValid = ref(false);
const expanded = ref(false); const expanded = ref(false);
const menuRevision = ref(0); const menuRevision = ref(0);
const pendingReservation = ref<CommandPatternEntry | null>(null); const pendingReservation = ref<{ requestId: number; entry: CommandPatternEntry } | null>(null);
let reservationRequestId = 0;
const editorElement = ref<HTMLElement | null>(null); const editorElement = ref<HTMLElement | null>(null);
const pickerElement = ref<HTMLElement | null>(null); const pickerElement = ref<HTMLElement | null>(null);
const collapsedRowCount = 15; const collapsedRowCount = 15;
const loadStorage = (key: string) => { const loadStorage = (key: string, editModeKey: string | undefined) => {
storage.value = new CommandStorage(key); storage.value = new CommandStorage(key, { editModeKey });
editMode.value = storage.value.editMode; editMode.value = storage.value.editMode;
activeCategory.value = storage.value.activeCategory; activeCategory.value = storage.value.activeCategory;
}; };
onMounted(() => { onMounted(() => {
loadStorage(props.storageKey); loadStorage(props.storageKey, props.editModeStorageKey);
}); });
watch( watch(
() => props.storageKey, () => [props.storageKey, props.editModeStorageKey] as const,
(key, previousKey) => { ([key, editModeKey], [previousKey, previousEditModeKey]) => {
if (key !== previousKey) loadStorage(key); if (key !== previousKey || editModeKey !== previousEditModeKey) loadStorage(key, editModeKey);
} }
); );
@@ -107,24 +113,6 @@ watch([editMode, activeCategory], () => {
if (editMode.value) quickTarget.value = null; if (editMode.value) quickTarget.value = null;
}); });
watch(
() => props.rows,
(rows) => {
const pending = pendingReservation.value;
if (!pending) return;
const saved = pending.turnList.every((index) => {
const row = rows[index];
return row?.action === pending.action && JSON.stringify(row.args ?? {}) === JSON.stringify(pending.args);
});
if (!saved) return;
storage.value?.pushRecent({ ...pending, turnList: [0] });
pendingReservation.value = null;
releaseSelection();
closePicker();
},
{ deep: true }
);
const scopedTable = computed<CommandTable | null>(() => { const scopedTable = computed<CommandTable | null>(() => {
if (!props.commandTable) return null; if (!props.commandTable) return null;
return { return {
@@ -146,6 +134,15 @@ const quickPickerTop = ref('38px');
const isRecruitmentCommand = computed( const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
); );
const isArgumentPickerExpanded = computed(() => {
const command = selectedCommand.value;
if (!command) return false;
const presentation = commandArgumentPresentation(command.key);
return Boolean(
(command.reqArg && presentation.lines.length) ||
resolveCommandArgumentMapTarget(command.key, command.inputFields)
);
});
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
const commandBrief = (entry: { action: string; args: unknown; label?: string }): string => const commandBrief = (entry: { action: string; args: unknown; label?: string }): string =>
formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) || formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) ||
@@ -253,9 +250,7 @@ const togglePicker = (turnIndex?: number) => {
}; };
const selectCommand = (commandKey: string) => { const selectCommand = (commandKey: string) => {
const table = props.commandTable; const table = props.commandTable;
const command = table?.[props.scope] const command = table?.[props.scope].flatMap((group) => group.values).find((entry) => entry.key === commandKey);
.flatMap((group) => group.values)
.find((entry) => entry.key === commandKey);
if (!table || !command) return; if (!table || !command) return;
selectedCommand.value = command; selectedCommand.value = command;
// Ref opens argument commands on a separate processing page. Keep the same // Ref opens argument commands on a separate processing page. Keep the same
@@ -269,16 +264,27 @@ const selectCommand = (commandKey: string) => {
}; };
commandArgs.value = {}; commandArgs.value = {};
commandArgsValid.value = !command.reqArg; commandArgsValid.value = !command.reqArg;
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital'; if (!command.reqArg) submitCommand();
if (!command.reqArg && !needsInformationalConfirmation) submitCommand();
}; };
const submitCommand = () => { const submitCommand = () => {
const command = selectedCommand.value; const command = selectedCommand.value;
if (!command || !commandArgsValid.value) return; if (!command || !commandArgsValid.value || pendingReservation.value) return;
const turnList = quickTarget.value === null ? selectedIndices() : [quickTarget.value]; const turnList = quickTarget.value === null ? selectedIndices() : [quickTarget.value];
const entry = { turnList, action: command.key, args: { ...commandArgs.value }, label: command.name }; const entry = { turnList, action: command.key, args: { ...commandArgs.value }, label: command.name };
emit('reserve-bulk', [entry]); const requestId = ++reservationRequestId;
pendingReservation.value = entry; pendingReservation.value = { requestId, entry };
emit('reserve-bulk', [entry], (success) => {
const pending = pendingReservation.value;
if (!pending || pending.requestId !== requestId) return;
pendingReservation.value = null;
if (!success) return;
storage.value?.pushRecent({ ...entry, turnList: [0] });
releaseSelection();
closePicker();
});
}; };
const returnToCommandList = () => { const returnToCommandList = () => {
selectedCommand.value = null; selectedCommand.value = null;
@@ -361,9 +367,7 @@ const clickOutsideMenu = (event: Event) => {
mobile: props.mobile, mobile: props.mobile,
'edit-mode': editMode, 'edit-mode': editMode,
'picker-open': pickerOpen, 'picker-open': pickerOpen,
'argument-expanded': Boolean( 'argument-expanded': isArgumentPickerExpanded,
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
),
}" }"
:data-command-scope="props.scope" :data-command-scope="props.scope"
> >
@@ -500,7 +504,7 @@ const clickOutsideMenu = (event: Event) => {
<summary>당기기</summary> <summary>당기기</summary>
<div class="menu-items"> <div class="menu-items">
<button <button
v-for="amount in props.maxPushTurn" v-for="amount in props.maxShiftTurn"
:key="amount" :key="amount"
@click=" @click="
emit('shift', -amount); emit('shift', -amount);
@@ -515,7 +519,7 @@ const clickOutsideMenu = (event: Event) => {
<summary>미루기</summary> <summary>미루기</summary>
<div class="menu-items"> <div class="menu-items">
<button <button
v-for="amount in props.maxPushTurn" v-for="amount in props.maxShiftTurn"
:key="amount" :key="amount"
@click=" @click="
emit('shift', amount); emit('shift', amount);
@@ -697,12 +701,38 @@ const clickOutsideMenu = (event: Event) => {
</div> </div>
<div v-if="!props.compact" class="bottom-actions"> <div v-if="!props.compact" class="bottom-actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)"> <details class="legacy-menu bottom-shift-menu">
당기기 <summary class="legacy-button legacy-button--secondary" role="button">당기기</summary>
<div class="menu-items">
<button
v-for="amount in props.maxShiftTurn"
:key="amount"
type="button"
@click="
emit('shift', -amount);
clickOutsideMenu($event);
"
>
{{ amount }}
</button> </button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)"> </div>
미루기 </details>
<details class="legacy-menu bottom-shift-menu">
<summary class="legacy-button legacy-button--secondary" role="button">미루기</summary>
<div class="menu-items">
<button
v-for="amount in props.maxShiftTurn"
:key="amount"
type="button"
@click="
emit('shift', amount);
clickOutsideMenu($event);
"
>
{{ amount }}
</button> </button>
</div>
</details>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded"> <button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }} {{ expanded ? '접기' : '펼치기' }}
</button> </button>
@@ -766,11 +796,7 @@ const clickOutsideMenu = (event: Event) => {
@submit="submitCommand" @submit="submitCommand"
/> />
<CommandArgumentForm <CommandArgumentForm
v-else-if=" v-else-if="commandInputSnapshot && selectedCommand.reqArg"
commandInputSnapshot &&
(selectedCommand.reqArg ||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
"
:command-key="selectedCommand.key" :command-key="selectedCommand.key"
:fields="selectedCommand.inputFields" :fields="selectedCommand.inputFields"
:options="commandInputSnapshot.options" :options="commandInputSnapshot.options"
@@ -833,7 +859,8 @@ const clickOutsideMenu = (event: Event) => {
} }
.control-pad > button, .control-pad > button,
.clock, .clock,
.legacy-menu > summary { .control-pad > .legacy-menu > summary,
.advanced-actions .legacy-menu > summary {
box-sizing: border-box; box-sizing: border-box;
min-height: 34px; min-height: 34px;
border: 0; border: 0;
@@ -1047,6 +1074,15 @@ const clickOutsideMenu = (event: Event) => {
font-size: 0.72em; font-size: 0.72em;
line-height: 1; line-height: 1;
} }
.reserved-command-editor.mobile .action-column > div.autonomous {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
}
.reserved-command-editor.mobile .action-column > div.autonomous > small {
flex: 0 0 auto;
}
.edit-column button { .edit-column button {
background: #444; background: #444;
cursor: pointer; cursor: pointer;
@@ -1062,6 +1098,9 @@ const clickOutsideMenu = (event: Event) => {
gap: 4px; gap: 4px;
padding-top: 3px; padding-top: 3px;
} }
.bottom-shift-menu > summary {
width: 100%;
}
.command-picker { .command-picker {
position: absolute; position: absolute;
z-index: 80; z-index: 80;
@@ -1,6 +1,6 @@
import type { CommandInputField } from './types'; import type { CommandInputField } from './types';
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital'; export type CommandArgumentMapTarget = 'city' | 'nation';
export type CommandArgumentPresentation = { export type CommandArgumentPresentation = {
lines: string[]; lines: string[];
@@ -9,7 +9,6 @@ export type CommandArgumentPresentation = {
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' }); const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' }); const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
const capitalTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'capital' });
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다. // Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다. // 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
@@ -38,15 +37,6 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
]), ]),
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']), cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']), che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
che_증축: capitalTarget([
'현재 수도를 증축해 인구·내정·성벽의 최대치를 높입니다.',
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
]),
che_감축: capitalTarget([
'현재 수도를 감축해 인구·내정·성벽의 최대치를 낮추고 국고를 회수합니다.',
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
]),
che_선전포고: nationTarget([ che_선전포고: nationTarget([
'선택한 국가에 선전포고합니다.', '선택한 국가에 선전포고합니다.',
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.', '고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
@@ -104,7 +94,6 @@ export const commandArgumentPresentation = (commandKey: string): CommandArgument
/** /**
* API가 . * API가 .
* · presentation의 target을 .
*/ */
export const resolveCommandArgumentMapTarget = ( export const resolveCommandArgumentMapTarget = (
commandKey: string, commandKey: string,
@@ -0,0 +1,33 @@
import type { CommandOption } from './types';
const npcStateOf = (option: CommandOption): number => option.npcState ?? 0;
/** 장수 선택지는 유저장부터 NPC 종류순으로 묶고, 명령별 우선순위는 같은 종류 안에서만 적용한다. */
export const sortCommandGeneralOptions = (
commandKey: string,
options: readonly CommandOption[],
isGold: boolean
): CommandOption[] => {
const resourceKey = isGold ? 'gold' : 'rice';
return options
.map((option, index) => ({ option, index }))
.sort((left, right) => {
const typeOrder = npcStateOf(left.option) - npcStateOf(right.option);
if (typeOrder !== 0) return typeOrder;
if (commandKey === 'che_포상') {
const resourceOrder = (left.option[resourceKey] ?? 0) - (right.option[resourceKey] ?? 0);
if (resourceOrder !== 0) return resourceOrder;
}
if (commandKey === 'che_몰수') {
const resourceOrder = (right.option[resourceKey] ?? 0) - (left.option[resourceKey] ?? 0);
if (resourceOrder !== 0) return resourceOrder;
}
if (commandKey === 'che_부대탈퇴지시') {
const availabilityOrder = Number(right.option.availableNow) - Number(left.option.availableNow);
if (availabilityOrder !== 0) return availabilityOrder;
}
return left.index - right.index;
})
.map(({ option }) => option);
};
@@ -95,6 +95,18 @@ export const moveQueueRange = (
return next.map((entry, index) => ({ turnList: [index], ...entry })); return next.map((entry, index) => ({ turnList: [index], ...entry }));
}; };
export const generalTurnEditorModeStorageKey = (profile: string | undefined, appBasePath: string): string => {
const runtimeProfile = profile?.trim().split(':', 1)[0];
const basePathProfile = appBasePath.split('/').find((segment) => segment.length > 0);
const profileId = runtimeProfile || basePathProfile || 'default';
return `core2026:profile:${encodeURIComponent(profileId)}:general-turn-editor`;
};
interface CommandStorageOptions {
maxRecent?: number;
editModeKey?: string;
}
export class CommandStorage { export class CommandStorage {
readonly recent = new Map<string, CommandPatternEntry>(); readonly recent = new Map<string, CommandPatternEntry>();
readonly templates = new Map<string, CommandPatternEntry[]>(); readonly templates = new Map<string, CommandPatternEntry[]>();
@@ -102,11 +114,13 @@ export class CommandStorage {
editMode = false; editMode = false;
activeCategory = ''; activeCategory = '';
private readonly key: string; private readonly key: string;
private readonly editModeKey: string;
private readonly maxRecent: number; private readonly maxRecent: number;
constructor(key: string, maxRecent = 10) { constructor(key: string, options: CommandStorageOptions = {}) {
this.key = key; this.key = key;
this.maxRecent = maxRecent; this.editModeKey = options.editModeKey ?? key;
this.maxRecent = options.maxRecent ?? 10;
this.load(); this.load();
} }
@@ -126,12 +140,18 @@ export class CommandStorage {
this.templates.set(name, entries); this.templates.set(name, entries);
} }
this.clipboard = this.read<CommandPatternEntry[] | undefined>('clipboard', undefined); this.clipboard = this.read<CommandPatternEntry[] | undefined>('clipboard', undefined);
this.editMode = localStorage.getItem(`${this.key}:editMode`) === '1'; const editModeStorageKey = `${this.editModeKey}:editMode`;
let storedEditMode = localStorage.getItem(editModeStorageKey);
if (storedEditMode === null && this.editModeKey !== this.key) {
storedEditMode = localStorage.getItem(`${this.key}:editMode`);
if (storedEditMode !== null) localStorage.setItem(editModeStorageKey, storedEditMode);
}
this.editMode = storedEditMode === '1';
this.activeCategory = this.read('category', ''); this.activeCategory = this.read('category', '');
} }
saveState(): void { saveState(): void {
localStorage.setItem(`${this.key}:editMode`, this.editMode ? '1' : '0'); localStorage.setItem(`${this.editModeKey}:editMode`, this.editMode ? '1' : '0');
localStorage.setItem(`${this.key}:category`, JSON.stringify(this.activeCategory)); localStorage.setItem(`${this.key}:category`, JSON.stringify(this.activeCategory));
} }
@@ -54,6 +54,7 @@ export type CommandInputField = {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
defaultValue?: string | number | boolean;
constValue?: string | number; constValue?: string | number;
options?: CommandOption[]; options?: CommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
@@ -64,6 +65,7 @@ export type CommandAvailability = {
key: string; key: string;
name: string; name: string;
turnDurationText?: string; turnDurationText?: string;
costText?: string;
reqArg: boolean; reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown'; status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean; possible: boolean;
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue'; import { computed, reactive, watch, type CSSProperties } from 'vue';
import MapViewer from './MapViewer.vue'; import MapViewer from './MapViewer.vue';
import NationColorSelect from './NationColorSelect.vue';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation'; import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
import { commandCityOptions } from '../command/commandArgumentOptions'; import { commandCityOptions } from '../command/commandArgumentOptions';
import { sortCommandGeneralOptions } from '../command/commandGeneralOptions';
import { import {
commandArgumentFieldContract, commandArgumentFieldContract,
shouldPreserveCommandArgumentValue, shouldPreserveCommandArgumentValue,
@@ -43,18 +45,7 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !
const amountPreset = computed(() => props.options.amountPresets?.[props.commandKey]); const amountPreset = computed(() => props.options.amountPresets?.[props.commandKey]);
const sortGeneralOptions = (options: CommandOption[]): CommandOption[] => { const sortGeneralOptions = (options: CommandOption[]): CommandOption[] => {
const result = [...options]; return sortCommandGeneralOptions(props.commandKey, options, values.isGold !== false);
const resourceKey = values.isGold === false ? 'rice' : 'gold';
if (props.commandKey === 'che_포상') {
return result.sort((left, right) => (left[resourceKey] ?? 0) - (right[resourceKey] ?? 0));
}
if (props.commandKey === 'che_몰수') {
return result.sort((left, right) => (right[resourceKey] ?? 0) - (left[resourceKey] ?? 0));
}
if (props.commandKey === 'che_부대탈퇴지시') {
return result.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
}
return result;
}; };
const optionsFor = (field: CommandInputField): CommandOption[] => { const optionsFor = (field: CommandInputField): CommandOption[] => {
@@ -77,6 +68,7 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
const defaultValue = (field: CommandInputField): unknown => { const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'hidden') return field.constValue; if (field.kind === 'hidden') return field.constValue;
if (field.defaultValue !== undefined) return field.defaultValue;
if (field.kind === 'boolean') return true; if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') { if (field.kind === 'numberTuple') {
const value = amountPreset.value?.defaultValue ?? field.min ?? 0; const value = amountPreset.value?.defaultValue ?? field.min ?? 0;
@@ -141,6 +133,11 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined => const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
optionsFor(field).find((entry) => entry.value === values[field.key]); optionsFor(field).find((entry) => entry.value === values[field.key]);
const selectedValueFor = (field: CommandInputField): string | number => {
const value = values[field.key];
return typeof value === 'string' || typeof value === 'number' ? value : '';
};
const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSSProperties | undefined => { const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSSProperties | undefined => {
if (field.optionSource === 'colors' && option?.color) { if (field.optionSource === 'colors' && option?.color) {
return { return {
@@ -184,10 +181,6 @@ const mapSelectedCityId = computed<number | null>(() => {
null null
); );
} }
if (mapTarget.value === 'capital') {
const myNation = props.mapData.myNation;
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
}
return null; return null;
}); });
@@ -208,11 +201,6 @@ const selectedMapTargetName = computed(() => {
if (typeof nationId !== 'number') return '-'; if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-'; return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
} }
if (mapTarget.value === 'capital') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
}
return '-'; return '-';
}); });
@@ -265,14 +253,6 @@ const mapTargetSummary = computed(() => {
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length; const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`; return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`;
} }
if (mapTarget.value === 'capital' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
return `${city.name} · ${props.mapLayout.regionMap[dynamic?.[4] ?? city.region]} · ${
props.mapLayout.levelMap[dynamic?.[1] ?? city.level]
} · 현재 수도`;
}
return ''; return '';
}); });
@@ -417,21 +397,17 @@ watch(
:detail-mode="true" :detail-mode="true"
:fit-container="true" :fit-container="true"
:show-current-city-marker="true" :show-current-city-marker="true"
:readonly="mapTarget === 'capital'"
@select-city="selectMapCity" @select-city="selectMapCity"
/> />
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small> <small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status"> <div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
<span v-if="mapTarget !== 'capital'" class="current-city-status"> <span class="current-city-status">
<span class="status-key">현재 도시</span> <span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong> <strong>{{ currentCityName }}</strong>
</span> </span>
<span v-if="mapTarget !== 'capital'" aria-hidden="true"></span> <span aria-hidden="true"></span>
<span class="selected-target-status"> <span class="selected-target-status">
<span class="status-key">{{ <span class="status-key">{{ mapTarget === 'nation' ? '선택 국가' : '선택 도시' }}</span>
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
}}</span>
<strong>{{ selectedMapTargetName }}</strong> <strong>{{ selectedMapTargetName }}</strong>
</span> </span>
</div> </div>
@@ -478,6 +454,13 @@ watch(
</option> </option>
</select> </select>
</div> </div>
<NationColorSelect
v-else-if="field.kind === 'select' && field.optionSource === 'colors'"
:id="`command-arg-${field.key}`"
:model-value="selectedValueFor(field)"
:options="optionsFor(field)"
@update:model-value="setSelectValue(field, String($event))"
/>
<select <select
v-else-if="field.kind === 'select'" v-else-if="field.kind === 'select'"
:id="`command-arg-${field.key}`" :id="`command-arg-${field.key}`"
@@ -2,8 +2,10 @@
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, onUnmounted, ref, watch } from 'vue';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { generalTurnEditorModeStorageKey } from '../command/commandQueue';
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection'; import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection';
import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig';
import type { import type {
CommandMapData, CommandMapData,
CommandMapLayout, CommandMapLayout,
@@ -12,6 +14,8 @@ import type {
ReservedCommandRow, ReservedCommandRow,
} from '../command/types'; } from '../command/types';
type ReservationCompletion = (success: boolean) => void;
const props = defineProps<{ const props = defineProps<{
commandTable: CommandTable | null; commandTable: CommandTable | null;
loading: boolean; loading: boolean;
@@ -29,14 +33,24 @@ const props = defineProps<{
storageKey?: string; storageKey?: string;
mapData?: CommandMapData | null; mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null; mapLayout?: CommandMapLayout | null;
mobile?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'set-general-turns', entries: CommandPatternEntry[]): void; (event: 'set-general-turns', entries: CommandPatternEntry[], complete?: ReservationCompletion): void;
(event: 'shift-general-turns', amount: number): void; (event: 'shift-general-turns', amount: number): void;
(event: 'repeat-general-turns', amount: number): void; (event: 'repeat-general-turns', amount: number): void;
}>(); }>();
const reserveBulk = (entries: CommandPatternEntry[], complete?: ReservationCompletion) => {
emit('set-general-turns', entries, complete);
};
const editModeStorageKey = generalTurnEditorModeStorageKey(
gameFrontendRuntimeConfig.profile,
gameFrontendRuntimeConfig.appBasePath
);
const labelMap = computed(() => { const labelMap = computed(() => {
const result = new Map<string, string>([['휴식', '휴식']]); const result = new Map<string, string>([['휴식', '휴식']]);
for (const group of props.commandTable?.general ?? []) { for (const group of props.commandTable?.general ?? []) {
@@ -139,11 +153,14 @@ onUnmounted(() => {
:command-table="props.commandTable" :command-table="props.commandTable"
:loading="props.loading" :loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`" :storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:edit-mode-storage-key="editModeStorageKey"
:current-time="currentServerTime" :current-time="currentServerTime"
:map-data="props.mapData" :map-data="props.mapData"
:map-layout="props.mapLayout" :map-layout="props.mapLayout"
:autonomous-until="autonomousUntil" :autonomous-until="autonomousUntil"
@reserve-bulk="emit('set-general-turns', $event)" :mobile="props.mobile"
:max-push-turn="12"
@reserve-bulk="reserveBulk"
@shift="emit('shift-general-turns', $event)" @shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)" @repeat="emit('repeat-general-turns', $event)"
/> />
@@ -6,6 +6,7 @@ type CommandAvailability = {
key: string; key: string;
name: string; name: string;
turnDurationText?: string; turnDurationText?: string;
costText?: string;
reqArg: boolean; reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown'; status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean; possible: boolean;
@@ -165,6 +166,7 @@ const commandTitle = (command: CommandAvailability) =>
<small v-if="command.turnDurationText" class="command-duration"> <small v-if="command.turnDurationText" class="command-duration">
/{{ command.turnDurationText }} /{{ command.turnDurationText }}
</small> </small>
<small v-if="command.costText" class="command-cost">{{ command.costText }}</small>
</button> </button>
</div> </div>
</div> </div>
@@ -255,6 +257,15 @@ const commandTitle = (command: CommandAvailability) =>
white-space: nowrap; white-space: nowrap;
} }
.command-cost {
display: block;
color: #f3d58b;
font-size: 0.875em;
font-weight: 400;
line-height: 1.1;
overflow-wrap: anywhere;
}
.empty { .empty {
color: rgba(232, 221, 196, 0.6); color: rgba(232, 221, 196, 0.6);
} }
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig';
const handleClick = (event: MouseEvent): void => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
window.location.replace(gameFrontendRuntimeConfig.gatewayWebUrl);
};
</script>
<template>
<a :href="gameFrontendRuntimeConfig.gatewayWebUrl" @click="handleClick"><slot>로비로</slot></a>
</template>
@@ -6,7 +6,7 @@ import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import RichTooltip from '../ui/RichTooltip.vue'; import RichTooltip from '../ui/RichTooltip.vue';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon'; import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets'; import { configuredGameAssetUrl } from '../../utils/imageAssets';
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor'; import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
@@ -164,7 +164,7 @@ const experiencePercent = computed(() =>
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {}); const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {})); const generalIconUrl = computed(() => resolveGeneralIconUrl(props.general ?? {}));
const crewTypeIconBackground = computed(() => { const crewTypeIconBackground = computed(() => {
const crewTypeId = props.general?.crewTypeId; const crewTypeId = props.general?.crewTypeId;
@@ -251,11 +251,11 @@ const specialText = computed(() => {
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<template v-else> <template v-else>
<div class="general-basic-grid general-body"> <div class="general-basic-grid general-body">
<span <img
class="general-image general-icon" class="general-image general-icon"
role="img" :src="generalIconUrl"
:aria-label="`${props.general.name} 초상`" :alt="`${props.general.name} 초상`"
:style="{ backgroundImage: generalIconBackground }" @error="useDefaultGeneralIcon"
/> />
<div class="general-title battle-general-name" :style="titleStyle"> <div class="general-title battle-general-name" :style="titleStyle">
{{ props.general.name }} {{ props.general.name }}
@@ -272,12 +272,20 @@ const specialText = computed(() => {
<strong class="stat-value" :style="{ color: injuryInfo.color }"> <strong class="stat-value" :style="{ color: injuryInfo.color }">
<span>{{ stat.value }}</span> <span>{{ stat.value }}</span>
<span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span> <span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span>
<span class="bar-cell" :data-stat-progress="stat.key"> <span class="bar-cell">
<RichTooltip
:title="`${stat.label} 성장`"
:description="`${stat.accumulated} / ${stat.limit}`"
:test-id="`stat-${stat.key}`"
>
<span :data-stat-progress="stat.key">
<LegacyProgressBar <LegacyProgressBar
:percent="stat.percent" :percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`" :label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/> />
</span> </span>
</RichTooltip>
</span>
</strong> </strong>
</template> </template>
@@ -505,6 +513,7 @@ const specialText = computed(() => {
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: contain; background-size: contain;
object-fit: contain;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
@@ -538,6 +547,12 @@ const specialText = computed(() => {
grid-column: 3; grid-column: 3;
} }
.bar-cell :deep(.rich-tooltip-trigger),
.bar-cell [data-stat-progress] {
display: block;
width: 100%;
}
.bar-cell, .bar-cell,
.experience-bar { .experience-bar {
display: grid; display: grid;
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatAutorunUserDetail, type AutorunUserSummary } from '../../utils/autorunUser';
const props = defineProps<{
autorun: AutorunUserSummary | null;
}>();
const detail = computed(() => (props.autorun ? formatAutorunUserDetail(props.autorun) : ''));
</script>
<template>
<span
class="main-autorun-status"
:class="{ 'main-autorun-status--enabled': autorun }"
:tabindex="autorun ? 0 : undefined"
:aria-describedby="autorun ? 'main-autorun-detail' : undefined"
data-main-autorun-status
>
기타 설정: {{ autorun ? '자율행동' : '표준' }}
<span v-if="autorun" id="main-autorun-detail" class="main-autorun-detail" role="tooltip">
{{ detail }}
</span>
</span>
</template>
<style scoped>
.main-autorun-status {
position: relative;
}
.main-autorun-status--enabled {
cursor: help;
}
.main-autorun-detail {
position: absolute;
z-index: 40;
top: calc(100% + 6px);
right: 4px;
visibility: hidden;
box-sizing: border-box;
width: max-content;
max-width: min(520px, calc(100vw - 32px));
padding: 6px 8px;
border: 1px solid #52525b;
border-radius: 4px;
background: #18181b;
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
color: #f4f4f5;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
opacity: 0;
pointer-events: none;
text-align: left;
white-space: normal;
}
.main-autorun-status--enabled:hover .main-autorun-detail,
.main-autorun-status--enabled:focus-visible .main-autorun-detail {
visibility: visible;
opacity: 1;
}
.main-autorun-status--enabled:focus-visible {
outline: 2px solid #fdba74;
outline-offset: -2px;
}
@media (max-width: 939.98px) {
.main-autorun-detail {
position: fixed;
z-index: 100;
top: auto;
right: 16px;
bottom: 61px;
left: 16px;
width: auto;
max-width: none;
}
}
</style>
@@ -173,6 +173,10 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightWhen === 'vote'
box-shadow: 0 -8px 18px rgb(0 0 0 / 45%); box-shadow: 0 -8px 18px rgb(0 0 0 / 45%);
} }
.main-global-menu[data-menu-position='top'] {
z-index: 101;
}
.main-global-menu[data-menu-position='bottom'] .menu-caret { .main-global-menu[data-menu-position='bottom'] .menu-caret {
border-top-width: 0; border-top-width: 0;
border-bottom: 4px solid currentColor; border-bottom: 4px solid currentColor;
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { legacyNationTextColor } from '../../utils/legacyNationColor'; import { legacyNationTextColor } from '../../utils/legacyNationColor';
import GatewayLobbyLink from './GatewayLobbyLink.vue';
import MainNavigationLink from './MainNavigationLink.vue'; import MainNavigationLink from './MainNavigationLink.vue';
import { import {
buildGlobalNavigation, buildGlobalNavigation,
@@ -29,7 +30,6 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
refresh: []; refresh: [];
toggleRealtime: []; toggleRealtime: [];
lobby: [];
quick: [item: QuickNavigationItem]; quick: [item: QuickNavigationItem];
action: [action: NonNullable<MainNavigationLinkItem['action']>]; action: [action: NonNullable<MainNavigationLinkItem['action']>];
}>(); }>();
@@ -212,14 +212,12 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
</li> </li>
</template> </template>
<li class="bottom-lobby" role="none"> <li class="bottom-lobby" role="none">
<button <GatewayLobbyLink
class="quick-link lobby-link legacy-button legacy-button--navigation" class="quick-link lobby-link legacy-button legacy-button--navigation"
type="button"
role="menuitem" role="menuitem"
@click="emit('lobby')"
> >
로비로 로비로
</button> </GatewayLobbyLink>
</li> </li>
</ul> </ul>
</div> </div>
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import GatewayLobbyLink from './GatewayLobbyLink.vue';
defineProps<{ defineProps<{
realtimeEnabled: boolean; realtimeEnabled: boolean;
refreshing: boolean; refreshing: boolean;
@@ -7,7 +9,6 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
refresh: []; refresh: [];
toggleRealtime: []; toggleRealtime: [];
lobby: [];
}>(); }>();
</script> </script>
@@ -34,13 +35,11 @@ const emit = defineEmits<{
<strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong> <strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong>
</button> </button>
</div> </div>
<button <GatewayLobbyLink
class="main-turn-controls__lobby legacy-button legacy-button--navigation" class="main-turn-controls__lobby legacy-button legacy-button--navigation"
type="button"
@click="emit('lobby')"
> >
로비로 로비로
</button> </GatewayLobbyLink>
</section> </section>
</template> </template>
@@ -78,7 +78,12 @@ const touchend = (event: TouchEvent) => {
readonly: props.readonly, readonly: props.readonly,
}, },
]" ]"
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }" :style="{
left: `${props.city.x}px`,
top: `${props.city.y}px`,
width: `${size}px`,
height: `${size}px`,
}"
@mouseenter="emit('hover', props.city.id)" @mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')" @mouseleave="emit('leave')"
@touchstart="touchstart" @touchstart="touchstart"
@@ -107,10 +112,7 @@ const touchend = (event: TouchEvent) => {
<style scoped> <style scoped>
.map-city { .map-city {
position: absolute; position: absolute;
display: flex; box-sizing: border-box;
flex-direction: column;
align-items: center;
gap: 2px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: 0.65rem; font-size: 0.65rem;
color: rgba(232, 221, 196, 0.8); color: rgba(232, 221, 196, 0.8);
@@ -126,7 +128,8 @@ const touchend = (event: TouchEvent) => {
} }
.city-dot { .city-dot {
position: relative; position: absolute;
inset: 0;
border: 1px solid rgba(232, 221, 196, 0.6); border: 1px solid rgba(232, 221, 196, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -179,6 +182,7 @@ const touchend = (event: TouchEvent) => {
.city-state { .city-state {
position: absolute; position: absolute;
background: rgba(232, 221, 196, 0.8); background: rgba(232, 221, 196, 0.8);
pointer-events: none;
} }
.city-state.state-war { .city-state.state-war {
@@ -194,6 +198,11 @@ const touchend = (event: TouchEvent) => {
} }
.city-name { .city-name {
position: absolute;
top: calc(100% + 2px);
left: 50%;
pointer-events: none;
transform: translateX(-50%);
white-space: nowrap; white-space: nowrap;
} }
</style> </style>
@@ -40,6 +40,7 @@ const DETAIL_SIZES: DetailSize[] = [
{ bgWidth: 84, bgHeight: 60, iconWidth: 28, iconHeight: 20, flagRight: -6, flagTop: -4 }, { bgWidth: 84, bgHeight: 60, iconWidth: 28, iconHeight: 20, flagRight: -6, flagTop: -4 },
{ bgWidth: 96, bgHeight: 72, iconWidth: 32, iconHeight: 24, flagRight: -6, flagTop: -3 }, { bgWidth: 96, bgHeight: 72, iconWidth: 32, iconHeight: 24, flagRight: -6, flagTop: -3 },
]; ];
const CR_GRID_TILE_SIZE = 40.5;
const props = defineProps<{ const props = defineProps<{
city: MapCityView; city: MapCityView;
@@ -75,16 +76,20 @@ const detailSize = computed(() => {
}; };
}); });
const baseSize = computed(() => ({ const cityBackgroundSize = computed(() => {
width: 40 * props.mapScale, if (props.themeName === 'cr') {
height: 30 * props.mapScale, const tileSize = CR_GRID_TILE_SIZE * props.mapScale;
})); return { bgWidth: tileSize, bgHeight: tileSize };
}
return detailSize.value;
});
const cityBaseStyle = computed(() => ({ const cityBaseStyle = computed(() => ({
left: `${props.city.x}px`, left: `${props.city.x}px`,
top: `${props.city.y}px`, top: `${props.city.y}px`,
width: `${baseSize.value.width}px`, width: `${detailSize.value.iconWidth}px`,
height: `${baseSize.value.height}px`, height: `${detailSize.value.iconHeight}px`,
})); }));
const cityBgStyle = computed(() => { const cityBgStyle = computed(() => {
@@ -92,8 +97,8 @@ const cityBgStyle = computed(() => {
return null; return null;
} }
const style: Record<string, string> = { const style: Record<string, string> = {
width: `${detailSize.value.bgWidth}px`, width: `${cityBackgroundSize.value.bgWidth}px`,
height: `${detailSize.value.bgHeight}px`, height: `${cityBackgroundSize.value.bgHeight}px`,
}; };
if (props.themeName === 'cr') { if (props.themeName === 'cr') {
@@ -125,8 +130,8 @@ const capitalIcon = computed(() => buildAssetUrl(props.imageBaseUrl, 'event51.gi
const cityBgWrapperStyle = computed(() => ({ const cityBgWrapperStyle = computed(() => ({
left: '50%', left: '50%',
top: '50%', top: '50%',
marginLeft: `${-detailSize.value.bgWidth / 2}px`, marginLeft: `${-cityBackgroundSize.value.bgWidth / 2}px`,
marginTop: `${-detailSize.value.bgHeight / 2}px`, marginTop: `${-cityBackgroundSize.value.bgHeight / 2}px`,
})); }));
const cityIconStyle = computed(() => ({ const cityIconStyle = computed(() => ({
@@ -241,12 +246,14 @@ const cityStateStyle = computed(() => ({
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 100% 100%; background-size: 100% 100%;
pointer-events: none;
} }
.city-img { .city-img {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
pointer-events: none;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
@@ -307,5 +314,6 @@ const cityStateStyle = computed(() => ({
.city-state { .city-state {
position: absolute; position: absolute;
left: 0; left: 0;
pointer-events: none;
} }
</style> </style>
@@ -75,6 +75,7 @@ const props = withDefaults(
fitContainer?: boolean; fitContainer?: boolean;
showCurrentCityMarker?: boolean; showCurrentCityMarker?: boolean;
showSelectionBorder?: boolean; showSelectionBorder?: boolean;
showTitleTooltip?: boolean;
readonly?: boolean; readonly?: boolean;
}>(), }>(),
{ {
@@ -82,6 +83,7 @@ const props = withDefaults(
detailMode: undefined, detailMode: undefined,
selectedCityId: undefined, selectedCityId: undefined,
showSelectionBorder: true, showSelectionBorder: true,
showTitleTooltip: false,
} }
); );
@@ -152,6 +154,7 @@ const mapControls = ref<HTMLElement | null>(null);
const tooltipElement = ref<HTMLElement | null>(null); const tooltipElement = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false); const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`; const mapOptionsMenuId = `map-options-${useId()}`;
const mapTitleTooltipId = `map-title-tooltip-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody); const { width: mapBodyWidth } = useElementSize(mapBody);
const { elementX, elementY } = useMouseInElement(mapArea); const { elementX, elementY } = useMouseInElement(mapArea);
@@ -630,9 +633,14 @@ const selectCity = (cityId: number) => {
<template> <template>
<div class="map-viewer"> <div class="map-viewer">
<div class="map-top" :style="titleBandStyle"> <div class="map-top" :style="titleBandStyle">
<div class="map-title" tabindex="0" :style="titleTextStyle"> <div
class="map-title"
:tabindex="props.showTitleTooltip ? 0 : undefined"
:aria-describedby="props.showTitleTooltip ? mapTitleTooltipId : undefined"
:style="titleTextStyle"
>
{{ mapSummary }} {{ mapSummary }}
<div class="map-title-tooltip" role="tooltip"> <div v-if="props.showTitleTooltip" :id="mapTitleTooltipId" class="map-title-tooltip" role="tooltip">
<div v-for="line in titleTooltipLines" :key="line">{{ line }}</div> <div v-for="line in titleTooltipLines" :key="line">{{ line }}</div>
</div> </div>
</div> </div>
@@ -467,5 +467,9 @@ const forwardResponse = (messageId: number, response: boolean) => {
z-index: 4; z-index: 4;
top: 62px; top: 62px;
} }
.Actions {
grid-template-columns: 1fr 1fr;
}
} }
</style> </style>
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
import type { CommandOption } from '../command/types';
const props = defineProps<{
id: string;
modelValue: string | number;
options: CommandOption[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: string | number];
}>();
const root = ref<HTMLElement | null>(null);
const trigger = ref<HTMLButtonElement | null>(null);
const open = ref(false);
const selectedOption = computed(() => props.options.find((option) => option.value === props.modelValue));
const selectedIndex = computed(() =>
Math.max(
props.options.findIndex((option) => option.value === props.modelValue),
0
)
);
const optionStyle = (option?: CommandOption) => {
if (!option?.color) return undefined;
return {
backgroundColor: option.color,
color: legacyNationTextColor(option.color),
};
};
const focusOption = async (index: number): Promise<void> => {
await nextTick();
root.value?.querySelector<HTMLButtonElement>(`[data-color-option-index="${index}"]`)?.focus();
};
const openAndFocus = (index: number): void => {
open.value = true;
void focusOption(index);
};
const toggleOpen = (): void => {
open.value = !open.value;
};
const selectOption = (option: CommandOption): void => {
emit('update:modelValue', option.value);
open.value = false;
void nextTick(() => trigger.value?.focus());
};
const handleTriggerKeydown = (event: KeyboardEvent): void => {
if (event.key === 'ArrowDown') {
event.preventDefault();
openAndFocus(selectedIndex.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
openAndFocus(open.value ? selectedIndex.value : props.options.length - 1);
} else if (event.key === 'Home') {
event.preventDefault();
openAndFocus(0);
} else if (event.key === 'End') {
event.preventDefault();
openAndFocus(props.options.length - 1);
} else if (event.key === 'Escape' && open.value) {
event.preventDefault();
event.stopPropagation();
open.value = false;
}
};
const handleOptionKeydown = (event: KeyboardEvent, index: number): void => {
let nextIndex: number | null = null;
if (event.key === 'ArrowDown') nextIndex = Math.min(index + 1, props.options.length - 1);
else if (event.key === 'ArrowUp') nextIndex = Math.max(index - 1, 0);
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = props.options.length - 1;
else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
open.value = false;
void nextTick(() => trigger.value?.focus());
return;
}
if (nextIndex === null) return;
event.preventDefault();
void focusOption(nextIndex);
};
const handleDocumentPointerDown = (event: PointerEvent): void => {
if (!root.value?.contains(event.target as Node)) open.value = false;
};
onMounted(() => document.addEventListener('pointerdown', handleDocumentPointerDown));
onBeforeUnmount(() => document.removeEventListener('pointerdown', handleDocumentPointerDown));
</script>
<template>
<div ref="root" class="nation-color-select">
<button
:id="id"
ref="trigger"
type="button"
class="nation-color-select-trigger"
:style="optionStyle(selectedOption)"
aria-haspopup="listbox"
:aria-expanded="open"
:aria-controls="`${id}-options`"
@click="toggleOpen"
@keydown="handleTriggerKeydown"
>
<span>{{ selectedOption?.label ?? '색상 선택' }}</span>
<span class="nation-color-select-arrow" aria-hidden="true"></span>
</button>
<div v-if="open" :id="`${id}-options`" class="nation-color-select-options" role="listbox">
<button
v-for="(option, index) in options"
:key="String(option.value)"
type="button"
class="nation-color-select-option"
role="option"
:aria-selected="option.value === modelValue"
:data-color-option-index="index"
:style="optionStyle(option)"
@click="selectOption(option)"
@keydown="handleOptionKeydown($event, index)"
>
<span>{{ option.label }}</span>
<span v-if="option.value === modelValue" aria-hidden="true"></span>
</button>
</div>
</div>
</template>
<style scoped>
.nation-color-select {
position: relative;
min-width: 0;
margin: 4px 6px 4px 0;
}
.nation-color-select-trigger,
.nation-color-select-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
border: 1px solid rgba(201, 164, 90, 0.45);
padding: 5px 6px;
font: inherit;
text-align: left;
cursor: pointer;
}
.nation-color-select-trigger {
min-height: 28px;
}
.nation-color-select-trigger:hover,
.nation-color-select-trigger[aria-expanded='true'] {
border-color: rgba(244, 216, 153, 0.9);
}
.nation-color-select-trigger:focus-visible,
.nation-color-select-option:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid #fff;
outline-offset: -3px;
}
.nation-color-select-arrow {
margin-left: 8px;
}
.nation-color-select-options {
position: absolute;
z-index: 60;
top: calc(100% + 2px);
right: 0;
left: 0;
max-height: 240px;
overflow-y: auto;
border: 1px solid rgba(201, 164, 90, 0.7);
background: #111;
box-shadow: 0 5px 14px rgb(0 0 0 / 75%);
}
.nation-color-select-option {
min-height: 34px;
border: 0;
border-bottom: 1px solid rgb(0 0 0 / 30%);
}
.nation-color-select-option:hover,
.nation-color-select-option[aria-selected='true'] {
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 78%);
}
@media (max-width: 520px) {
.nation-color-select-options {
position: static;
margin-top: 2px;
}
.nation-color-select-option {
min-height: 44px;
}
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue'; import GeneralIdentity from '../ui/GeneralIdentity.vue';
import RichTooltip from '../ui/RichTooltip.vue';
import { import {
buildTournamentBracket, buildTournamentBracket,
resolveTournamentCoreStat, resolveTournamentCoreStat,
@@ -9,17 +10,28 @@ import {
type TournamentBracketSlot, type TournamentBracketSlot,
} from '../../utils/tournamentBracket'; } from '../../utils/tournamentBracket';
const props = defineProps<{ const props = withDefaults(
defineProps<{
participants: TournamentBracketParticipant[]; participants: TournamentBracketParticipant[];
matches: TournamentBracketMatch[]; matches: TournamentBracketMatch[];
winnerId?: number; winnerId?: number;
betTotals?: Record<number, number>; betTotals?: Record<number, number>;
myBetTotals?: Record<number, number>; myBetTotals?: Record<number, number>;
totalBet: number; totalBet?: number;
tournamentType?: number; tournamentType?: number;
showLegend?: boolean; showLegend?: boolean;
bettingOpen?: boolean; bettingOpen?: boolean;
}>(); bettingMode?: boolean;
}>(),
{
winnerId: undefined,
betTotals: undefined,
myBetTotals: undefined,
totalBet: 0,
tournamentType: undefined,
bettingMode: false,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
requestBet: [slot: TournamentBracketSlot]; requestBet: [slot: TournamentBracketSlot];
@@ -37,10 +49,10 @@ const roundColumns = computed(() => [
]); ]);
const desktopX = [110, 355, 600, 845, 1090]; const desktopX = [110, 355, 600, 845, 1090];
const cardWidth = 190; const cardWidth = 190;
const desktopSlotHeight = 88; const desktopSlotHeight = computed(() => (props.bettingMode ? 100 : 88));
const desktopCanvasHeight = desktopSlotHeight * 16; const desktopCanvasHeight = computed(() => desktopSlotHeight.value * 16);
const slotY = (columnIndex: number, slotIndex: number) => { const slotY = (columnIndex: number, slotIndex: number) => {
const slotHeight = desktopSlotHeight * 2 ** columnIndex; const slotHeight = desktopSlotHeight.value * 2 ** columnIndex;
return slotHeight / 2 + slotIndex * slotHeight; return slotHeight / 2 + slotIndex * slotHeight;
}; };
const connections = computed(() => const connections = computed(() =>
@@ -76,6 +88,18 @@ const odds = (id: number | null) => {
return (props.totalBet / amount).toFixed(2); return (props.totalBet / amount).toFixed(2);
}; };
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0)); const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
const myExpectedReturn = (id: number | null): number | null => {
if (id === null) return null;
const invested = myBet(id);
const targetTotal = props.betTotals?.[id] ?? 0;
if (invested <= 0 || targetTotal <= 0) return null;
return Math.round((invested * props.totalBet) / targetTotal);
};
const myExpectedReturnDescription = (id: number | null): string | null => {
const expectedReturn = myExpectedReturn(id);
if (id === null || expectedReturn === null) return null;
return `현재 배당 ${odds(id)} × 내 투자 금${myBet(id).toLocaleString('ko-KR')} = 금${expectedReturn.toLocaleString('ko-KR')}`;
};
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) => const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
resolveTournamentCoreStat(slot, props.tournamentType ?? 0); resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
const requestBet = (slot: TournamentBracketSlot) => { const requestBet = (slot: TournamentBracketSlot) => {
@@ -127,6 +151,7 @@ const mobilePairs = computed(() => {
:class="{ :class="{
advanced: slot.advanced, advanced: slot.advanced,
'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null, 'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null,
'betting-candidate': columnIndex === 0 && bettingMode,
}" }"
:data-general-id="slot.id ?? undefined" :data-general-id="slot.id ?? undefined"
:style="{ :style="{
@@ -134,12 +159,29 @@ const mobilePairs = computed(() => {
top: `${slotY(columnIndex, slotIndex)}px`, top: `${slotY(columnIndex, slotIndex)}px`,
}" }"
> >
<span
v-if="columnIndex === 0 && bettingMode && slot.id !== null"
class="bracket-candidate-identity"
>
<RichTooltip placement="right" :max-width="180" :test-id="`candidate-icon-${slot.id}`">
<GeneralIdentity :name="slot.name" :hide-icon="true" :npc-state="slot.npcState" />
<template #content>
<GeneralIdentity <GeneralIdentity
:name="slot.name" :name="slot.name"
:picture="slot.picture" :picture="slot.picture"
:image-server="slot.imageServer" :image-server="slot.imageServer"
:npc-state="slot.npcState" :npc-state="slot.npcState"
/> />
</template>
</RichTooltip>
</span>
<GeneralIdentity
v-else
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:npc-state="slot.npcState"
/>
<button <button
v-if="columnIndex === 0 && bettingOpen && slot.id !== null" v-if="columnIndex === 0 && bettingOpen && slot.id !== null"
type="button" type="button"
@@ -149,12 +191,24 @@ const mobilePairs = computed(() => {
> >
베팅하기 베팅하기
</button> </button>
<div v-if="columnIndex === 0" class="bracket-bet-summary"> <div v-if="columnIndex === 0 && bettingMode" class="bracket-bet-summary">
<small v-if="coreStat(slot)" class="bracket-core-stat"> <small v-if="coreStat(slot)" class="bracket-core-stat">
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }} {{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
</small> </small>
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small> <small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
<small class="bracket-my-bet"> 투자 {{ myBet(slot.id) }}</small> <span class="bracket-my-bet-tooltip">
<RichTooltip
title="현재 배당 기준 예상 환수금"
:description="myExpectedReturnDescription(slot.id)"
placement="bottom"
:max-width="260"
:test-id="slot.id === null ? undefined : `candidate-return-${slot.id}`"
>
<small class="bracket-my-bet"
> 투자 {{ myBet(slot.id).toLocaleString('ko-KR') }}</small
>
</RichTooltip>
</span>
</div> </div>
</article> </article>
</template> </template>
@@ -184,15 +238,37 @@ const mobilePairs = computed(() => {
:class="{ :class="{
advanced: slot.advanced, advanced: slot.advanced,
'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null, 'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null,
'betting-candidate': activeMobileRound === 0 && bettingMode,
}" }"
:data-general-id="slot.id ?? undefined" :data-general-id="slot.id ?? undefined"
> >
<span
v-if="activeMobileRound === 0 && bettingMode && slot.id !== null"
class="bracket-candidate-identity"
>
<RichTooltip
placement="bottom"
:max-width="180"
:test-id="`mobile-candidate-icon-${slot.id}`"
>
<GeneralIdentity :name="slot.name" :hide-icon="true" :npc-state="slot.npcState" />
<template #content>
<GeneralIdentity <GeneralIdentity
:name="slot.name" :name="slot.name"
:picture="slot.picture" :picture="slot.picture"
:image-server="slot.imageServer" :image-server="slot.imageServer"
:npc-state="slot.npcState" :npc-state="slot.npcState"
/> />
</template>
</RichTooltip>
</span>
<GeneralIdentity
v-else
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:npc-state="slot.npcState"
/>
<button <button
v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null" v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null"
type="button" type="button"
@@ -202,12 +278,24 @@ const mobilePairs = computed(() => {
> >
베팅하기 베팅하기
</button> </button>
<div v-if="activeMobileRound === 0" class="bracket-bet-summary"> <div v-if="activeMobileRound === 0 && bettingMode" class="bracket-bet-summary">
<small v-if="coreStat(slot)" class="bracket-core-stat"> <small v-if="coreStat(slot)" class="bracket-core-stat">
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }} {{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
</small> </small>
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small> <small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
<small class="bracket-my-bet"> 투자 {{ myBet(slot.id) }}</small> <span class="bracket-my-bet-tooltip">
<RichTooltip
title="현재 배당 기준 예상 환수금"
:description="myExpectedReturnDescription(slot.id)"
placement="bottom"
:max-width="260"
:test-id="slot.id === null ? undefined : `mobile-candidate-return-${slot.id}`"
>
<small class="bracket-my-bet"
> 투자 {{ myBet(slot.id).toLocaleString('ko-KR') }}</small
>
</RichTooltip>
</span>
</div> </div>
</div> </div>
<strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong> <strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong>
@@ -272,6 +360,13 @@ const mobilePairs = computed(() => {
color: #fff; color: #fff;
padding: 1px 3px; padding: 1px 3px;
} }
.desktop-bracket-name.betting-candidate {
width: clamp(150px, 18vw, 216px);
min-height: 92px;
align-content: center;
gap: 5px;
padding: 5px 6px;
}
.betting-target { .betting-target {
position: absolute; position: absolute;
} }
@@ -312,12 +407,30 @@ const mobilePairs = computed(() => {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
} }
.bracket-bet-summary { .bracket-candidate-identity {
display: flex; display: flex;
min-width: 0; min-width: 0;
min-height: 24px;
align-items: center; align-items: center;
justify-content: space-between; padding-right: 62px;
gap: 4px; text-align: left;
}
.bracket-candidate-identity :deep(.rich-tooltip-trigger) {
display: block;
min-width: 0;
}
.betting-candidate .bracket-candidate-identity :deep(.general-identity-name) {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.bracket-bet-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-width: 0;
align-items: center;
gap: 3px 7px;
white-space: nowrap; white-space: nowrap;
} }
.bracket-core-stat, .bracket-core-stat,
@@ -331,11 +444,20 @@ const mobilePairs = computed(() => {
} }
.bracket-core-stat { .bracket-core-stat {
color: #fff; color: #fff;
text-align: left;
}
.bracket-odds {
text-align: right;
}
.bracket-my-bet-tooltip {
grid-column: 1 / -1;
min-width: 0;
color: orange;
text-align: left;
} }
.bracket-my-bet { .bracket-my-bet {
overflow: hidden;
color: orange; color: orange;
text-overflow: ellipsis; font-variant-numeric: tabular-nums;
} }
.mobile-bracket { .mobile-bracket {
display: none; display: none;
@@ -382,6 +504,10 @@ const mobilePairs = computed(() => {
background: rgb(58 33 24 / 94%); background: rgb(58 33 24 / 94%);
padding: 1px 3px; padding: 1px 3px;
} }
.mobile-bracket-name.betting-candidate {
min-height: 92px;
padding: 5px 6px;
}
.versus { .versus {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -2,9 +2,10 @@
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useGameFeedback, type GameFeedbackKind } from '../../composables/useGameFeedback'; import { useGameFeedback, type GameFeedbackKind } from '../../composables/useGameFeedback';
const { toasts, dialog, dismissToast, acknowledgeDialog } = useGameFeedback(); const { toasts, dialog, dismissToast, acknowledgeDialog, cancelDialog } = useGameFeedback();
const dialogPanel = ref<HTMLElement | null>(null); const dialogPanel = ref<HTMLElement | null>(null);
const acknowledgeButton = ref<HTMLButtonElement | null>(null); const acknowledgeButton = ref<HTMLButtonElement | null>(null);
const cancelButton = ref<HTMLButtonElement | null>(null);
let returnFocus: HTMLElement | null = null; let returnFocus: HTMLElement | null = null;
let previousBodyOverflow = ''; let previousBodyOverflow = '';
@@ -37,7 +38,7 @@ watch(
} }
if (next) { if (next) {
await nextTick(); await nextTick();
acknowledgeButton.value?.focus(); (next.cancelLabel ? cancelButton.value : acknowledgeButton.value)?.focus();
return; return;
} }
if (previous) restorePage(); if (previous) restorePage();
@@ -48,7 +49,8 @@ watch(
const handleDialogKeydown = (event: KeyboardEvent): void => { const handleDialogKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
acknowledgeDialog(); if (dialog.value?.cancelLabel) cancelDialog();
else acknowledgeDialog();
return; return;
} }
if (event.key !== 'Tab' || !dialogPanel.value) return; if (event.key !== 'Tab' || !dialogPanel.value) return;
@@ -119,6 +121,15 @@ onBeforeUnmount(() => {
</header> </header>
<p id="game-dialog-message">{{ dialog.message }}</p> <p id="game-dialog-message">{{ dialog.message }}</p>
<footer> <footer>
<button
v-if="dialog.cancelLabel"
ref="cancelButton"
type="button"
class="game-dialog-cancel"
@click="cancelDialog"
>
{{ dialog.cancelLabel }}
</button>
<button ref="acknowledgeButton" type="button" @click="acknowledgeDialog"> <button ref="acknowledgeButton" type="button" @click="acknowledgeDialog">
{{ dialog.acknowledgeLabel }} {{ dialog.acknowledgeLabel }}
</button> </button>
@@ -277,6 +288,7 @@ onBeforeUnmount(() => {
.game-dialog-panel footer { .game-dialog-panel footer {
display: flex; display: flex;
gap: 0.5rem;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -296,6 +308,12 @@ onBeforeUnmount(() => {
outline-offset: 2px; outline-offset: 2px;
} }
.game-dialog-panel footer .game-dialog-cancel {
color: #ddd;
background: #292929;
border-color: #626262;
}
.game-toast-enter-active, .game-toast-enter-active,
.game-toast-leave-active, .game-toast-leave-active,
.game-toast-move, .game-toast-move,
@@ -14,6 +14,7 @@ export type GameNoticeDialog = {
title: string; title: string;
message: string; message: string;
acknowledgeLabel: string; acknowledgeLabel: string;
cancelLabel: string | null;
}; };
export type GameNoticeDialogOptions = { export type GameNoticeDialogOptions = {
@@ -23,9 +24,13 @@ export type GameNoticeDialogOptions = {
acknowledgeLabel?: string; acknowledgeLabel?: string;
}; };
export type GameConfirmDialogOptions = GameNoticeDialogOptions & {
cancelLabel?: string;
};
type QueuedDialog = { type QueuedDialog = {
dialog: GameNoticeDialog; dialog: GameNoticeDialog;
resolve: () => void; resolve: (confirmed: boolean) => void;
}; };
const titleFor = (kind: GameFeedbackKind): string => { const titleFor = (kind: GameFeedbackKind): string => {
@@ -39,7 +44,7 @@ export const createGameFeedbackStore = () => {
const activeDialog = ref<GameNoticeDialog | null>(null); const activeDialog = ref<GameNoticeDialog | null>(null);
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>(); const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
const dialogQueue: QueuedDialog[] = []; const dialogQueue: QueuedDialog[] = [];
let activeDialogResolve: (() => void) | null = null; let activeDialogResolve: ((confirmed: boolean) => void) | null = null;
let nextId = 1; let nextId = 1;
const dismissToast = (id: number): void => { const dismissToast = (id: number): void => {
@@ -61,7 +66,10 @@ export const createGameFeedbackStore = () => {
const id = nextId++; const id = nextId++;
visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }]; visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }];
if (durationMs > 0) { if (durationMs > 0) {
dismissTimers.set(id, setTimeout(() => dismissToast(id), durationMs)); dismissTimers.set(
id,
setTimeout(() => dismissToast(id), durationMs)
);
} }
return id; return id;
}; };
@@ -89,6 +97,28 @@ export const createGameFeedbackStore = () => {
title: options.title?.trim() || titleFor(kind), title: options.title?.trim() || titleFor(kind),
message, message,
acknowledgeLabel: options.acknowledgeLabel?.trim() || '확인', acknowledgeLabel: options.acknowledgeLabel?.trim() || '확인',
cancelLabel: null,
},
resolve: () => resolve(),
});
if (!activeDialog.value) activateNextDialog();
});
};
const confirm = (options: GameConfirmDialogOptions | string): Promise<boolean> => {
const normalizedOptions = typeof options === 'string' ? { message: options } : options;
const message = normalizedOptions.message.trim();
if (!message) return Promise.resolve(false);
const kind = normalizedOptions.kind ?? 'info';
return new Promise((resolve) => {
dialogQueue.push({
dialog: {
id: nextId++,
kind,
title: normalizedOptions.title?.trim() || '확인',
message,
acknowledgeLabel: normalizedOptions.acknowledgeLabel?.trim() || '확인',
cancelLabel: normalizedOptions.cancelLabel?.trim() || '취소',
}, },
resolve, resolve,
}); });
@@ -96,14 +126,17 @@ export const createGameFeedbackStore = () => {
}); });
}; };
const acknowledgeDialog = (): void => { const resolveDialog = (confirmed: boolean): void => {
const resolve = activeDialogResolve; const resolve = activeDialogResolve;
activeDialog.value = null; activeDialog.value = null;
activeDialogResolve = null; activeDialogResolve = null;
resolve?.(); resolve?.(confirmed);
activateNextDialog(); activateNextDialog();
}; };
const acknowledgeDialog = (): void => resolveDialog(true);
const cancelDialog = (): void => resolveDialog(false);
return { return {
toasts: readonly(visibleToasts), toasts: readonly(visibleToasts),
dialog: readonly(activeDialog), dialog: readonly(activeDialog),
@@ -112,7 +145,9 @@ export const createGameFeedbackStore = () => {
error: (message: string, durationMs?: number) => showToast(message, 'error', durationMs), error: (message: string, durationMs?: number) => showToast(message, 'error', durationMs),
info: (message: string, durationMs?: number) => showToast(message, 'info', durationMs), info: (message: string, durationMs?: number) => showToast(message, 'info', durationMs),
showDialog, showDialog,
confirm,
acknowledgeDialog, acknowledgeDialog,
cancelDialog,
dismissToast, dismissToast,
}; };
}; };
@@ -1041,9 +1041,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const setGeneralTurns = async ( const setGeneralTurns = async (
entries: Array<{ turnList: number[]; action: string; args: Record<string, unknown> }> entries: Array<{ turnList: number[]; action: string; args: Record<string, unknown> }>
) => { ): Promise<boolean> => {
const id = generalId.value; const id = generalId.value;
if (!id || !entries.length) return; if (!id || !entries.length) return false;
try { try {
const result = await trpc.turns.reserved.setGeneralBulk.mutate({ const result = await trpc.turns.reserved.setGeneralBulk.mutate({
generalId: id, generalId: id,
@@ -1053,6 +1053,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
reservedGeneralTurns.value = result.turns; reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision; reservedGeneralRevision.value = result.revision;
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
return true;
} catch (err) { } catch (err) {
error.value = resolveErrorMessage(err); error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
@@ -1061,6 +1062,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
reservedGeneralRevision.value = snapshot.revision; reservedGeneralRevision.value = snapshot.revision;
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
} }
return false;
} }
}; };

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