Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b2980e89 | ||
|
|
1a2c7ba775 | ||
|
|
97a8b1daef | ||
|
|
b1984669d4 | ||
|
|
77299ffe41 | ||
|
|
89a33a8349 | ||
|
|
76da41a00b | ||
|
|
63e29743d9 | ||
|
|
426e1127ef | ||
|
|
9c82eb0cf5 | ||
|
|
dd84e6d6b3 | ||
|
|
8e0e7291de | ||
|
|
50a443d94b | ||
|
|
bf13dabc01 | ||
|
|
7080ae5504 | ||
|
|
96f6d85cf4 | ||
|
|
3ef86448d7 | ||
|
|
bd826b1ff8 | ||
|
|
0bc4be9a13 | ||
|
|
898d756036 | ||
|
|
29014e9c2d | ||
|
|
99a477b47e | ||
|
|
8d88603305 | ||
|
|
d4e83661ca | ||
|
|
70a4980a31 | ||
|
|
f3eaab1fa9 | ||
|
|
71b9449903 | ||
|
|
6162e56583 | ||
|
|
fa7898b6a2 | ||
|
|
c769d915a6 | ||
|
|
4e4dff55a1 | ||
|
|
dd66da14f4 | ||
|
|
c56610b179 | ||
|
|
24e57ee94c | ||
|
|
d68209879b | ||
|
|
7377aa62ed | ||
|
|
11fca1ed8b | ||
|
|
254d321e8b | ||
|
|
52a75ca9b7 | ||
|
|
23678df9de | ||
|
|
95d5246d4b | ||
|
|
342994f77b | ||
|
|
71ee529d35 | ||
|
|
20a2812d65 | ||
|
|
26952fcdc7 | ||
|
|
fa9fe71539 | ||
|
|
e6873f813a | ||
|
|
b251120e45 | ||
|
|
eb755757ec | ||
|
|
ddbfa0afa9 | ||
|
|
b45fe5ba39 | ||
|
|
59f0a051d8 | ||
|
|
8ad10e6b2c | ||
|
|
c741ef4be5 | ||
|
|
f43a1db136 | ||
|
|
ab0f1b9cd5 | ||
|
|
ca3e028603 | ||
|
|
212c13bf16 | ||
|
|
4b06330753 | ||
|
|
6a60882005 | ||
|
|
9e5e580134 | ||
|
|
68870be5e5 | ||
|
|
7c07742690 |
@@ -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/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 소유시 가능',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user