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`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
|
||||
지침 순으로 적용합니다.
|
||||
|
||||
목표는 `../ref/sam`의 PHP 서비스를 TypeScript 런타임으로 호환 이관하는
|
||||
것입니다. 내부 구조를 더 깔끔하게 만드는 일보다 기존 결과, 상태 전이, 권한,
|
||||
화면과 운영 경계를 보존하는 일이 우선입니다.
|
||||
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
|
||||
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
|
||||
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
|
||||
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
|
||||
|
||||
## 현재 기준과 저장소 경계
|
||||
|
||||
@@ -41,7 +42,9 @@ commit 또는 삭제를 하지 말아 주세요.
|
||||
판정해 주세요. 기능 목록이나 report의 완료 표현만으로 전체 이관이 끝났다고
|
||||
판정하지 말아 주세요.
|
||||
|
||||
- 새 차등 fixture가 mismatch를 드러내면 다시 제품 결함으로 분류해 주세요.
|
||||
- 새 차등 fixture가 mismatch를 드러내면 계승 계약, 의도적 제품 차이, 누락·회귀,
|
||||
비교 불가 중 무엇인지 조사한 뒤 분류해 주세요. mismatch만으로 제품 결함을
|
||||
확정하지 않습니다.
|
||||
- green unit test는 ref 호환, 실제 DB transaction, Chromium geometry 또는
|
||||
운영 장애 복구를 자동으로 증명하지 않습니다.
|
||||
- 환경 변수가 없는 기본 test에서 skip된 integration은 실행된 검증이 아닙니다.
|
||||
@@ -95,6 +98,20 @@ priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로
|
||||
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
|
||||
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
|
||||
|
||||
Ref 비교 전에 대상 계약도 구분해 주세요.
|
||||
|
||||
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
|
||||
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
|
||||
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
|
||||
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
|
||||
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
|
||||
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
|
||||
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
|
||||
|
||||
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
|
||||
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
|
||||
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
|
||||
|
||||
ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
|
||||
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
|
||||
@@ -105,7 +122,7 @@ ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
확인해 주세요.
|
||||
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
|
||||
|
||||
## 호환성 우선순위
|
||||
## 계승 계약의 호환성 우선순위
|
||||
|
||||
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
|
||||
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
|
||||
@@ -113,10 +130,12 @@ ref 계측이 필요하면 다음을 지켜 주세요.
|
||||
4. 문구, 로그, 화면 흐름과 룩앤필
|
||||
5. 내부 구현 세부사항
|
||||
|
||||
레거시의 이상해 보이는 동작도 계약일 수 있습니다. 보안 또는 데이터 손상 위험이
|
||||
아니면 우선 같은 동작을 재현하고 개선 제안은 분리해 주세요. 허용하는 차이는
|
||||
사용자 경험, 저장 상태와 후속 턴 결과에 영향이 없다는 근거를 mapping/report에
|
||||
남겨 주세요.
|
||||
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
|
||||
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
|
||||
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
|
||||
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
|
||||
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
|
||||
이름만으로 완화하지 않습니다.
|
||||
|
||||
## 인증과 권한
|
||||
|
||||
@@ -282,6 +301,11 @@ pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
루트 `pnpm test`와 `pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
|
||||
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
|
||||
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
|
||||
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
|
||||
|
||||
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
|
||||
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
|
||||
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
|
||||
|
||||
@@ -175,6 +175,17 @@ pnpm build
|
||||
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는 격리된 서비스를 준비한 뒤 실행합니다.
|
||||
|
||||
```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: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",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "pnpm -w lint:target app/game-api",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-api",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
|
||||
},
|
||||
|
||||
@@ -76,12 +76,11 @@ const isFrontStatusPollActive = (poll: FrontStatusPoll, gameTime: CurrentGameTim
|
||||
return started && !ended;
|
||||
};
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
const zGeneralSettings = z.strictObject({
|
||||
tnmt: z.number().int().optional(),
|
||||
defence_train: z.number().int().optional(),
|
||||
use_treatment: 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_promotion: 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),
|
||||
// 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_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
|
||||
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
|
||||
@@ -335,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
troopLeaderFirstTurn,
|
||||
accessLog,
|
||||
rankRows,
|
||||
gameTime,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -415,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
@@ -590,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
parsedLastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
gameTime.now
|
||||
),
|
||||
crewTypeId: general.crewTypeId,
|
||||
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
|
||||
@@ -1023,10 +1022,10 @@ export const generalRouter = router({
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock is held at the future open anchor, so comparing them with
|
||||
// JavaScript wall time inside a timestamp-without-time-zone predicate can
|
||||
// hide an otherwise active poll. Resolve the same tick-first clock contract
|
||||
// used by voting instead; hasVoted only affects the notice, not activity.
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
|
||||
// predicate can hide an otherwise active poll. Resolve the same tick-first
|
||||
// clock contract used by voting; hasVoted only affects the notice, not activity.
|
||||
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
||||
|
||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||
|
||||
@@ -302,6 +302,7 @@ export const joinRouter = router({
|
||||
}
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
const configConst = asRecord(config.const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
@@ -348,7 +349,8 @@ export const joinRouter = router({
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||
allowDirectCreation: (blockGeneralCreate & 1) === 0,
|
||||
allowCustomName: (blockGeneralCreate & 2) === 0,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
|
||||
: null,
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
|
||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
||||
myGeneral,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveRefreshScoreText,
|
||||
resolveRemainingMinutes,
|
||||
} from '../../services/generalBasicCardProjection.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||
@@ -72,7 +73,7 @@ export const troopRouter = router({
|
||||
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({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||
@@ -121,6 +122,7 @@ export const troopRouter = router({
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
if (!nation) {
|
||||
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 =>
|
||||
names.get(code)?.name ?? 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
|
||||
.map((troop) => {
|
||||
const leader = generalMap.get(troop.troopLeaderId);
|
||||
@@ -348,8 +341,7 @@ export const troopRouter = router({
|
||||
killTurn: readNumber(meta.killturn ?? meta.killTurn),
|
||||
remainingMinutes: resolveRemainingMinutes(
|
||||
general.turnTime,
|
||||
lastExecuted,
|
||||
worldState?.tickSeconds ?? 0
|
||||
gameTime.now
|
||||
),
|
||||
troopId: general.troopId,
|
||||
troop: {
|
||||
|
||||
@@ -446,6 +446,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
nationGenerals,
|
||||
realNationCount: nations.filter((entry) => entry.id > 0).length,
|
||||
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 directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
|
||||
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
|
||||
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 level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
|
||||
return Math.max(0, Math.min(level, maxLevel));
|
||||
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
|
||||
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
|
||||
const [nations, generals, cities] = await Promise.all([
|
||||
const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -96,6 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
meta: true,
|
||||
@@ -107,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
select: { id: true, name: true, nationId: true },
|
||||
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)
|
||||
@@ -135,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
}
|
||||
const citiesByNation = new Map<number, typeof cities>();
|
||||
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) {
|
||||
const list = citiesByNation.get(city.nationId) ?? [];
|
||||
list.push(city);
|
||||
@@ -168,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
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 {
|
||||
id: nation.id,
|
||||
@@ -183,6 +260,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
|
||||
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.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,
|
||||
ambassadorNames: secretPermissions
|
||||
.filter(({ permission }) => permission === 4)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
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 { DatabaseClient } from '../context.js';
|
||||
@@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batch
|
||||
};
|
||||
|
||||
export const flushDeferredGeneralAccessBatch = async (
|
||||
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>,
|
||||
db: Pick<DatabaseClient, '$transaction' | '$queryRaw' | '$executeRaw' | 'worldState'>,
|
||||
batchId: string,
|
||||
entries: readonly DeferredGeneralAccessEntry[]
|
||||
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
|
||||
@@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
await markBatchProcessed(db, batchId);
|
||||
return { states: [], refreshLimit: 0 };
|
||||
}
|
||||
if (!db.$transaction) {
|
||||
throw new Error('Deferred general access persistence requires transaction support.');
|
||||
}
|
||||
const worldState = await db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
@@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
}))
|
||||
);
|
||||
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 (
|
||||
INSERT INTO "general_access_batch" ("id")
|
||||
VALUES (${batchId})
|
||||
@@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
END,
|
||||
resolved."weight"
|
||||
FROM resolved
|
||||
ORDER BY resolved."general_id"
|
||||
ON CONFLICT ("general_id") DO UPDATE SET
|
||||
"user_id" = EXCLUDED."user_id",
|
||||
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
|
||||
@@ -394,7 +402,8 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
LEFT JOIN access_updates ON access_updates."general_id" = actor."id"
|
||||
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
|
||||
ORDER BY actor."id"
|
||||
`);
|
||||
`);
|
||||
});
|
||||
return {
|
||||
states,
|
||||
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -228,6 +228,7 @@ export const upsertGeneralAccess = async (
|
||||
throw new Error('Traffic access persistence requires transaction support.');
|
||||
}
|
||||
await db.$transaction(async (transaction) => {
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||
const periodKey = input.year * 12 + input.month - 1;
|
||||
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
|
||||
GamePrisma.sql`
|
||||
|
||||
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
|
||||
|
||||
export const resolveRemainingMinutes = (
|
||||
turnTime: Date,
|
||||
lastExecuted: Date | null,
|
||||
turnTermSeconds: number
|
||||
currentGameTime: Date | null
|
||||
): number | null => {
|
||||
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null;
|
||||
let nextTurnMillis = turnTime.getTime();
|
||||
if (nextTurnMillis < lastExecuted.getTime()) {
|
||||
nextTurnMillis += turnTermSeconds * 1_000;
|
||||
}
|
||||
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
|
||||
if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
|
||||
return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
|
||||
};
|
||||
|
||||
export interface NextTurnMonthOffsetInput {
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface TurnCommandInputField {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: TurnCommandOptionValue | boolean;
|
||||
constValue?: TurnCommandOptionValue;
|
||||
options?: TurnCommandOption[];
|
||||
optionSource?: TurnCommandOptionSource;
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
TriggerValue,
|
||||
UnitSetDefinition,
|
||||
} 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 { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.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 type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js';
|
||||
import { formatCrewTypeRequirement } from '../services/gameDisplayNames.js';
|
||||
import {
|
||||
buildTurnCommandInputFields,
|
||||
loadTurnCommandSpecs,
|
||||
@@ -40,6 +41,7 @@ export interface TurnCommandAvailability {
|
||||
key: string;
|
||||
name: string;
|
||||
turnDurationText?: string;
|
||||
costText?: string;
|
||||
reqArg: boolean;
|
||||
possible: boolean;
|
||||
status: AvailabilityStatus;
|
||||
@@ -326,9 +328,12 @@ const resolveMaxNation = (worldState: WorldStateRow): number => {
|
||||
const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
const config = asRecord(worldState.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const meta = asRecord(worldState.meta);
|
||||
const configuredDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
|
||||
return {
|
||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
// daemon은 월간 변동이 반영된 world.meta.develcost를 실행 직전에 우선한다.
|
||||
develCost: resolveNumber(meta, ['develcost', 'develCost'], configuredDevelCost),
|
||||
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
|
||||
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
|
||||
trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1),
|
||||
@@ -554,7 +559,10 @@ export const buildRecruitmentCommandInfo = (options: {
|
||||
avoid: crewType.avoid,
|
||||
baseCost: displayCost.gold,
|
||||
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);
|
||||
};
|
||||
|
||||
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 =>
|
||||
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 = (
|
||||
env: CommandEnv,
|
||||
specs: TurnCommandSpec[],
|
||||
options: { foundingAvailable?: boolean } = {}
|
||||
options: { foundingAvailable?: boolean; currentYear?: number; currentMonth?: number } = {}
|
||||
): CommandEntry[] => {
|
||||
const entries: CommandEntry[] = [];
|
||||
|
||||
for (const spec of specs) {
|
||||
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 = {
|
||||
category: spec.category,
|
||||
definition,
|
||||
reqArg: spec.reqArg,
|
||||
availabilityArgs: spec.reqArg ? spec.availabilityArgs : {},
|
||||
inputFields: buildTurnCommandInputFields(spec),
|
||||
inputFields,
|
||||
};
|
||||
|
||||
if (spec.key === 'che_포상') {
|
||||
@@ -711,23 +768,85 @@ const getTurnDurationText = (definition: GeneralActionDefinition): string | unde
|
||||
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 = (
|
||||
entries: CommandEntry[],
|
||||
ctx: ConstraintContext,
|
||||
view: StateView,
|
||||
includeTurnDuration = false
|
||||
includeTurnDuration = false,
|
||||
env: CommandEnv,
|
||||
scope: 'general' | 'nation',
|
||||
currentYearMonth: number
|
||||
): TurnCommandGroup[] => {
|
||||
const groups = new Map<string, TurnCommandAvailability[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const availability = entry.evaluate
|
||||
const baseAvailability = entry.evaluate
|
||||
? entry.evaluate(ctx, view)
|
||||
: 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 costText = getCommandCostText(entry, ctx, view, env);
|
||||
const value: TurnCommandAvailability = {
|
||||
key: entry.definition.key,
|
||||
name: entry.definition.name,
|
||||
...(turnDurationText ? { turnDurationText } : {}),
|
||||
...(costText ? { costText } : {}),
|
||||
reqArg: entry.reqArg,
|
||||
inputFields: entry.inputFields,
|
||||
...availability,
|
||||
@@ -785,6 +904,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
/** Ref's nation-table row count. Core callers must exclude synthetic id=0. */
|
||||
realNationCount?: number;
|
||||
inputOptions?: TurnCommandInputOptions;
|
||||
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||
}): Promise<TurnCommandTable> => {
|
||||
// 턴 입력 화면에서 쓰는 사전 판단이므로 최소 정보로 가능/불가만 계산한다.
|
||||
const general = mapGeneralRow(options.general);
|
||||
@@ -803,7 +923,10 @@ export const buildTurnCommandTable = async (options: {
|
||||
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 {
|
||||
general: generalSpecs,
|
||||
@@ -817,19 +940,30 @@ export const buildTurnCommandTable = async (options: {
|
||||
? undefined
|
||||
: 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 {
|
||||
general: buildGroups(
|
||||
projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view
|
||||
view,
|
||||
false,
|
||||
env,
|
||||
'general',
|
||||
currentYearMonth
|
||||
),
|
||||
nation: buildGroups(
|
||||
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
|
||||
ctx,
|
||||
view,
|
||||
true
|
||||
true,
|
||||
env,
|
||||
'nation',
|
||||
currentYearMonth
|
||||
),
|
||||
inputOptions: options.inputOptions ?? {
|
||||
cities: [],
|
||||
|
||||
@@ -56,27 +56,36 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
}): RefGeneralTargetOptions => {
|
||||
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
|
||||
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 isTroopExit = action === 'che_부대탈퇴지시';
|
||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||
const label = (() => {
|
||||
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
|
||||
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||
})();
|
||||
const details = [
|
||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||
entry.troopId
|
||||
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
|
||||
: '탑승 부대 없음',
|
||||
action === 'che_발령' || action === 'che_포상' || action === 'che_몰수'
|
||||
? null
|
||||
: entry.troopId
|
||||
? `탑승 부대 ${troopLabel}`
|
||||
: '탑승 부대 없음',
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (isTroopExit) {
|
||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||
}
|
||||
return {
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
|
||||
options.cityNames.get(entry.cityId) ?? '재야'
|
||||
})`,
|
||||
label,
|
||||
description: details.join(' · '),
|
||||
...(availableNow === undefined ? {} : { availableNow }),
|
||||
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
||||
|
||||
@@ -102,6 +102,9 @@ const buildNation = (): NationRow =>
|
||||
}) as unknown as NationRow;
|
||||
|
||||
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 () => {
|
||||
const table = await buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
@@ -211,6 +214,29 @@ describe('buildTurnCommandTable', () => {
|
||||
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
|
||||
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 () => {
|
||||
@@ -239,6 +265,13 @@ describe('buildTurnCommandTable', () => {
|
||||
'che_물자조달',
|
||||
'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.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 () => {
|
||||
const buildScenarioTable = async (scenarioId: number) => {
|
||||
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 () => {
|
||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||
buildTurnCommandTable({
|
||||
@@ -444,15 +595,17 @@ describe('buildTurnCommandTable', () => {
|
||||
nationGenerals: null,
|
||||
});
|
||||
|
||||
const appointment = table.general
|
||||
.flatMap((group) => group.values)
|
||||
.find((command) => command.key === 'che_임관');
|
||||
for (const commandKey of ['che_임관', 'che_등용']) {
|
||||
const command = table.general
|
||||
.flatMap((group) => group.values)
|
||||
.find((candidate) => candidate.key === commandKey);
|
||||
|
||||
expect(appointment).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
expect(command).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '랜덤 임관만 가능합니다',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('hides founding at the Ref maxnation boundary without counting Core id=0', async () => {
|
||||
@@ -525,7 +678,11 @@ describe('buildTurnCommandTable', () => {
|
||||
magicCoef: 0,
|
||||
cost: 12,
|
||||
rice: 10,
|
||||
requirements: [{ type: 'ReqTech', tech: 2000 }],
|
||||
requirements: [
|
||||
{ type: 'ReqTech', tech: 2000 },
|
||||
{ type: 'ReqRegions', regions: ['중원'] },
|
||||
{ type: 'ReqCities', cities: ['TestCity'] },
|
||||
],
|
||||
attackCoef: {},
|
||||
defenceCoef: {},
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
@@ -583,7 +740,12 @@ describe('buildTurnCommandTable', () => {
|
||||
special: true,
|
||||
attack: 175,
|
||||
defence: 225,
|
||||
info: ['강력하지만 기술이 필요합니다.'],
|
||||
info: [
|
||||
'강력하지만 기술이 필요합니다.',
|
||||
'기술력 2000 이상 필요',
|
||||
'중원 지역 소유시 가능',
|
||||
'TestCity 소유시 가능',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,19 @@ describe('Ref command general targets', () => {
|
||||
troopId: 3,
|
||||
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 () => {
|
||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||
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.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||
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),
|
||||
$executeRaw: db.$executeRaw.bind(db),
|
||||
};
|
||||
|
||||
@@ -88,6 +88,9 @@ describe('deferred general access', () => {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: vi.fn(async () => 1),
|
||||
};
|
||||
Object.assign(db, {
|
||||
$transaction: vi.fn(async (run: (transaction: typeof db) => Promise<unknown>) => run(db)),
|
||||
});
|
||||
|
||||
await expect(
|
||||
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
|
||||
@@ -101,12 +104,14 @@ describe('deferred general access', () => {
|
||||
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
|
||||
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
|
||||
expect(statement.sql).toContain('jsonb_to_recordset');
|
||||
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
|
||||
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
|
||||
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_outbox');
|
||||
});
|
||||
|
||||
@@ -112,6 +112,7 @@ const directoryGeneral = (overrides: Record<string, unknown> = {}) => ({
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
crew: 100,
|
||||
experience: 1_000,
|
||||
meta: { killturn: 3 },
|
||||
penalty: {},
|
||||
@@ -124,6 +125,9 @@ const globalGenerals = [
|
||||
name: '군주',
|
||||
dedication: 900,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
strength: 80,
|
||||
intel: 80,
|
||||
meta: { killturn: 4, owner_name: '통일유저' },
|
||||
experience: 10_000,
|
||||
}),
|
||||
@@ -132,6 +136,9 @@ const globalGenerals = [
|
||||
name: '외교관',
|
||||
dedication: 800,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 40,
|
||||
meta: { killturn: 2, permission: 'ambassador' },
|
||||
experience: 5_000,
|
||||
}),
|
||||
@@ -140,6 +147,9 @@ const globalGenerals = [
|
||||
name: '제재외교관',
|
||||
dedication: 700,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 40,
|
||||
intel: 80,
|
||||
meta: { killturn: 1, permission: 'ambassador' },
|
||||
penalty: { noTopSecret: true },
|
||||
experience: 2_000,
|
||||
@@ -149,6 +159,9 @@ const globalGenerals = [
|
||||
name: '조언자',
|
||||
dedication: 600,
|
||||
officerLevel: 1,
|
||||
leadership: 30,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
meta: { killturn: 5, permission: 'auditor' },
|
||||
experience: 1_000,
|
||||
}),
|
||||
@@ -161,6 +174,8 @@ const globalGenerals = [
|
||||
dedication: 500,
|
||||
officerLevel: 12,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 65,
|
||||
meta: { killturn: 0 },
|
||||
experience: 20_000,
|
||||
}),
|
||||
@@ -221,7 +236,12 @@ const createContext = (
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
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]]);
|
||||
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);
|
||||
for (const privateField of [
|
||||
'userId',
|
||||
@@ -304,15 +338,74 @@ describe('legacy global nation/general directories', () => {
|
||||
id: 1,
|
||||
generalCount: 4,
|
||||
cityCount: 1,
|
||||
forceEstimate: {
|
||||
totalGeneralCount: 4,
|
||||
userCombatGeneralCount: 1,
|
||||
userTroops: 8_000,
|
||||
npcCombatGeneralCount: 0,
|
||||
npcTroops: 0,
|
||||
inactiveGeneralCount: 2,
|
||||
},
|
||||
ambassadorNames: ['군주', '외교관'],
|
||||
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]?.officers[0]).toMatchObject({
|
||||
officerLevel: 12,
|
||||
general: { id: 10, name: '군주' },
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -7,8 +7,8 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
||||
({
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
||||
clockTick: 36_000_000n,
|
||||
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
@@ -17,14 +17,14 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
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 preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||
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'),
|
||||
tick: 36_000_000,
|
||||
tick: -108_000_000,
|
||||
mode: 'realtime',
|
||||
running: false,
|
||||
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'));
|
||||
expect(opened).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||
tick: 36_300_000,
|
||||
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||
tick: 300_000,
|
||||
running: true,
|
||||
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'));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
now: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tick: 0,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
});
|
||||
|
||||
@@ -194,7 +194,11 @@ describe('general access tracking', () => {
|
||||
});
|
||||
expect(transaction).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[] };
|
||||
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
|
||||
@@ -208,12 +212,12 @@ describe('general access tracking', () => {
|
||||
expect(periodStatement.values).toContain(now);
|
||||
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('ON CONFLICT (period_id, general_id) DO NOTHING');
|
||||
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('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(accessStatement.values).toContain(7);
|
||||
@@ -230,9 +234,9 @@ describe('general access tracking', () => {
|
||||
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
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(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 () => {
|
||||
|
||||
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
|
||||
expect(resolveRefreshScoreText(12_800)).toBe('헐...');
|
||||
});
|
||||
|
||||
it('matches the Ref remaining-minute calculation and one-turn rollover', () => {
|
||||
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull();
|
||||
it('matches the Ref remaining-minute calculation against the current game clock', () => {
|
||||
const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
|
||||
expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
|
||||
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', () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ const createContext = (options: {
|
||||
troopLeaderAction?: string | null;
|
||||
refreshScore?: number;
|
||||
refreshScoreTotal?: number;
|
||||
gameClockNow?: Date;
|
||||
rankRows?: Array<{ generalId?: number; type: string; value: number }>;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
@@ -164,6 +165,10 @@ const createContext = (options: {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: { upgradeLimit: 20 } },
|
||||
clockBaseTime: options.gameClockNow ?? null,
|
||||
clockTick: options.gameClockNow ? 0n : null,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: options.gameClockNow ?? null,
|
||||
})),
|
||||
},
|
||||
logEntry: {
|
||||
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
|
||||
troopLeaderAction: '휴식',
|
||||
refreshScore: 3,
|
||||
refreshScoreTotal: 1_141,
|
||||
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
|
||||
retirementYear: 70,
|
||||
defenceTrain: 80,
|
||||
killTurn: 6,
|
||||
remainingMinutes: null,
|
||||
remainingMinutes: 7,
|
||||
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
|
||||
refreshScore: { current: 3, total: 1_141, text: '열심' },
|
||||
},
|
||||
@@ -584,7 +590,6 @@ describe('in-game my information ownership', () => {
|
||||
defence_train: 80,
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
@@ -612,6 +617,18 @@ describe('in-game my information ownership', () => {
|
||||
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 () => {
|
||||
const transaction = vi.fn(async () => {
|
||||
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 { appRouter } from '../src/router.js';
|
||||
@@ -47,6 +47,10 @@ const buildContext = (
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
describe('lobby season state', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||
@@ -96,15 +100,18 @@ describe('lobby season state', () => {
|
||||
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 wallNow = new Date('2099-08-21T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(wallNow);
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||
tick: 36_000_000n,
|
||||
baseTime: wallAnchor,
|
||||
tick: 0n,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
}
|
||||
@@ -112,13 +119,22 @@ describe('lobby season state', () => {
|
||||
)
|
||||
.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.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||
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 () => {
|
||||
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ const buildContext = (
|
||||
tickSeconds: 3600,
|
||||
...(options.preopenClock
|
||||
? {
|
||||
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
|
||||
clockTick: -180_000_000n,
|
||||
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
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 () => {
|
||||
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 });
|
||||
|
||||
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 {
|
||||
buildNpcSelectionTokenSeed,
|
||||
createTurnDaemonRuntime,
|
||||
seedScenarioToDatabase,
|
||||
type TurnDaemonRuntime,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
|
||||
const delayedUserId = 'npc-possession-integration-delayed';
|
||||
const cleanupUserId = 'npc-possession-integration-cleanup';
|
||||
const raceUserId = 'npc-possession-integration-race';
|
||||
const preopenUserId = 'npc-possession-integration-preopen';
|
||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||
|
||||
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 cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
||||
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
||||
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
|
||||
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.logEntry.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' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 24 }, (_, index) => ({
|
||||
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await closeDb?.();
|
||||
}, 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();
|
||||
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([
|
||||
appRouter.createCaller(buildContext('npc-possession-token-a')).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).toBeLessThanOrEqual(5);
|
||||
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.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||
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);
|
||||
}, 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 () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-current'))
|
||||
|
||||
@@ -1418,6 +1418,20 @@ describe('appRouter', () => {
|
||||
message: expect.stringContaining('랜덤 임관만 가능합니다'),
|
||||
});
|
||||
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 () => {
|
||||
@@ -1462,6 +1476,28 @@ describe('appRouter', () => {
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
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 () => {
|
||||
|
||||
@@ -8,5 +8,6 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
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 .",
|
||||
"dev": "tsdown -c ./tsdown.config.ts --watch",
|
||||
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "pnpm -w lint:target app/game-engine",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-engine",
|
||||
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
|
||||
"profile:npc-unification-timing": "node scripts/profile-npc-unification-timing.mjs",
|
||||
"profile:npc-lifecycle-memory": "node scripts/profile-npc-lifecycle-memory.mjs",
|
||||
|
||||
@@ -488,6 +488,23 @@ export class GeneralAI {
|
||||
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 {
|
||||
this.updateInstance();
|
||||
if (!this.worldRef) {
|
||||
@@ -1198,6 +1215,13 @@ export class GeneralAI {
|
||||
) {
|
||||
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';
|
||||
candidate.officerLevel = 11;
|
||||
candidate.meta = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GeneralAI } from '../core.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 { resolveNationIncome } from './helpers.js';
|
||||
|
||||
@@ -99,7 +99,8 @@ export const do불가침제의 = (ai: GeneralAI) => {
|
||||
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(
|
||||
'che_불가침제의',
|
||||
{ destNationId, year: targetYear, month: targetMonth },
|
||||
|
||||
@@ -61,10 +61,9 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
|
||||
NPC전방발령: true,
|
||||
};
|
||||
|
||||
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital';
|
||||
export type UserRulerAutomationFeature = 'war' | 'promotion' | 'finance' | 'capital';
|
||||
|
||||
const USER_RULER_AUTOMATION_META_KEY = {
|
||||
diplomacy: 'use_auto_nation_diplomacy',
|
||||
war: 'use_auto_nation_war',
|
||||
promotion: 'use_auto_nation_promotion',
|
||||
finance: 'use_auto_nation_finance',
|
||||
@@ -72,7 +71,6 @@ const USER_RULER_AUTOMATION_META_KEY = {
|
||||
} as const satisfies Record<UserRulerAutomationFeature, string>;
|
||||
|
||||
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
|
||||
불가침제의: 'diplomacy',
|
||||
선전포고: 'war',
|
||||
천도: 'capital',
|
||||
};
|
||||
|
||||
@@ -142,12 +142,11 @@ const zSetMySetting = z.object({
|
||||
type: z.literal('setMySetting'),
|
||||
userId: z.string().min(1),
|
||||
generalId: zFiniteNumber,
|
||||
settings: z.object({
|
||||
settings: z.strictObject({
|
||||
tnmt: z.number().int().optional(),
|
||||
defence_train: z.number().int().optional(),
|
||||
use_treatment: 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_promotion: z.number().int().optional(),
|
||||
use_auto_nation_finance: z.number().int().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
writeReadModelChangeJournal,
|
||||
enqueuePrivateMessageWebPush,
|
||||
@@ -1322,6 +1323,20 @@ export const createDatabaseTurnHooks = async (
|
||||
const beforeLifecycleLogs = 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 persistInheritanceLogs(beforeLifecycleLogs);
|
||||
await persistGeneralLifecycleEvents(
|
||||
|
||||
@@ -656,6 +656,16 @@ export class InMemoryTurnWorld {
|
||||
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 {
|
||||
return this.getGameClock().dateToTick(date);
|
||||
}
|
||||
|
||||
@@ -383,7 +383,9 @@ export const resolveJoinTurnTime = (
|
||||
}
|
||||
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
|
||||
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;
|
||||
};
|
||||
@@ -523,9 +525,11 @@ export const createGeneralFromJoin = async (options: {
|
||||
worldState: WorldStateRow;
|
||||
input: JoinCreateGeneralInput;
|
||||
acceptedAt: Date;
|
||||
turnScheduleAt?: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
|
||||
const turnScheduleAt = options.turnScheduleAt ?? acceptedAt;
|
||||
await lockJoinMutation(db, input.userId);
|
||||
await assertGeneralIdSnapshotMatches(db, world);
|
||||
|
||||
@@ -707,7 +711,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
const turnTime = resolveJoinTurnTime(
|
||||
rng,
|
||||
worldState,
|
||||
acceptedAt,
|
||||
turnScheduleAt,
|
||||
world.getState().lastTurnTime,
|
||||
input.inheritTurntimeZone
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
addOccupiedUniqueItemKeys,
|
||||
buildGenericUniqueSeed,
|
||||
countOccupiedUniqueItems,
|
||||
cloneItemInventory,
|
||||
createItemModuleRegistry,
|
||||
loadItemModules,
|
||||
resolveUniqueConfig,
|
||||
@@ -267,12 +268,13 @@ export const applyLegacyGeneralProgression = (
|
||||
const resolveConstraintEnv = (
|
||||
world: TurnWorldState,
|
||||
scenarioMeta: ScenarioMeta | undefined,
|
||||
env: TurnCommandEnv
|
||||
env: TurnCommandEnv,
|
||||
worldConfig: Record<string, unknown>
|
||||
): Record<string, unknown> => {
|
||||
const worldMeta = asRecord(world.meta);
|
||||
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.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 killturnRaw = worldMeta.killturn;
|
||||
const killturn =
|
||||
@@ -317,6 +319,7 @@ const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number)
|
||||
|
||||
const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
|
||||
...general,
|
||||
...(general.itemInventory ? { itemInventory: cloneItemInventory(general.itemInventory) } : {}),
|
||||
...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}),
|
||||
stats: { ...general.stats },
|
||||
role: {
|
||||
@@ -1035,7 +1038,12 @@ export const createReservedTurnHandler = async (options: {
|
||||
const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null;
|
||||
const worldView = worldOverlay?.view ?? worldRef;
|
||||
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.unitSet ? { unitSet: options.unitSet } : {}),
|
||||
};
|
||||
@@ -1401,14 +1409,19 @@ export const createReservedTurnHandler = async (options: {
|
||||
progressionLogs
|
||||
);
|
||||
}
|
||||
logs.push(
|
||||
...orderLegacyCommandLogs(
|
||||
actionKey,
|
||||
resolution.logs,
|
||||
progressionLogs,
|
||||
resolution.postProgressionLogs
|
||||
)
|
||||
);
|
||||
// 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(
|
||||
...orderLegacyCommandLogs(
|
||||
actionKey,
|
||||
resolution.logs,
|
||||
progressionLogs,
|
||||
resolution.postProgressionLogs
|
||||
)
|
||||
);
|
||||
}
|
||||
if (
|
||||
!resolution.alternative &&
|
||||
kind === 'nation' &&
|
||||
@@ -1992,6 +2005,10 @@ export const createReservedTurnHandler = async (options: {
|
||||
// The following general command therefore observes and adds to
|
||||
// those fractions before the turn's final persistence boundary.
|
||||
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 (
|
||||
worldView &&
|
||||
(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 constraintEnv = {
|
||||
...resolveConstraintEnv(state, options.scenarioMeta, env),
|
||||
...resolveConstraintEnv(state, options.scenarioMeta, env, options.world.getWorldConfig()),
|
||||
...(options.map ? { map: options.map } : {}),
|
||||
...(options.world.getUnitSet() ? { unitSet: options.world.getUnitSet() } : {}),
|
||||
cities: options.world.listCities(),
|
||||
|
||||
@@ -651,11 +651,18 @@ const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => {
|
||||
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 seconds = rng.nextRangeInt(0, termSeconds - 1);
|
||||
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: {
|
||||
@@ -702,6 +709,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
uniqueName: string;
|
||||
personality: string;
|
||||
now?: Date;
|
||||
turnScheduleAt?: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
seedOwnerIdentity?: string | number;
|
||||
ownerPicture?: string;
|
||||
@@ -761,7 +769,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
);
|
||||
}
|
||||
const city = rng.choice(cities);
|
||||
const turnTime = buildInitialTurnTime(rng, worldState, now);
|
||||
const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now);
|
||||
const age = 20;
|
||||
const specialityAges = resolveSpecialityAges(worldState, age);
|
||||
const nextChangeAt = new Date(
|
||||
|
||||
@@ -340,6 +340,7 @@ async function handleJoinCreateGeneral(
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
@@ -377,6 +378,7 @@ async function handleJoinCreateGeneral(
|
||||
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
||||
},
|
||||
acceptedAt,
|
||||
turnScheduleAt,
|
||||
operationalAcceptedAt,
|
||||
})),
|
||||
};
|
||||
@@ -455,6 +457,7 @@ async function handleSelectPoolCreate(
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const turnScheduleAt = ctx.world.getRunnableGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -471,6 +474,7 @@ async function handleSelectPoolCreate(
|
||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
||||
now: acceptedAt,
|
||||
turnScheduleAt,
|
||||
operationalAcceptedAt,
|
||||
})),
|
||||
};
|
||||
@@ -1896,7 +1900,6 @@ async function handleSetMySetting(
|
||||
nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn === 0 ? 0 : 1;
|
||||
}
|
||||
for (const key of [
|
||||
'use_auto_nation_diplomacy',
|
||||
'use_auto_nation_war',
|
||||
'use_auto_nation_promotion',
|
||||
'use_auto_nation_finance',
|
||||
|
||||
@@ -98,6 +98,23 @@ describe('authenticated actor-bound command registry and execution', () => {
|
||||
).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', () => {
|
||||
for (const command of buildActorBoundCommands()) {
|
||||
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', () => {
|
||||
const run = (belong: number) => {
|
||||
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' }]);
|
||||
});
|
||||
|
||||
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 } });
|
||||
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_finance: 1,
|
||||
};
|
||||
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true);
|
||||
// 기존 DB에 제거된 플래그가 남아 있어도 사용자 군주에게는 다시 활성화되지 않는다.
|
||||
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
|
||||
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
|
||||
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true);
|
||||
expect(canUseRulerAutomation(ruler, 'finance')).toBe(true);
|
||||
|
||||
ruler.meta = {
|
||||
...ruler.meta,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 1,
|
||||
};
|
||||
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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 { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.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 () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: makeSnapshot(makeGeneral()),
|
||||
@@ -401,6 +429,38 @@ describe('legacy general-turn execution contract', () => {
|
||||
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 () => {
|
||||
const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 });
|
||||
const harness = await createTurnTestHarness({
|
||||
|
||||
@@ -75,6 +75,7 @@ export type TurnTestHarnessOptions = {
|
||||
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
|
||||
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
|
||||
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
|
||||
commandEnv?: Parameters<typeof createReservedTurnHandler>[0]['commandEnv'];
|
||||
wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler;
|
||||
extraCalendarHandlers?: TurnCalendarHandler[];
|
||||
collectLogs?: boolean;
|
||||
@@ -115,6 +116,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
||||
onActionResolved: options.onActionResolved,
|
||||
onActionProfiled: options.onActionProfiled,
|
||||
commandRngFactory: options.commandRngFactory,
|
||||
commandEnv: options.commandEnv,
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
||||
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
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 { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js';
|
||||
|
||||
@@ -136,7 +135,6 @@ const segments = [
|
||||
},
|
||||
] as const satisfies readonly CatalogSegment[];
|
||||
|
||||
const KNOWN_MISSING_SCENARIO_RESOURCES = [] as const;
|
||||
const KNOWN_MISSING_MONTHLY_ACTIONS = [] as const;
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const refActionDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'sammo', 'Event', 'Action');
|
||||
const refActions = (await listBasenames(refActionDirectory, /\.php$/)).map((fileName) =>
|
||||
|
||||
@@ -161,6 +161,7 @@ const buildImmediateActionWorld = (options: {
|
||||
availableInstantRetreat?: boolean;
|
||||
lastTurnTime?: Date;
|
||||
scenarioConst?: Record<string, unknown>;
|
||||
worldConfig?: Record<string, unknown>;
|
||||
}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
@@ -209,6 +210,7 @@ const buildImmediateActionWorld = (options: {
|
||||
},
|
||||
},
|
||||
scenarioMeta,
|
||||
worldConfig: options.worldConfig,
|
||||
map: options.map,
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
@@ -237,7 +239,6 @@ describe('my information world commands', () => {
|
||||
defence_train: 94,
|
||||
use_treatment: 200,
|
||||
use_auto_nation_turn: 0,
|
||||
use_auto_nation_diplomacy: 1,
|
||||
use_auto_nation_war: 1,
|
||||
use_auto_nation_promotion: 1,
|
||||
use_auto_nation_finance: 1,
|
||||
@@ -254,7 +255,6 @@ describe('my information world commands', () => {
|
||||
defence_train: 999,
|
||||
use_treatment: 100,
|
||||
use_auto_nation_turn: 0,
|
||||
use_auto_nation_diplomacy: 1,
|
||||
use_auto_nation_war: 1,
|
||||
use_auto_nation_promotion: 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 () => {
|
||||
const deletedRecruiterId = 99;
|
||||
const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } };
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('NPC 원조 기반 불가침 제의 lifecycle', () => {
|
||||
.peekDirtyState()
|
||||
.messages.filter((message) => message.msgType === 'diplomacy' && message.option?.action === 'noAggression');
|
||||
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!;
|
||||
expect(tryEntry).toHaveLength(3);
|
||||
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.
|
||||
});
|
||||
|
||||
it('should fall back to rest when args are invalid', async () => {
|
||||
it('should log invalid argument failures without appending rest', async () => {
|
||||
const invalidSnapshot: TurnWorldSnapshot = {
|
||||
generals: [
|
||||
{
|
||||
@@ -503,10 +503,13 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth })
|
||||
)
|
||||
).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 = {
|
||||
generals: [
|
||||
{
|
||||
@@ -536,7 +539,7 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
npcState,
|
||||
} as TurnGeneral,
|
||||
],
|
||||
cities: [
|
||||
@@ -545,7 +548,7 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
name: 'City_1',
|
||||
nationId: 1,
|
||||
viewName: 'City_1',
|
||||
agriculture: 100,
|
||||
agriculture: 2000,
|
||||
agricultureMax: 2000,
|
||||
commerce: 100,
|
||||
commerceMax: 2000,
|
||||
@@ -629,7 +632,7 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
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 reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
||||
@@ -639,6 +642,12 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
await reservedTurnStore.loadAll();
|
||||
|
||||
const wrapper = { world: null as InMemoryTurnWorld | null };
|
||||
const outcomes: Array<{
|
||||
requestedAction: string;
|
||||
actionKey: string;
|
||||
usedFallback: boolean;
|
||||
completed?: boolean;
|
||||
}> = [];
|
||||
const handler = await createReservedTurnHandler({
|
||||
reservedTurns: reservedTurnStore,
|
||||
scenarioConfig: invalidSnapshot.scenarioConfig,
|
||||
@@ -646,6 +655,11 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
map: MINIMAL_MAP,
|
||||
unitSet: invalidSnapshot.unitSet,
|
||||
getWorld: () => wrapper.world,
|
||||
onActionResolved: (outcome) => {
|
||||
if (outcome.kind === 'general') {
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const world = new InMemoryTurnWorld(invalidState, invalidSnapshot, {
|
||||
@@ -662,12 +676,19 @@ describe('Reserved Turn Execution Integration', () => {
|
||||
});
|
||||
|
||||
const dirty = world.consumeDirtyState();
|
||||
expect(world.getGeneralById(1)!.cityId).toBe(1);
|
||||
const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.'));
|
||||
expect(denyLog?.text).toContain('이동 실패.');
|
||||
expect(denyLog?.meta?.constraintName).toBe('notSameDestCity');
|
||||
expect(world.getCityById(1)!.agriculture).toBe(2000);
|
||||
const denyLog = dirty.logs.find((log) => log.text.includes('농지 개간이 충분합니다.'));
|
||||
expect(denyLog?.text).toContain('농지 개간 실패.');
|
||||
expect(denyLog?.meta?.constraintName).toBe('remainCityCapacity');
|
||||
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', () => {
|
||||
|
||||
@@ -174,6 +174,26 @@ describe('runtime clock shift', () => {
|
||||
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([
|
||||
[5, 6],
|
||||
[10, 3],
|
||||
|
||||
@@ -4,7 +4,11 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
||||
|
||||
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';
|
||||
|
||||
interface TestPoolRow {
|
||||
@@ -28,6 +32,30 @@ interface PoolWhere {
|
||||
|
||||
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[] =>
|
||||
Array.from({ length: 29 }, (_, index) => {
|
||||
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,
|
||||
});
|
||||
const processor = new InMemoryTurnProcessor(world);
|
||||
const clock = new ManualClock(acceptedAt.getTime());
|
||||
const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => {
|
||||
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;
|
||||
});
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new ManualClock(acceptedAt.getTime()),
|
||||
clock,
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 1),
|
||||
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.listGenerals().filter((entry) => entry.npcState === 9)).toHaveLength(10);
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-19T21:15:00.000Z');
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([
|
||||
'2026-08-20T06:24:58.611Z',
|
||||
'2026-08-20T06:25:00.000Z',
|
||||
]);
|
||||
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
maxWorkers: 4,
|
||||
reporters: [process.env.SAMMO_TEST_REPORTER === 'verbose' ? 'default' : 'minimal'],
|
||||
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 {
|
||||
container: { x: containerRect.x, width: containerRect.width },
|
||||
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 },
|
||||
cells,
|
||||
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.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.cells[0]).toBeCloseTo(66.66, 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 {
|
||||
container: rect('#container'),
|
||||
topBar: rect('.top-back-bar'),
|
||||
topButton: rect('.top-back-bar .back-button'),
|
||||
titleLabel: rect('.form-label'),
|
||||
submitArticle: rect('#submitArticle'),
|
||||
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.titleLabel.width).toBeCloseTo(83.33, 1);
|
||||
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).toMatchObject({ y: 271.25, height: 20.1875 });
|
||||
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).toMatchObject({ size: '14px', lineHeight: '18.2px' });
|
||||
expect(geometry.whiteSpace).toBe('pre');
|
||||
|
||||
@@ -128,86 +128,89 @@ const inputOptions = {
|
||||
che_포상: [
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
label: '장수 (업)',
|
||||
gold: 5000,
|
||||
rice: 400,
|
||||
crew: 500,
|
||||
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
||||
description: '금 5,000 · 쌀 400 · 병력 500',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
label: '관우 (업)',
|
||||
gold: 100,
|
||||
rice: 4000,
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
label: '여포NPC (업)',
|
||||
npcState: 2,
|
||||
gold: 3000,
|
||||
rice: 500,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500',
|
||||
},
|
||||
],
|
||||
che_몰수: [
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
label: '장수 (업)',
|
||||
gold: 5000,
|
||||
rice: 400,
|
||||
crew: 500,
|
||||
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
||||
description: '금 5,000 · 쌀 400 · 병력 500',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
label: '관우 (업)',
|
||||
gold: 100,
|
||||
rice: 4000,
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
label: '여포NPC (업)',
|
||||
npcState: 2,
|
||||
gold: 3000,
|
||||
rice: 500,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500',
|
||||
},
|
||||
],
|
||||
che_발령: [
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
label: '장수 (부대 없음 · 업)',
|
||||
crew: 500,
|
||||
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
||||
description: '금 5,000 · 쌀 400 · 병력 500',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
label: '관우 (청룡대 (부대장) · 업)',
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
label: '여포NPC (청룡대 · 업)',
|
||||
npcState: 2,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500',
|
||||
},
|
||||
],
|
||||
che_부대탈퇴지시: [
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
npcState: 2,
|
||||
availableNow: true,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
@@ -274,7 +277,12 @@ const inputOptions = {
|
||||
avoid: 20,
|
||||
baseCost: 13.8,
|
||||
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' },
|
||||
],
|
||||
});
|
||||
const buildSimpleCommand = (key: string, name: string, turnDurationText?: string) => ({
|
||||
const buildSimpleCommand = (key: string, name: string, turnDurationText?: string, costText?: string) => ({
|
||||
key,
|
||||
name,
|
||||
...(turnDurationText ? { turnDurationText } : {}),
|
||||
...(costText ? { costText } : {}),
|
||||
reqArg: false,
|
||||
possible: true,
|
||||
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_불가침파기제의', '불가침 파기 제의'),
|
||||
@@ -637,9 +671,9 @@ const refChiefCommandTable = {
|
||||
category: '특수',
|
||||
values: [
|
||||
buildSimpleCommand('che_초토화', '초토화', '3턴'),
|
||||
buildSimpleCommand('che_천도', '천도', '1+거리×2턴'),
|
||||
buildSimpleCommand('che_증축', '증축', '6턴'),
|
||||
buildSimpleCommand('che_감축', '감축', '6턴'),
|
||||
buildSimpleCommand('che_천도', '천도', '1+거리×2턴', '금·쌀 500 × 2^거리'),
|
||||
buildSimpleCommand('che_증축', '증축', '6턴', '금 110,000 · 쌀 110,000'),
|
||||
buildSimpleCommand('che_감축', '감축', '6턴', '금 80,000 · 쌀 80,000 회수'),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -751,7 +785,7 @@ const chiefCenter = {
|
||||
officerLevel,
|
||||
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
|
||||
npcState: officerLevel === 8 ? 2 : 0,
|
||||
turnTime: null,
|
||||
turnTime: '2026-08-30T01:46:47.590Z',
|
||||
revision: 0,
|
||||
turns:
|
||||
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 currentGeneralContext = {
|
||||
...generalContext,
|
||||
general: { ...generalContext.general, id: generalId },
|
||||
};
|
||||
const generalTurns = turns(30);
|
||||
const nationTurns = turns(12);
|
||||
let generalRevision = 0;
|
||||
@@ -811,7 +854,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
? {
|
||||
kind: 'snapshot',
|
||||
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
data: generalContext,
|
||||
data: currentGeneralContext,
|
||||
}
|
||||
: { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' },
|
||||
commandTable: initial
|
||||
@@ -830,7 +873,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
: { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' },
|
||||
});
|
||||
}
|
||||
if (name === 'general.me') return response(generalContext);
|
||||
if (name === 'general.me') return response(currentGeneralContext);
|
||||
if (name === 'world.getMapLayout')
|
||||
return response({
|
||||
mapName: 'che',
|
||||
@@ -844,7 +887,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
if (name === 'auth.status') return response({ ok: true });
|
||||
if (name === 'lobby.info')
|
||||
return response({
|
||||
myGeneral: { id: 1, name: '장수' },
|
||||
myGeneral: { id: generalId, name: '장수' },
|
||||
year: 200,
|
||||
month: 1,
|
||||
turnTerm: 10,
|
||||
@@ -937,6 +980,52 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
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 }) => {
|
||||
await install(page);
|
||||
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') });
|
||||
});
|
||||
|
||||
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 = [
|
||||
{ value: 0, label: '색상 1', color: '#FF0000' },
|
||||
{ value: 15, label: '색상 16', color: '#6495ED' },
|
||||
@@ -1044,7 +1135,7 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
|
||||
nation: [],
|
||||
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.goto('/');
|
||||
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();
|
||||
|
||||
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).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
||||
await expect(color16).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||||
await colorType.selectOption('15');
|
||||
await expect(colorType).toHaveValue('15');
|
||||
await color16.hover();
|
||||
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('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('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 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');
|
||||
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
|
||||
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
|
||||
await mobilePicker.getByLabel('국가명').fill('신국');
|
||||
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('color', 'rgb(255, 255, 255)');
|
||||
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);
|
||||
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.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 = {
|
||||
general: [
|
||||
{
|
||||
@@ -1105,6 +1230,24 @@ test('reserves force move, retirement, and resignation from the user command pic
|
||||
reason: '나이가 60세 이상이어야 합니다.',
|
||||
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 });
|
||||
await expect(retirement).toHaveClass(/blocked/);
|
||||
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.focus();
|
||||
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);
|
||||
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 })).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') });
|
||||
});
|
||||
|
||||
@@ -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 expect(picker.locator('.command-grid .command-item')).toHaveText([
|
||||
'초토화 /3턴',
|
||||
'천도 /1+거리×2턴',
|
||||
'증축 /6턴',
|
||||
'감축 /6턴',
|
||||
'천도 /1+거리×2턴금·쌀 500 × 2^거리',
|
||||
'증축 /6턴금 110,000 · 쌀 110,000',
|
||||
'감축 /6턴금 80,000 · 쌀 80,000 회수',
|
||||
]);
|
||||
const durationGeometry = await picker.locator('.command-grid .command-item').evaluateAll((buttons) =>
|
||||
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);
|
||||
await picker.screenshot({ path: test.info().outputPath('chief-command-costs-desktop-1200.png') });
|
||||
await picker.getByRole('button', { name: '기타', exact: true }).click();
|
||||
const rename = picker.getByRole('button', { name: '국호변경', exact: true });
|
||||
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.categoryColumns.split(' ')).toHaveLength(3);
|
||||
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 mobileExpand.hover();
|
||||
await mobileExpand.focus();
|
||||
await expect(mobileExpand).toBeFocused();
|
||||
await mobileExpand.dispatchEvent('pointerdown');
|
||||
await expect(mobileExpand.locator('.command-duration')).toHaveText('/6턴');
|
||||
await expect(mobileExpand.locator('.command-cost')).toHaveText('금 110,000 · 쌀 110,000');
|
||||
await mobileExpand.dispatchEvent('pointerup');
|
||||
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') });
|
||||
@@ -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 }) => {
|
||||
await install(page);
|
||||
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);
|
||||
}
|
||||
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,
|
||||
}));
|
||||
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 });
|
||||
await expect(unavailable).toBeVisible();
|
||||
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 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');
|
||||
|
||||
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 expect(estimatedGold).toHaveText('363금');
|
||||
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const cases = [
|
||||
@@ -2244,22 +2522,21 @@ test('offers Ref amount presets and rich, command-specific general lists', async
|
||||
await expect(amount).toHaveValue('1375');
|
||||
|
||||
let generalList = form.getByTestId('general-target-list');
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText([
|
||||
'관우 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'장수 (아국 · 업)',
|
||||
await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([
|
||||
'관우 (업)',
|
||||
'장수 (업)',
|
||||
'여포NPC (업)',
|
||||
]);
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText(['관우 (업)', '장수 (업)', '여포NPC (업)']);
|
||||
await expect(generalList.locator('.target-option').filter({ hasText: '여포NPC' }).locator('strong')).toHaveCSS(
|
||||
'color',
|
||||
'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 expect(generalList.locator('.target-option strong')).toHaveText([
|
||||
'장수 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
]);
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']);
|
||||
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
|
||||
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||||
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();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
generalList = form.getByTestId('general-target-list');
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText([
|
||||
'장수 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
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 page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /발령/ }).click();
|
||||
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.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();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
generalList = form.getByTestId('general-target-list');
|
||||
await expect(generalList.locator('.target-option strong').first()).toHaveText('여포NPC (아국 · 업)');
|
||||
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('우선 대상');
|
||||
await expect(generalList.locator('.target-option').nth(1).locator('.target-state')).toHaveText('현재 불가');
|
||||
await expect(generalList.locator('.target-option strong')).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.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('"amountList":[20000,1370]');
|
||||
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 }) => {
|
||||
@@ -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.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||||
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.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 }) => {
|
||||
@@ -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 mobileFirstRow = mobileEditor.locator('.action-column > div').first();
|
||||
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 mobileFirstRow.focus();
|
||||
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 }) => {
|
||||
const requests = await install(page);
|
||||
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 });
|
||||
});
|
||||
|
||||
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,
|
||||
capitalCityId: 2,
|
||||
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,
|
||||
officers: Array.from({ length: 8 }, (_, index) => ({
|
||||
officerLevel: 12 - index,
|
||||
@@ -38,6 +53,26 @@ const nationDirectory = [
|
||||
power: 1000,
|
||||
capitalCityId: 1,
|
||||
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,
|
||||
officers: Array.from({ length: 8 }, (_, index) => ({
|
||||
officerLevel: 12 - index,
|
||||
@@ -60,6 +95,15 @@ const nationDirectory = [
|
||||
power: 0,
|
||||
capitalCityId: 0,
|
||||
generalCount: 1,
|
||||
forceEstimate: {
|
||||
totalGeneralCount: 1,
|
||||
userCombatGeneralCount: 0,
|
||||
userTroops: 0,
|
||||
npcCombatGeneralCount: 1,
|
||||
npcTroops: 7_000,
|
||||
inactiveGeneralCount: 0,
|
||||
},
|
||||
generalGroups: [],
|
||||
cityCount: 1,
|
||||
officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })),
|
||||
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)');
|
||||
});
|
||||
|
||||
test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async (
|
||||
{ page },
|
||||
testInfo
|
||||
) => {
|
||||
test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requestedPortraits = new Set<string>();
|
||||
page.on('request', (request) => {
|
||||
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 expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0);
|
||||
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(
|
||||
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('[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-injury-leadership-10"] > .wounded')).toHaveText(
|
||||
'81'
|
||||
);
|
||||
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText('81');
|
||||
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
|
||||
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
|
||||
await mobileInjury.focus();
|
||||
|
||||
@@ -58,9 +58,9 @@ const city = {
|
||||
};
|
||||
const castleFixtures = [
|
||||
{ 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: 3, level: 2, layoutLevel: 8, x: 300, y: 100, width: 20, height: 14 },
|
||||
{ id: 4, level: 3, layoutLevel: 8, x: 400, y: 100, width: 14, height: 14 },
|
||||
{ id: 2, name: '진류', level: 6, layoutLevel: 6, x: 365, y: 185, width: 26, height: 18 },
|
||||
{ id: 3, name: '관도', level: 2, layoutLevel: 2, x: 340, y: 165, width: 20, 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: 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 },
|
||||
@@ -419,28 +419,52 @@ const install = async (
|
||||
: [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }],
|
||||
},
|
||||
...(denseCurrentCity
|
||||
? Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 2,
|
||||
name: `NPC장수이름이긴${index + 1}`,
|
||||
npcState: 2,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
nationId: 1,
|
||||
nationName: '아국',
|
||||
leadership: 60,
|
||||
strength: 60,
|
||||
intelligence: 60,
|
||||
injury: 0,
|
||||
officerLevel: 1,
|
||||
leadershipBonus: 0,
|
||||
defenceTrain: 80,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
crew: 500,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
turns: [],
|
||||
}))
|
||||
? [
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 2,
|
||||
name: `NPC장수이름이긴${index + 1}`,
|
||||
npcState: 2,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
nationId: 1,
|
||||
nationName: '아국',
|
||||
leadership: 60,
|
||||
strength: 60,
|
||||
intelligence: 60,
|
||||
injury: 0,
|
||||
officerLevel: 1,
|
||||
leadershipBonus: 0,
|
||||
defenceTrain: 80,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
crew: 500,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
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: {
|
||||
@@ -551,6 +575,63 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
|
||||
await desktopCity.hover();
|
||||
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 ['남만', '교지', '남해', '대']) {
|
||||
await page.getByRole('link', { name: cityName, exact: true }).hover();
|
||||
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 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 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);
|
||||
expect(titleTextBackground).toContain('ad.gif');
|
||||
expect(titleTextBackground).toContain('spring.gif');
|
||||
await mapTitle.hover();
|
||||
const titleTooltip = page.locator('.map-title-tooltip');
|
||||
await expect(titleTooltip).toBeVisible();
|
||||
await expect(titleTooltip).toContainText('기술등급 제한 : 5등급 (205년 해제)');
|
||||
await expect(titleTooltip).toHaveCount(0);
|
||||
const titleGeometry = await page.locator('.map-top').evaluate((element) => {
|
||||
const band = element.getBoundingClientRect();
|
||||
const title = element.querySelector('.map-title')?.getBoundingClientRect();
|
||||
const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect();
|
||||
return {
|
||||
band: { width: band.width, height: band.height },
|
||||
title: title ? { width: title.width, height: title.height } : null,
|
||||
tooltip: tooltip ? { width: tooltip.width, height: tooltip.height } : null,
|
||||
};
|
||||
});
|
||||
expect(titleGeometry).toEqual({
|
||||
band: { width: 700, height: 20 },
|
||||
title: { width: 160, height: 20 },
|
||||
tooltip: { width: 220, height: 28 },
|
||||
});
|
||||
if (artifactRoot) {
|
||||
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.summary?.width).toBe(500);
|
||||
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 band = element.getBoundingClientRect();
|
||||
const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect();
|
||||
return {
|
||||
band: { width: band.width, height: band.height },
|
||||
tooltip: tooltip ? { x: tooltip.x, width: tooltip.width } : null,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 });
|
||||
expect(mobileTitleGeometry.tooltip?.width).toBe(220);
|
||||
expect(mobileTitleGeometry.documentWidth).toBe(500);
|
||||
await hoveredCastle.hover();
|
||||
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 page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, 'global-info');
|
||||
|
||||
const mapTitle = page.locator('.map-title');
|
||||
await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)');
|
||||
await mapTitle.hover();
|
||||
await expect(page.locator('.map-title-tooltip')).toHaveText(
|
||||
'초반제한 기간 : 0년 12개월 (201년)기술등급 제한 : 1등급 (203년 해제)'
|
||||
);
|
||||
await expect(mapTitle).not.toHaveAttribute('tabindex');
|
||||
await expect(page.locator('.map-title-tooltip')).toHaveCount(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await install(page, 'admin');
|
||||
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.stats).toEqual({ x: 100, y: 165.375, width: 1000, height: 106.9375 });
|
||||
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 290.3125, width: 1024 });
|
||||
expect(legacyGeometry.stats).toEqual({ x: 100, y: 130.875, width: 1000, height: 106.9375 });
|
||||
expect(legacyGeometry.generals).toMatchObject({ x: 100, y: 255.8125, width: 1000 });
|
||||
expect(legacyGeometry.titleAlign).toBe('start');
|
||||
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
|
||||
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 reservedTurns = rows.nth(0).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('2 : 【업】에 화계실행');
|
||||
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(npcTurns).toHaveText('NPC 장수');
|
||||
await expect(npcTurns).not.toHaveClass(/turns--reserved/);
|
||||
await expect(enemyNpcTurns).toHaveText('【적국】 장수');
|
||||
await expect(enemyNpcTurns).not.toHaveClass(/turns--reserved/);
|
||||
|
||||
const turnFontSizes = await Promise.all([
|
||||
reservedTurns.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
|
||||
@@ -276,7 +276,6 @@ const myGeneral = (state: FixtureState) => ({
|
||||
defence_train: 80,
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
@@ -1077,6 +1076,62 @@ test('개인턴·수뇌턴 실패 사유를 메인 개인 기록에 표시한다
|
||||
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 }) => {
|
||||
const seedText = '(전투시드: 0123456789abcdef)';
|
||||
const logText =
|
||||
@@ -1347,13 +1402,12 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
]);
|
||||
const rulerAutomation = page.locator('.ruler-automation-settings');
|
||||
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 promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
|
||||
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
|
||||
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
|
||||
for (const checkbox of [
|
||||
diplomacyAutomation,
|
||||
warAutomation,
|
||||
promotionAutomation,
|
||||
financeAutomation,
|
||||
@@ -1420,8 +1474,8 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
await page.locator('#set_my_setting').click();
|
||||
await expect.poll(() => state.settingMutations.length).toBe(1);
|
||||
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
|
||||
expect(state.settingMutations[0]).not.toHaveProperty('myset');
|
||||
expect(state.settingMutations[0]).toMatchObject({
|
||||
use_auto_nation_diplomacy: 1,
|
||||
use_auto_nation_war: 1,
|
||||
use_auto_nation_promotion: 1,
|
||||
use_auto_nation_finance: 1,
|
||||
@@ -1953,7 +2007,11 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
|
||||
accessPages: [],
|
||||
createGeneralInputs: [],
|
||||
joinConfig: {
|
||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
||||
rules: {
|
||||
stat: { total: 150, min: 30, max: 70 },
|
||||
allowDirectCreation: true,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
displayName: '생성장수',
|
||||
@@ -2002,7 +2060,11 @@ test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청
|
||||
accessPages: [],
|
||||
createGeneralInputs: [],
|
||||
joinConfig: {
|
||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
||||
rules: {
|
||||
stat: { total: 150, min: 30, max: 70 },
|
||||
allowDirectCreation: true,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
displayName: '생성장수',
|
||||
@@ -2400,3 +2462,27 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
await page.reload();
|
||||
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({
|
||||
rules: {
|
||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||
allowDirectCreation: true,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
@@ -109,14 +110,14 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
state.generalRequests += 1;
|
||||
return response([
|
||||
{
|
||||
id: 1,
|
||||
name: '유비',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
nationName: '촉',
|
||||
leadership: 72,
|
||||
strength: 67,
|
||||
intelligence: 76,
|
||||
id: 3,
|
||||
name: '황건장수',
|
||||
npcState: 2,
|
||||
nationId: 0,
|
||||
nationName: '무주',
|
||||
leadership: 55,
|
||||
strength: 65,
|
||||
intelligence: 45,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -129,14 +130,14 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
intelligence: 80,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '황건장수',
|
||||
npcState: 2,
|
||||
nationId: 0,
|
||||
nationName: '무주',
|
||||
leadership: 55,
|
||||
strength: 65,
|
||||
intelligence: 45,
|
||||
id: 1,
|
||||
name: '유비',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
nationName: '촉',
|
||||
leadership: 72,
|
||||
strength: 67,
|
||||
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.locator('.create-form').getByLabel('통솔')).toHaveValue('55');
|
||||
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 defaultButtonStyle = await randomButton.evaluate((element) => {
|
||||
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: '장수 목록' });
|
||||
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);
|
||||
await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉');
|
||||
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) =>
|
||||
buttons.map((button) => {
|
||||
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.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 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 expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused();
|
||||
await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true });
|
||||
|
||||
@@ -35,6 +35,8 @@ type NavigationFixture = {
|
||||
generalMeCalls: number;
|
||||
operations: string[];
|
||||
generalName?: string;
|
||||
generalPicture?: string | null;
|
||||
generalImageServer?: number;
|
||||
generalTurnTime?: string;
|
||||
nextTurnMonthOffset?: 0 | 1;
|
||||
serverTime?: string;
|
||||
@@ -78,6 +80,7 @@ type NavigationFixture = {
|
||||
messages?: unknown;
|
||||
messageContacts?: unknown;
|
||||
autorunLimit?: number | null;
|
||||
autorunUser?: { limitMinutes: number; options: string[] } | null;
|
||||
dashboardResponses?: Array<{
|
||||
bytes: number;
|
||||
contextKind: string | null;
|
||||
@@ -445,8 +448,8 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
officerCityName: state.officerLevel >= 2 && state.officerLevel <= 4 ? '업' : null,
|
||||
generalType: '용장',
|
||||
leadershipBonus: state.officerLevel === 12 ? state.nationLevel * 2 : 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
picture: state.generalPicture ?? null,
|
||||
imageServer: state.generalImageServer ?? 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
@@ -633,6 +636,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
clockStartsAt: state.clockStartsAt ?? null,
|
||||
turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning,
|
||||
scenarioTitle: state.scenarioTitle ?? '',
|
||||
autorunUser: state.autorunUser ?? null,
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}, 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);
|
||||
});
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -1701,7 +1866,11 @@ test('tournament split main action follows recruitment, betting, finals, and tou
|
||||
const main = nationMenu.locator('[data-navigation-id="tournament"]');
|
||||
await expect(main).toHaveText(lifecycle.label);
|
||||
await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`);
|
||||
await expect(main).toHaveClass(/highlight/);
|
||||
if (lifecycle.stage === 1 || lifecycle.stage === 6) {
|
||||
await expect(main).toHaveClass(/highlight/);
|
||||
} else {
|
||||
await expect(main).not.toHaveClass(/highlight/);
|
||||
}
|
||||
|
||||
const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]');
|
||||
await toggle.click();
|
||||
@@ -1712,9 +1881,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou
|
||||
if (lifecycle.stage === 6) {
|
||||
await expect(tournamentItem).not.toHaveClass(/highlight/);
|
||||
await expect(bettingItem).toHaveClass(/highlight/);
|
||||
} else {
|
||||
} else if (lifecycle.stage === 1) {
|
||||
await expect(tournamentItem).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');
|
||||
|
||||
@@ -1726,9 +1898,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou
|
||||
if (lifecycle.stage === 6) {
|
||||
await expect(mobileTournament).not.toHaveClass(/highlight/);
|
||||
await expect(mobileBetting).toHaveClass(/highlight/);
|
||||
} else {
|
||||
} else if (lifecycle.stage === 1) {
|
||||
await expect(mobileTournament).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');
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const state: NavigationFixture = {
|
||||
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('백마대');
|
||||
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 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 });
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
const state: NavigationFixture = {
|
||||
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: '갱 신' }),
|
||||
{ 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) =>
|
||||
@@ -3947,6 +4284,8 @@ test('all main Lumen button families share the rounded pressed geometry', async
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
parentWidth: element.parentElement?.getBoundingClientRect().width ?? 0,
|
||||
height: rect.height,
|
||||
marginTop: style.marginTop,
|
||||
borderTop: style.borderTopWidth,
|
||||
@@ -3979,6 +4318,9 @@ test('all main Lumen button families share the rounded pressed geometry', async
|
||||
radius: expectedGeometry?.radius ?? '5.25px',
|
||||
filter: 'none',
|
||||
});
|
||||
if (label === '당기기' || label === '미루기') {
|
||||
expect(base.width, `${label} fills its menu column`).toBeCloseTo(base.parentWidth, 2);
|
||||
}
|
||||
|
||||
await control.focus();
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click();
|
||||
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('.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('link', { name: '로비로' }),
|
||||
];
|
||||
for (const [index, control] of controls.entries()) {
|
||||
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`);
|
||||
});
|
||||
|
||||
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 = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
@@ -4349,10 +4703,20 @@ test('mobile single document refreshes once and preserves tokens on lobby return
|
||||
await page.evaluate(() => {
|
||||
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 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(
|
||||
await page.evaluate(() => ({
|
||||
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 { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
import { expectLumenButtonStates } from './lumenButton.js';
|
||||
import { acceptAppConfirmation } from './appConfirmation.js';
|
||||
|
||||
type Role = 'leader' | 'head' | 'member';
|
||||
type FixtureState = {
|
||||
@@ -14,6 +15,7 @@ type FixtureState = {
|
||||
appointedGeneralId?: number;
|
||||
appointedCityId?: number;
|
||||
appointedOfficerLevel?: number;
|
||||
kickedGeneralId?: number;
|
||||
permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] };
|
||||
noticeMutationInput?: string;
|
||||
scoutMutationInput?: string;
|
||||
@@ -100,7 +102,7 @@ const personnelFixture = (state: FixtureState) => {
|
||||
general(3, '하후돈', 4),
|
||||
general(4, '곽가', 3),
|
||||
general(5, '정욱', 2),
|
||||
general(6, '장료', 1),
|
||||
general(6, '장료', 1, { npcState: 2 }),
|
||||
general(7, '허저', 1, { permission: 'ambassador' }),
|
||||
general(8, '가후', 1, { permission: 'auditor' }),
|
||||
general(9, '전위', 1),
|
||||
@@ -255,7 +257,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
};
|
||||
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 (state.failNextRate) {
|
||||
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);
|
||||
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 acceptAppConfirmation(page, '외교권자를 변경할까요?');
|
||||
await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible();
|
||||
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 auditorOptions.getByRole('option', { name: '장료' }).click();
|
||||
await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명');
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('조언자를 변경할까요?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '조언자 임명 반영' }).click();
|
||||
await acceptAppConfirmation(page, '조언자를 변경할까요?');
|
||||
await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible();
|
||||
await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] });
|
||||
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');
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible();
|
||||
await expect(picker.locator('.personnel-picker-card:not(.vacancy-card)')).toHaveText([
|
||||
/가후/,
|
||||
/곽가/,
|
||||
/순욱/,
|
||||
/전위/,
|
||||
/정욱/,
|
||||
/하후돈/,
|
||||
/허저/,
|
||||
/장료/,
|
||||
]);
|
||||
await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료');
|
||||
const candidate = picker.getByRole('button', { name: /장료/ });
|
||||
await expect(candidate).toContainText('허창 · 일반 장수');
|
||||
@@ -441,11 +450,8 @@ test('personnel selects an informed general and reports the JosaUtil-composed re
|
||||
await candidate.focus();
|
||||
expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none');
|
||||
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 acceptAppConfirmation(page, '장료를 주부직에 임명하시겠습니까?');
|
||||
|
||||
await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.');
|
||||
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.gradientCount).toBe(0);
|
||||
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
|
||||
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
|
||||
'장수 선택',
|
||||
'하후돈 (70/70/70)',
|
||||
'곽가 (70/70/70)',
|
||||
'정욱 (70/70/70)',
|
||||
'장료 (70/70/70)',
|
||||
]);
|
||||
await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
|
||||
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)
|
||||
).not.toBe('none');
|
||||
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 acceptAppConfirmation(page, '장료를 허창 태수직에 임명하시겠습니까?');
|
||||
await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.');
|
||||
expect(state.appointedGeneralId).toBe(6);
|
||||
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');
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await installFixture(page, { role: 'member', rate: 20 });
|
||||
await gotoOffice(page, 'nation/personnel');
|
||||
await expect(page.getByRole('button', { name: /변경하기/ })).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);
|
||||
const auditorCell = page.locator('.city-officer-cell').filter({ hasText: '곽가' });
|
||||
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');
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
const state: FixtureState = { role: 'head', rate: 20, failNextRate: true };
|
||||
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 { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
import { touchDrag } from './touchDrag.js';
|
||||
import { acceptAppConfirmation } from './appConfirmation.js';
|
||||
|
||||
type FixtureState = {
|
||||
permissionLevel: number;
|
||||
failNextMutation?: boolean;
|
||||
failLoad?: boolean;
|
||||
mutations: string[];
|
||||
nationPriorityInput?: string[];
|
||||
currentNationPriority?: string[];
|
||||
};
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
@@ -124,7 +127,7 @@ const policyFixture = (state: FixtureState) => ({
|
||||
reqNPCDevelGold: 540,
|
||||
},
|
||||
defaultNationPriority: nationPriority,
|
||||
currentNationPriority: nationPriority,
|
||||
currentNationPriority: state.currentNationPriority ?? nationPriority,
|
||||
availableNationPriorityItems: nationPriority,
|
||||
defaultGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: generalPriority,
|
||||
@@ -155,7 +158,14 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
}
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
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 === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
@@ -170,6 +180,9 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
operation === 'npc.setGeneralPriority'
|
||||
) {
|
||||
state.mutations.push(operation);
|
||||
if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput)) {
|
||||
state.nationPriorityInput = jsonInput.map(String);
|
||||
}
|
||||
if (state.failNextMutation) {
|
||||
state.failNextMutation = false;
|
||||
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
|
||||
@@ -280,8 +293,8 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf
|
||||
).toBeVisible();
|
||||
|
||||
await goldInput.fill('12345');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
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(
|
||||
'NPC 정책이 반영되었습니다.'
|
||||
);
|
||||
@@ -289,6 +302,29 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf
|
||||
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 }) => {
|
||||
await installFixture(page, { permissionLevel: 4, mutations: [] });
|
||||
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 activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list');
|
||||
const activeRows = activeList.locator('.priority-item');
|
||||
await touchDrag(
|
||||
mobilePage,
|
||||
activeRows.nth(0),
|
||||
activeRows.nth(3),
|
||||
{ targetYRatio: 0.9 }
|
||||
);
|
||||
await touchDrag(mobilePage, activeRows.nth(0), activeRows.nth(3), { targetYRatio: 0.9 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
activeList
|
||||
.locator('.priority-item .priority_info > span:nth-child(2)')
|
||||
.first()
|
||||
.textContent()
|
||||
)
|
||||
.poll(() => activeList.locator('.priority-item .priority_info > span:nth-child(2)').first().textContent())
|
||||
.toBe('선전포고');
|
||||
|
||||
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 expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
activeList.getByText('불가침제의', { exact: true })
|
||||
).toHaveCount(0);
|
||||
await expect(activeList.getByText('불가침제의', { exact: true })).toHaveCount(0);
|
||||
await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true });
|
||||
} finally {
|
||||
await context.close();
|
||||
|
||||
@@ -141,9 +141,16 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
return response({
|
||||
rules: {
|
||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||
allowDirectCreation: false,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
|
||||
user: {
|
||||
id: 'npc-user',
|
||||
displayName: '빙의사용자',
|
||||
canCreateGeneral: true,
|
||||
icons: [],
|
||||
preferredPicture: null,
|
||||
},
|
||||
personalities: [{ key: 'Random', name: '???', info: '' }],
|
||||
warSpecials: [],
|
||||
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 ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -239,6 +323,8 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
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.locator('.npc-card')).toHaveCount(5);
|
||||
|
||||
@@ -33,6 +33,7 @@ export default defineConfig({
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
'auction.spec.ts',
|
||||
'nationBetting.spec.ts',
|
||||
'tournamentBracket.spec.ts',
|
||||
'battleSimulator.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.close).toEqual({ width: 88, height: 44 });
|
||||
|
||||
const firstSlot = page.locator('.desktop-bracket-name').first();
|
||||
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');
|
||||
await expect(page.locator('.desktop-bracket .bracket-bet-summary')).toHaveCount(0);
|
||||
|
||||
const preliminaryGroups = page.locator('.preliminary-grid .tournament-group-card');
|
||||
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.nameTop).toBeLessThan(identity.iconBottom);
|
||||
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop);
|
||||
const firstMobileSlot = bracket.locator('.mobile-bracket-name').first();
|
||||
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(bracket.locator('.mobile-bracket .bracket-bet-summary')).toHaveCount(0);
|
||||
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
||||
await page.getByRole('tab', { name: '二조' }).first().click();
|
||||
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 });
|
||||
const { placedBets } = await installFixture(page, { tournamentStage: 6 });
|
||||
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 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 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 {
|
||||
topOffset: button.top - own.top,
|
||||
rightOffset: own.right - button.right,
|
||||
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(corner.topOffset).toBeLessThanOrEqual(4);
|
||||
expect(corner.rightOffset).toBeGreaterThanOrEqual(2);
|
||||
expect(corner.rightOffset).toBeLessThanOrEqual(4);
|
||||
expect(corner.contained).toBe(true);
|
||||
expect(layout.topOffset).toBeGreaterThanOrEqual(2);
|
||||
expect(layout.topOffset).toBeLessThanOrEqual(6);
|
||||
expect(layout.rightOffset).toBeGreaterThanOrEqual(2);
|
||||
expect(layout.rightOffset).toBeLessThanOrEqual(6);
|
||||
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 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 expect(dialog.getByText('배당 28.00')).toBeVisible();
|
||||
await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
|
||||
await dialog.getByLabel('베팅 금액').selectOption('50');
|
||||
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible();
|
||||
await dialog.getByLabel('베팅 금액').selectOption('100');
|
||||
await expect(dialog.getByText('예상 환수금 2,800')).toBeVisible();
|
||||
await persistScreenshot(
|
||||
page,
|
||||
'tournament-betting-dialog-mobile',
|
||||
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 expect(dialog).not.toBeVisible();
|
||||
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.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);
|
||||
});
|
||||
|
||||
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,
|
||||
}, testInfo) => {
|
||||
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('.desktop-bracket .bracket-bet-button:visible')).toHaveCount(16);
|
||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
||||
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px');
|
||||
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px');
|
||||
const firstCandidate = page.locator('.desktop-bracket-name.betting-candidate[data-general-id="1"]');
|
||||
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
|
||||
.locator('.desktop-bracket-name.betting-target[data-general-id="1"]')
|
||||
.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 { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
import { acceptAppConfirmation } from './appConfirmation.js';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
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);
|
||||
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}`);
|
||||
});
|
||||
await fulfillJson(route, results);
|
||||
@@ -555,19 +561,19 @@ test('shows API failure then creates a troop successfully', async ({ page }) =>
|
||||
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 = {
|
||||
me: { id: 1, troopId: 1 },
|
||||
permission: 4,
|
||||
troops: baseTroops(),
|
||||
};
|
||||
await installApiFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
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('[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('combobox', { name: '추방할 부대원' }).selectOption('3');
|
||||
await page.getByRole('button', { name: '추방', exact: true }).click();
|
||||
await acceptAppConfirmation(page, '백마의종 부대에서 조운을 추방하시겠습니까?');
|
||||
await expect(
|
||||
page.locator('[data-testid="game-toast"][data-feedback-kind="success"]').filter({ hasText: '조운' })
|
||||
).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 }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 3, troopId: 1 },
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
"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: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: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: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: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",
|
||||
@@ -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: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",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "pnpm -w lint:target app/game-frontend",
|
||||
"lint:fix": "pnpm -w lint:target:fix app/game-frontend",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -121,6 +121,15 @@ input[inputmode='search'] {
|
||||
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 {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
ReservedCommandRow,
|
||||
} from '../command/types';
|
||||
|
||||
type ReservationCompletion = (success: boolean) => void;
|
||||
|
||||
const props = defineProps<{
|
||||
officerLevelText: string;
|
||||
name: string | null;
|
||||
@@ -26,10 +28,14 @@ const props = defineProps<{
|
||||
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'reserve-bulk', entries: CommandPatternEntry[]): void;
|
||||
(event: 'reserve-bulk', entries: CommandPatternEntry[], complete?: ReservationCompletion): void;
|
||||
(event: 'shift', amount: number): void;
|
||||
(event: 'repeat', amount: number): void;
|
||||
}>();
|
||||
|
||||
const reserveBulk = (entries: CommandPatternEntry[], complete?: ReservationCompletion) => {
|
||||
emit('reserve-bulk', entries, complete);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +53,7 @@ const emit = defineEmits<{
|
||||
:current-time="props.rows[0]?.time"
|
||||
:map-data="props.mapData"
|
||||
:map-layout="props.mapLayout"
|
||||
@reserve-bulk="emit('reserve-bulk', $event)"
|
||||
@reserve-bulk="reserveBulk"
|
||||
@shift="emit('shift', $event)"
|
||||
@repeat="emit('repeat', $event)"
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,10 @@ const filledAmount = (crewType: RecruitmentCrewType | null): number => {
|
||||
}
|
||||
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 = () => {
|
||||
showUnavailable.value = Object.fromEntries(props.info.groups.map((group) => [group.armType, false]));
|
||||
@@ -246,6 +250,9 @@ watch(
|
||||
"
|
||||
/>
|
||||
<span>00명</span>
|
||||
<output :aria-label="`${crewType.name} 예상 자금`">
|
||||
{{ estimatedGoldFor(crewType).toLocaleString() }}금
|
||||
</output>
|
||||
</label>
|
||||
</span>
|
||||
<span class="crew-action" @click.stop>
|
||||
@@ -393,7 +400,7 @@ watch(
|
||||
}
|
||||
.amount-panel label {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto minmax(76px, auto);
|
||||
align-items: center;
|
||||
}
|
||||
.amount-panel input {
|
||||
@@ -402,6 +409,14 @@ watch(
|
||||
box-sizing: border-box;
|
||||
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,
|
||||
.submit-recruit {
|
||||
width: 100%;
|
||||
@@ -452,12 +467,7 @@ watch(
|
||||
grid-template-columns: auto minmax(0, 1fr) auto 85px;
|
||||
}
|
||||
.mobile-selected-panel output {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
background: #ddd;
|
||||
color: #303030;
|
||||
text-align: right;
|
||||
}
|
||||
.crew-grid {
|
||||
grid-template-areas:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
||||
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
||||
import { commandArgumentPresentation } from './commandArgumentPresentation';
|
||||
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from './commandArgumentPresentation';
|
||||
import DragSelect from './DragSelect.vue';
|
||||
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
|
||||
import {
|
||||
@@ -30,7 +30,9 @@ const props = withDefaults(
|
||||
commandTable: CommandTable | null;
|
||||
loading: boolean;
|
||||
storageKey: string;
|
||||
editModeStorageKey?: string;
|
||||
maxPushTurn?: number;
|
||||
maxShiftTurn?: number;
|
||||
compact?: boolean;
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
@@ -41,7 +43,9 @@ const props = withDefaults(
|
||||
autonomousUntil?: string | null;
|
||||
}>(),
|
||||
{
|
||||
editModeStorageKey: undefined,
|
||||
maxPushTurn: 6,
|
||||
maxShiftTurn: 6,
|
||||
compact: false,
|
||||
mobile: false,
|
||||
title: '',
|
||||
@@ -54,7 +58,7 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
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: 'repeat', amount: number): void;
|
||||
}>();
|
||||
@@ -77,25 +81,27 @@ const commandArgs = ref<Record<string, unknown>>({});
|
||||
const commandArgsValid = ref(false);
|
||||
const expanded = ref(false);
|
||||
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 pickerElement = ref<HTMLElement | null>(null);
|
||||
const collapsedRowCount = 15;
|
||||
|
||||
const loadStorage = (key: string) => {
|
||||
storage.value = new CommandStorage(key);
|
||||
const loadStorage = (key: string, editModeKey: string | undefined) => {
|
||||
storage.value = new CommandStorage(key, { editModeKey });
|
||||
editMode.value = storage.value.editMode;
|
||||
activeCategory.value = storage.value.activeCategory;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStorage(props.storageKey);
|
||||
loadStorage(props.storageKey, props.editModeStorageKey);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
(key, previousKey) => {
|
||||
if (key !== previousKey) loadStorage(key);
|
||||
() => [props.storageKey, props.editModeStorageKey] as const,
|
||||
([key, editModeKey], [previousKey, previousEditModeKey]) => {
|
||||
if (key !== previousKey || editModeKey !== previousEditModeKey) loadStorage(key, editModeKey);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -107,24 +113,6 @@ watch([editMode, activeCategory], () => {
|
||||
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>(() => {
|
||||
if (!props.commandTable) return null;
|
||||
return {
|
||||
@@ -146,6 +134,15 @@ const quickPickerTop = ref('38px');
|
||||
const isRecruitmentCommand = computed(
|
||||
() => 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 commandBrief = (entry: { action: string; args: unknown; label?: string }): string =>
|
||||
formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) ||
|
||||
@@ -253,9 +250,7 @@ const togglePicker = (turnIndex?: number) => {
|
||||
};
|
||||
const selectCommand = (commandKey: string) => {
|
||||
const table = props.commandTable;
|
||||
const command = table?.[props.scope]
|
||||
.flatMap((group) => group.values)
|
||||
.find((entry) => entry.key === commandKey);
|
||||
const command = table?.[props.scope].flatMap((group) => group.values).find((entry) => entry.key === commandKey);
|
||||
if (!table || !command) return;
|
||||
selectedCommand.value = command;
|
||||
// Ref opens argument commands on a separate processing page. Keep the same
|
||||
@@ -269,16 +264,27 @@ const selectCommand = (commandKey: string) => {
|
||||
};
|
||||
commandArgs.value = {};
|
||||
commandArgsValid.value = !command.reqArg;
|
||||
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
|
||||
if (!command.reqArg && !needsInformationalConfirmation) submitCommand();
|
||||
if (!command.reqArg) submitCommand();
|
||||
};
|
||||
const submitCommand = () => {
|
||||
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 entry = { turnList, action: command.key, args: { ...commandArgs.value }, label: command.name };
|
||||
emit('reserve-bulk', [entry]);
|
||||
pendingReservation.value = entry;
|
||||
const requestId = ++reservationRequestId;
|
||||
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 = () => {
|
||||
selectedCommand.value = null;
|
||||
@@ -361,9 +367,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
mobile: props.mobile,
|
||||
'edit-mode': editMode,
|
||||
'picker-open': pickerOpen,
|
||||
'argument-expanded': Boolean(
|
||||
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
|
||||
),
|
||||
'argument-expanded': isArgumentPickerExpanded,
|
||||
}"
|
||||
:data-command-scope="props.scope"
|
||||
>
|
||||
@@ -500,7 +504,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
<summary>당기기</summary>
|
||||
<div class="menu-items">
|
||||
<button
|
||||
v-for="amount in props.maxPushTurn"
|
||||
v-for="amount in props.maxShiftTurn"
|
||||
:key="amount"
|
||||
@click="
|
||||
emit('shift', -amount);
|
||||
@@ -515,7 +519,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
<summary>미루기</summary>
|
||||
<div class="menu-items">
|
||||
<button
|
||||
v-for="amount in props.maxPushTurn"
|
||||
v-for="amount in props.maxShiftTurn"
|
||||
:key="amount"
|
||||
@click="
|
||||
emit('shift', amount);
|
||||
@@ -697,12 +701,38 @@ const clickOutsideMenu = (event: Event) => {
|
||||
</div>
|
||||
|
||||
<div v-if="!props.compact" class="bottom-actions">
|
||||
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
|
||||
당기기
|
||||
</button>
|
||||
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)">
|
||||
미루기
|
||||
</button>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</details>
|
||||
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
|
||||
{{ expanded ? '접기' : '펼치기' }}
|
||||
</button>
|
||||
@@ -766,11 +796,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
@submit="submitCommand"
|
||||
/>
|
||||
<CommandArgumentForm
|
||||
v-else-if="
|
||||
commandInputSnapshot &&
|
||||
(selectedCommand.reqArg ||
|
||||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
|
||||
"
|
||||
v-else-if="commandInputSnapshot && selectedCommand.reqArg"
|
||||
:command-key="selectedCommand.key"
|
||||
:fields="selectedCommand.inputFields"
|
||||
:options="commandInputSnapshot.options"
|
||||
@@ -833,7 +859,8 @@ const clickOutsideMenu = (event: Event) => {
|
||||
}
|
||||
.control-pad > button,
|
||||
.clock,
|
||||
.legacy-menu > summary {
|
||||
.control-pad > .legacy-menu > summary,
|
||||
.advanced-actions .legacy-menu > summary {
|
||||
box-sizing: border-box;
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
@@ -1047,6 +1074,15 @@ const clickOutsideMenu = (event: Event) => {
|
||||
font-size: 0.72em;
|
||||
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 {
|
||||
background: #444;
|
||||
cursor: pointer;
|
||||
@@ -1062,6 +1098,9 @@ const clickOutsideMenu = (event: Event) => {
|
||||
gap: 4px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
.bottom-shift-menu > summary {
|
||||
width: 100%;
|
||||
}
|
||||
.command-picker {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandInputField } from './types';
|
||||
|
||||
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
|
||||
export type CommandArgumentMapTarget = 'city' | 'nation';
|
||||
|
||||
export type CommandArgumentPresentation = {
|
||||
lines: string[];
|
||||
@@ -9,7 +9,6 @@ export type CommandArgumentPresentation = {
|
||||
|
||||
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
|
||||
const capitalTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'capital' });
|
||||
|
||||
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
|
||||
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
|
||||
@@ -38,15 +37,6 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
||||
]),
|
||||
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
|
||||
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
|
||||
che_증축: capitalTarget([
|
||||
'현재 수도를 증축해 인구·내정·성벽의 최대치를 높입니다.',
|
||||
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
|
||||
]),
|
||||
che_감축: capitalTarget([
|
||||
'현재 수도를 감축해 인구·내정·성벽의 최대치를 낮추고 국고를 회수합니다.',
|
||||
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
|
||||
]),
|
||||
|
||||
che_선전포고: nationTarget([
|
||||
'선택한 국가에 선전포고합니다.',
|
||||
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
|
||||
@@ -104,7 +94,6 @@ export const commandArgumentPresentation = (commandKey: string): CommandArgument
|
||||
|
||||
/**
|
||||
* 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다.
|
||||
* 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다.
|
||||
*/
|
||||
export const resolveCommandArgumentMapTarget = (
|
||||
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 }));
|
||||
};
|
||||
|
||||
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 {
|
||||
readonly recent = new Map<string, CommandPatternEntry>();
|
||||
readonly templates = new Map<string, CommandPatternEntry[]>();
|
||||
@@ -102,11 +114,13 @@ export class CommandStorage {
|
||||
editMode = false;
|
||||
activeCategory = '';
|
||||
private readonly key: string;
|
||||
private readonly editModeKey: string;
|
||||
private readonly maxRecent: number;
|
||||
|
||||
constructor(key: string, maxRecent = 10) {
|
||||
constructor(key: string, options: CommandStorageOptions = {}) {
|
||||
this.key = key;
|
||||
this.maxRecent = maxRecent;
|
||||
this.editModeKey = options.editModeKey ?? key;
|
||||
this.maxRecent = options.maxRecent ?? 10;
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -126,12 +140,18 @@ export class CommandStorage {
|
||||
this.templates.set(name, entries);
|
||||
}
|
||||
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', '');
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export type CommandInputField = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: string | number | boolean;
|
||||
constValue?: string | number;
|
||||
options?: CommandOption[];
|
||||
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
||||
@@ -64,6 +65,7 @@ export type CommandAvailability = {
|
||||
key: string;
|
||||
name: string;
|
||||
turnDurationText?: string;
|
||||
costText?: string;
|
||||
reqArg: boolean;
|
||||
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||
possible: boolean;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
||||
import MapViewer from './MapViewer.vue';
|
||||
import NationColorSelect from './NationColorSelect.vue';
|
||||
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
|
||||
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||
import { sortCommandGeneralOptions } from '../command/commandGeneralOptions';
|
||||
import {
|
||||
commandArgumentFieldContract,
|
||||
shouldPreserveCommandArgumentValue,
|
||||
@@ -43,18 +45,7 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !
|
||||
const amountPreset = computed(() => props.options.amountPresets?.[props.commandKey]);
|
||||
|
||||
const sortGeneralOptions = (options: CommandOption[]): CommandOption[] => {
|
||||
const result = [...options];
|
||||
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;
|
||||
return sortCommandGeneralOptions(props.commandKey, options, values.isGold !== false);
|
||||
};
|
||||
|
||||
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||
@@ -77,6 +68,7 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||
|
||||
const defaultValue = (field: CommandInputField): unknown => {
|
||||
if (field.kind === 'hidden') return field.constValue;
|
||||
if (field.defaultValue !== undefined) return field.defaultValue;
|
||||
if (field.kind === 'boolean') return true;
|
||||
if (field.kind === 'numberTuple') {
|
||||
const value = amountPreset.value?.defaultValue ?? field.min ?? 0;
|
||||
@@ -141,6 +133,11 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
|
||||
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
|
||||
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 => {
|
||||
if (field.optionSource === 'colors' && option?.color) {
|
||||
return {
|
||||
@@ -184,10 +181,6 @@ const mapSelectedCityId = computed<number | null>(() => {
|
||||
null
|
||||
);
|
||||
}
|
||||
if (mapTarget.value === 'capital') {
|
||||
const myNation = props.mapData.myNation;
|
||||
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -208,11 +201,6 @@ const selectedMapTargetName = computed(() => {
|
||||
if (typeof nationId !== 'number') return '-';
|
||||
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 '-';
|
||||
});
|
||||
|
||||
@@ -265,14 +253,6 @@ const mapTargetSummary = computed(() => {
|
||||
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||
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 '';
|
||||
});
|
||||
|
||||
@@ -417,21 +397,17 @@ watch(
|
||||
:detail-mode="true"
|
||||
:fit-container="true"
|
||||
:show-current-city-marker="true"
|
||||
:readonly="mapTarget === 'capital'"
|
||||
@select-city="selectMapCity"
|
||||
/>
|
||||
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||
<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>
|
||||
<strong>{{ currentCityName }}</strong>
|
||||
</span>
|
||||
<span v-if="mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span class="selected-target-status">
|
||||
<span class="status-key">{{
|
||||
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
|
||||
}}</span>
|
||||
<span class="status-key">{{ mapTarget === 'nation' ? '선택 국가' : '선택 도시' }}</span>
|
||||
<strong>{{ selectedMapTargetName }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
@@ -478,6 +454,13 @@ watch(
|
||||
</option>
|
||||
</select>
|
||||
</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
|
||||
v-else-if="field.kind === 'select'"
|
||||
:id="`command-arg-${field.key}`"
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { addMinutes } from 'date-fns';
|
||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||
import { generalTurnEditorModeStorageKey } from '../command/commandQueue';
|
||||
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection';
|
||||
import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig';
|
||||
import type {
|
||||
CommandMapData,
|
||||
CommandMapLayout,
|
||||
@@ -12,6 +14,8 @@ import type {
|
||||
ReservedCommandRow,
|
||||
} from '../command/types';
|
||||
|
||||
type ReservationCompletion = (success: boolean) => void;
|
||||
|
||||
const props = defineProps<{
|
||||
commandTable: CommandTable | null;
|
||||
loading: boolean;
|
||||
@@ -29,14 +33,24 @@ const props = defineProps<{
|
||||
storageKey?: string;
|
||||
mapData?: CommandMapData | null;
|
||||
mapLayout?: CommandMapLayout | null;
|
||||
mobile?: boolean;
|
||||
}>();
|
||||
|
||||
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: '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 result = new Map<string, string>([['휴식', '휴식']]);
|
||||
for (const group of props.commandTable?.general ?? []) {
|
||||
@@ -139,11 +153,14 @@ onUnmounted(() => {
|
||||
:command-table="props.commandTable"
|
||||
:loading="props.loading"
|
||||
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
||||
:edit-mode-storage-key="editModeStorageKey"
|
||||
:current-time="currentServerTime"
|
||||
:map-data="props.mapData"
|
||||
:map-layout="props.mapLayout"
|
||||
: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)"
|
||||
@repeat="emit('repeat-general-turns', $event)"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ type CommandAvailability = {
|
||||
key: string;
|
||||
name: string;
|
||||
turnDurationText?: string;
|
||||
costText?: string;
|
||||
reqArg: boolean;
|
||||
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||
possible: boolean;
|
||||
@@ -165,6 +166,7 @@ const commandTitle = (command: CommandAvailability) =>
|
||||
<small v-if="command.turnDurationText" class="command-duration">
|
||||
/{{ command.turnDurationText }}
|
||||
</small>
|
||||
<small v-if="command.costText" class="command-cost">{{ command.costText }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,6 +257,15 @@ const commandTitle = (command: CommandAvailability) =>
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-cost {
|
||||
display: block;
|
||||
color: #f3d58b;
|
||||
font-size: 0.875em;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.empty {
|
||||
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 { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
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 { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
|
||||
|
||||
@@ -164,7 +164,7 @@ const experiencePercent = computed(() =>
|
||||
|
||||
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 crewTypeId = props.general?.crewTypeId;
|
||||
@@ -251,11 +251,11 @@ const specialText = computed(() => {
|
||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||
<template v-else>
|
||||
<div class="general-basic-grid general-body">
|
||||
<span
|
||||
<img
|
||||
class="general-image general-icon"
|
||||
role="img"
|
||||
:aria-label="`${props.general.name} 초상`"
|
||||
:style="{ backgroundImage: generalIconBackground }"
|
||||
:src="generalIconUrl"
|
||||
:alt="`${props.general.name} 초상`"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
<div class="general-title battle-general-name" :style="titleStyle">
|
||||
{{ props.general.name }} 【
|
||||
@@ -272,11 +272,19 @@ const specialText = computed(() => {
|
||||
<strong class="stat-value" :style="{ color: injuryInfo.color }">
|
||||
<span>{{ stat.value }}</span>
|
||||
<span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span>
|
||||
<span class="bar-cell" :data-stat-progress="stat.key">
|
||||
<LegacyProgressBar
|
||||
:percent="stat.percent"
|
||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
||||
/>
|
||||
<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
|
||||
:percent="stat.percent"
|
||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
||||
/>
|
||||
</span>
|
||||
</RichTooltip>
|
||||
</span>
|
||||
</strong>
|
||||
</template>
|
||||
@@ -505,6 +513,7 @@ const specialText = computed(() => {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
@@ -538,6 +547,12 @@ const specialText = computed(() => {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.bar-cell :deep(.rich-tooltip-trigger),
|
||||
.bar-cell [data-stat-progress] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar-cell,
|
||||
.experience-bar {
|
||||
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%);
|
||||
}
|
||||
|
||||
.main-global-menu[data-menu-position='top'] {
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.main-global-menu[data-menu-position='bottom'] .menu-caret {
|
||||
border-top-width: 0;
|
||||
border-bottom: 4px solid currentColor;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||
import GatewayLobbyLink from './GatewayLobbyLink.vue';
|
||||
import MainNavigationLink from './MainNavigationLink.vue';
|
||||
import {
|
||||
buildGlobalNavigation,
|
||||
@@ -29,7 +30,6 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
toggleRealtime: [];
|
||||
lobby: [];
|
||||
quick: [item: QuickNavigationItem];
|
||||
action: [action: NonNullable<MainNavigationLinkItem['action']>];
|
||||
}>();
|
||||
@@ -212,14 +212,12 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
|
||||
</li>
|
||||
</template>
|
||||
<li class="bottom-lobby" role="none">
|
||||
<button
|
||||
<GatewayLobbyLink
|
||||
class="quick-link lobby-link legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="emit('lobby')"
|
||||
>
|
||||
로비로
|
||||
</button>
|
||||
</GatewayLobbyLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import GatewayLobbyLink from './GatewayLobbyLink.vue';
|
||||
|
||||
defineProps<{
|
||||
realtimeEnabled: boolean;
|
||||
refreshing: boolean;
|
||||
@@ -7,7 +9,6 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
toggleRealtime: [];
|
||||
lobby: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -34,13 +35,11 @@ const emit = defineEmits<{
|
||||
<strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
<GatewayLobbyLink
|
||||
class="main-turn-controls__lobby legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
@click="emit('lobby')"
|
||||
>
|
||||
로비로
|
||||
</button>
|
||||
</GatewayLobbyLink>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -78,7 +78,12 @@ const touchend = (event: TouchEvent) => {
|
||||
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)"
|
||||
@mouseleave="emit('leave')"
|
||||
@touchstart="touchstart"
|
||||
@@ -107,10 +112,7 @@ const touchend = (event: TouchEvent) => {
|
||||
<style scoped>
|
||||
.map-city {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
box-sizing: border-box;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
@@ -126,7 +128,8 @@ const touchend = (event: TouchEvent) => {
|
||||
}
|
||||
|
||||
.city-dot {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid rgba(232, 221, 196, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -179,6 +182,7 @@ const touchend = (event: TouchEvent) => {
|
||||
.city-state {
|
||||
position: absolute;
|
||||
background: rgba(232, 221, 196, 0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.city-state.state-war {
|
||||
@@ -194,6 +198,11 @@ const touchend = (event: TouchEvent) => {
|
||||
}
|
||||
|
||||
.city-name {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: 50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,7 @@ const DETAIL_SIZES: DetailSize[] = [
|
||||
{ bgWidth: 84, bgHeight: 60, iconWidth: 28, iconHeight: 20, flagRight: -6, flagTop: -4 },
|
||||
{ bgWidth: 96, bgHeight: 72, iconWidth: 32, iconHeight: 24, flagRight: -6, flagTop: -3 },
|
||||
];
|
||||
const CR_GRID_TILE_SIZE = 40.5;
|
||||
|
||||
const props = defineProps<{
|
||||
city: MapCityView;
|
||||
@@ -75,16 +76,20 @@ const detailSize = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const baseSize = computed(() => ({
|
||||
width: 40 * props.mapScale,
|
||||
height: 30 * props.mapScale,
|
||||
}));
|
||||
const cityBackgroundSize = computed(() => {
|
||||
if (props.themeName === 'cr') {
|
||||
const tileSize = CR_GRID_TILE_SIZE * props.mapScale;
|
||||
return { bgWidth: tileSize, bgHeight: tileSize };
|
||||
}
|
||||
|
||||
return detailSize.value;
|
||||
});
|
||||
|
||||
const cityBaseStyle = computed(() => ({
|
||||
left: `${props.city.x}px`,
|
||||
top: `${props.city.y}px`,
|
||||
width: `${baseSize.value.width}px`,
|
||||
height: `${baseSize.value.height}px`,
|
||||
width: `${detailSize.value.iconWidth}px`,
|
||||
height: `${detailSize.value.iconHeight}px`,
|
||||
}));
|
||||
|
||||
const cityBgStyle = computed(() => {
|
||||
@@ -92,8 +97,8 @@ const cityBgStyle = computed(() => {
|
||||
return null;
|
||||
}
|
||||
const style: Record<string, string> = {
|
||||
width: `${detailSize.value.bgWidth}px`,
|
||||
height: `${detailSize.value.bgHeight}px`,
|
||||
width: `${cityBackgroundSize.value.bgWidth}px`,
|
||||
height: `${cityBackgroundSize.value.bgHeight}px`,
|
||||
};
|
||||
|
||||
if (props.themeName === 'cr') {
|
||||
@@ -125,8 +130,8 @@ const capitalIcon = computed(() => buildAssetUrl(props.imageBaseUrl, 'event51.gi
|
||||
const cityBgWrapperStyle = computed(() => ({
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
marginLeft: `${-detailSize.value.bgWidth / 2}px`,
|
||||
marginTop: `${-detailSize.value.bgHeight / 2}px`,
|
||||
marginLeft: `${-cityBackgroundSize.value.bgWidth / 2}px`,
|
||||
marginTop: `${-cityBackgroundSize.value.bgHeight / 2}px`,
|
||||
}));
|
||||
|
||||
const cityIconStyle = computed(() => ({
|
||||
@@ -241,12 +246,14 @@ const cityStateStyle = computed(() => ({
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.city-img {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@@ -307,5 +314,6 @@ const cityStateStyle = computed(() => ({
|
||||
.city-state {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,6 +75,7 @@ const props = withDefaults(
|
||||
fitContainer?: boolean;
|
||||
showCurrentCityMarker?: boolean;
|
||||
showSelectionBorder?: boolean;
|
||||
showTitleTooltip?: boolean;
|
||||
readonly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
@@ -82,6 +83,7 @@ const props = withDefaults(
|
||||
detailMode: undefined,
|
||||
selectedCityId: undefined,
|
||||
showSelectionBorder: true,
|
||||
showTitleTooltip: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -152,6 +154,7 @@ const mapControls = ref<HTMLElement | null>(null);
|
||||
const tooltipElement = ref<HTMLElement | null>(null);
|
||||
const mapOptionsOpen = ref(false);
|
||||
const mapOptionsMenuId = `map-options-${useId()}`;
|
||||
const mapTitleTooltipId = `map-title-tooltip-${useId()}`;
|
||||
const { width: mapBodyWidth } = useElementSize(mapBody);
|
||||
const { elementX, elementY } = useMouseInElement(mapArea);
|
||||
|
||||
@@ -630,9 +633,14 @@ const selectCity = (cityId: number) => {
|
||||
<template>
|
||||
<div class="map-viewer">
|
||||
<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 }}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -467,5 +467,9 @@ const forwardResponse = (messageId: number, response: boolean) => {
|
||||
z-index: 4;
|
||||
top: 62px;
|
||||
}
|
||||
|
||||
.Actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
import { computed, ref } from 'vue';
|
||||
import GeneralIdentity from '../ui/GeneralIdentity.vue';
|
||||
import RichTooltip from '../ui/RichTooltip.vue';
|
||||
import {
|
||||
buildTournamentBracket,
|
||||
resolveTournamentCoreStat,
|
||||
@@ -9,17 +10,28 @@ import {
|
||||
type TournamentBracketSlot,
|
||||
} from '../../utils/tournamentBracket';
|
||||
|
||||
const props = defineProps<{
|
||||
participants: TournamentBracketParticipant[];
|
||||
matches: TournamentBracketMatch[];
|
||||
winnerId?: number;
|
||||
betTotals?: Record<number, number>;
|
||||
myBetTotals?: Record<number, number>;
|
||||
totalBet: number;
|
||||
tournamentType?: number;
|
||||
showLegend?: boolean;
|
||||
bettingOpen?: boolean;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
participants: TournamentBracketParticipant[];
|
||||
matches: TournamentBracketMatch[];
|
||||
winnerId?: number;
|
||||
betTotals?: Record<number, number>;
|
||||
myBetTotals?: Record<number, number>;
|
||||
totalBet?: number;
|
||||
tournamentType?: number;
|
||||
showLegend?: boolean;
|
||||
bettingOpen?: boolean;
|
||||
bettingMode?: boolean;
|
||||
}>(),
|
||||
{
|
||||
winnerId: undefined,
|
||||
betTotals: undefined,
|
||||
myBetTotals: undefined,
|
||||
totalBet: 0,
|
||||
tournamentType: undefined,
|
||||
bettingMode: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
requestBet: [slot: TournamentBracketSlot];
|
||||
@@ -37,10 +49,10 @@ const roundColumns = computed(() => [
|
||||
]);
|
||||
const desktopX = [110, 355, 600, 845, 1090];
|
||||
const cardWidth = 190;
|
||||
const desktopSlotHeight = 88;
|
||||
const desktopCanvasHeight = desktopSlotHeight * 16;
|
||||
const desktopSlotHeight = computed(() => (props.bettingMode ? 100 : 88));
|
||||
const desktopCanvasHeight = computed(() => desktopSlotHeight.value * 16);
|
||||
const slotY = (columnIndex: number, slotIndex: number) => {
|
||||
const slotHeight = desktopSlotHeight * 2 ** columnIndex;
|
||||
const slotHeight = desktopSlotHeight.value * 2 ** columnIndex;
|
||||
return slotHeight / 2 + slotIndex * slotHeight;
|
||||
};
|
||||
const connections = computed(() =>
|
||||
@@ -76,6 +88,18 @@ const odds = (id: number | null) => {
|
||||
return (props.totalBet / amount).toFixed(2);
|
||||
};
|
||||
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]) =>
|
||||
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
|
||||
const requestBet = (slot: TournamentBracketSlot) => {
|
||||
@@ -127,6 +151,7 @@ const mobilePairs = computed(() => {
|
||||
:class="{
|
||||
advanced: slot.advanced,
|
||||
'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null,
|
||||
'betting-candidate': columnIndex === 0 && bettingMode,
|
||||
}"
|
||||
:data-general-id="slot.id ?? undefined"
|
||||
:style="{
|
||||
@@ -134,7 +159,24 @@ const mobilePairs = computed(() => {
|
||||
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
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
:npc-state="slot.npcState"
|
||||
/>
|
||||
</template>
|
||||
</RichTooltip>
|
||||
</span>
|
||||
<GeneralIdentity
|
||||
v-else
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
@@ -149,12 +191,24 @@ const mobilePairs = computed(() => {
|
||||
>
|
||||
베팅하기
|
||||
</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">
|
||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||
</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>
|
||||
</article>
|
||||
</template>
|
||||
@@ -184,10 +238,32 @@ const mobilePairs = computed(() => {
|
||||
:class="{
|
||||
advanced: slot.advanced,
|
||||
'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null,
|
||||
'betting-candidate': activeMobileRound === 0 && bettingMode,
|
||||
}"
|
||||
: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
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
:npc-state="slot.npcState"
|
||||
/>
|
||||
</template>
|
||||
</RichTooltip>
|
||||
</span>
|
||||
<GeneralIdentity
|
||||
v-else
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
@@ -202,12 +278,24 @@ const mobilePairs = computed(() => {
|
||||
>
|
||||
베팅하기
|
||||
</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">
|
||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||
</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>
|
||||
<strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong>
|
||||
@@ -272,6 +360,13 @@ const mobilePairs = computed(() => {
|
||||
color: #fff;
|
||||
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 {
|
||||
position: absolute;
|
||||
}
|
||||
@@ -312,12 +407,30 @@ const mobilePairs = computed(() => {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.bracket-bet-summary {
|
||||
.bracket-candidate-identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding-right: 62px;
|
||||
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;
|
||||
}
|
||||
.bracket-core-stat,
|
||||
@@ -331,11 +444,20 @@ const mobilePairs = computed(() => {
|
||||
}
|
||||
.bracket-core-stat {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
color: orange;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mobile-bracket {
|
||||
display: none;
|
||||
@@ -382,6 +504,10 @@ const mobilePairs = computed(() => {
|
||||
background: rgb(58 33 24 / 94%);
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.mobile-bracket-name.betting-candidate {
|
||||
min-height: 92px;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
.versus {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
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 acknowledgeButton = ref<HTMLButtonElement | null>(null);
|
||||
const cancelButton = ref<HTMLButtonElement | null>(null);
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
let previousBodyOverflow = '';
|
||||
|
||||
@@ -37,7 +38,7 @@ watch(
|
||||
}
|
||||
if (next) {
|
||||
await nextTick();
|
||||
acknowledgeButton.value?.focus();
|
||||
(next.cancelLabel ? cancelButton.value : acknowledgeButton.value)?.focus();
|
||||
return;
|
||||
}
|
||||
if (previous) restorePage();
|
||||
@@ -48,7 +49,8 @@ watch(
|
||||
const handleDialogKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
acknowledgeDialog();
|
||||
if (dialog.value?.cancelLabel) cancelDialog();
|
||||
else acknowledgeDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !dialogPanel.value) return;
|
||||
@@ -119,6 +121,15 @@ onBeforeUnmount(() => {
|
||||
</header>
|
||||
<p id="game-dialog-message">{{ dialog.message }}</p>
|
||||
<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">
|
||||
{{ dialog.acknowledgeLabel }}
|
||||
</button>
|
||||
@@ -277,6 +288,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.game-dialog-panel footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -296,6 +308,12 @@ onBeforeUnmount(() => {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.game-dialog-panel footer .game-dialog-cancel {
|
||||
color: #ddd;
|
||||
background: #292929;
|
||||
border-color: #626262;
|
||||
}
|
||||
|
||||
.game-toast-enter-active,
|
||||
.game-toast-leave-active,
|
||||
.game-toast-move,
|
||||
|
||||
@@ -14,6 +14,7 @@ export type GameNoticeDialog = {
|
||||
title: string;
|
||||
message: string;
|
||||
acknowledgeLabel: string;
|
||||
cancelLabel: string | null;
|
||||
};
|
||||
|
||||
export type GameNoticeDialogOptions = {
|
||||
@@ -23,9 +24,13 @@ export type GameNoticeDialogOptions = {
|
||||
acknowledgeLabel?: string;
|
||||
};
|
||||
|
||||
export type GameConfirmDialogOptions = GameNoticeDialogOptions & {
|
||||
cancelLabel?: string;
|
||||
};
|
||||
|
||||
type QueuedDialog = {
|
||||
dialog: GameNoticeDialog;
|
||||
resolve: () => void;
|
||||
resolve: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
const titleFor = (kind: GameFeedbackKind): string => {
|
||||
@@ -39,7 +44,7 @@ export const createGameFeedbackStore = () => {
|
||||
const activeDialog = ref<GameNoticeDialog | null>(null);
|
||||
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
const dialogQueue: QueuedDialog[] = [];
|
||||
let activeDialogResolve: (() => void) | null = null;
|
||||
let activeDialogResolve: ((confirmed: boolean) => void) | null = null;
|
||||
let nextId = 1;
|
||||
|
||||
const dismissToast = (id: number): void => {
|
||||
@@ -61,7 +66,10 @@ export const createGameFeedbackStore = () => {
|
||||
const id = nextId++;
|
||||
visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }];
|
||||
if (durationMs > 0) {
|
||||
dismissTimers.set(id, setTimeout(() => dismissToast(id), durationMs));
|
||||
dismissTimers.set(
|
||||
id,
|
||||
setTimeout(() => dismissToast(id), durationMs)
|
||||
);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
@@ -89,6 +97,28 @@ export const createGameFeedbackStore = () => {
|
||||
title: options.title?.trim() || titleFor(kind),
|
||||
message,
|
||||
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,
|
||||
});
|
||||
@@ -96,14 +126,17 @@ export const createGameFeedbackStore = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const acknowledgeDialog = (): void => {
|
||||
const resolveDialog = (confirmed: boolean): void => {
|
||||
const resolve = activeDialogResolve;
|
||||
activeDialog.value = null;
|
||||
activeDialogResolve = null;
|
||||
resolve?.();
|
||||
resolve?.(confirmed);
|
||||
activateNextDialog();
|
||||
};
|
||||
|
||||
const acknowledgeDialog = (): void => resolveDialog(true);
|
||||
const cancelDialog = (): void => resolveDialog(false);
|
||||
|
||||
return {
|
||||
toasts: readonly(visibleToasts),
|
||||
dialog: readonly(activeDialog),
|
||||
@@ -112,7 +145,9 @@ export const createGameFeedbackStore = () => {
|
||||
error: (message: string, durationMs?: number) => showToast(message, 'error', durationMs),
|
||||
info: (message: string, durationMs?: number) => showToast(message, 'info', durationMs),
|
||||
showDialog,
|
||||
confirm,
|
||||
acknowledgeDialog,
|
||||
cancelDialog,
|
||||
dismissToast,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1041,9 +1041,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
|
||||
const setGeneralTurns = async (
|
||||
entries: Array<{ turnList: number[]; action: string; args: Record<string, unknown> }>
|
||||
) => {
|
||||
): Promise<boolean> => {
|
||||
const id = generalId.value;
|
||||
if (!id || !entries.length) return;
|
||||
if (!id || !entries.length) return false;
|
||||
try {
|
||||
const result = await trpc.turns.reserved.setGeneralBulk.mutate({
|
||||
generalId: id,
|
||||
@@ -1053,6 +1053,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
reservedGeneralTurns.value = result.turns;
|
||||
reservedGeneralRevision.value = result.revision;
|
||||
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
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;
|
||||
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