Author SHA1 Message Date
Hide_D 5771701007 이미지 업로드와 WebPush 응답의 자원 경계를 제한한다 2026-10-09 07:09:23 +09:00
Hide_D c5716cf144 SSE 연결과 미완료 준비 작업의 자원 상한을 보강한다 2026-10-09 06:30:04 +09:00
Hide_D 005f88d7ca 게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다 2026-10-09 05:59:28 +09:00
Hide_D eeda225429 게임 세션의 원자적 발급과 추적 상한 및 회수를 보강한다 2026-10-09 05:10:05 +09:00
Hide_D 6755f09ee7 원격 릴리스 빌드에 토큰 파일 인증과 응답 제한을 적용한다 2026-10-09 04:31:44 +09:00
Hide_D 3d9f7392df 온라인 인증 시도와 처리 비용 및 Kakao 응답 경계를 제한한다 2026-10-09 03:43:59 +09:00
Hide_D 9dceaf8e4e SSE 헤더 인증과 연결 수명 검증 및 HTTP 오류 정보 보호 2026-10-09 02:35:45 +09:00
Hide_D 87f810a878 취약 의존성을 갱신하고 PM2 파일 감시를 비활성화한다 2026-10-09 01:35:28 +09:00
Hide_D e378936051 OAuth state 중복 소비와 Web Push 내부망 요청을 차단한다 2026-10-09 00:59:06 +09:00
Hide_D df6c9269fa 밸런스 실험에 모집 경제와 조건부 효과 대조를 보강한다 2026-09-29 11:26:00 +00:00
Hide_D 4b00c1052d 내부 밸런스 비교 CLI와 조건별 대조 실험을 추가한다 2026-09-29 10:55:43 +00:00
Hide_D b060bc83bf 도시 관직 요청 화면의 공통 글자 크기 통합 검증을 추가한다 2026-09-29 09:52:11 +00:00
Hide_D 7c65cf60f3 프로필 UI 글자 크기를 네 단계와 공통 축소 규칙으로 통일한다 2026-09-29 09:41:39 +00:00
Hide_D 5bafdfc1e7 인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가 2026-09-29 09:41:01 +00:00
Hide_D cd6ded4970 중복 문서를 통합하고 완료된 구현 계획을 정리한다 2026-09-29 09:08:28 +00:00
Hide_D 08c3d0e936 독자 수준별 구조 문서와 게이머 입문·전술 안내를 재정비한다 2026-09-29 08:53:30 +00:00
Hide_D 71155186b7 짧은 UI 텍스트의 세로 정렬과 두 축 정렬 원칙을 적용한다 2026-09-29 08:51:05 +00:00
Hide_D 295ecb39a3 fix: 전용 장수 아이콘 뒤 기본 이미지 중첩 제거 2026-09-29 08:43:13 +00:00
Hide_D aea5056834 NPC 감사에서 최근 기록 월을 기본 조회하고 실제 시나리오로 검증한다 2026-09-26 17:37:32 +00:00
Hide_D 6b65633324 감사 수집과 저장 장애를 게임 진행에서 분리한다 2026-09-26 16:26:02 +00:00
Hide_D daf7a87799 감사 정책 head 유실을 복구하고 데몬 준비 상태로 배포를 검증한다 2026-09-26 15:21:17 +00:00
Hide_D 11d0a4afdd fix: 턴 상세 입력 확인 영역을 콘텐츠 높이에 맞춰 배치 2026-09-26 14:30:27 +00:00
Hide_D 51d0317a69 플레이 감사에 장수 수치 정렬과 시점별 도시 지도를 추가한다 2026-09-26 04:52:18 +00:00
Hide_D e35679335c 플레이 감사를 3단 대시보드와 국가 추이 그래프로 개선한다 2026-09-26 04:36:31 +00:00
Hide_D 2b2edeaeff 왕조 연감에 해당 기수의 지도 레이아웃을 적용한다 2026-09-24 16:03:51 +00:00
Hide_D 8823434610 계정 아이콘 등록과 목록 내리기 제한을 조정한다 2026-09-24 15:46:11 +00:00
Hide_D 4bf812b601 예약 초기화에서 구버전 릴리스 빌드 호환성을 확인한다 2026-09-24 05:47:46 +00:00
Hide_DandClaude Opus 5.5 3c75de16e9 사령부 명령 목록을 자기 수뇌 열과 클릭한 턴에 맞춘다
1000px 사령부 desktop에서 compact 명령 목록이 `position: fixed`로 첫째 줄 첫째 열에
고정되어, 다른 수뇌 자리에서 입력해도 군주 열 위에 떠 있던 문제를 고친다.
Ref ChiefReservedCommand처럼 목록을 자기 수뇌 열 안에 두고, 턴을 누르면 그 행
바로 아래에, 고급 모드의 턴 미지정 목록은 턴 표 하단 기준으로 위로 펼친다.
수뇌 12·6·9·5 위치별 Chromium geometry 회귀 테스트를 추가한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:49:45 +00:00
Hide_D 08be92b6fa 조건부 통합 테스트 등록과 시계 fixture를 바로잡는다 2026-09-23 16:21:52 +00:00
Hide_DandClaude Opus 5.5 7e1783d651 장비매매 불가 표시·숙련전환 전후 미리보기·건국 성향표를 Ref처럼 복원
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:45:25 +00:00
Hide_DandClaude Opus 5.5 f74c3322b7 국가 대상 명령 입력에 Ref 권유문·원조 한도·피장파장 전략 대기 복원
- 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과
  임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다.
- 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다.
- 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인
  전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다.
- 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:24:31 +00:00
Hide_DandClaude Opus 5.5 4a50c354fe 등용·장수대상임관 대상의 타국 장수 정보를 Ref 공개 범위로 제한하고 국가색 묶음 복원
- 명령표의 등용·장수대상임관·공통 장수 fallback에서 타국·재야 장수의 금·쌀·병력·훈련·사기·부대·위치 도시를 제외
- Ref exportJSVars 범위(이름·국가·NPC·통/무/지)만 싣고 국가 묶음용 nationId 추가
- 재야 actor에게는 같은 국가 명령 후보를 보내지 않음
- 등용·장수대상임관 후보를 Ref SelectGeneral groupByNation처럼 국가색 머리와 optgroup으로 묶음
- 증여·포상·몰수·부대탈퇴지시·선양 후보에 Ref의 통/무/지 표시

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:55:39 +00:00
Hide_DandClaude Opus 5.5 0a9c47eddf 도시 대상 명령 입력에 Ref 거리별 도시 목록 복원
Ref ProcessCity의 `N칸 떨어진 도시:` 목록(CitiesBasedOnDistance)을 전체화면 인자 입력에
되살린다. 이동·출병 1칸, 강행·첩보·화계·탈취·파괴·선동 3칸을 map layout path의 BFS로
Ref searchDistance와 같은 순서로 나열하고, 거리별 magenta/orange/yellow 밑줄 도시를 누르면
대상 도시를 고른다. che 지도 94개 도시 결과를 Ref PHP 원문 실행 fixture와 비교하는 unit test와
1200/390px e2e를 추가한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:35:40 +00:00
Hide_DandClaude Opus 5.5 b90dd4499d 전체화면 명령 입력의 제출 버튼을 primary로 구분
하단 `입력`이 막대 위 글자로만 보여 제출인지 알기 어려웠고 녹색 상단 버튼이 제출처럼
보였다. Ref처럼 녹색은 이동(명령 취소)에만 두고, 제출은 명령명을 붙인 primary 버튼
(`화계 입력`)으로, `명령 다시 선택`은 secondary로 낮춘다. e2e locator와 버튼 색·
geometry assertion을 함께 갱신한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:04:49 +00:00
Hide_DandClaude Opus 5.5 a373ed327d 예약 명령 인자 입력을 전체화면 overlay로 전환
Ref v_processing처럼 인자가 필요한 장수·사령턴 명령은 작은 popup 대신
body 위 전체화면 dialog에서 입력한다. 명령 목록 단계는 기존 popup을 유지한다.

- 상단 막대: 명령 취소 · 명령 다시 선택 · 명령명과 대상 턴 · 검색 켜짐/꺼짐
- 검색 토글 상태를 useCommandTargetSearchEnabled()로 폼과 공유
- URL은 유지하고 같은 URL의 history entry로 Back이 overlay만 닫게 함
- 1120px 이상에서는 원본 700px 지도와 대상 목록을 두 열로 배치
- 징병·모병 sticky 기준을 공용 header 높이 변수로 연결
- e2e를 전체화면 계약에 맞게 갱신하고 overlay·Back·geometry 테스트 추가

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:39:02 +00:00
Hide_D 9d0a1a4add add symlink CLAUDE.md 2026-09-23 12:40:58 +00:00
313 changed files with 19733 additions and 4407 deletions
+8
View File
@@ -38,6 +38,10 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000 ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
WEB_PUSH_OUTBOX_POLL_MS=1000 WEB_PUSH_OUTBOX_POLL_MS=1000
GATEWAY_PUBLIC_URL=http://localhost:13000 GATEWAY_PUBLIC_URL=http://localhost:13000
# Gateway authentication protection shares atomic 60s counters under this prefix.
# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally;
# expensive auth work is capped at 4/process. Forwarded headers are not trusted.
# These are security defaults in auth/attemptBudget.ts, not disable-able env toggles.
GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_REDIS_PREFIX=sammo:gateway
GATEWAY_DB_SCHEMA=public GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026 GATEWAY_WORKSPACE_ROOT=/path/to/core2026
@@ -103,3 +107,7 @@ VITE_CASUAL_CHAT_URL=
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다. # 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다. # Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
TURN_BUILD_COMMIT_SHA= TURN_BUILD_COMMIT_SHA=
# Set both only when builds use the dedicated remote builder; never expose token to VITE_*.
RELEASE_BUILDER_URL=
RELEASE_BUILDER_TOKEN_FILE=
+4
View File
@@ -126,6 +126,10 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m
분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와 분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와
검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로 검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로
`build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요. `build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요.
- 짧은 제목·상태·버튼·정보 셀은 가로와 세로 정렬을 각각 결정합니다. 가로 가운데만
지정하고 끝내지 말고, 왼쪽/오른쪽 정렬 값도 세로 가운데가 적절한지 확인합니다.
여러 줄 본문·로그는 읽기 시작점을 보존하고, 좁은 화면의 줄바꿈과 overflow를
검증합니다. 구현 방법과 예외는 [두 축 텍스트 정렬](docs/frontend-css-architecture.md#text-alignment-on-both-axes-2026-09-29)을 따릅니다.
- 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`, - 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`,
`.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요. `.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요.
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을 - `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+7
View File
@@ -5,6 +5,13 @@
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다. 비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다. 작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
## 독자별 시작점
- 게임이 처음인 플레이어: [삼국지 모의전투 첫 안내](docs/user/index.md)
- 프로그래밍 기초만 아는 독자: [화면·서버·저장부터 배우기](docs/developer/first-steps.md)
- 개발 경험이 있는 독자: [요청·실행·운영의 구조 읽기](docs/developer/system-walkthrough.md)
- 전체 문서 탐색: [핸드북](docs/index.md), [게시판 용어 사전](docs/user/glossary.md)
## 저장소 구성 ## 저장소 구성
| 경로 | 책임 | | 경로 | 책임 |
+3 -3
View File
@@ -40,10 +40,10 @@
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"fastify": "^5.6.2", "fastify": "^5.12.5",
"redis": "^5.10.0", "redis": "^5.10.0",
"sanitize-html": "2.17.6", "sanitize-html": "2.17.7",
"sharp": "^0.35.0", "sharp": "^0.35.5",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+65 -1
View File
@@ -1,3 +1,10 @@
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
parseSessionRevocationWatermark,
SessionRevocationCommandBudget,
} from '@sammo-ts/common/auth/sessionRevocation';
export interface GatewayUserFlushEvent { export interface GatewayUserFlushEvent {
userId: string; userId: string;
flushedAt: string; flushedAt: string;
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
} }
export interface FlushStore { export interface FlushStore {
getFlushedAt(userId: string): Date | null; getFlushedAt(userId: string): Date | null | Promise<Date | null>;
applyFlush(event: GatewayUserFlushEvent): void; applyFlush(event: GatewayUserFlushEvent): void;
} }
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
} }
} }
interface RevocationRedisClient {
readonly isReady?: boolean;
mGet(keys: string[]): Promise<(string | null)[]>;
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
}
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
export class RedisFlushStore implements FlushStore {
private readonly local = new Map<string, Date>();
private readonly commands = new SessionRevocationCommandBudget(64);
constructor(
private readonly client: RevocationRedisClient,
private readonly channel: string
) {}
private commandClient(): RevocationRedisClient {
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
}
async assertInitialized(): Promise<void> {
const values = await this.commands.run(() =>
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
);
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
throw new Error('Gateway session revocation must be initialized before game API startup.');
}
async getFlushedAt(userId: string): Promise<Date | null> {
const values = await this.commands.run(() =>
this.commandClient().mGet([
buildSessionRevocationBaselineKey(this.channel),
buildUserSessionRevocationKey(this.channel, userId),
])
);
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
const baseline = parseSessionRevocationWatermark(values[0]!);
if (!baseline) throw new Error('Session revocation storage is unavailable.');
const persisted = parseSessionRevocationWatermark(values[1]!);
return new Date(
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
);
}
applyFlush(event: GatewayUserFlushEvent): void {
const parsed = new Date(event.flushedAt);
if (Number.isNaN(parsed.getTime())) return;
const prior = this.local.get(event.userId);
if (!prior || parsed > prior) {
this.local.delete(event.userId);
this.local.set(event.userId, parsed);
}
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
}
}
export class RedisGatewayFlushSubscriber { export class RedisGatewayFlushSubscriber {
private readonly client: { private readonly client: {
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>; subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
+9 -2
View File
@@ -66,8 +66,10 @@ const resolveMapName = async (
} }
}; };
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => { export const loadMapLayoutByName = async (
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById); mapName: string,
options: MapLayoutLoaderOptions = {}
): Promise<MapLayout> => {
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap; const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
const cached = useCache ? layoutCache.get(mapName) : undefined; const cached = useCache ? layoutCache.get(mapName) : undefined;
if (cached) { if (cached) {
@@ -98,3 +100,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp
} }
return layout; return layout;
}; };
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
return loadMapLayoutByName(mapName, options);
};
@@ -1,5 +1,10 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra'; import {
withPlayAuditSavepoint,
persistAuditDiplomacyEvents,
type AuditDiplomacyEventDraft,
type GamePrisma,
} from '@sammo-ts/infra';
import type { ApiInputExecutionContext } from '../inputEventBoundary.js'; import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
import { asRecord, JosaUtil } from '@sammo-ts/common'; import { asRecord, JosaUtil } from '@sammo-ts/common';
@@ -104,6 +109,7 @@ const persistEffects = async (
} }
): Promise<void> => { ): Promise<void> => {
const changes: AuditDiplomacyEventDraft[] = []; const changes: AuditDiplomacyEventDraft[] = [];
let collectionFailed = false;
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row])); const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({ const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
state: row.stateCode, state: row.stateCode,
@@ -129,6 +135,7 @@ const persistEffects = async (
}, },
}); });
if (audit) { if (audit) {
try {
const key = `${effect.srcNationId}:${effect.destNationId}`; const key = `${effect.srcNationId}:${effect.destNationId}`;
const previous = states.get(key); const previous = states.get(key);
if (!previous) throw new Error('Missing locked diplomacy audit state'); if (!previous) throw new Error('Missing locked diplomacy audit state');
@@ -145,6 +152,9 @@ const persistEffects = async (
}); });
} }
states.set(key, updated); states.set(key, updated);
} catch {
collectionFailed = true;
}
} }
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) { } else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
const patch = effect.patch; const patch = effect.patch;
@@ -159,7 +169,11 @@ const persistEffects = async (
} }
} }
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId); await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
await persistAuditDiplomacyEvents(db, changes); if (changes.length || collectionFailed)
await withPlayAuditSavepoint(db, async (auditDb) => {
if (collectionFailed) throw new Error('Audit relation collection failed');
await persistAuditDiplomacyEvents(auditDb, changes);
});
}; };
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => { const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
@@ -0,0 +1,90 @@
import type { ServerResponse } from 'node:http';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
const MAX_PENDING_FRAMES = 32;
const MAX_BUFFER_BYTES = 64 * 1024;
/** Revalidate before work and before writing its result; never retain unbounded frames. */
export class AuthenticatedRealtimeConnection {
private queue = Promise.resolve();
private pending = 0;
private ended = false;
private readonly expiryTimer: ReturnType<typeof setTimeout>;
private cleanup = new Set<() => void>();
constructor(
readonly auth: GameSessionTokenPayload,
private readonly response: ServerResponse,
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
) {
// AccessTokenStore requires a full remaining TTL second on every lookup.
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
this.expiryTimer.unref();
response.once('close', this.close);
response.once('error', this.close);
}
get closed(): boolean {
return this.ended;
}
whenIdle(): Promise<void> {
return this.queue;
}
onClose(cleanup: () => void): void {
if (this.ended) cleanup();
else this.cleanup.add(cleanup);
}
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
if (this.ended) return null;
const current = await this.resolveAuth();
if (
!current ||
this.ended ||
current.sessionId !== this.auth.sessionId ||
current.user.id !== this.auth.user.id ||
current.profile !== this.auth.profile
) {
this.close();
return null;
}
return current;
}
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
if (this.ended) return;
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
this.close();
return;
}
this.pending += 1;
this.queue = this.queue
.then(async () => {
const current = await this.currentAuth();
if (!current) return;
const frame = await work(current);
if (!frame || !(await this.currentAuth())) return;
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
})
.catch(() => {
// Redis/auth failure is fail closed. Reconnect must authenticate again.
this.close();
})
.finally(() => {
this.pending -= 1;
});
}
close = (): void => {
if (this.ended) return;
this.ended = true;
clearTimeout(this.expiryTimer);
this.response.off('close', this.close);
this.response.off('error', this.close);
for (const cleanup of this.cleanup) cleanup();
this.cleanup.clear();
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
};
}
@@ -0,0 +1,93 @@
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
const OPENING_DEADLINE_MS = 5000;
export interface RealtimeConnectionReservation {
canContinue(): boolean;
reserveUser(userId: string): boolean;
startStreaming(): boolean;
cancelOpening(): void;
release(): void;
}
/** One game API process owns both pending setup and live streams; no waiting queue. */
export class RealtimeConnectionAdmission {
private readonly limits: { connections: number; perUser: number; opening: number };
private total = 0;
private opening = 0;
private closed = false;
private readonly byUser = new Map<string, number>();
private readonly pending = new Set<() => void>();
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
this.limits = { ...DEFAULT_LIMITS, ...limits };
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
throw new Error('Invalid realtime connection limits.');
}
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
this.total += 1;
this.opening += 1;
let userId: string | null = null;
let streaming = false;
let cancelled = false;
let released = false;
const deadline = Date.now() + OPENING_DEADLINE_MS;
const expire = () => {
if (released || streaming || cancelled) return;
cancelled = true;
clearTimeout(timer);
onTimeout();
};
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
timer.unref();
this.pending.add(expire);
const canContinue = () => {
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
return !released && !streaming && !cancelled;
};
return {
canContinue,
reserveUser: (id) => {
if (!canContinue() || userId !== null) return false;
const count = this.byUser.get(id) ?? 0;
if (count >= this.limits.perUser) return false;
this.byUser.set(id, count + 1);
userId = id;
return true;
},
startStreaming: () => {
if (!canContinue() || userId === null) return false;
streaming = true;
this.opening -= 1;
clearTimeout(timer);
this.pending.delete(expire);
return true;
},
cancelOpening: () => {
if (released || streaming) return;
cancelled = true;
clearTimeout(timer);
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
},
release: () => {
if (released) return;
released = true;
this.total -= 1;
if (!streaming) this.opening -= 1;
clearTimeout(timer);
this.pending.delete(expire);
if (userId !== null) {
const count = (this.byUser.get(userId) ?? 1) - 1;
if (count === 0) this.byUser.delete(userId);
else this.byUser.set(userId, count);
}
},
};
}
close(): void {
this.closed = true;
for (const expire of this.pending) expire();
}
}
+9 -1
View File
@@ -66,7 +66,15 @@ export const authRouter = router({
message: 'Game access is restricted for this account.', message: 'Game access is restricted for this account.',
}); });
} }
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id); let flushedAt: Date | null;
try {
flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id);
} catch {
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Authentication protection is unavailable.',
});
}
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) { if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
+75 -10
View File
@@ -1,3 +1,9 @@
import {
ImageWorkBudget,
ImageWorkBusyError,
ImageUploadError,
isRasterImage,
} from '@sammo-ts/common/images/imageUpload';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
const boardImageBudget = new ImageWorkBudget();
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 1024 * 1024; const MAX_UPLOAD_BYTES = 1024 * 1024;
const MAX_LONG_EDGE = 2048; const MAX_LONG_EDGE = 2048;
const WEBP_QUALITY = 80; const WEBP_QUALITY = 80;
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
buffer: Buffer, buffer: Buffer,
{ animated, resize }: { animated: boolean; resize: boolean } { animated, resize }: { animated: boolean; resize: boolean }
): Promise<Buffer> => { ): Promise<Buffer> => {
let pipeline = sharp(buffer, { animated: true }); let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) { if (resize) {
pipeline = pipeline.resize({ pipeline = pipeline.resize({
width: MAX_LONG_EDGE, width: MAX_LONG_EDGE,
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
...(animated ? { animated: true } : {}), ...(animated ? { animated: true } : {}),
}; };
return pipeline.webp(webpOptions).toBuffer(); return pipeline
.webp(webpOptions)
.toBuffer()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
});
}; };
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => { const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
let pipeline = sharp(buffer, { animated: true }); let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) { if (resize) {
pipeline = pipeline.resize({ pipeline = pipeline.resize({
width: MAX_LONG_EDGE, width: MAX_LONG_EDGE,
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
withoutEnlargement: true, withoutEnlargement: true,
}); });
} }
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer(); return pipeline
.avif({ quality: 60, effort: 4 })
.toBuffer()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
});
}; };
export const boardRouter = router({ export const boardRouter = router({
@@ -213,16 +232,39 @@ export const boardRouter = router({
return { id: comment.id }; return { id: comment.id };
}), }),
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => { uploadImage: authedProcedure
.input(
z.object({
dataUrl: z
.string()
.min(1)
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
})
)
.mutation(async ({ ctx, input }) => {
const { permission } = await getBoardActor(ctx); const { permission } = await getBoardActor(ctx);
assertBoardAccess(permission, true); assertBoardAccess(permission, true);
try {
return await boardImageBudget.run(async () => {
const buffer = parseDataUrl(input.dataUrl); const buffer = parseDataUrl(input.dataUrl);
if (buffer.length > MAX_UPLOAD_BYTES) { if (buffer.length > MAX_UPLOAD_BYTES) {
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' }); throw new TRPCError({
code: 'PAYLOAD_TOO_LARGE',
message: '이미지 용량 제한(1MB)을 초과했습니다.',
});
} }
const metadata = await sharp(buffer, { animated: true }).metadata(); if (!isRasterImage(buffer))
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
});
if ((metadata.pages ?? 1) > 256)
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
if (!metadata.format || !metadata.width || !metadata.height) { if (!metadata.format || !metadata.width || !metadata.height) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
} }
@@ -249,7 +291,11 @@ export const boardRouter = router({
animated: isAnimated || format === 'gif', animated: isAnimated || format === 'gif',
resize: needsResize, resize: needsResize,
}); });
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) { if (
format === 'webp' &&
!needsResize &&
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
) {
outputBuffer = buffer; outputBuffer = buffer;
outputFormat = 'webp'; outputFormat = 'webp';
} else { } else {
@@ -259,7 +305,10 @@ export const boardRouter = router({
} }
if (!ctx.contentImageUpload) { if (!ctx.contentImageUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' }); throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '이미지 저장소가 설정되지 않았습니다.',
});
} }
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`; const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
const uploaded = await ctx.contentImageUpload.upload({ const uploaded = await ctx.contentImageUpload.upload({
@@ -268,7 +317,9 @@ export const boardRouter = router({
body: outputBuffer, body: outputBuffer,
}); });
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata(); const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata();
return { return {
url: uploaded.publicUrl, url: uploaded.publicUrl,
@@ -278,5 +329,19 @@ export const boardRouter = router({
animated: isAnimated, animated: isAnimated,
size: outputBuffer.length, size: outputBuffer.length,
}; };
});
} catch (error) {
if (error instanceof ImageWorkBusyError)
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
});
if (error instanceof ImageUploadError)
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
});
throw error;
}
}), }),
}); });
+24 -2
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic'; import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common'; import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { import {
@@ -989,7 +989,7 @@ export const generalRouter = router({
const now = new Date(); const now = new Date();
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta); const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([ const [onlineAccess, ownNation, openPolls, gameTime, officeRequestRows] = await Promise.all([
ctx.db.generalAccessLog.findMany({ ctx.db.generalAccessLog.findMany({
where: { where: {
lastActionAt: { lastActionAt: {
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
}, },
}), }),
loadCurrentGameTime(ctx.db, now), loadCurrentGameTime(ctx.db, now),
me.nationId > 0 && me.officerLevel >= 5 && !asRecord(me.penalty).noChief
? ctx.db.general.findMany({
where: {
nationId: me.nationId,
meta: { path: ['cityOfficeRequest', 'status'], equals: 'pending' },
},
select: { id: true, userId: true, meta: true },
})
: Promise.resolve([]),
]); ]);
// vote_poll timestamps are logical game-wall values. During PREOPEN the // vote_poll timestamps are logical game-wall values. During PREOPEN the
// logical clock advances through negative ticks before the opening anchor, // logical clock advances through negative ticks before the opening anchor,
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
return { return {
serverId, serverId,
cityOfficeRequests: {
generalId: me.id,
nationId: me.nationId,
ids: officeRequestRows.flatMap((row) => {
const request = readOfficeRequest(row.meta);
return request?.status === 'pending' &&
request.generalId === row.id &&
request.nationId === me.nationId &&
request.userId === row.userId
? [request.id]
: [];
}),
},
onlineUserCount: onlineGenerals.length, onlineUserCount: onlineGenerals.length,
onlineNations, onlineNations,
onlineGenerals: myOnlineGenerals, onlineGenerals: myOnlineGenerals,
@@ -0,0 +1,173 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import {
asRecord,
GAME_TICKS_PER_TURN,
officeRequestQuarter,
readOfficeRequest,
type CityOfficeLevel,
} from '@sammo-ts/common';
import { resolveOfficeRequestEligibility, type OfficeCandidate } from '@sammo-ts/logic';
import { accessAuthedProcedure, engineAuthedProcedure } from '../../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
import { assertNationAccess, resolveChiefStatMin } from '../shared.js';
import { loadCurrentGameTime } from '../../../services/gameClock.js';
export const cityOfficeRequest = engineAuthedProcedure
.input(
z.discriminatedUnion('action', [
z.object({
action: z.literal('request'),
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]),
}),
z.object({
action: z.enum(['approve', 'reject', 'withdraw']),
targetGeneralId: z.number().int().positive(),
officeRequestId: z.string().min(1),
}),
])
)
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
...input,
type: 'cityOfficeRequest',
userId: getAuthenticatedUserId(ctx),
generalId: general.id,
...(ctx.requestId
? { requestId: `${ctx.requestId}:nation.cityOfficeRequest:engine:0:cityOfficeRequest` }
: {}),
});
throwIfCommandRejected(result);
if (!result || result.type !== 'cityOfficeRequest')
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
if (!result.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
return { ok: true };
});
export const getCityOfficeRequests = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, cities, rows, state, clock] = await Promise.all([
ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { meta: true } }),
ctx.db.city.findMany({
where: { nationId: me.nationId },
select: { id: true, nationId: true, name: true, meta: true },
}),
ctx.db.general.findMany({
where: { nationId: me.nationId },
select: {
id: true,
userId: true,
name: true,
nationId: true,
cityId: true,
officerLevel: true,
npcState: true,
strength: true,
intel: true,
meta: true,
penalty: true,
},
}),
ctx.db.worldState.findFirst(),
loadCurrentGameTime(ctx.db),
]);
if (!nation || !state)
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가 또는 게임 상태를 찾을 수 없습니다.' });
const candidates = rows.map((row) => ({ ...row, intelligence: row.intel }));
const applicant: OfficeCandidate = { ...me, intelligence: me.intel };
const city = cities.find((entry) => entry.id === me.cityId) ?? null;
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
const own = readOfficeRequest(me.meta);
const requests = candidates.flatMap((general) => {
const request = readOfficeRequest(general.meta);
if (
!request ||
request.generalId !== general.id ||
request.nationId !== me.nationId ||
request.userId !== general.userId
)
return [];
return [{ general, request }];
});
const evaluate = (general: OfficeCandidate, cityId: number, level: CityOfficeLevel) =>
resolveOfficeRequestEligibility({
applicant: general,
city: cities.find((entry) => entry.id === cityId) ?? null,
level,
incumbent:
candidates.find(
(entry) =>
entry.officerLevel === level &&
Number(asRecord(entry.meta).officerCity ?? asRecord(entry.meta).officer_city ?? 0) === cityId
) ?? null,
nationMeta: nation.meta,
chiefStatMin: resolveChiefStatMin(state),
});
const canManage = me.officerLevel >= 5 && !asRecord(me.penalty).noChief;
const nowTick = clock.tick;
const options = ([4, 3, 2] as const).map((level) => {
const check = evaluate(applicant, me.cityId, level);
const occupied = requests.some(
({ request }) =>
request.status === 'pending' && request.cityId === me.cityId && request.officerLevel === level
);
const reason =
check.reason ??
(own?.status === 'pending'
? '요청 대기 중'
: own?.quarter === quarter
? '이번 분기 자원 완료'
: occupied
? '다른 장수 자원 중'
: nowTick === null
? '게임 시계 준비 중'
: null);
return { level, ...check, allowed: !reason, reason };
});
return {
canManage,
generalId: me.id,
nationId: me.nationId,
serverId: String(asRecord(state.meta).serverId ?? ctx.profile?.name ?? 'game'),
city: city ? { id: city.id, name: city.name } : null,
options,
running: clock.running,
tickSeconds: state.tickSeconds,
requests: requests
.filter(({ general, request }) => general.id === me.id || (canManage && request.status === 'pending'))
.map(({ general, request }) => {
const check = evaluate(general, request.cityId, request.officerLevel);
const defaultDecision =
request.defaultDecision === 'reject' || check.defaultDecision === 'reject'
? ('reject' as const)
: ('approve' as const);
const months = Math.max(
0,
Math.ceil((request.dueTick - Number(state.lastTurnTick ?? 0)) / GAME_TICKS_PER_TURN)
);
const monthIndex = state.currentYear * 12 + state.currentMonth - 1 + months;
return {
id: request.id,
generalId: general.id,
generalName: general.name,
cityId: request.cityId,
cityName: cities.find((entry) => entry.id === request.cityId)?.name ?? '-',
officerLevel: request.officerLevel,
status: request.status,
defaultDecision,
defaultReason: check.defaultReason === 'secret' ? ('secret' as const) : request.defaultReason,
resultReason: request.resultReason ?? null,
dueYear: Math.floor(monthIndex / 12),
dueMonth: (monthIndex % 12) + 1,
remainingSeconds:
nowTick === null
? null
: Math.max(0, ((request.dueTick - nowTick) / GAME_TICKS_PER_TURN) * state.tickSeconds),
invalidReason: request.status === 'pending' ? check.reason : null,
};
}),
};
});
+3
View File
@@ -1,3 +1,4 @@
import { cityOfficeRequest, getCityOfficeRequests } from './endpoints/cityOfficeRequests.js';
import { router } from '../../trpc.js'; import { router } from '../../trpc.js';
import { appoint } from './endpoints/appoint.js'; import { appoint } from './endpoints/appoint.js';
import { changePermission } from './endpoints/changePermission.js'; import { changePermission } from './endpoints/changePermission.js';
@@ -25,6 +26,8 @@ export const nationRouter = router({
getSecretGeneralList, getSecretGeneralList,
getCityOverview, getCityOverview,
getPersonnelInfo, getPersonnelInfo,
getCityOfficeRequests,
cityOfficeRequest,
getStratFinan, getStratFinan,
setNotice, setNotice,
setScoutMsg, setScoutMsg,
@@ -0,0 +1,93 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
import { auditProcedure, readAudit, readAuditWorld, findAuditMonth, zAuditMonth } from './shared.js';
import { citySelect, projectCurrentCity, zAuditCityData } from './projection.js';
/** 도시 표본 전체를 한 지도 단위로 읽는다. 장수/로그/trace는 읽지 않는다. */
export const cityMap = auditProcedure.input(z.object({ at: zAuditMonth.optional() }).strict()).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
if (input.at && !sample)
return {
...world,
collected: false,
sample,
map: null,
layout: null,
unmappedCityIds: [],
nextCursor: null,
};
const state = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { config: true } });
const environment = asRecord(asRecord(state?.config).environment);
const layout =
typeof environment.mapName === 'string' && environment.mapName.trim()
? await loadMapLayoutByName(environment.mapName)
: await loadMapLayout(ctx.profile.scenario);
const cities = sample
? (
await tx.playAuditCity.findMany({
where: { sampleId: sample.id },
select: { data: true },
take: 1025,
})
).map((row) => zAuditCityData.parse(row.data))
: (await tx.city.findMany({ select: citySelect, take: 1025 })).map(projectCurrentCity);
if (cities.length > 1024)
throw new TRPCError({
code: 'BAD_REQUEST',
message: '지도 조회 범위를 초과했습니다. 도시 목록으로 조회해 주세요.',
});
const nationIds = [...new Set(cities.map((city) => city.nationId))];
const identity = z.object({ id: z.number(), name: z.string(), color: z.string() });
const nations = sample
? (
await tx.playAuditNation.findMany({
where: { sampleId: sample.id, nationId: { in: nationIds } },
select: { data: true },
})
).map((row) => ({ ...identity.parse(row.data), capitalCityId: 0 }))
: await tx.nation.findMany({
where: { id: { in: nationIds } },
select: { id: true, name: true, color: true, capitalCityId: true },
});
const byCity = new Map(cities.map((city) => [city.id, city]));
const byNation = new Map(nations.map((nation) => [nation.id, nation]));
const layoutIds = new Set(layout.cityList.map((city) => city.id));
// 없는 표본을 무주 도시로 그리지 않는다. 당시 이름과 소유만 사용한다.
const visibleLayout = {
...layout,
cityList: layout.cityList
.filter((city) => byCity.has(city.id))
.map((city) => ({ ...city, name: byCity.get(city.id)!.name })),
};
return {
...world,
collected: true,
sample,
layout: visibleLayout,
nextCursor: null,
unmappedCityIds: cities.filter((city) => !layoutIds.has(city.id)).map((city) => city.id),
map: {
year: input.at?.year ?? world.year,
month: input.at?.month ?? world.month,
startYear: world.startYear,
cityList: visibleLayout.cityList.map((layoutCity): [number, number, number, number, number, number] => {
const city = byCity.get(layoutCity.id)!;
return [city.id, city.level, city.state, city.nationId, layoutCity.region, city.supplyState];
}),
nationList: nationIds.map((id): [number, string, string, number] => {
const nation = byNation.get(id);
return [
id,
nation?.name ?? (id === 0 ? '무주' : `국가 #${id} (미수집)`),
nation?.color ?? '#888888',
nation?.capitalCityId ?? 0,
];
}),
},
};
})
);
+31 -1
View File
@@ -173,7 +173,35 @@ export const decisionHistory = auditProcedure
.query(({ ctx, input }) => .query(({ ctx, input }) =>
readAudit(ctx, async (tx) => { readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx); const world = await readAuditWorld(tx);
const month = input.month ?? { year: world.year, month: world.month }; // 장수 턴은 월 경계와 동시에 실행되지 않는다. 현재 상태에서는 마지막으로
// 실제 결정이 저장된 월을 열고, 명시한 과거 월은 빈 월이어도 그대로 보존한다.
const latest =
!input.month && world.serverId
? await tx.playAuditDecision.findFirst({
where: {
serverId: world.serverId,
generalId: input.generalId,
phase: input.phase,
AND: [
{
OR: [
{ year: { gt: world.startYear } },
{ year: world.startYear, month: { gte: world.startMonth } },
],
},
{
OR: [
{ year: { lt: world.year } },
{ year: world.year, month: { lte: world.month } },
],
},
],
},
orderBy: [{ year: 'desc' }, { month: 'desc' }, { tick: 'desc' }, { id: 'desc' }],
select: { year: true, month: true },
})
: null;
const month = input.month ?? latest ?? { year: world.year, month: world.month };
const ordinal = monthOrdinal(month.year, month.month); const ordinal = monthOrdinal(month.year, month.month);
if ( if (
ordinal < monthOrdinal(world.startYear, world.startMonth) || ordinal < monthOrdinal(world.startYear, world.startMonth) ||
@@ -206,6 +234,8 @@ export const decisionHistory = auditProcedure
return { return {
...world, ...world,
month, month,
currentMonth: { year: world.year, month: world.month },
selection: input.month ? ('MONTH' as const) : ('LATEST' as const),
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const), coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
items: rows.slice(0, input.limit).map(project), items: rows.slice(0, input.limit).map(project),
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null, nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
@@ -0,0 +1,108 @@
import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { z } from 'zod';
import { generalSelect, projectCurrentGeneral, zAuditGeneralData } from './projection.js';
export const zGeneralSort = z.enum([
'id',
'gold',
'rice',
'crew',
'train',
'atmos',
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
]);
export const zGeneralCursor = z.object({ id: z.number().int().nonnegative(), value: z.number().nullable() }).strict();
const columns: Record<z.infer<typeof zGeneralSort>, string> = {
id: 'id',
gold: 'gold',
rice: 'rice',
crew: 'crew',
train: 'train',
atmos: 'atmos',
leadership: 'leadership',
strength: 'strength',
intelligence: 'intel',
experience: 'experience',
dedication: 'dedication',
dex1: 'dex1',
dex2: 'dex2',
dex3: 'dex3',
dex4: 'dex4',
dex5: 'dex5',
};
export const readSortedGenerals = async (
tx: GamePrisma.TransactionClient,
input: {
sort: z.infer<typeof zGeneralSort>;
order: 'asc' | 'desc';
limit: number;
cursor?: number | z.infer<typeof zGeneralCursor>;
nationId?: number;
cityId?: number;
population?: 'human' | 'npc' | 'troopNpc';
name?: string;
},
sampleId?: string
) => {
if (typeof input.cursor === 'number')
throw new TRPCError({ code: 'BAD_REQUEST', message: '정렬 조건에 맞는 다음 페이지를 선택해 주세요.' });
// SQL 식별자/JSON 경로는 서버 allowlist에서만 조립한다. 사용자 문자열은 parameter다.
const id = GamePrisma.raw(sampleId ? 'general_id' : 'id');
const path = ['leadership', 'strength', 'intelligence'].includes(input.sort)
? `stats,${input.sort}`
: input.sort.startsWith('dex')
? `dex,${input.sort}`
: input.sort;
const metric = sampleId
? GamePrisma.raw(`(data #>> '{${path}}')::double precision`)
: input.sort.startsWith('dex')
? GamePrisma.raw(
`CASE WHEN jsonb_typeof(meta->'${input.sort}') = 'number' THEN (meta->>'${input.sort}')::double precision ELSE 0 END`
)
: GamePrisma.raw(columns[input.sort]);
const filters: GamePrisma.Sql[] = [];
if (sampleId) filters.push(GamePrisma.sql`sample_id = ${sampleId}`);
if (input.nationId !== undefined) filters.push(GamePrisma.sql`nation_id = ${input.nationId}`);
if (input.cityId !== undefined) filters.push(GamePrisma.sql`city_id = ${input.cityId}`);
if (input.population === 'human') filters.push(GamePrisma.sql`npc_state < 2`);
if (input.population === 'npc') filters.push(GamePrisma.sql`npc_state >= 2 AND npc_state <> 5`);
if (input.population === 'troopNpc') filters.push(GamePrisma.sql`npc_state = 5`);
if (input.name)
filters.push(
GamePrisma.sql`${GamePrisma.raw(sampleId ? "data->>'name'" : 'name')} LIKE ${`%${input.name.replace(/[\\%_]/g, '\\$&')}%`}`
);
if (input.cursor) {
const { value, id: cursorId } = input.cursor;
filters.push(
value === null
? GamePrisma.sql`(${metric} IS NULL AND ${id} > ${cursorId})`
: GamePrisma.sql`(${metric} ${GamePrisma.raw(input.order === 'asc' ? '>' : '<')} ${value} OR (${metric} = ${value} AND ${id} > ${cursorId}) OR ${metric} IS NULL)`
);
}
const rows = await tx.$queryRaw<{ id: number; value: number | null; data: unknown }[]>(GamePrisma.sql`
SELECT ${id} AS id, ${metric} AS value, ${sampleId ? GamePrisma.raw('data') : GamePrisma.sql`NULL`} AS data
FROM ${GamePrisma.raw(sampleId ? 'play_audit_general' : 'general')}
WHERE ${filters.length ? GamePrisma.join(filters, ' AND ') : GamePrisma.sql`TRUE`}
ORDER BY ${metric} ${GamePrisma.raw(input.order.toUpperCase())} NULLS LAST, ${id} ASC LIMIT ${input.limit + 1}
`);
const page = rows.slice(0, input.limit);
const current = sampleId
? []
: await tx.general.findMany({ where: { id: { in: page.map((row) => row.id) } }, select: generalSelect });
const byId = new Map(current.map((row) => [row.id, projectCurrentGeneral(row)]));
return {
items: page.map((row) => (sampleId ? zAuditGeneralData.parse(row.data) : byId.get(row.id)!)),
nextCursor:
rows.length > input.limit ? { id: page[page.length - 1]!.id, value: page[page.length - 1]!.value } : null,
};
};
@@ -1,3 +1,6 @@
import { cityMap } from './cityMap.js';
import { TRPCError } from '@trpc/server';
import { readSortedGenerals, zGeneralSort, zGeneralCursor } from './generalSort.js';
import { requestState } from './requests.js'; import { requestState } from './requests.js';
import { decisionHistory, decisionDetail } from './decisions.js'; import { decisionHistory, decisionDetail } from './decisions.js';
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js'; import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
@@ -27,6 +30,7 @@ import {
} from './projection.js'; } from './projection.js';
export const playAuditRouter = router({ export const playAuditRouter = router({
cityMap,
requestState, requestState,
decisionHistory, decisionHistory,
decisionDetail, decisionDetail,
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
population: z.enum(['human', 'npc', 'troopNpc']).optional(), population: z.enum(['human', 'npc', 'troopNpc']).optional(),
name: z.string().trim().max(64).optional(), name: z.string().trim().max(64).optional(),
order: z.enum(['asc', 'desc']).default('asc'), order: z.enum(['asc', 'desc']).default('asc'),
sort: zGeneralSort.default('id'),
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
}) })
) )
.query(({ ctx, input }) => .query(({ ctx, input }) =>
readAudit(ctx, async (tx) => { readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx); const world = await readAuditWorld(tx);
if (input.sort !== 'id') {
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
if (input.at && !sample) return { ...world, sample, collected: false, items: [], nextCursor: null };
return { ...world, sample, collected: true, ...(await readSortedGenerals(tx, input, sample?.id)) };
}
if (typeof input.cursor === 'object')
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 번호 정렬의 다음 페이지가 아닙니다.' });
const npcState = const npcState =
input.population === 'human' input.population === 'human'
? { lt: 2 } ? { lt: 2 }
@@ -1,3 +1,5 @@
import { GamePrisma } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
@@ -5,6 +7,7 @@ import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth }
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() }); const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
const zPopulation = z.object({ const zPopulation = z.object({
count: z.number().int().nonnegative(), count: z.number().int().nonnegative(),
crew: z.number().nonnegative().nullable().default(null),
gold: z.number(), gold: z.number(),
rice: z.number(), rice: z.number(),
dex: zDex, dex: zDex,
@@ -92,6 +95,34 @@ export const summarizeNationPeriod = (start: number, width: number, months: Nati
}; };
}; };
/** 같은 transaction에서 commit된 당월 정산만 반환한다. 미관측을 0으로 만들지 않는다. */
export const projectCurrentSettlement = (meta: unknown, year: number, month: number, nationId: number) => {
const flows = asRecord(asRecord(meta).playAuditFlows);
const matches = flows.year === year && flows.month === month;
const entries = asRecord(flows.entries);
const resource = (key: 'gold' | 'rice') => {
const row = asRecord(entries[`${nationId}:${key}`]);
if (
!matches ||
row.nationId !== nationId ||
row.resource !== key ||
typeof row.income !== 'number' ||
!Number.isFinite(row.income) ||
typeof row.paid !== 'number' ||
!Number.isFinite(row.paid)
)
return null;
return { income: row.income, paid: row.paid };
};
return {
year,
month,
gold: resource('gold'),
rice: resource('rice'),
complete: matches && flows.complete === true,
};
};
const zCalendarMonth = zAuditMonth.omit({ kind: true }); const zCalendarMonth = zAuditMonth.omit({ kind: true });
export const nationSeries = auditProcedure export const nationSeries = auditProcedure
.input( .input(
@@ -151,6 +182,35 @@ export const nationSeries = auditProcedure
}) })
: []; : [];
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)])); const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
// 기존 집계는 같은 월의 장수 표본을 DB에서 합산한다. 원문은 전송하지 않는다.
const legacySamples = [...dataBySample]
.filter(([, nation]) => Object.values(nation.populations).some((group) => group.crew === null))
.map(([id]) => id);
if (legacySamples.length) {
const troops = await tx.$queryRaw<
{ sampleId: string; population: 'human' | 'npc' | 'troopNpc'; count: number; crew: number | null }[]
>(GamePrisma.sql`
SELECT sample_id AS "sampleId",
CASE WHEN npc_state = 5 THEN 'troopNpc' WHEN npc_state < 2 THEN 'human' ELSE 'npc' END AS population,
count(*)::int AS count,
CASE WHEN bool_and(jsonb_typeof(data->'crew') = 'number' AND data->'crew' IS NOT NULL)
THEN sum(CASE WHEN jsonb_typeof(data->'crew') = 'number' THEN (data->>'crew')::double precision END) ELSE NULL END AS crew
FROM play_audit_general
WHERE nation_id = ${input.nationId} AND sample_id IN (${GamePrisma.join(legacySamples)})
GROUP BY sample_id, population
`);
for (const sampleId of legacySamples) {
const nation = dataBySample.get(sampleId)!;
for (const key of ['human', 'npc', 'troopNpc'] as const) {
const group = nation.populations[key];
if (group.crew !== null) continue;
const row = troops.find((item) => item.sampleId === sampleId && item.population === key);
// 저장 인원수와 원본 표본 수가 같을 때만 확정한다.
if (group.count === 0 && !row) group.crew = 0;
else if (row?.count === group.count) group.crew = row.crew;
}
}
}
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample])); const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
const items: ReturnType<typeof summarizeNationPeriod>[] = []; const items: ReturnType<typeof summarizeNationPeriod>[] = [];
for (let period = start; period <= end; period += width) { for (let period = start; period <= end; period += width) {
@@ -166,6 +226,17 @@ export const nationSeries = auditProcedure
} }
items.push(summarizeNationPeriod(period, width, months)); items.push(summarizeNationPeriod(period, width, months));
} }
return { ...world, items, nextCursor: end < to ? dateOf(start + input.limit * width) : null }; const currentState =
world.serverId && end === current
? await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } })
: null;
return {
...world,
items,
currentSettlement: currentState
? projectCurrentSettlement(currentState.meta, world.year, world.month, input.nationId)
: null,
nextCursor: end < to ? dateOf(start + input.limit * width) : null,
};
}) })
); );
@@ -117,6 +117,12 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
startYear, startYear,
startMonth, startMonth,
collectionStart, collectionStart,
historyGap: (() => {
const gap = asRecord(meta.playAuditGap);
return gap.serverId === serverId && Number.isInteger(gap.firstYear) && Number.isInteger(gap.firstMonth)
? { firstYear: Number(gap.firstYear), firstMonth: Number(gap.firstMonth) }
: null;
})(),
tick: world.lastTurnTick?.toString() ?? null, tick: world.lastTurnTick?.toString() ?? null,
asOf: new Date().toISOString(), asOf: new Date().toISOString(),
}; };
+22
View File
@@ -11,6 +11,7 @@ import {
buildRecruitmentCommandInfo, buildRecruitmentCommandInfo,
buildTurnCommandTable, buildTurnCommandTable,
evaluateReservedTurnPermission, evaluateReservedTurnPermission,
resolveCommandJoinEnv,
} from '../../turns/commandTable.js'; } from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import { import {
@@ -45,7 +46,10 @@ import {
buildRefAmountPresets, buildRefAmountPresets,
buildRefGeneralTargetOptions, buildRefGeneralTargetOptions,
buildRefNationTargetOptions, buildRefNationTargetOptions,
COUNTER_STRATEGY_COMMAND_KEYS,
} from '../../turns/commandTargets.js'; } from '../../turns/commandTargets.js';
import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js';
import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js';
const zPushAmount = z const zPushAmount = z
.number() .number()
@@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId); if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
} }
} }
// Ref che_피장파장 availableCommandTypeList: 아국 nation_env next_execute_<전략명> 기준 남은 턴.
const strategyCooldowns = Object.fromEntries(
resolveImpossibleStrategicCommands(nation?.meta, worldState.currentYear, worldState.currentMonth).map(
(entry) => [`che_${entry.name}`, entry.remainingTurns]
)
);
const nationTargetOptions = buildRefNationTargetOptions({ const nationTargetOptions = buildRefNationTargetOptions({
actorNationId: general.nationId, actorNationId: general.nationId,
join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) },
strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined),
nations: nations.map((entry) => { nations: nations.map((entry) => {
const relation = diplomacyByNation.get(entry.id); const relation = diplomacyByNation.get(entry.id);
const meta = asRecord(entry.meta);
const gennum = readGeneralMetaNumber(meta, 'gennum');
return { return {
id: entry.id, id: entry.id,
name: entry.name, name: entry.name,
@@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
diplomacyTerm: relation?.term ?? 0, diplomacyTerm: relation?.term ?? 0,
adjacent: adjacentNationIds.has(entry.id), adjacent: adjacentNationIds.has(entry.id),
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0, diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
...(gennum === null ? {} : { gennum }),
blockScout: resolveNationBlockScout(meta),
scoutMessage: resolveNationScoutMessage(meta),
}; };
}), }),
}); });
@@ -398,6 +415,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
item: general.itemCode, item: general.itemCode,
}, },
}); });
const dexterityByArmType: Record<string, number> = {};
const inputOptions: TurnCommandInputOptions = { const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({ cities: cities.map((entry) => ({
value: entry.id, value: entry.id,
@@ -406,6 +424,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
})), })),
nations: nationTargetOptions.nations, nations: nationTargetOptions.nations,
nationTargets: nationTargetOptions.nationTargets, nationTargets: nationTargetOptions.nationTargets,
nationScoutMessages: nationTargetOptions.nationScoutMessages,
strategyCooldowns,
generals: generalTargetOptions.generals, generals: generalTargetOptions.generals,
generalTargets: generalTargetOptions.generalTargets, generalTargets: generalTargetOptions.generalTargets,
crewTypes: (environment.unitSet.crewTypes ?? []) crewTypes: (environment.unitSet.crewTypes ?? [])
@@ -413,6 +433,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
.map((entry) => ({ value: entry.id, label: entry.name })), .map((entry) => ({ value: entry.id, label: entry.name })),
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => { armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`); const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
if (dexterity !== null) dexterityByArmType[value] = dexterity;
return { return {
value: Number(value), value: Number(value),
label, label,
@@ -449,6 +470,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
actorRice: general.rice, actorRice: general.rice,
...(city ? { citySecurity: city.security } : {}), ...(city ? { citySecurity: city.security } : {}),
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}), ...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
dexterity: dexterityByArmType,
}, },
}; };
+13
View File
@@ -6,6 +6,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js'; import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import { import {
formatGeneralAccessLimitMessage, formatGeneralAccessLimitMessage,
@@ -339,11 +340,23 @@ export const yearbookRouter = router({
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth; const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth; const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth; const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth;
// Ref v_history.php resolves an archived generation's map from ng_games.map.
const mapLayout = target.isCurrentProfile
? await loadMapLayout(ctx.profile.scenario)
: await loadMapLayoutByName(
(
await ctx.db.gameHistory.findUnique({
where: { serverId: target.archiveKey },
select: { map: true },
})
)?.map || 'che'
);
return { return {
firstYearMonth, firstYearMonth,
lastYearMonth, lastYearMonth,
currentYearMonth: selectedYearMonth, currentYearMonth: selectedYearMonth,
mapLayout,
}; };
}), }),
getHistory: authedProcedure getHistory: authedProcedure
+141 -68
View File
@@ -1,8 +1,10 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import path from 'path'; import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { import {
buildGameEventChannel, buildGameEventChannel,
@@ -21,7 +23,7 @@ import {
import { resolveGameApiConfigFromEnv } from './config.js'; import { resolveGameApiConfigFromEnv } from './config.js';
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js'; import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js'; import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js'; import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import { import {
consumeRealtimeAccessGrantHeader, consumeRealtimeAccessGrantHeader,
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js';
import { RealtimeConnectionAdmission } from './realtime/connectionAdmission.js';
import { formatSseFrame } from './realtime/sse.js'; import { formatSseFrame } from './realtime/sse.js';
import { import {
shouldForwardRealtimeEvent, shouldForwardRealtimeEvent,
@@ -51,7 +56,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
import { scopeHttpIdempotencyKey } from './requestId.js'; import { scopeHttpIdempotencyKey } from './requestId.js';
import { loadClockReadiness } from './services/clockReadiness.js'; import { loadProfileReadiness } from './services/clockReadiness.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => { const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) { if (!value) {
@@ -80,7 +85,12 @@ const resolveAuthFromToken = async (
if (!stored) { if (!stored) {
return null; return null;
} }
const flushedAt = flushStore.getFlushedAt(stored.user.id); let flushedAt: Date | null;
try {
flushedAt = await flushStore.getFlushedAt(stored.user.id);
} catch {
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
}
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) { if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
return null; return null;
} }
@@ -99,11 +109,18 @@ export const createGameApiServer = async () => {
imageUploadSecret imageUploadSecret
); );
const app = fastify({ const app = fastify({
logger: true, logger: safeHttpLoggerOptions,
routerOptions: { routerOptions: {
maxParamLength: 2048, maxParamLength: 2048,
}, },
}); });
app.setErrorHandler(safeHttpErrorHandler);
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
const realtimeAdmission = new RealtimeConnectionAdmission();
app.addHook('preClose', async () => {
realtimeAdmission.close();
for (const connection of realtimeConnections) connection.close();
});
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
const redis = createRedisConnector(resolveRedisConfigFromEnv()); const redis = createRedisConnector(resolveRedisConfigFromEnv());
@@ -134,7 +151,18 @@ export const createGameApiServer = async () => {
requestTimeoutMs: config.battleSimRequestTimeoutMs, requestTimeoutMs: config.battleSimRequestTimeoutMs,
resultTtlSeconds: config.battleSimResultTtlSeconds, resultTtlSeconds: config.battleSimResultTtlSeconds,
}); });
const flushStore = new InMemoryFlushStore(); const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
try {
await flushStore.assertInitialized();
} catch {
await Promise.allSettled([
Promise.resolve().then(() => {
if (redis.client.isOpen) redis.client.destroy();
}),
postgres.disconnect(),
]);
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
}
const flushSubscriberClient = redis.client.duplicate(); const flushSubscriberClient = redis.client.duplicate();
try { try {
await flushSubscriberClient.connect(); await flushSubscriberClient.connect();
@@ -150,6 +178,12 @@ export const createGameApiServer = async () => {
config.flushChannel, config.flushChannel,
flushStore, flushStore,
async (event) => { async (event) => {
const flushedAt = Date.parse(event.flushedAt);
for (const connection of realtimeConnections) {
if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) {
connection.close();
}
}
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]); await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
}, },
(error, event) => { (error, event) => {
@@ -299,17 +333,68 @@ export const createGameApiServer = async () => {
}, },
}); });
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
if (
!auth ||
auth.profile !== config.profileName ||
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
)
return null;
return auth;
};
app.get(config.eventsPath, async (request, reply) => { app.get(config.eventsPath, async (request, reply) => {
const query = request.query as { token?: string; scope?: string }; const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard'; const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization); const tokenFromHeader = extractBearerToken(request.headers.authorization);
const tokenFromQuery = typeof query.token === 'string' ? query.token : null; if (query.token !== undefined || !tokenFromHeader) {
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
if (!auth) {
await reply.status(401).send({ ok: false, error: 'unauthorized' }); await reply.status(401).send({ ok: false, error: 'unauthorized' });
return; return;
} }
let expired = false;
let disconnected = false;
const opening = realtimeAdmission.begin(() => {
expired = true;
if (!reply.sent && !reply.raw.destroyed) {
reply.header('Retry-After', '15');
void reply.status(503).send({ ok: false, error: 'realtime_unavailable' });
}
});
if (!opening) {
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
return;
}
const cancelOpening = () => {
disconnected = true;
opening.cancelOpening();
};
request.raw.once('aborted', cancelOpening);
reply.raw.once('close', cancelOpening);
let transferred = false;
try {
let auth: GameSessionTokenPayload | null = null;
try {
auth = await resolveRealtimeAuth(tokenFromHeader);
} catch {
if (opening.canContinue() && !reply.raw.destroyed)
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
return;
}
if (!opening.canContinue() || reply.raw.destroyed) return;
if (!auth || auth.profile !== config.profileName) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
if (!opening.reserveUser(auth.user.id)) {
if (opening.canContinue())
await reply
.header('Retry-After', '15')
.status(429)
.send({ ok: false, error: 'realtime_connection_limit' });
return;
}
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => { const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({ const general = await postgres.prisma.general.findFirst({
@@ -334,6 +419,7 @@ export const createGameApiServer = async () => {
}; };
}; };
let viewerIdentity = await loadViewerIdentity(); let viewerIdentity = await loadViewerIdentity();
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
reply.hijack(); reply.hijack();
const requestOrigin = request.headers.origin; const requestOrigin = request.headers.origin;
@@ -351,28 +437,20 @@ export const createGameApiServer = async () => {
reply.raw.setTimeout?.(0); reply.raw.setTimeout?.(0);
reply.raw.flushHeaders?.(); reply.raw.flushHeaders?.();
const sendFrame = (payload: string) => { const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
try { resolveRealtimeAuth(tokenFromHeader)
reply.raw.write(payload);
} catch {
return;
}
};
sendFrame(
formatSseFrame({
event: 'ready',
data: '{}',
})
); );
realtimeConnections.add(connection);
let closed = false; connection.onClose(() => {
let eventQueue = Promise.resolve(); realtimeConnections.delete(connection);
// A disconnected socket can still own an in-flight Redis/SQL operation.
void connection.whenIdle().then(opening.release, opening.release);
});
transferred = true;
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
const unsubscribe = realtimeHub.subscribe((event) => { const unsubscribe = realtimeHub.subscribe((event) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return; if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
eventQueue = eventQueue connection.enqueue(async (currentAuth) => {
.then(async () => {
if (closed) return;
const identities = [viewerIdentity]; const identities = [viewerIdentity];
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) { if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
const nextIdentity = await loadViewerIdentity(); const nextIdentity = await loadViewerIdentity();
@@ -381,69 +459,64 @@ export const createGameApiServer = async () => {
} }
let refreshGrant: string | undefined; let refreshGrant: string | undefined;
const publicEvent = toPublicRealtimeEvent(event, identities, () => { const publicEvent = toPublicRealtimeEvent(event, identities, () => {
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret); refreshGrant ??= createRealtimeAccessGrant(
currentAuth,
config.profileName,
config.gameTokenSecret
);
return refreshGrant; return refreshGrant;
}); });
if (!publicEvent || closed) return; if (!publicEvent || connection.closed) return null;
if (refreshGrant) { if (refreshGrant) {
try { try {
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName); await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
} catch { } catch {
// Preserve the invalidation. An unregistered grant safely falls back // An unregistered grant falls back to the scored refresh path.
// to the normal scored refresh path.
} }
} }
sendFrame( return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
formatSseFrame({
event: publicEvent.type,
data: JSON.stringify(publicEvent),
})
);
})
.catch(() => {
// A best-effort notification must not affect committed game state.
}); });
}); });
connection.onClose(unsubscribe);
let heartbeatPending = false; let heartbeatPending = false;
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
if (heartbeatPending) return; if (heartbeatPending || connection.closed) return;
heartbeatPending = true; heartbeatPending = true;
void turnEngineStatus connection.enqueue(async () => {
.get() try {
.then((turnEngineRunning) => { return formatSseFrame({
if (closed) return;
sendFrame(
formatSseFrame({
event: 'ping', event: 'ping',
data: JSON.stringify({ turnEngineRunning }), data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
})
);
})
.finally(() => {
heartbeatPending = false;
}); });
}, 15000); } finally {
heartbeatPending = false;
const close = () => { }
closed = true; });
}, 15_000);
connection.onClose(() => {
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); });
}; request.raw.once('aborted', connection.close);
connection.onClose(() => {
request.raw.on('close', close); request.raw.off('aborted', connection.close);
request.raw.on('aborted', close); });
} catch (error) {
if (!expired && !disconnected) throw error;
} finally {
request.raw.off('aborted', cancelOpening);
reply.raw.off('close', cancelOpening);
if (!transferred) opening.release();
}
}); });
app.get('/healthz', async (_request, reply) => { app.get('/healthz', async (_request, reply) => {
const clock = await loadClockReadiness(postgres.prisma); const readiness = await loadProfileReadiness(postgres.prisma, config.profileName);
if (!clock.reconciliationComplete) reply.code(503); if (!readiness.ok) reply.code(503);
return { return {
ok: clock.reconciliationComplete, ...readiness,
profile: config.profileName, profile: config.profileName,
postgresPool: postgres.getPoolStats(), postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(), accountIconReconciliation: accountIconResetReconciler.getHealth(),
clock,
}; };
}); });
@@ -1,4 +1,5 @@
import { parseGameClockPhase } from '@sammo-ts/common'; import { parseGameClockPhase } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
@@ -90,3 +91,20 @@ export const loadClockAdminStatus = async (db: DatabaseClient) => {
}, },
}; };
}; };
/** API 기동과 별개로 데몬의 초기 감사 저장 및 clock 복구가 끝나야 배포 준비 완료다. */
export const loadProfileReadiness = async (db: DatabaseClient, profileName: string) => {
const [clock, leases] = await Promise.all([
loadClockReadiness(db),
db.$queryRaw<Array<{ ready: boolean }>>(GamePrisma.sql`
SELECT EXISTS (
SELECT 1 FROM turn_daemon_lease
WHERE profile = ${profileName}
AND clock_ready = TRUE
AND lease_until > (clock_timestamp() AT TIME ZONE 'UTC')
) AS ready
`),
]);
const daemonReady = leases[0]?.ready === true;
return { ok: clock.reconciliationComplete && daemonReady, clock, daemonReady };
};
@@ -3,6 +3,7 @@ import {
GamePrisma, GamePrisma,
hashAuditDiplomacyDocument, hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents, persistAuditDiplomacyEvents,
withPlayAuditSavepoint,
projectAuditDocumentState, projectAuditDocumentState,
readTurnRuntimeReady, readTurnRuntimeReady,
type AuditDiplomacyEventDraft, type AuditDiplomacyEventDraft,
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
}; };
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => { export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
let collectionFailed = false;
const changes: { const changes: {
letter: Letter; letter: Letter;
before: Record<string, unknown> | null; before: Record<string, unknown> | null;
@@ -92,15 +94,21 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
return { return {
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => { record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
if (!ctx.auditInput) return; if (!ctx.auditInput) return;
try {
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType }); changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
} catch {
collectionFailed = true;
}
}, },
flush: async (): Promise<void> => { flush: async (): Promise<void> => {
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다. // 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
const input = ctx.auditInput; const input = ctx.auditInput;
if (!input || !changes.length) return; if (!input || (!changes.length && !collectionFailed)) return;
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id) if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
throw new Error('Play audit diplomacy actor mismatch'); throw new Error('Play audit diplomacy actor mismatch');
const coordinate = await readCoordinate(ctx); await withPlayAuditSavepoint(ctx.db, async (auditDb) => {
if (collectionFailed) throw new Error('Audit document collection failed');
const coordinate = await readCoordinate({ ...ctx, db: auditDb });
if (!coordinate) return; if (!coordinate) return;
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({ const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
schemaVersion: 1, schemaVersion: 1,
@@ -133,7 +141,8 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
before: change.before, before: change.before,
after: change.after, after: change.after,
})); }));
await persistAuditDiplomacyEvents(ctx.db, events); await persistAuditDiplomacyEvents(auditDb, events);
});
}, },
}; };
}; };
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto'; import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface ContentImageUploadResult { export interface ContentImageUploadResult {
publicUrl: string; publicUrl: string;
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) { if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid content image filename.'); throw new Error('Invalid content image filename.');
} }
const pathname = `/v1/uploads/content/core2026/${input.filename}`; await performSignedImageUpload(
const expires = String(Math.floor(this.now() / 1000) + 60); {
const requestId = randomUUID(); ...input,
const digest = createHash('sha256').update(input.body).digest('hex'); kind: 'content',
const signature = createHmac('sha256', this.secret) baseUrl: this.baseUrl,
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`) secret: this.secret,
.digest('hex');
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature,
}, },
body: new Uint8Array(input.body), this.fetchImpl,
}); this.now
if (!response.ok) { );
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
const expectedPath = `uploads/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
throw new Error('Image repository returned an unexpected content path.');
}
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` }; return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server'; import { initTRPC, TRPCError } from '@trpc/server';
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import'; import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
@@ -20,7 +21,7 @@ import {
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js'; import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
import { recordGeneralActivity } from './services/generalActivity.js'; import { recordGeneralActivity } from './services/generalActivity.js';
const t = initTRPC.context<GameApiContext>().create(); const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
const requireAuthMiddleware = t.middleware(({ ctx, next }) => { const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) { if (!ctx.auth) {
+31 -7
View File
@@ -36,6 +36,8 @@ export interface TurnCommandOption {
crew?: number; crew?: number;
troopId?: number; troopId?: number;
npcState?: number; npcState?: number;
/** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */
nationId?: number;
} }
export interface TurnCommandAmountPreset { export interface TurnCommandAmountPreset {
@@ -97,10 +99,21 @@ export interface TurnCommandInputField {
tupleLabels?: string[]; tupleLabels?: string[];
} }
/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */
export interface TurnCommandNationScoutMessage {
nationId: number;
name: string;
color: string;
message: string;
}
export interface TurnCommandInputOptions { export interface TurnCommandInputOptions {
cities: TurnCommandOption[]; cities: TurnCommandOption[];
nations: TurnCommandOption[]; nations: TurnCommandOption[];
nationTargets?: Record<string, TurnCommandOption[]>; nationTargets?: Record<string, TurnCommandOption[]>;
nationScoutMessages?: TurnCommandNationScoutMessage[];
/** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */
strategyCooldowns?: Record<string, number>;
generals: TurnCommandOption[]; generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>; generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[]; crewTypes: TurnCommandOption[];
@@ -117,6 +130,8 @@ export interface TurnCommandInputOptions {
nationGold?: number; nationGold?: number;
nationRice?: number; nationRice?: number;
nationLevel?: number; nationLevel?: number;
/** 숙련전환 전/후 미리보기용 본인 병과별 숙련(`dex<armType>`). */
dexterity?: Record<string, number>;
}; };
} }
@@ -151,6 +166,8 @@ export const buildEquipmentTradeItemOptions = (options: {
{ {
value: 'None', value: 'None',
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`, label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 붉은색 `(불가)`로 표시한다.
...(ownedItem ? {} : { availableNow: false }),
description: ownedItem description: ownedItem
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금` ? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
: ownedCode && ownedCode !== 'None' : ownedCode && ownedCode !== 'None'
@@ -177,6 +194,7 @@ export const buildEquipmentTradeItemOptions = (options: {
items[item.slot].push({ items[item.slot].push({
value: item.key, value: item.key,
label: item.name, label: item.name,
availableNow: options.currentSecurity >= item.reqSecu && options.generalGold >= cost,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`, description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
}); });
} }
@@ -229,8 +247,9 @@ const FIELD_LABELS: Record<string, string> = {
nationName: '국가명', nationName: '국가명',
nationType: '국가 성향', nationType: '국가 성향',
colorType: '국기 색상', colorType: '국기 색상',
srcArmType: '기존 병과', // 숙련전환 전용. Ref che_숙련전환.vue의 '감소 대상 숙련 :'·'전환 대상 숙련 :'.
destArmType: '변경 병과', srcArmType: '감소 대상 숙련',
destArmType: '전환 대상 숙련',
itemType: '장비 종류', itemType: '장비 종류',
itemCode: '장비', itemCode: '장비',
crewType: '병종', crewType: '병종',
@@ -240,7 +259,8 @@ const FIELD_LABELS: Record<string, string> = {
optionText: '행동', optionText: '행동',
year: '기간(년)', year: '기간(년)',
month: '기간(월)', month: '기간(월)',
commandType: '대응 명령', // 피장파장 전용. Ref che_피장파장.vue의 '전략 :'.
commandType: '전략',
amountList: '지원 물자', amountList: '지원 물자',
}; };
@@ -266,10 +286,14 @@ const STATIC_LABELS: Record<string, Record<string, string>> = {
item: '도구', item: '도구',
}, },
commandType: { commandType: {
che_선전포고: '선전포고', // 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()).
che_불가침제의: '불가침 제의', che_필사즉생: '필사즉생',
che_불가침파기제의: '불가침 파기 제의', che_백성동원: '백성동원',
che_종전제의: '종전 제의', che_수몰: '수몰',
che_허보: '허보',
che_의병모집: '의병모집',
che_이호경식: '이호경식',
che_급습: '급습',
}, },
}; };
+13
View File
@@ -479,6 +479,19 @@ const mapCityRow = (row: CityRow): City => {
}; };
}; };
/** 명령표의 임관 대상 표시에 쓰는 Ref AllowJoinDestNation 시점 값. */
export const resolveCommandJoinEnv = (
worldState: WorldStateRow
): { relYear: number; openingPartYear: number; initialNationGenLimit: number } => {
const constValues = asRecord(asRecord(worldState.config).const);
const env = buildConstraintEnv(worldState);
return {
relYear: Number(env.relYear),
openingPartYear: Number(env.openingPartYear),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
};
};
const mapNationRow = (row: NationRow): Nation => ({ const mapNationRow = (row: NationRow): Nation => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
+150 -29
View File
@@ -1,6 +1,6 @@
import { DIPLOMACY_STATE } from '@sammo-ts/logic'; import { DIPLOMACY_STATE } from '@sammo-ts/logic';
import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js'; import type { TurnCommandAmountPreset, TurnCommandNationScoutMessage, TurnCommandOption } from './commandInput.js';
export interface GeneralTargetSource { export interface GeneralTargetSource {
id: number; id: number;
@@ -33,6 +33,20 @@ export interface NationTargetSource {
diplomacyTerm: number; diplomacyTerm: number;
adjacent: boolean; adjacent: boolean;
diplomacyRestricted?: boolean; diplomacyRestricted?: boolean;
/** Ref nation.gennum. 없으면 generalCount를 쓴다. */
gennum?: number;
/** 임관 금지(nation.scout). */
blockScout?: boolean;
/** 서버에서 purifyNationHtml로 정제한 임관 권유문. */
scoutMessage?: string;
}
/** Ref AllowJoinDestNation이 쓰는 actor·시점 정보. */
export interface NationJoinContext {
actorNpcState: number;
relYear: number;
openingPartYear: number;
initialNationGenLimit: number;
} }
export interface RefGeneralTargetOptions { export interface RefGeneralTargetOptions {
@@ -43,11 +57,21 @@ export interface RefGeneralTargetOptions {
export interface RefNationTargetOptions { export interface RefNationTargetOptions {
nations: TurnCommandOption[]; nations: TurnCommandOption[];
nationTargets: Record<string, TurnCommandOption[]>; nationTargets: Record<string, TurnCommandOption[]>;
nationScoutMessages: TurnCommandNationScoutMessage[];
} }
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const; const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const; const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
const statsText = (entry: GeneralTargetSource): string =>
[
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · ');
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */ /** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
export const buildRefGeneralTargetOptions = (options: { export const buildRefGeneralTargetOptions = (options: {
actorId: number; actorId: number;
@@ -64,29 +88,30 @@ export const buildRefGeneralTargetOptions = (options: {
const isTroopExit = action === 'che_부대탈퇴지시'; const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => { const label = (() => {
if (action === 'che_발령') return `${entry.name} (${cityName})`; // 같은 국가 후보만 있는 명령은 국명을 반복하지 않는다.
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`; if (['che_발령', 'che_포상', 'che_몰수', 'che_선양'].includes(action ?? '')) {
return `${entry.name} (${cityName})`;
}
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`; return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})(); })();
const details = [ // Ref che_선양.vue는 후보에 (통/무/지)만 보인다.
const details = (
action === 'che_선양'
? []
: [
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`, entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`, entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`, entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
].filter((value): value is string => Boolean(value)); ]
).filter((value): value is string => Boolean(value));
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다. // 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다. // 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
const assignmentDetails = const assignmentDetails =
action === 'che_발령' action === 'che_발령'
? [ ? [
[ statsText(entry),
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
[ [
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`, entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`, entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
@@ -104,6 +129,9 @@ export const buildRefGeneralTargetOptions = (options: {
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
: undefined; : undefined;
// Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다.
const stats = statsText(entry);
if (stats && action !== 'che_발령') details.push(stats);
if (isTroopExit) { if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가'); details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
} }
@@ -125,33 +153,48 @@ export const buildRefGeneralTargetOptions = (options: {
npcState: entry.npcState, npcState: entry.npcState,
}; };
}; };
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] => // Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의
options.generals.filter(predicate).map((entry) => toOption(entry)); // no,name,nation,officer_level,npc,leadership,strength,intel만 내보낸다.
// 명령표는 모든 장수에게 전달되므로 금·쌀·병력·훈련·사기·부대·위치 도시를 싣지 않는다.
const toPublicOption = (entry: GeneralTargetSource): TurnCommandOption => {
const nationName = options.nationNames.get(entry.nationId) ?? null;
return {
value: entry.id,
label: `${entry.name} (${nationName ?? '재야'})`,
targetNames: { name: entry.name, nationName },
description: statsText(entry),
npcState: entry.npcState,
nationId: entry.nationId,
};
};
const projectPublic = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
options.generals.filter(predicate).map(toPublicOption);
// 재야는 국가가 아니므로 같은 국가 명령 후보가 없다. 해당 명령도 NotBeNeutral로 막힌다.
const isSameNation = (entry: GeneralTargetSource): boolean =>
options.actorNationId !== 0 && entry.nationId === options.actorNationId;
const generalTargets: Record<string, TurnCommandOption[]> = {}; const generalTargets: Record<string, TurnCommandOption[]> = {};
for (const action of SAME_NATION_GENERAL_COMMANDS) { for (const action of SAME_NATION_GENERAL_COMMANDS) {
generalTargets[action] = options.generals generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action));
.filter((entry) => entry.nationId === options.actorNationId)
.map((entry) => toOption(entry, action));
} }
for (const action of SAME_NATION_NATION_COMMANDS) { for (const action of SAME_NATION_NATION_COMMANDS) {
generalTargets[action] = options.generals generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId) .filter(isSameNation)
.map((entry) => toOption(entry, action)) .map((entry) => toOption(entry, action))
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow)); .sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
} }
generalTargets.che_선양 = project( generalTargets.che_선양 = options.generals
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId .filter((entry) => isSameNation(entry) && entry.id !== options.actorId)
); .map((entry) => toOption(entry, 'che_선양'));
generalTargets.che_등용 = project( generalTargets.che_등용 = projectPublic(
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId (entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
); );
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId); generalTargets.che_장수대상임관 = projectPublic((entry) => entry.id !== options.actorId);
return { return {
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다. // 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다.
generals: project((entry) => entry.npcState < 2), generals: projectPublic((entry) => entry.npcState < 2),
generalTargets, generalTargets,
}; };
}; };
@@ -169,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
'che_선전포고', 'che_선전포고',
'che_종전제의', 'che_종전제의',
'che_불가침파기제의', 'che_불가침파기제의',
'che_피장파장',
'che_이호경식',
'che_급습',
] as const; ] as const;
const nationAvailability = ( /** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
action: (typeof NATION_TARGET_COMMANDS)[number], export const COUNTER_STRATEGY_COMMAND_KEYS = [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
] as const;
/** Ref che_임관 exportJSVars()의 notAvailable: AllowJoinDestNation의 대상 국가 조건. */
const joinAvailability = (
join: NationJoinContext,
actorNationId: number, actorNationId: number,
target: NationTargetSource target: NationTargetSource
): { available: boolean; reason: string } => { ): { available: boolean; reason: string } => {
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' }; if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
const gennum = target.gennum ?? target.generalCount;
if (join.relYear < join.openingPartYear && join.initialNationGenLimit > 0 && gennum >= join.initialNationGenLimit) {
return { available: false, reason: '임관이 제한되고 있습니다.' };
}
if (target.blockScout) return { available: false, reason: '임관이 금지되어 있습니다.' };
if (join.actorNpcState < 2 && target.name.startsWith('ⓤ')) {
return { available: false, reason: '유저장은 태수국에 임관할 수 없습니다.' };
}
if (join.actorNpcState !== 9 && target.name.startsWith('ⓞ')) {
return { available: false, reason: '이민족 국가에 임관할 수 없습니다.' };
}
return { available: true, reason: '현재 임관 가능' };
};
const nationAvailability = (
action: (typeof NATION_TARGET_COMMANDS)[number],
actorNationId: number,
target: NationTargetSource,
strategyAvailable: boolean
): { available: boolean; reason: string } => {
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
// Ref 피장파장은 쓸 수 있는 전략이 하나도 없으면 모든 국가를 불가로 표시한다.
if (action === 'che_피장파장' && !strategyAvailable) {
return { available: false, reason: '사용할 수 있는 전략이 없습니다.' };
}
if (action === 'che_피장파장' || action === 'che_이호경식') {
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
return { available, reason: available ? '현재 발동 가능' : '선포, 전쟁중인 상대국에게만 가능합니다.' };
}
if (action === 'che_급습') {
// Ref AllowDiplomacyWithTerm(1, 12): 선포 상태이고 남은 기간이 12개월 이상.
const available = target.diplomacyState === DIPLOMACY_STATE.DECLARATION && target.diplomacyTerm >= 12;
return { available, reason: available ? '현재 발동 가능' : '선포 12개월 이상인 상대국에만 가능합니다.' };
}
if (action === 'che_물자원조') { if (action === 'che_물자원조') {
return target.diplomacyRestricted return target.diplomacyRestricted
? { available: false, reason: '상대국이 외교제한 중입니다.' } ? { available: false, reason: '상대국이 외교제한 중입니다.' }
@@ -205,7 +297,12 @@ const nationAvailability = (
export const buildRefNationTargetOptions = (options: { export const buildRefNationTargetOptions = (options: {
actorNationId: number; actorNationId: number;
nations: readonly NationTargetSource[]; nations: readonly NationTargetSource[];
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
join?: NationJoinContext;
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
strategyAvailable?: boolean;
}): RefNationTargetOptions => { }): RefNationTargetOptions => {
const strategyAvailable = options.strategyAvailable ?? true;
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({ const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
value: entry.id, value: entry.id,
label: entry.name, label: entry.name,
@@ -217,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
for (const action of NATION_TARGET_COMMANDS) { for (const action of NATION_TARGET_COMMANDS) {
nationTargets[action] = options.nations nationTargets[action] = options.nations
.map((entry) => { .map((entry) => {
const availability = nationAvailability(action, options.actorNationId, entry); const availability = nationAvailability(action, options.actorNationId, entry, strategyAvailable);
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`; const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : ''; const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
return { return {
@@ -238,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
) )
.map(({ power: _power, ...entry }) => entry); .map(({ power: _power, ...entry }) => entry);
} }
return { nations: baseOptions, nationTargets }; const join = options.join;
if (join) {
// Ref 임관 목록은 nation 표 순서 그대로이며 불가 국가도 남긴다. 재야(0)는 국가가 아니다.
nationTargets.che_임관 = options.nations.flatMap((entry, index) => {
if (entry.id <= 0) return [];
const availability = joinAvailability(join, options.actorNationId, entry);
return [
{
...baseOptions[index]!,
availableNow: availability.available,
description: `${availability.reason} · ${baseOptions[index]!.description}`,
},
];
});
}
// Ref che_임관·che_장수대상임관은 모든 국가의 권유문(scout_msg)을 함께 내보낸다.
const nationScoutMessages = options.nations
.filter((entry) => entry.id > 0)
.map((entry) => ({
nationId: entry.id,
name: entry.name,
color: entry.color,
message: entry.scoutMessage ?? '',
}));
return { nations: baseOptions, nationTargets, nationScoutMessages };
}; };
const RESOURCE_ACTION_GUIDE = [ const RESOURCE_ACTION_GUIDE = [
@@ -5,6 +5,7 @@ import { SystemClock } from '@sammo-ts/common';
import { import {
createDatabaseTurnHooks, createDatabaseTurnHooks,
DatabaseTurnDaemonCommandQueue, DatabaseTurnDaemonCommandQueue,
DatabaseTurnDaemonLease,
EngineStateManager, EngineStateManager,
InMemoryTurnStateStore, InMemoryTurnStateStore,
InMemoryTurnWorld, InMemoryTurnWorld,
@@ -695,6 +696,10 @@ liveDescribe('auction worker durable recovery', () => {
const redisConnector = createRedisConnector(resolveRedisConfigFromEnv()); const redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
await redisConnector.connect(); await redisConnector.connect();
// 독립 worker는 활성 daemon의 clock_ready fence가 있을 때만 GAME_TIME을 진행한다.
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile: profileName });
await lease.acquire();
await lease.markClockReady();
const keys = buildAuctionTimerKeys(profileName); const keys = buildAuctionTimerKeys(profileName);
const abortController = new AbortController(); const abortController = new AbortController();
const worker = runAuctionWorker({ signal: abortController.signal }); const worker = runAuctionWorker({ signal: abortController.signal });
@@ -725,6 +730,8 @@ liveDescribe('auction worker durable recovery', () => {
} finally { } finally {
abortController.abort(); abortController.abort();
await worker; await worker;
await lease.close();
await connector.prisma.turnDaemonLease.delete({ where: { profile: profileName } });
await redisConnector.client.del([keys.timerKey, keys.historyKey]); await redisConnector.client.del([keys.timerKey, keys.historyKey]);
await redisConnector.disconnect(); await redisConnector.disconnect();
await connector.prisma.errorLog.deleteMany({ await connector.prisma.errorLog.deleteMany({
@@ -0,0 +1,95 @@
import { PassThrough } from 'node:stream';
import type { ServerResponse } from 'node:http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
const payload = (): GameSessionTokenPayload => ({
version: 1,
sessionId: 'test-session',
profile: 'che:default',
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
user: {
id: 'test-user',
username: 'test',
displayName: 'test',
roles: ['user'],
createdAt: new Date().toISOString(),
},
sanctions: {},
});
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
const response = new PassThrough();
const frames: string[] = [];
response.on('data', (chunk) => frames.push(String(chunk)));
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
return { response, frames, connection };
};
afterEach(() => vi.useRealTimers());
describe('authenticated realtime connection', () => {
it('does not forward an in-flight result after the token is revoked during its work', async () => {
const auth = payload();
let current: GameSessionTokenPayload | null = auth;
const { connection, frames } = fixture(async () => current, auth);
connection.enqueue(async () => {
current = null;
return 'private invalidation';
});
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(frames).toEqual([]);
});
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
const auth = payload();
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
connection.enqueue(async () => 'private invalidation');
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(frames).toEqual([]);
});
it('fails closed when authentication storage fails', async () => {
const { connection, frames } = fixture(async () => {
throw new Error('redis unavailable');
});
connection.enqueue(async () => 'private invalidation');
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(frames).toEqual([]);
});
it('ends an idle expired stream and releases subscriptions only once', async () => {
vi.useFakeTimers();
const auth = payload();
const { connection } = fixture(async () => auth, auth);
const cleanup = vi.fn();
connection.onClose(cleanup);
await vi.advanceTimersByTimeAsync(60_001);
expect(connection.closed).toBe(true);
expect(cleanup).toHaveBeenCalledTimes(1);
connection.close();
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('bounds queued work while an earlier event is blocked', async () => {
const auth = payload();
const { connection, frames } = fixture(async () => auth, auth);
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
connection.enqueue(async () => {
await waiting;
return 'private invalidation';
});
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
expect(connection.closed).toBe(true);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(frames).toEqual([]);
});
it('ends a slow socket instead of retaining further frames', async () => {
const auth = payload();
const { connection, response } = fixture(async () => auth, auth);
response.write = vi.fn(() => false);
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
await vi.waitFor(() => expect(connection.closed).toBe(true));
expect(response.writableEnded).toBe(true);
});
});
+75
View File
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false }); expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
}); });
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
.png()
.toBuffer();
expect(png.length).toBeLessThan(1024 * 1024);
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(upload).not.toHaveBeenCalled();
});
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
const pending: Array<() => void> = [];
const upload = vi.fn(
() =>
new Promise<{ publicUrl: string }>((resolve) => {
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
})
);
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const caller = appRouter.createCaller(fixture.context);
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
try {
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
});
it('rejects SVG before a native image parser or upload is used', async () => {
const upload = vi.fn();
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(upload).not.toHaveBeenCalled();
});
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const caller = appRouter.createCaller(fixture.context);
const makeGif = (pages: number) =>
sharp(Buffer.alloc(pages * 4, 255), {
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
})
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
.toBuffer();
const excessive = await makeGif(257);
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(upload).not.toHaveBeenCalled();
const ordinary = await makeGif(3);
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
animated: true,
format: 'webp',
});
});
it('preserves an AVIF editor image when resizing is unnecessary', async () => { it('preserves an AVIF editor image when resizing is unnecessary', async () => {
const upload = vi.fn(async ({ filename }: { filename: string }) => ({ const upload = vi.fn(async ({ filename }: { filename: string }) => ({
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`, publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
+16 -1
View File
@@ -1,9 +1,24 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js'; import { loadClockAdminStatus, loadClockReadiness, loadProfileReadiness } from '../src/services/clockReadiness.js';
describe('clock reconciliation readiness', () => { describe('clock reconciliation readiness', () => {
it.each([false, true])('requires the profile daemon lease to be ready: %s', async (ready) => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({ clockPhase: 'PREOPEN', clockRevision: 1n, deadlineGeneration: 0n })),
},
clockProjectionOutbox: { count: vi.fn(async () => 0) },
$queryRaw: vi.fn(async () => [{ ready }]),
} as unknown as DatabaseClient;
await expect(loadProfileReadiness(db, 'che:default')).resolves.toMatchObject({
ok: ready,
daemonReady: ready,
clock: { reconciliationComplete: true, gameplayEnabled: false },
});
});
it('fails closed when the reconciliation schema is not available', async () => { it('fails closed when the reconciliation schema is not available', async () => {
const db = {} as DatabaseClient; const db = {} as DatabaseClient;
await expect(loadClockReadiness(db)).resolves.toEqual({ await expect(loadClockReadiness(db)).resolves.toEqual({
+27
View File
@@ -53,6 +53,29 @@ describe('turn command argument input', () => {
{ key: 'destNationId', kind: 'select', optionSource: 'nations' }, { key: 'destNationId', kind: 'select', optionSource: 'nations' },
{ key: 'amountList', kind: 'numberTuple' }, { key: 'amountList', kind: 'numberTuple' },
]); ]);
// Ref che_피장파장.vue: '전략 :' select에 자신을 뺀 전략 이름을 보인다.
expect(fields.find((entry) => entry.key === 'che_피장파장')?.fields).toMatchObject([
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
{
key: 'commandType',
label: '전략',
kind: 'select',
options: [
{ value: 'che_필사즉생', label: '필사즉생' },
{ value: 'che_백성동원', label: '백성동원' },
{ value: 'che_수몰', label: '수몰' },
{ value: 'che_허보', label: '허보' },
{ value: 'che_의병모집', label: '의병모집' },
{ value: 'che_이호경식', label: '이호경식' },
{ value: 'che_급습', label: '급습' },
],
},
]);
// Ref che_숙련전환.vue: '감소 대상 숙련 :', '전환 대상 숙련 :'.
expect(fields.find((entry) => entry.key === 'che_숙련전환')?.fields).toMatchObject([
{ key: 'srcArmType', label: '감소 대상 숙련', kind: 'select', optionSource: 'armTypes' },
{ key: 'destArmType', label: '전환 대상 숙련', kind: 'select', optionSource: 'armTypes' },
]);
expect( expect(
nationFields nationFields
@@ -321,6 +344,9 @@ describe('turn command argument input', () => {
'che_계략_향낭', 'che_계략_향낭',
]); ]);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명'); expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
expect(items.item[1]?.availableNow).toBe(true);
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 불가로 표시한다.
expect(items.item[0]?.availableNow).toBe(false);
}); });
it('shows only zero-count buyable items selected by an explicit scenario pool', () => { it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
@@ -341,5 +367,6 @@ describe('turn command argument input', () => {
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']); expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요'); expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
expect(items.item[1]?.availableNow).toBe(false);
}); });
}); });
+190 -3
View File
@@ -5,6 +5,7 @@ import {
buildRefGeneralTargetOptions, buildRefGeneralTargetOptions,
buildRefNationTargetOptions, buildRefNationTargetOptions,
type GeneralTargetSource, type GeneralTargetSource,
type NationTargetSource,
} from '../src/turns/commandTargets.js'; } from '../src/turns/commandTargets.js';
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({ const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
@@ -49,8 +50,9 @@ describe('Ref command general targets', () => {
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => { it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
const options = buildRefGeneralTargetOptions({ const options = buildRefGeneralTargetOptions({
actorId: 1, actorId: 1,
actorNationId: 0, // 이름·국가·도시·부대 원본이 없는 경우. 재야 actor는 같은 국가 후보가 없으므로 국가 ID만 둔다.
generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })], actorNationId: 5,
generals: [general({ name: '없음', nationId: 5, cityId: 0, troopId: 99 })],
nationNames: new Map(), nationNames: new Map(),
cityNames: new Map(), cityNames: new Map(),
troopNames: new Map(), troopNames: new Map(),
@@ -118,7 +120,10 @@ describe('Ref command general targets', () => {
); );
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000'); expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000');
expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대'); expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대');
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔'); // Ref ProcessGeneralAmount.vue처럼 증여 후보도 (통/무/지)를 함께 보인다. 발령의 줄바꿈 형식은 쓰지 않는다.
expect(detailed.generalTargets.che_증여?.[1]?.description).toBe(
'금 100 · 쌀 200 · 병력 900 · 훈련 80 · 사기 70 · 통솔 100 · 무력 95 · 지력 0'
);
expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([ expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([
{ name: '본인', nationName: '아국', cityName: '업', troopName: null }, { name: '본인', nationName: '아국', cityName: '업', troopName: null },
{ name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' }, { name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
@@ -132,6 +137,106 @@ describe('Ref command general targets', () => {
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000'); expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대'); expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
}); });
it('limits cross-nation recruitment and joining targets to the fields Ref exports', () => {
const foreign = general({
id: 7,
name: '타국장수',
nationId: 2,
cityId: 20,
officerLevel: 1,
gold: 9000,
rice: 8000,
crew: 7000,
train: 90,
atmos: 80,
troopId: 7,
leadership: 75,
strength: 60,
intel: 45,
});
const wanderer = general({ id: 8, name: '재야장수', nationId: 0, cityId: 30, gold: 500, rice: 400 });
const options = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: [general({}), foreign, wanderer],
nationNames: new Map([
[1, '아국'],
[2, '타국'],
]),
cityNames: new Map([
[10, '업'],
[20, '허창'],
[30, '단양'],
]),
troopNames: new Map([[7, '비밀부대']]),
});
for (const list of [
options.generalTargets.che_등용,
options.generalTargets.che_장수대상임관,
options.generals,
]) {
const target = list?.find((entry) => entry.value === 7);
// Ref: no,name,nation,officer_level,npc,leadership,strength,intel
expect(target).toEqual({
value: 7,
label: '타국장수 (타국)',
targetNames: { name: '타국장수', nationName: '타국' },
description: '통솔 75 · 무력 60 · 지력 45',
npcState: 0,
nationId: 2,
});
const serialized = JSON.stringify(list);
for (const hidden of ['9,000', '9000', '8000', '7000', '허창', '단양', '비밀부대', '훈련', '사기']) {
expect(serialized).not.toContain(hidden);
}
expect(list?.find((entry) => entry.value === 8)).toMatchObject({
label: '재야장수 (재야)',
targetNames: { name: '재야장수', nationName: null },
nationId: 0,
});
}
});
it('gives a wandering actor no same-nation general candidates', () => {
const options = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 0,
generals: [general({ nationId: 0 }), general({ id: 2, name: '다른재야', nationId: 0, gold: 900 })],
nationNames: new Map(),
cityNames: new Map([[10, '업']]),
});
for (const action of ['che_증여', 'che_선양', 'che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) {
expect(options.generalTargets[action]).toEqual([]);
}
expect(options.generalTargets.che_장수대상임관?.map((entry) => entry.value)).toEqual([2]);
expect(JSON.stringify(options.generalTargets.che_장수대상임관)).not.toContain('900');
});
it('shows only city and Ref stats for abdication candidates', () => {
const options = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: [
general({}),
general({
id: 2,
name: '후계',
gold: 100,
rice: 200,
crew: 300,
leadership: 80,
strength: 70,
intel: 60,
}),
],
nationNames: new Map([[1, '아국']]),
cityNames: new Map([[10, '업']]),
});
expect(options.generalTargets.che_선양).toMatchObject([
{ value: 2, label: '후계 (업)', description: '통솔 80 · 무력 70 · 지력 60' },
]);
});
}); });
describe('Ref nation target guidance', () => { describe('Ref nation target guidance', () => {
@@ -219,6 +324,88 @@ describe('Ref nation target guidance', () => {
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴'); expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false }); expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
}); });
it('marks counter-strategy targets like Ref exportJSVars notAvailable', () => {
const withDeclaration = [
...nations,
{ ...nations[3]!, id: 5, name: '선포국', diplomacyState: 1, diplomacyTerm: 12 },
{ ...nations[3]!, id: 6, name: '신규선포국', diplomacyState: 1, diplomacyTerm: 11 },
];
const result = buildRefNationTargetOptions({ actorNationId: 1, nations: withDeclaration });
const available = (action: string) =>
result.nationTargets[action]?.filter((entry) => entry.availableNow).map((entry) => entry.value);
// 피장파장·이호경식: AllowDiplomacyBetweenStatus([0, 1])
expect(available('che_피장파장')).toEqual([4, 5, 6]);
expect(available('che_이호경식')).toEqual([4, 5, 6]);
// 급습: AllowDiplomacyWithTerm(1, 12)
expect(available('che_급습')).toEqual([5]);
expect(result.nationTargets.che_급습?.find((entry) => entry.value === 6)?.description).toContain(
'선포 12개월 이상인 상대국에만 가능합니다.'
);
expect(result.nationTargets.che_피장파장?.find((entry) => entry.value === 1)).toMatchObject({
availableNow: false,
});
const exhausted = buildRefNationTargetOptions({
actorNationId: 1,
nations: withDeclaration,
strategyAvailable: false,
});
expect(exhausted.nationTargets.che_피장파장?.every((entry) => entry.availableNow === false)).toBe(true);
expect(exhausted.nationTargets.che_피장파장?.find((entry) => entry.value === 4)?.description).toContain(
'사용할 수 있는 전략이 없습니다.'
);
expect(exhausted.nationTargets.che_이호경식?.some((entry) => entry.availableNow)).toBe(true);
});
it('keeps the Ref nation order for appointment targets and exports purified scout messages', () => {
const joinNations: NationTargetSource[] = [
{ ...nations[0]!, scoutMessage: '<p>어서 오시오</p>' },
{ ...nations[1]!, blockScout: true },
{ ...nations[2]!, name: 'ⓤ태수국' },
{ ...nations[3]!, name: 'ⓞ이민족' },
{ ...nations[3]!, id: 5, name: '초반만원국', generalCount: 3, gennum: 10 },
];
const join = { actorNpcState: 0, relYear: 1, openingPartYear: 3, initialNationGenLimit: 10 };
const result = buildRefNationTargetOptions({ actorNationId: 0, nations: joinNations, join });
expect(result.nationTargets.che_임관?.map((entry) => [entry.value, entry.availableNow])).toEqual([
[1, true],
[2, false],
[3, false],
[4, false],
[5, false],
]);
expect(result.nationTargets.che_임관?.map((entry) => entry.description?.split(' · ')[0])).toEqual([
'현재 임관 가능',
'임관이 금지되어 있습니다.',
'유저장은 태수국에 임관할 수 없습니다.',
'이민족 국가에 임관할 수 없습니다.',
'임관이 제한되고 있습니다.',
]);
// 초반 제한이 끝나면 인원 제한은 없고, NPC(9)는 이민족 국가에 임관할 수 있다.
const later = buildRefNationTargetOptions({
actorNationId: 0,
nations: joinNations,
join: { ...join, actorNpcState: 9, relYear: 3 },
});
expect(later.nationTargets.che_임관?.map((entry) => entry.availableNow)).toEqual([
true,
false,
true,
true,
true,
]);
expect(result.nationScoutMessages).toEqual(
joinNations.map((entry) => ({
nationId: entry.id,
name: entry.name,
color: entry.color,
message: entry.scoutMessage ?? '',
}))
);
// join 정보가 없으면 임관은 기존 공통 국가 목록을 그대로 쓴다.
expect(buildRefNationTargetOptions({ actorNationId: 0, nations }).nationTargets.che_임관).toBeUndefined();
});
}); });
describe('Ref amount presets', () => { describe('Ref amount presets', () => {
@@ -384,8 +384,8 @@ integration('generic general creation through the durable turn daemon', () => {
actorUserId: userId, actorUserId: userId,
}); });
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const turnGridOffsetSeconds = // 상속 턴 시간대는 daemon cursor가 아닌 5분 격자의 위상을 보존한다.
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300; const turnGridOffsetSeconds = ((created.turnTime.getTime() / 1000) % 300 + 300) % 300;
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35); expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
expect(turnGridOffsetSeconds).toBeLessThan(40); expect(turnGridOffsetSeconds).toBeLessThan(40);
@@ -1,3 +1,4 @@
import { asRecord } from '@sammo-ts/common';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
@@ -742,7 +743,7 @@ integration('diplomacy document message persistence', () => {
} }
); );
it('rolls back the document and notices if the audit insert fails', async () => { it('commits the document and notices with a gap when audit insert fails', async () => {
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`); BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
@@ -754,15 +755,19 @@ integration('diplomacy document message persistence', () => {
brief: '감사 실패', brief: '감사 실패',
detail: '롤백', detail: '롤백',
}) })
).rejects.toThrow('injected audit insert failure'); ).resolves.toBeDefined();
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0); expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(1);
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0); expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBeGreaterThan(0);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0); expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
expect( expect(
await db.inputEvent.findUniqueOrThrow({ await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` }, where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
}) })
).toMatchObject({ status: 'FAILED', attempts: 1 }); ).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(
asRecord((await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldStateId } })).meta)
.playAuditGap
).toMatchObject({ serverId: requestPrefix });
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
const deleteProfileRedisKeys = async (): Promise<void> => { const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) return; if (!redis) return;
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) { for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
if (keys.length > 0) await redis.client.del(keys); if (keys.length > 0) await redis.client.del(keys);
} }
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
if (!created) throw new Error('failed to seed diplomacy HTML access token'); if (!created) throw new Error('failed to seed diplomacy HTML access token');
accessToken = created.accessToken; accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer(); server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port }); baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000); }, 30_000);
@@ -59,6 +59,7 @@ const classifications = {
'join.reselectPoolGeneral', 'join.reselectPoolGeneral',
'join.selectPoolGeneral', 'join.selectPoolGeneral',
'nation.appoint', 'nation.appoint',
'nation.cityOfficeRequest',
'nation.changePermission', 'nation.changePermission',
'nation.kick', 'nation.kick',
'nation.setBill', 'nation.setBill',
@@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => {
// count independently catches mutations that were added to a router but never mounted. // count independently catches mutations that were added to a router but never mounted.
expect(declaredCount).toBe(actual.length); expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length); expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(88);
expect(actual).toEqual(classified); expect(actual).toEqual(classified);
}); });
+84 -1
View File
@@ -1,6 +1,8 @@
import { createServer, type Socket } from 'node:net';
import { createClient } from 'redis';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js'; import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
describe('RedisGatewayFlushSubscriber', () => { describe('RedisGatewayFlushSubscriber', () => {
it('reports asynchronous flush handler failures with event context', async () => { it('reports asynchronous flush handler failures with event context', async () => {
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
expect(stopped).toBe(true); expect(stopped).toBe(true);
}); });
}); });
describe('RedisFlushStore authorization state', () => {
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
let persisted: string | null = null;
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
await store.assertInitialized();
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
persisted = '200';
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
expect(mGet).toHaveBeenCalledTimes(4);
});
it('retains revocation after local cache eviction and a fresh store instance', async () => {
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
const store = new RedisFlushStore(client, 'fixture:flush');
for (let i = 0; i < 4100; i++)
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
});
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
'rejects missing or corrupt initialization %s',
async (baseline) => {
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
await expect(store.assertInitialized()).rejects.toThrow();
}
);
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
});
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
const offlineGet = vi.fn(async () => ['0', null]);
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
expect(offlineGet).not.toHaveBeenCalled();
const mGet = vi.fn(async () => ['0', '100']);
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
});
});
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
const sockets = new Set<Socket>();
let receivedRead = false;
const server = createServer((socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('data', (data) => {
const request = data.toString();
// Real node-redis handshake succeeds; its MGET command receives no reply.
if (request.includes('MGET')) {
receivedRead = true;
return;
}
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
socket.write('+OK\r\n'.repeat(count));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
try {
await client.connect();
expect(client.isReady).toBe(true);
const start = Date.now();
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
expect(receivedRead).toBe(true);
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
expect(Date.now() - start).toBeLessThan(4500);
} finally {
if (client.isOpen) client.destroy();
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
@@ -0,0 +1,51 @@
import fastify from 'fastify';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { describe, expect, it } from 'vitest';
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
const internalFailure = () => {
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
};
const badInput = () => {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '잘못된 입력입니다.',
cause: new Error('synthetic-private-cause'),
});
};
describe('public HTTP error privacy', () => {
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/game',
trpcOptions: {
router: gameRouter({
internal: sessionActivityProcedure.query(internalFailure),
invalid: sessionActivityProcedure.query(badInput),
}),
createContext: () => ({}),
},
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
for (const prefix of ['game']) {
for (const procedure of ['internal', 'invalid']) {
const response = await fetch(`${address}/${prefix}/${procedure}`);
const body = (await response.json()) as {
error: { message: string; data: { code: string; stack?: string } };
};
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
expect(body.error.message).toBe(
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
);
expect(body.error.data.stack).toBeUndefined();
expect(JSON.stringify(body)).not.toContain('synthetic-');
}
}
} finally {
await app.close();
}
});
});
@@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = {
}; };
const buildContext = ( const buildContext = (
options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {} options: {
auth?: GameSessionTokenPayload | null;
hasVoted?: boolean;
preopenClock?: boolean;
officerLevel?: number;
officeRows?: Array<{ id: number; userId: string; meta: Record<string, unknown> }>;
} = {}
) => ) =>
({ ({
auth: options.auth === undefined ? auth : options.auth, auth: options.auth === undefined ? auth : options.auth,
@@ -30,14 +36,19 @@ const buildContext = (
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
id: 7, id: 7,
userId: 'owner', userId: 'owner',
officerLevel: options.officerLevel ?? 1,
nationId: 2, nationId: 2,
})), })),
findMany: vi.fn(async () => [ findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
input.where?.meta
? (options.officeRows ?? [])
: [
{ id: 7, name: '유비', nationId: 2 }, { id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 }, { id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 }, { id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 }, { id: 10, name: '재야장수', nationId: 0 },
]), ]
),
}, },
worldState: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
@@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({ expect(result).toEqual({
serverId: 'che_260819_front', serverId: 'che_260819_front',
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
onlineUserCount: 4, onlineUserCount: 4,
onlineNations: '【촉】, 【위】', onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우', onlineGenerals: '유비, 관우',
@@ -173,3 +185,31 @@ describe('general.getFrontStatus', () => {
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
}); });
}); });
it('only notifies chiefs about pending requests belonging to current members and owners', async () => {
const pending = {
id: '8:760',
generalId: 8,
userId: 'user-8',
nationId: 2,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: 0,
dueTick: 3600000,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
};
const officeRows = [
{ id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } },
{ id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } },
{ id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } },
{ id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } },
];
const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows }));
expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']);
const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows }));
expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]);
});
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
const expectedOwnerCounts: Record<string, number> = { const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19, 'durable-journal': 19,
'engine-owned': 38, 'engine-owned': 39,
'explicit-no-realtime-consumer': 7, 'explicit-no-realtime-consumer': 7,
'external-upload': 1, 'external-upload': 1,
'mixed-saga': 9, 'mixed-saga': 9,
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
const rows = parseManifest(); const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route); const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(88);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length); expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort()); expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames()); expect(manifestRoutes).toEqual(mountedMutationNames());
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) { if (!redis) {
return; return;
} }
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
for await (const keys of redis.client.scanIterator({ for await (const keys of redis.client.scanIterator({
MATCH: `sammo:game:*:${profileName}:*`, MATCH: `sammo:game:*:${profileName}:*`,
COUNT: 100, COUNT: 100,
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
} }
accessToken = created.accessToken; accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer(); server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port }); baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000); }, 30_000);
@@ -1,3 +1,6 @@
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { once } from 'node:events';
import type { AddressInfo } from 'node:net';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common'; import { ChangeJournal } from '@sammo-ts/common';
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
expect(memberCommand).not.toHaveBeenCalled(); expect(memberCommand).not.toHaveBeenCalled();
}); });
}); });
describe('city office requests', () => {
it.each(['approve', 'reject', 'withdraw'] as const)(
'binds %s to the session actor and durable request ID',
async (action) => {
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand }));
await expect(
caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' })
).resolves.toEqual({ ok: true });
expect(requestCommand).toHaveBeenCalledWith({
type: 'cityOfficeRequest',
userId: 'user-22',
generalId: 22,
requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest',
action,
targetGeneralId: 30,
officeRequestId: '30:760',
});
}
);
it('validates request fields and propagates engine rejection', async () => {
const requestCommand = vi
.fn()
.mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' });
const caller = appRouter.createCaller(createContext({ requestCommand }));
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({
message: '이번 분기 자원 완료',
});
// @ts-expect-error invalid officer must also be rejected at the HTTP boundary
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(requestCommand).toHaveBeenCalledTimes(1);
});
it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => {
const office = {
id: '30:760',
generalId: 30,
userId: 'user-30',
nationId: 1,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: 0,
dueTick: 3600000,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
};
const me = { ...baseGeneral, officerLevel };
const caller = appRouter.createCaller(
createContext({
me,
db: {
general: {
findFirst: vi.fn(async () => me),
findMany: vi.fn(async () => [
me,
{
...baseGeneral,
id: 30,
userId: 'user-30',
officerLevel: 1,
meta: { belong: 5, cityOfficeRequest: office },
},
]),
},
nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) },
city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) },
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
meta: {},
config: { stat: { chiefMin: 65 } },
clockBaseTime: new Date('2026-01-01'),
clockTick: 0n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-01-01'),
clockPhase: 'RUNNING',
})),
},
},
})
);
const result = await caller.nation.getCityOfficeRequests();
expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0);
expect(JSON.stringify(result)).not.toContain('user-30');
expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false);
});
});
it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => {
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
const server = createHTTPServer({
router: appRouter,
createContext: ({ req }): GameApiContext => ({
...createContext({ requestId: 'office-http-wire', requestCommand }),
auth: req.headers['x-test-session'] ? auth : null,
}),
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address() as AddressInfo;
const send = (body: unknown, authenticated = true) =>
fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) },
body: JSON.stringify(body),
});
try {
expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401);
expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400);
expect(requestCommand).not.toHaveBeenCalled();
expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200);
expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' }));
requestCommand.mockResolvedValueOnce({
type: 'cityOfficeRequest',
ok: false,
generalId: 22,
reason: '이미 처리된 요청',
});
expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
summarizeNationPeriod, summarizeNationPeriod,
projectCurrentSettlement,
zAuditNation,
type AuditNationData, type AuditNationData,
type NationMonthPoint, type NationMonthPoint,
} from '../src/router/playAudit/nationSeries.js'; } from '../src/router/playAudit/nationSeries.js';
const population = { const population = {
count: 0, count: 0,
crew: null,
gold: 0, gold: 0,
rice: 0, rice: 0,
dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }, dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
@@ -89,3 +92,44 @@ describe('play audit half-year summary', () => {
}); });
}); });
}); });
describe('current committed settlement', () => {
it.each([
[1, 'gold'],
[7, 'rice'],
] as const)('exposes month %s settlement before month-end', (month, resource) => {
const meta = {
playAuditFlows: {
year: 200,
month,
complete: true,
entries: { [`2:${resource}`]: { nationId: 2, resource, income: 1234.5, paid: 234 } },
},
};
expect(projectCurrentSettlement(meta, 200, month, 2)[resource]).toEqual({ income: 1234.5, paid: 234 });
expect(projectCurrentSettlement(meta, 200, month + 1, 2)[resource]).toBeNull();
expect(projectCurrentSettlement(meta, 200, month, 3)[resource]).toBeNull();
});
it('keeps absent and malformed observations unknown, preserves actual zero', () => {
expect(projectCurrentSettlement({}, 200, 1, 2).gold).toBeNull();
const meta = {
playAuditFlows: {
year: 200,
month: 1,
complete: false,
entries: { '2:gold': { nationId: 2, resource: 'gold', income: 0, paid: 0 } },
},
};
expect(projectCurrentSettlement(meta, 200, 1, 2)).toMatchObject({
gold: { income: 0, paid: 0 },
complete: false,
});
meta.playAuditFlows.entries['2:gold'].income = NaN;
expect(projectCurrentSettlement(meta, 200, 1, 2).gold).toBeNull();
});
it('reads old snapshots without inventing troop counts', () => {
const old = JSON.parse(JSON.stringify(nation(1)));
delete old.populations.human.crew;
expect(zAuditNation.parse(old).populations.human.crew).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { loadProfileReadiness } from '../src/services/clockReadiness.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('profile deployment readiness', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
});
afterAll(async () => {
await close?.();
});
it('rejects absent, initializing, expired and other-profile leases; accepts ready paused/preopen runtime', async () => {
const rollback = new Error('readiness fixture rollback');
await expect(
db.$transaction(async (tx) => {
// Shared integration fixtures may leave projection work; rollback restores it.
await tx.clockProjectionOutbox.deleteMany();
await tx.worldState.create({
data: {
id: -998901,
scenarioCode: 'readiness-fixture',
currentYear: 190,
currentMonth: 1,
tickSeconds: 300,
clockPhase: 'PREOPEN',
config: {},
meta: {},
},
});
const profile = 'readiness:fixture';
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
await tx.turnDaemonLease.create({
data: {
profile,
ownerId: 'fixture',
fencingEpoch: 1n,
heartbeatAt: new Date(),
leaseUntil: new Date(Date.now() + 60_000),
clockReady: false,
},
});
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
await tx.turnDaemonLease.update({ where: { profile }, data: { clockReady: true } });
expect((await loadProfileReadiness(tx, profile)).ok).toBe(true);
expect((await loadProfileReadiness(tx, 'readiness:other')).ok).toBe(false);
await tx.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 60_000) },
});
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
throw rollback;
})
).rejects.toBe(rollback);
});
});
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PassThrough } from 'node:stream';
import type { ServerResponse } from 'node:http';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { RealtimeConnectionAdmission } from '../src/realtime/connectionAdmission.js';
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
afterEach(() => vi.useRealTimers());
describe('realtime connection admission', () => {
it('reserves the process total before authentication and counts live streams too', () => {
const budget = new RealtimeConnectionAdmission({ connections: 2, perUser: 2, opening: 2 });
const first = budget.begin(vi.fn())!;
const second = budget.begin(vi.fn())!;
expect(first.reserveUser('actor')).toBe(true);
expect(first.startStreaming()).toBe(true);
expect(budget.begin(vi.fn())).toBeNull();
second.release();
const replacement = budget.begin(vi.fn())!;
expect(replacement).not.toBeNull();
replacement.release();
first.release();
});
it('shares the authenticated actor limit while allowing another actor', () => {
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 1, opening: 5 });
const first = budget.begin(vi.fn())!;
expect(first.reserveUser('one')).toBe(true);
const rejected = budget.begin(vi.fn())!;
expect(rejected.reserveUser('one')).toBe(false);
expect(rejected.startStreaming()).toBe(false);
rejected.release();
const other = budget.begin(vi.fn())!;
expect(other.reserveUser('two')).toBe(true);
first.release();
first.release();
const replacement = budget.begin(vi.fn())!;
expect(replacement.reserveUser('one')).toBe(true);
other.release();
replacement.release();
});
it('keeps timed-out and disconnected backend work admitted until it actually settles', async () => {
vi.useFakeTimers();
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 5, opening: 2 });
const timeout = vi.fn();
const stalled = budget.begin(timeout)!;
const disconnected = budget.begin(vi.fn())!;
expect(stalled.reserveUser('stalled')).toBe(true);
disconnected.cancelOpening();
await vi.advanceTimersByTimeAsync(5000);
expect(timeout).toHaveBeenCalledTimes(1);
expect(stalled.canContinue()).toBe(false);
expect(stalled.startStreaming()).toBe(false);
expect(disconnected.canContinue()).toBe(false);
expect(budget.begin(vi.fn())).toBeNull();
stalled.release();
const recovered = budget.begin(vi.fn())!;
expect(recovered.reserveUser('stalled')).toBe(true);
expect(recovered.startStreaming()).toBe(true);
await vi.advanceTimersByTimeAsync(5000);
expect(timeout).toHaveBeenCalledTimes(1);
disconnected.release();
recovered.release();
});
it('stops pending setup on shutdown and permanently refuses new admissions', () => {
const budget = new RealtimeConnectionAdmission();
const timeout = vi.fn();
const pending = budget.begin(timeout)!;
budget.close();
budget.close();
expect(timeout).toHaveBeenCalledTimes(1);
expect(pending.canContinue()).toBe(false);
expect(budget.begin(vi.fn())).toBeNull();
pending.release();
});
it('keeps a closed stream counted while its in-flight authentication is still pending', async () => {
const budget = new RealtimeConnectionAdmission({ connections: 1, perUser: 1, opening: 1 });
const slot = budget.begin(vi.fn())!;
expect(slot.reserveUser('actor')).toBe(true);
expect(slot.startStreaming()).toBe(true);
let finish!: () => void;
let started!: () => void;
const inFlight = new Promise<void>((resolve) => {
finish = resolve;
});
const running = new Promise<void>((resolve) => {
started = resolve;
});
const auth: GameSessionTokenPayload = {
version: 1,
sessionId: 'session',
profile: 'che:default',
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
user: {
id: 'actor',
username: 'actor',
displayName: 'actor',
roles: ['user'],
createdAt: new Date().toISOString(),
},
sanctions: {},
};
const response = new PassThrough();
const connection = new AuthenticatedRealtimeConnection(
auth,
response as unknown as ServerResponse,
async () => {
started();
await inFlight;
return auth;
}
);
connection.onClose(() => {
void connection.whenIdle().then(slot.release, slot.release);
});
connection.enqueue(async () => 'event: ready\ndata: {}\n\n');
await running;
connection.close();
expect(budget.begin(vi.fn())).toBeNull();
finish();
await connection.whenIdle();
const replacement = budget.begin(vi.fn())!;
expect(replacement.reserveUser('actor')).toBe(true);
replacement.release();
});
it('enforces the production 512 total and 32 pending openings without accumulating a queue', () => {
const budget = new RealtimeConnectionAdmission();
const pending = Array.from({ length: 32 }, () => budget.begin(vi.fn())!);
expect(pending.every(Boolean)).toBe(true);
expect(budget.begin(vi.fn())).toBeNull();
for (const slot of pending) slot.release();
const live = Array.from({ length: 512 }, (_, index) => {
const slot = budget.begin(vi.fn())!;
expect(slot.reserveUser(`actor-${index}`)).toBe(true);
expect(slot.startStreaming()).toBe(true);
return slot;
});
expect(budget.begin(vi.fn())).toBeNull();
live[0]!.release();
const replacement = budget.begin(vi.fn())!;
expect(replacement.reserveUser('replacement')).toBe(true);
expect(replacement.startStreaming()).toBe(true);
for (const slot of live) slot.release();
replacement.release();
});
});
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`); expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
expect(Object.values(headers)).not.toContain(secret); expect(Object.values(headers)).not.toContain(secret);
}); });
it('rejects oversized upload response JSON before trusting its path', async () => {
const filename = `${'d'.repeat(32)}.png`;
const store = new RemoteContentImageStore(
'https://image.example',
'https://image.example/public',
'synthetic-secret',
async () =>
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
);
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
});
}); });
+46
View File
@@ -0,0 +1,46 @@
import { Writable } from 'node:stream';
import fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
describe('HTTP log credential boundaries', () => {
it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => {
let output = '';
const stream = new Writable({
write(chunk, _encoding, done) {
output += String(chunk);
done();
},
});
const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } });
app.setErrorHandler(safeHttpErrorHandler);
app.post('/events', async () => {
throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), {
code: 'P2002',
detail: 'synthetic-detail-secret',
});
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const response = await fetch(`${address}/events?token=synthetic-query-secret`, {
method: 'POST',
headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' },
body: JSON.stringify({ password: 'synthetic-body-secret' }),
});
expect(await response.json()).toMatchObject({ message: 'Internal server error' });
await fetch(`${address}/synthetic-path-secret`);
const logs = output
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true);
expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true);
expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true);
for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) {
expect(output).not.toContain(`synthetic-${field}-secret`);
}
} finally {
await app.close();
}
});
});
@@ -1,3 +1,5 @@
import sharp from 'sharp';
import { buildGameEventChannel } from '@sammo-ts/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
@@ -172,7 +174,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) { if (!redis) {
return; return;
} }
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
for await (const keys of redis.client.scanIterator({ for await (const keys of redis.client.scanIterator({
MATCH: pattern, MATCH: pattern,
COUNT: 100, COUNT: 100,
@@ -416,7 +418,7 @@ const readAccessTelemetryState = async () => ({
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => { const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
if (!redis) return []; if (!redis) return [];
const keys = new Set<string>(); const keys = new Set<string>();
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) { for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
for (const key of batch) keys.add(key); for (const key of batch) keys.add(key);
} }
@@ -742,6 +744,8 @@ integration('game API security over HTTP transport', () => {
await redis.connect(); await redis.connect();
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName); accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer(); server = await createGameApiServer();
baseUrl = await server.app.listen({ baseUrl = await server.app.listen({
host: server.config.host, host: server.config.host,
@@ -818,6 +822,28 @@ integration('game API security over HTTP transport', () => {
} }
}); });
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
const input = { dataUrl: 'data:image/png;base64,AA==' };
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
expect(unauthenticated.response.status).toBe(401);
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
expect(denied.response.status).toBe(403);
const officer = await createAccessToken('image-officer', {});
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
expect(malformed.response.status).toBe(400);
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const oversized = await requestTrpc('board.uploadImage', {
method: 'POST',
accessToken: officer,
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
});
expect(oversized.response.status).toBe(400);
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
});
it('accepts an authenticated query from a POST JSON body', async () => { it('accepts an authenticated query from a POST JSON body', async () => {
const accessToken = await createAccessToken('json-query-body', {}); const accessToken = await createAccessToken('json-query-body', {});
const general = await requestTrpc('general.me', { const general = await requestTrpc('general.me', {
@@ -2317,9 +2343,51 @@ integration('game API security over HTTP transport', () => {
{ {
decisionId: decisionIds[0]!, decisionId: decisionIds[0]!,
ordinal: 0, ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127 steps: Array.from({ length: 128 }, (_, sequence) =>
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} } sequence === 127
: ({ ...step, sequence })), ? {
...step,
sequence,
kind: 'DECISION_START',
reservedAction: '휴식',
effectivePolicy: {
schemaVersion: 1,
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
secret: 'decision-secret',
},
secret: 'decision-secret',
},
}
: { ...step, sequence }
),
}, },
{ {
decisionId: decisionIds[0]!, decisionId: decisionIds[0]!,
@@ -2352,6 +2420,33 @@ integration('game API security over HTTP transport', () => {
], ],
}); });
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 }; const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
// 월 경계 뒤 장수의 첫 턴 전에도 직전 결정에 도달할 수 있어야 한다.
expect((await get('decisionHistory', admin, { generalId: decisionGeneral })).body).toMatchObject({
result: {
data: {
selection: 'LATEST',
month: { year: 190, month: 1 },
currentMonth: { year: 190, month: 2 },
items: expect.arrayContaining([expect.objectContaining({ id: decisionIds[0] })]),
},
},
});
expect(
(await get('decisionHistory', admin, { generalId: decisionGeneral, month: { year: 190, month: 2 } }))
.body
).toMatchObject({
result: { data: { selection: 'MONTH', month: { year: 190, month: 2 }, items: [] } },
});
expect(
(await get('decisionHistory', admin, { generalId: decisionGeneral, phase: 'nation' })).body
).toMatchObject({
result: {
data: { selection: 'LATEST', month: { year: 190, month: 1 }, items: [{ id: decisionIds[1] }] },
},
});
expect((await get('decisionHistory', admin, { generalId: 2147483647 })).body).toMatchObject({
result: { data: { selection: 'LATEST', month: { year: 190, month: 2 }, items: [] } },
});
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401); expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403); expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
const decisionList = await get('decisionHistory', admin, decisionInput); const decisionList = await get('decisionHistory', admin, decisionInput);
@@ -2852,6 +2947,132 @@ integration('game API security over HTTP transport', () => {
expect((await get('capabilities', admin)).body).toMatchObject({ expect((await get('capabilities', admin)).body).toMatchObject({
result: { data: { read: true, accounts: false } }, result: { data: { read: true, accounts: false } },
}); });
// 현재/과거 정렬은 전체 조건 안에서 수행하고 동점은 ID로 안정적으로 넘긴다.
const originalSortRows = await db.general.findMany({
where: { id: { in: [generalId, sameNationGeneralId] } },
select: { id: true, gold: true },
});
await db.general.updateMany({
where: { id: { in: [generalId, sameNationGeneralId] } },
data: { gold: 54321 },
});
const sortedInput = { nationId: ownerNationId, sort: 'gold', order: 'desc', limit: 1 };
expect((await get('generals', admin, sortedInput)).body).toMatchObject({
result: {
data: {
items: [{ id: generalId, gold: 54321 }],
nextCursor: { id: generalId, value: 54321 },
},
},
});
expect(
(await get('generals', admin, { ...sortedInput, cursor: { id: generalId, value: 54321 } })).body
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }] } } });
for (const row of originalSortRows)
await db.general.update({ where: { id: row.id }, data: { gold: row.gold } });
for (const sort of [
'gold',
'rice',
'crew',
'train',
'atmos',
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
]) {
for (const at of [undefined, { year: 190, month: 1 }]) {
expect((await get('generals', admin, { sort, at })).status).toBe(200);
}
}
const oldGeneral = await db.playAuditGeneral.findUniqueOrThrow({
where: { sampleId_generalId: { sampleId, generalId } },
});
await db.playAuditGeneral.create({
data: {
sampleId,
generalId: sameNationGeneralId,
nationId: oldGeneral.nationId,
cityId: oldGeneral.cityId,
npcState: oldGeneral.npcState,
data: { ...asRecord(oldGeneral.data), id: sameNationGeneralId, name: '동점과거장수' },
},
});
const historicalValue = past.gold;
expect(
(await get('generals', admin, { sort: 'gold', order: 'desc', at: { year: 190, month: 1 }, limit: 1 }))
.body
).toMatchObject({
result: { data: { items: [{ id: generalId }], nextCursor: { id: generalId, value: historicalValue } } },
});
expect(
(
await get('generals', admin, {
sort: 'gold',
order: 'desc',
at: { year: 190, month: 1 },
limit: 1,
cursor: { id: generalId, value: historicalValue },
})
).body
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }], nextCursor: null } } });
await db.playAuditGeneral.delete({
where: { sampleId_generalId: { sampleId, generalId: sameNationGeneralId } },
});
expect((await get('generals', admin, { sort: 'gold', cursor: 1 })).status).toBe(400);
expect((await get('generals', admin, { sort: 'gold; DROP TABLE general' })).status).toBe(400);
expect(
(await get('generals', admin, { sort: 'gold', at: { year: 190, month: 1 }, name: '%' })).body
).toMatchObject({ result: { data: { items: [] } } });
for (const accessToken of [
undefined,
await token(['admin']),
await token(['admin.playAudit.read:other:default']),
]) {
expect((await get('cityMap', accessToken, {})).status).toBe(accessToken ? 403 : 401);
expect((await get('generals', accessToken, { sort: 'crew' })).status).toBe(accessToken ? 403 : 401);
}
const citySource = await db.playAuditCity.findFirstOrThrow({ where: { sampleId }, select: { data: true } });
await db.playAuditCity.create({
data: {
sampleId,
cityId: 1,
nationId: ownerNationId,
data: { ...asRecord(citySource.data), id: 1, name: '지도과거도시' },
},
});
const map = await get('cityMap', admin, { at: { year: 190, month: 1 } });
expect(map.status).toBe(200);
expect(map.body).toMatchObject({
result: {
data: {
collected: true,
map: {
year: 190,
month: 1,
cityList: expect.arrayContaining([[1, 4, 0, ownerNationId, expect.any(Number), 1]]),
},
layout: {
cityList: expect.arrayContaining([
expect.objectContaining({ id: 1, name: '지도과거도시' }),
]),
},
},
},
});
expect(JSON.stringify(map.body)).not.toContain('must-not-expose');
await db.playAuditCity.delete({ where: { sampleId_cityId: { sampleId, cityId: 1 } } });
expect((await get('cityMap', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
result: { data: { collected: false, map: null } },
});
expect((await get('cityMap', admin, {})).status).toBe(200);
expect((await get('cityMap', admin, { at: { year: 9999, month: 1 } })).status).toBe(400);
const first = await get('generals', admin, { limit: 1 }); const first = await get('generals', admin, { limit: 1 });
expect(first.status).toBe(200); expect(first.status).toBe(200);
expect(first.body).toMatchObject({ expect(first.body).toMatchObject({
@@ -3119,6 +3340,39 @@ integration('game API security over HTTP transport', () => {
}, },
}, },
}); });
await db.playAuditNation.update({
where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } },
data: {
data: {
...asRecord(finalNation.data),
populations: {
human: { ...population, count: 2 },
npc: { ...population, count: 1 },
troopNpc: population,
},
},
},
});
await db.playAuditGeneral.createMany({
data: [1, 2].map((id) => ({
sampleId: `${seasonId}:190:6`,
generalId: id,
nationId: ownerNationId,
cityId: 0,
npcState: 0,
data: { crew: id * 1200 },
})),
});
await db.playAuditGeneral.create({
data: {
sampleId: `${seasonId}:190:6`,
generalId: 3,
nationId: ownerNationId,
cityId: 0,
npcState: 2,
data: { crew: 'invalid' },
},
});
const series = await get('nationSeries', admin, { const series = await get('nationSeries', admin, {
nationId: ownerNationId, nationId: ownerNationId,
from: { year: 190, month: 1 }, from: { year: 190, month: 1 },
@@ -3134,7 +3388,10 @@ integration('game API security over HTTP transport', () => {
year: 190, year: 190,
month: 1, month: 1,
complete: true, complete: true,
stock: { gold: 600 }, stock: {
gold: 600,
populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 0 } },
},
flows: { incomeGold: 21, incomeRice: 0 }, flows: { incomeGold: 21, incomeRice: 0 },
}, },
], ],
@@ -3166,6 +3423,59 @@ integration('game API security over HTTP transport', () => {
result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } }, result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } },
}); });
// 정산 commit 직후, 아직 월말 표본이 없는 상태를 HTTP로 확인한다.
for (const [settlementMonth, resource] of [
[1, 'gold'],
[7, 'rice'],
] as const) {
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
currentYear: 191,
currentMonth: settlementMonth,
meta: {
serverId: seasonId,
scenarioMeta: { startYear: 190 },
playAuditFlows: {
year: 191,
month: settlementMonth,
complete: true,
entries: {
[`${ownerNationId}:${resource}`]: {
nationId: ownerNationId,
resource,
income: 8765.5,
paid: 4321,
},
},
},
},
},
});
const input = {
nationId: ownerNationId,
from: { year: 191, month: settlementMonth },
to: { year: 191, month: settlementMonth },
};
expect((await get('nationSeries', admin, input)).body).toMatchObject({
result: {
data: {
currentSettlement: {
year: 191,
month: settlementMonth,
[resource]: { income: 8765.5, paid: 4321 },
},
items: [{ stock: null, complete: false }],
},
},
});
expect((await get('nationSeries', undefined, input)).status).toBe(401);
expect((await get('nationSeries', await token(['admin']), input)).status).toBe(403);
expect(
(await get('nationSeries', await token(['admin.playAudit.read:other:default']), input)).status
).toBe(403);
}
// A synchronized opening may start before the scenario's gameplay year. // A synchronized opening may start before the scenario's gameplay year.
await db.worldState.update({ await db.worldState.update({
where: { id: fixtureWorldId }, where: { id: fixtureWorldId },
@@ -3288,6 +3598,362 @@ integration('game API security over HTTP transport', () => {
} }
}); });
const openRealtime = async (accessToken: string) => {
const abort = new AbortController();
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${accessToken}` },
signal: abort.signal,
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
const reader = response.body!.getReader();
const ready = await reader.read();
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
return { abort, reader };
};
it('SSE limits simultaneous openings for one authenticated user', async () => {
const actor = `security-http-sse-concurrent-${process.pid}`;
const token = await createAccessToken('sse-concurrent', {}, actor);
const aborts = Array.from({ length: 10 }, () => new AbortController());
try {
const responses = await Promise.all(
aborts.map((abort) =>
fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${token}` },
signal: abort.signal,
})
)
);
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
for (const response of responses.filter((item) => item.status === 429)) {
expect(response.headers.get('retry-after')).toBe('15');
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
}
} finally {
for (const abort of aborts) abort.abort();
await accessTokenStore.revoke(token);
}
});
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
const actor = `security-http-sse-shared-${process.pid}`;
const token = await createAccessToken('sse-shared-a', {}, actor);
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
const replacementAbort = new AbortController();
try {
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
headers: { authorization: `Bearer ${otherToken}` },
});
expect(rejected.status).toBe(429);
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
streams.push(await openRealtime(otherActorToken));
await streams[0]!.reader.cancel();
streams[0]!.abort.abort();
let replacement: Response | undefined;
await expect
.poll(async () => {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
headers: { authorization: `Bearer ${otherToken}` },
signal: replacementAbort.signal,
});
if (response.status === 200) replacement = response;
else await response.arrayBuffer();
return response.status;
})
.toBe(200);
const ready = await replacement!.body!.getReader().read();
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
} finally {
replacementAbort.abort();
for (const stream of streams) stream.abort.abort();
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
}
});
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
const actor = `security-http-sse-pending-${process.pid}`;
const tokens = await Promise.all(
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
);
const aborts = Array.from({ length: 33 }, () => new AbortController());
let unlock!: () => void;
const release = new Promise<void>((resolve) => {
unlock = resolve;
});
let ready!: (tx: GamePrisma.TransactionClient) => void;
let lockError!: (error: unknown) => void;
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
ready = resolve;
lockError = reject;
});
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
const locking = db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
ready(tx);
await release;
},
{ timeout: 20_000 }
);
void locking.catch(lockError);
try {
const tx = await locked;
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${tokens[0]!}` },
signal: aborts[0]!.signal,
}).then(
() => false,
() => true
);
await expect
.poll(
async () => {
const rows = await tx.$queryRaw<Array<{ count: number }>>`
SELECT COUNT(*)::int AS count FROM pg_locks
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
`;
return rows[0]!.count;
},
{ timeout: 3000 }
)
.toBeGreaterThan(0);
const startedAt = Date.now();
const waiting = tokens.slice(1, 32).map((token, index) =>
fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${token}` },
signal: aborts[index + 1]!.signal,
})
);
aborts[0]!.abort();
expect(await first).toBe(true);
const responses = await Promise.all(waiting);
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
expect(Date.now() - startedAt).toBeLessThan(8500);
for (const response of responses) {
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
}
const refusedAt = Date.now();
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${tokens[32]!}` },
signal: aborts[32]!.signal,
});
expect(refused.status).toBe(503);
expect(refused.headers.get('retry-after')).toBe('15');
expect(Date.now() - refusedAt).toBeLessThan(2000);
await refused.arrayBuffer();
unlock();
await locking;
let recovered: Response | undefined;
await expect
.poll(async () => {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${tokens[32]!}` },
signal: aborts[32]!.signal,
});
if (response.status === 200) recovered = response;
else await response.arrayBuffer();
return response.status;
})
.toBe(200);
const frame = await recovered!.body!.getReader().read();
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
} finally {
unlock();
for (const abort of aborts) abort.abort();
await locking;
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
}
}, 15_000);
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
const token = await createAccessToken('sse-query', {});
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
expect(queryOnly.status).toBe(401);
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
if (!foreign) throw new Error('SSE foreign token fixture failed');
try {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${foreign.accessToken}` },
});
expect(response.status).toBe(401);
const stream = await openRealtime(token);
stream.abort.abort();
} finally {
await accessTokenStore.revoke(token);
await accessTokenStore.revoke(foreign.accessToken);
}
});
it.each([
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
{
label: 'profile game restriction',
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
},
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
const token = await createAccessToken(`sse-${label}`, sanctions);
try {
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${token}` },
});
expect(response.status).toBe(401);
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
} finally {
await accessTokenStore.revoke(token);
}
});
it('SSE closes on idle expiry without forwarding another frame', async () => {
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
const created = await accessTokenStore.create(auth);
if (!created) throw new Error('SSE expiry token fixture failed');
const stream = await openRealtime(created.accessToken);
try {
expect(await stream.reader.read()).toMatchObject({ done: true });
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
} finally {
stream.abort.abort();
await accessTokenStore.revoke(created.accessToken);
}
});
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
const accessToken = await createAccessToken('sse-revoke', {});
const stream = await openRealtime(accessToken);
const channel = server!.config.profileName;
try {
const event = JSON.stringify({ type: 'tournamentChanged' });
await redis!.client.publish(buildGameEventChannel(channel), event);
const valid = await stream.reader.read();
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
await redis!.client.publish(buildGameEventChannel(channel), event);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(accessToken);
}
});
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
const sseUser = `security-http-sse-flush-${process.pid}`;
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
if (!created) throw new Error('SSE flush fixture failed');
const stream = await openRealtime(created.accessToken);
try {
await redis!.client.publish(
`${redisPrefix}:flush`,
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(created.accessToken);
}
});
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
const actor = `security-http-durable-missed-${process.pid}`;
const accessToken = await createAccessToken('durable-missed', {}, actor);
try {
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
} finally {
await accessTokenStore.revoke(accessToken);
}
});
it('retains persisted revocation state across an actual API restart', async () => {
const actor = `security-http-durable-restart-${process.pid}`;
const accessToken = await createAccessToken('durable-restart', {}, actor);
try {
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
await server!.app.close();
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
method: 'POST',
input: { gatewayToken },
});
expect(exchanged.response.status).toBe(401);
} finally {
await accessTokenStore.revoke(accessToken);
}
});
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
const actor = `security-http-durable-sse-${process.pid}`;
const accessToken = await createAccessToken('durable-sse', {}, actor);
const stream = await openRealtime(accessToken);
try {
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
await redis!.client.publish(
buildGameEventChannel(profileName),
JSON.stringify({ type: 'tournamentChanged' })
);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(accessToken);
}
});
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
const actor = `security-http-durable-cutoff-${process.pid}`;
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
try {
await redis!.client.set(key, String(Date.now()));
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
const fresh = await accessTokenStore.create({
...buildPayload('fresh-cutoff', {}, actor),
issuedAt: new Date(Date.now() + 1).toISOString(),
});
expect(fresh).not.toBeNull();
try {
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
200
);
} finally {
await accessTokenStore.revoke(fresh!.accessToken);
}
} finally {
await redis!.client.set(key, '0');
await accessTokenStore.revoke(accessToken);
}
});
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
const actor = `security-http-durable-missing-${process.pid}`;
const accessToken = await createAccessToken('durable-missing', {}, actor);
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
try {
await redis!.client.del(key);
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(sse.status).toBe(503);
await expect(createGameApiServer()).rejects.toThrow('startup refused');
} finally {
await redis!.client.set(key, '0');
await accessTokenStore.revoke(accessToken);
}
});
// Flush invalidates every token issued before the user watermark. Keep it // Flush invalidates every token issued before the user watermark. Keep it
// last so this lifecycle assertion cannot invalidate the actor tokens used // last so this lifecycle assertion cannot invalidate the actor tokens used
// by the transport authorization matrix above. // by the transport authorization matrix above.
@@ -142,6 +142,10 @@ const buildContext = (
meta: options.worldMeta ?? { serverId: currentServerId }, meta: options.worldMeta ?? { serverId: currentServerId },
}), }),
}, },
gameHistory: {
findUnique: async ({ where }: { where: { serverId: string } }) =>
where.serverId === archiveServerId ? { map: 'miniche' } : null,
},
yearbookHistory: { yearbookHistory: {
findFirst: async (args: { findFirst: async (args: {
where: { profileName: string; year?: number; month?: number }; where: { profileName: string; year?: number; month?: number };
@@ -187,11 +191,13 @@ describe('historical yearbook access from dynasty', () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a'))); const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
const result = await caller.yearbook.getRange({ serverID: archiveServerId }); const result = await caller.yearbook.getRange({ serverID: archiveServerId });
expect(result).toEqual({ expect(result).toMatchObject({
firstYearMonth: 200 * 12, firstYearMonth: 200 * 12,
lastYearMonth: 200 * 12 + 1, lastYearMonth: 200 * 12 + 1,
currentYearMonth: 200 * 12 + 1, currentYearMonth: 200 * 12 + 1,
mapLayout: { mapName: 'miniche' },
}); });
expect(result.mapLayout.cityList.length).toBeGreaterThan(0);
}); });
it('returns the same archived public history for distinct general owners', async () => { it('returns the same archived public history for distinct general owners', async () => {
@@ -249,20 +255,22 @@ describe('historical yearbook access from dynasty', () => {
expect(canonical).toEqual(alias); expect(canonical).toEqual(alias);
expect(alias).toEqual(omitted); expect(alias).toEqual(omitted);
expect(canonical).toEqual({ expect(canonical).toMatchObject({
firstYearMonth: 219 * 12 + 11, firstYearMonth: 219 * 12 + 11,
lastYearMonth: 219 * 12 + 11, lastYearMonth: 219 * 12 + 11,
currentYearMonth: 220 * 12, currentYearMonth: 220 * 12,
mapLayout: { mapName: 'che' },
}); });
}); });
it('reads imported history under the short profile ID when world metadata has no server ID', async () => { it('reads imported history under the short profile ID when world metadata has no server ID', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} })); const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} }));
await expect(caller.yearbook.getRange()).resolves.toEqual({ await expect(caller.yearbook.getRange()).resolves.toMatchObject({
firstYearMonth: 219 * 12 + 10, firstYearMonth: 219 * 12 + 10,
lastYearMonth: 219 * 12 + 10, lastYearMonth: 219 * 12 + 10,
currentYearMonth: 220 * 12, currentYearMonth: 220 * 12,
mapLayout: { mapName: 'che' },
}); });
}); });
@@ -0,0 +1,34 @@
import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
/** A bounded, sticky notice: later successful writes cannot make history complete again. */
export const markAuditGap = (world: InMemoryTurnWorld, stage: string): void => {
const state = world.getState();
const previous = asRecord(state.meta.playAuditGap);
world.updateWorldMeta({
playAuditGap: {
serverId: state.meta.serverId ?? null,
firstYear:
previous.serverId === state.meta.serverId
? (previous.firstYear ?? state.currentYear)
: state.currentYear,
firstMonth:
previous.serverId === state.meta.serverId
? (previous.firstMonth ?? state.currentMonth)
: state.currentMonth,
lastYear: state.currentYear,
lastMonth: state.currentMonth,
stage,
},
});
console.warn(`[play-audit] ${stage}: history gap recorded; gameplay continues.`);
};
export const collectPlayAudit = <T>(world: InMemoryTurnWorld, stage: string, collect: () => T, fallback: T): T => {
try {
return collect();
} catch {
markAuditGap(world, stage);
return fallback;
}
};
+25 -6
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js';
import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js'; import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js';
@@ -32,7 +33,11 @@ const readFlows = (world: InMemoryTurnWorld): MonthlyFlows => {
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries }; return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
}; };
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void => { export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void =>
collectPlayAudit(
world,
'recordAuditSettlement',
() => {
if (typeof world.getState().meta.serverId !== 'string') return; if (typeof world.getState().meta.serverId !== 'string') return;
const flows = readFlows(world); const flows = readFlows(world);
const key = `${settlement.nationId}:${settlement.resource}`; const key = `${settlement.nationId}:${settlement.resource}`;
@@ -44,12 +49,18 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
}; };
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다. // 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
world.updateWorldMeta({ playAuditFlows: flows }); world.updateWorldMeta({ playAuditFlows: flows });
}; },
undefined
);
export const queueAuditMonth = ( export const queueAuditMonth = (
world: InMemoryTurnWorld, world: InMemoryTurnWorld,
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END' kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
): void => { ): void =>
collectPlayAudit(
world,
'queueAuditMonth',
() => {
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다. // identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
@@ -71,10 +82,16 @@ export const queueAuditMonth = (
kind, kind,
settlementsComplete: flows.complete, settlementsComplete: flows.complete,
}); });
}; },
undefined
);
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */ /** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => { export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
collectPlayAudit(
world,
'initializeAuditCollection',
() => {
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false; if (typeof serverId !== 'string' || !serverId.trim()) return false;
@@ -111,7 +128,9 @@ export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt =
}, },
}); });
return true; return true;
}; },
false
);
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({ export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => { beforeMonthChanged: (context) => {
+39 -9
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { TurnDiplomacy } from '../turn/types.js'; import type { TurnDiplomacy } from '../turn/types.js';
@@ -7,7 +8,11 @@ export const recordMonthlyAuditDiplomacy = (
world: InMemoryTurnWorld, world: InMemoryTurnWorld,
before: readonly TurnDiplomacy[], before: readonly TurnDiplomacy[],
afterByKey: ReadonlyMap<string, TurnDiplomacy> afterByKey: ReadonlyMap<string, TurnDiplomacy>
): void => { ): void =>
collectPlayAudit(
world,
'recordMonthlyAuditDiplomacy',
() => {
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return; if (typeof serverId !== 'string' || !serverId.trim()) return;
@@ -46,7 +51,9 @@ export const recordMonthlyAuditDiplomacy = (
after: nextState, after: nextState,
}); });
} }
}; },
undefined
);
export interface AuditDiplomacyAction { export interface AuditDiplomacyAction {
actionKey: string; actionKey: string;
@@ -68,7 +75,11 @@ export const recordTurnAuditDiplomacy = (
after: TurnDiplomacy, after: TurnDiplomacy,
action: AuditDiplomacyAction, action: AuditDiplomacyAction,
turn: { generalId: number; tick: number; ordinal: number } turn: { generalId: number; tick: number; ordinal: number }
): void => { ): void =>
collectPlayAudit(
world,
'recordTurnAuditDiplomacy',
() => {
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return; if (typeof serverId !== 'string' || !serverId.trim()) return;
@@ -95,14 +106,25 @@ export const recordTurnAuditDiplomacy = (
ordinal: turn.ordinal, ordinal: turn.ordinal,
requestId: null, requestId: null,
inputSequence: null, inputSequence: null,
actor: { ...action.actor, actionKey: action.actionKey, kind: action.kind, actionOrdinal: action.actionOrdinal }, actor: {
...action.actor,
actionKey: action.actionKey,
kind: action.kind,
actionOrdinal: action.actionOrdinal,
},
before: previousState, before: previousState,
after: nextState, after: nextState,
}); });
}; },
undefined
);
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */ /** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => { export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
collectPlayAudit(
world,
'initializeAuditDiplomacy',
() => {
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false; if (typeof serverId !== 'string' || !serverId.trim()) return false;
@@ -177,7 +199,9 @@ export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt =
}, },
}); });
return true; return true;
}; },
false
);
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */ /** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
export const recordNationAuditDiplomacy = ( export const recordNationAuditDiplomacy = (
@@ -186,7 +210,11 @@ export const recordNationAuditDiplomacy = (
relations: readonly TurnDiplomacy[], relations: readonly TurnDiplomacy[],
operation: 'CREATED' | 'REMOVED', operation: 'CREATED' | 'REMOVED',
observedTick?: number observedTick?: number
): void => { ): void =>
collectPlayAudit(
world,
'recordNationAuditDiplomacy',
() => {
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return; if (typeof serverId !== 'string' || !serverId.trim()) return;
@@ -229,4 +257,6 @@ export const recordNationAuditDiplomacy = (
after: operation === 'CREATED' ? value : null, after: operation === 'CREATED' ? value : null,
}); });
} }
}; },
undefined
);
+17 -4
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { Nation } from '@sammo-ts/logic'; import type { Nation } from '@sammo-ts/logic';
@@ -70,7 +71,11 @@ export const recordAuditPolicyChange = (options: {
actor?: TurnGeneral; actor?: TurnGeneral;
permission?: number; permission?: number;
requestId?: string; requestId?: string;
}): { _playAuditPolicy?: Record<string, PolicyHead> } => { }): { _playAuditPolicy?: Record<string, PolicyHead> } =>
collectPlayAudit(
options.world,
'recordAuditPolicyChange',
() => {
const { world, nation, area } = options; const { world, nation, area } = options;
const state = world.getState(); const state = world.getState();
const serverId = state.meta.serverId; const serverId = state.meta.serverId;
@@ -134,9 +139,15 @@ export const recordAuditPolicyChange = (options: {
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after); if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
if (!head) throw new Error('Play audit policy baseline missing'); if (!head) throw new Error('Play audit policy baseline missing');
return { _playAuditPolicy: { ...heads, [area]: head } }; return { _playAuditPolicy: { ...heads, [area]: head } };
}; },
{}
);
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => { export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void =>
collectPlayAudit(
world,
'initializeNationAuditPolicies',
() => {
let nation = world.getNationById(nationId); let nation = world.getNationById(nationId);
if (!nation) return; if (!nation) return;
for (const area of AUDIT_POLICY_AREAS) { for (const area of AUDIT_POLICY_AREAS) {
@@ -148,7 +159,9 @@ export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!; nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
} }
} }
}; },
undefined
);
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => { export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id); for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
@@ -1,5 +1,7 @@
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra'; import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
import { auditPolicyHash, type PendingAuditPolicy } from './policy.js'; import { asRecord } from '@sammo-ts/common';
import { AUDIT_POLICY_AREAS, auditPolicyHash, type PendingAuditPolicy } from './policy.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
export const persistAuditPolicies = async ( export const persistAuditPolicies = async (
tx: GamePrisma.TransactionClient, tx: GamePrisma.TransactionClient,
@@ -43,3 +45,48 @@ export const persistAuditPolicies = async (
throw new Error('Play audit policy replay payload conflict'); throw new Error('Play audit policy replay payload conflict');
} }
}; };
/** 원장은 그대로 두고, 구버전 NPC 생성이 유실한 head projection만 복구한다. */
export const restoreMissingAuditPolicyHeads = async (
tx: GamePrisma.TransactionClient,
world: InMemoryTurnWorld
): Promise<boolean> => {
const serverId = world.getState().meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false;
const missing = world.listNations().flatMap((nation) => {
const heads = asRecord(nation.meta._playAuditPolicy);
return AUDIT_POLICY_AREAS.filter((area) => heads[area] === undefined).map(
(area) => GamePrisma.sql`(nation_id = ${nation.id} AND area = ${area})`
);
});
if (!missing.length) return false;
// DISTINCT ON으로 각 국가/영역의 마지막 revision만 읽는다. 과거 기수는 복구하지 않는다.
const rows = await tx.$queryRaw<
{ id: string; nationId: number; area: string; revision: number; after: InputJsonValue }[]
>(GamePrisma.sql`
SELECT DISTINCT ON (nation_id, area)
id, nation_id AS "nationId", area, revision, "after"
FROM play_audit_policy
WHERE server_id = ${serverId} AND (${GamePrisma.join(missing, ' OR ')})
ORDER BY nation_id, area, revision DESC
`);
for (const row of rows) {
if (row.revision < 1 || row.id !== auditPolicyHash([serverId, row.nationId, row.area, row.revision])) {
throw new Error('Invalid persisted play audit policy head');
}
const nation = world.getNationById(row.nationId)!;
const heads = nation.meta._playAuditPolicy;
world.updateNation(nation.id, {
meta: {
...nation.meta,
_playAuditPolicy: {
...(heads && typeof heads === 'object' && !Array.isArray(heads) ? heads : {}),
[row.area]: { id: row.id, revision: row.revision, hash: auditPolicyHash(row.after), serverId },
},
},
});
}
// 실제 정책이 원장 after와 다르면 후속 initializeAuditPolicies가 OBSERVED_GAP을 남긴다.
// 이 메타데이터와 새 이력은 기존 lease/fencing을 거친 startup flush에서 함께 commit된다.
return rows.length > 0;
};
@@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
export interface AuditPopulationSummary { export interface AuditPopulationSummary {
count: number; count: number;
crew: number;
gold: number; gold: number;
rice: number; rice: number;
dex: AuditDex; dex: AuditDex;
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }); const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
const emptyPopulation = (): AuditPopulationSummary => ({ const emptyPopulation = (): AuditPopulationSummary => ({
count: 0, count: 0,
crew: 0,
gold: 0, gold: 0,
rice: 0, rice: 0,
dex: emptyDex(), dex: emptyDex(),
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
const population = nations.get(general.nationId)?.populations[projected.population]; const population = nations.get(general.nationId)?.populations[projected.population];
if (!population) continue; if (!population) continue;
population.count++; population.count++;
population.crew += projected.crew;
population.gold += projected.gold; population.gold += projected.gold;
population.rice += projected.rice; population.rice += projected.rice;
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key]; for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
@@ -0,0 +1,199 @@
import {
asRecord,
GAME_TICKS_PER_TURN,
OFFICE_REQUEST_KEY,
officeRequestQuarter,
readOfficeRequest,
type OfficeRequest,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
} from '@sammo-ts/common';
import {
LogCategory,
LogFormat,
LogScope,
resolveOfficeRequestEligibility,
type OfficeCandidate,
} from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
const candidate = (general: TurnGeneral): OfficeCandidate => ({
...general,
strength: general.stats.strength,
intelligence: general.stats.intelligence,
});
const incumbentFor = (world: InMemoryTurnWorld, cityId: number, level: number): TurnGeneral | undefined =>
world
.listGenerals()
.find((g) => g.officerLevel === level && Number(g.meta.officerCity ?? g.meta.officer_city ?? 0) === cityId);
const eligibility = (world: InMemoryTurnWorld, general: TurnGeneral, cityId: number, level: 2 | 3 | 4) => {
const incumbent = incumbentFor(world, cityId, level);
return resolveOfficeRequestEligibility({
applicant: candidate(general),
city: world.getCityById(cityId) ?? null,
level,
incumbent: incumbent ? candidate(incumbent) : null,
nationMeta: world.getNationById(general.nationId)?.meta,
chiefStatMin: world.getScenarioConfig().stat.chiefMin,
});
};
const save = (world: InMemoryTurnWorld, generalId: number, request: OfficeRequest) => {
const general = world.getGeneralById(generalId);
if (general) world.updateGeneral(generalId, { meta: { ...general.meta, [OFFICE_REQUEST_KEY]: { ...request } } });
};
const finish = (world: InMemoryTurnWorld, request: OfficeRequest, status: OfficeRequest['status'], reason: string) => {
save(world, request.generalId, { ...request, status, resultReason: reason });
const general = world.getGeneralById(request.generalId);
if (!general) return;
const city = world.getCityById(request.cityId);
const title = { 2: '종사', 3: '군사', 4: '태수' }[request.officerLevel];
const text = `${general.name} · ${city?.name ?? '도시'} ${title} 자원: ${reason}`;
world.pushLog({
scope: LogScope.GENERAL,
generalId: general.id,
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
text,
});
world.pushLog({
scope: LogScope.NATION,
nationId: request.nationId,
category: LogCategory.HISTORY,
format: LogFormat.PLAIN,
text,
});
};
const invalidReason = (world: InMemoryTurnWorld, request: OfficeRequest): string | null => {
const general = world.getGeneralById(request.generalId);
if (!general || general.nationId !== request.nationId || general.userId !== request.userId)
return '소속 또는 장수 소유자 변경';
if ((incumbentFor(world, request.cityId, request.officerLevel)?.id ?? 0) !== request.incumbentId)
return '재직자 변경';
return eligibility(world, general, request.cityId, request.officerLevel).reason;
};
const approve = (world: InMemoryTurnWorld, request: OfficeRequest) => {
const incumbent = incumbentFor(world, request.cityId, request.officerLevel);
if (incumbent)
world.updateGeneral(incumbent.id, {
officerLevel: 1,
meta: { ...incumbent.meta, officerCity: 0, officer_city: 0 },
});
const general = world.getGeneralById(request.generalId)!;
world.updateGeneral(general.id, {
officerLevel: request.officerLevel,
meta: { ...general.meta, officerCity: request.cityId, officer_city: request.cityId },
});
const city = world.getCityById(request.cityId)!;
world.updateCity(city.id, {
meta: { ...city.meta, officer_set: Number(city.meta.officer_set ?? 0) | (1 << request.officerLevel) },
});
finish(world, request, 'approved', '임명');
};
// 분기 lock 초기화 뒤 실행한다. 저장된 기본 거부는 조건이 완화되어도 자동 승인으로 바꾸지 않는다.
export const settleCityOfficeRequests = (world: InMemoryTurnWorld, tick: number): void => {
for (const general of world.listGenerals()) {
const request = readOfficeRequest(general.meta);
if (!request || request.status !== 'pending') continue;
const invalid = invalidReason(world, request);
if (invalid) {
finish(world, request, 'cancelled', invalid);
continue;
}
if (tick < request.dueTick) continue;
const current = eligibility(world, general, request.cityId, request.officerLevel);
if (request.defaultDecision === 'approve' && current.defaultDecision === 'approve') approve(world, request);
else finish(world, request, 'rejected', '기한 만료 · 자동 거부');
}
};
export const createCityOfficeRequestCalendarHandler = (
getWorld: () => InMemoryTurnWorld | null
): TurnCalendarHandler => ({
beforeMonthChanged: () => {
const world = getWorld();
if (world) settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
},
onMonthChanged: (context) => {
const world = getWorld();
if (world) settleCityOfficeRequests(world, world.dateToGameTick(context.turnTime));
},
});
export const handleCityOfficeRequest = (
world: InMemoryTurnWorld,
command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
acceptedAt: Date
): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => {
const fail = (reason: string): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => ({
type: 'cityOfficeRequest',
generalId: command.generalId,
ok: false,
reason,
});
const actor = world.getGeneralById(command.generalId);
if (!actor || actor.userId !== command.userId || !actor.nationId)
return fail('장수 소유권 또는 소속이 변경되었습니다.');
const tick = Math.max(world.dateToGameTick(world.getGameNow(acceptedAt)), world.getState().lastTurnTick ?? 0);
settleCityOfficeRequests(world, tick);
if (command.action === 'request') {
if (!command.officerLevel) return fail('관직을 선택해 주세요.');
const currentActor = world.getGeneralById(actor.id)!;
const state = world.getState();
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
const previous = readOfficeRequest(currentActor.meta);
if (previous?.status === 'pending') return fail('이미 대기 중인 요청이 있습니다.');
if (previous?.quarter === quarter) return fail('이번 분기에 이미 자원했습니다.');
const check = eligibility(world, currentActor, currentActor.cityId, command.officerLevel);
if (!check.allowed) return fail(check.reason!);
const occupied = world.listGenerals().some((g) => {
const pending = readOfficeRequest(g.meta);
return (
pending?.status === 'pending' &&
pending.cityId === actor.cityId &&
pending.officerLevel === command.officerLevel
);
});
if (occupied) return fail('이미 다른 장수가 자원한 관직입니다.');
const boundary = state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime);
const dueTick =
boundary +
Math.max(1, Math.ceil((tick - boundary + GAME_TICKS_PER_TURN) / GAME_TICKS_PER_TURN)) * GAME_TICKS_PER_TURN;
save(world, actor.id, {
id: `${actor.id}:${quarter}`,
generalId: actor.id,
userId: command.userId,
nationId: actor.nationId,
cityId: actor.cityId,
officerLevel: command.officerLevel,
incumbentId: incumbentFor(world, actor.cityId, command.officerLevel)?.id ?? 0,
quarter,
createdTick: tick,
dueTick,
defaultDecision: check.defaultDecision,
defaultReason: check.defaultReason,
status: 'pending',
});
} else {
const target = world.getGeneralById(command.targetGeneralId ?? actor.id);
const request = readOfficeRequest(target?.meta);
if (!request || request.id !== command.officeRequestId || request.status !== 'pending')
return fail('이미 처리되었거나 없는 요청입니다.');
if (request.nationId !== actor.nationId) return fail('다른 국가의 요청입니다.');
if (command.action === 'withdraw') {
if (request.generalId !== actor.id) return fail('본인의 요청만 철회할 수 있습니다.');
finish(world, request, 'withdrawn', '본인 철회');
} else {
if (actor.officerLevel < 5 || asRecord(actor.penalty).noChief) return fail('수뇌만 처리할 수 있습니다.');
if (command.action === 'reject') finish(world, request, 'rejected', '수뇌 거부');
else {
if (asRecord(actor.penalty).noChiefChange && target && target.officerLevel >= 4)
return fail('수뇌인 장수를 변경할 수 없는 상태입니다.');
const invalid = invalidReason(world, request);
if (invalid) return fail(invalid);
approve(world, request);
}
}
}
return { type: 'cityOfficeRequest', generalId: actor.id, ok: true };
};
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { import {
GAME_TICKS_PER_TURN, GAME_TICKS_PER_TURN,
readOfficeRequest,
MAX_SAFE_GAME_TICK, MAX_SAFE_GAME_TICK,
GameClock, GameClock,
buildClockAlignmentPlan, buildClockAlignmentPlan,
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : []; return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
}) })
), ),
snapshot(
'city-office-request-occurrence',
'KEEP',
generals.flatMap(({ id, meta }) => {
const request = readOfficeRequest(meta);
return request ? [{ id, createdTick: request.createdTick }] : [];
})
),
snapshot(
'city-office-request-deadline',
'SHIFT',
generals.flatMap(({ id, meta }) => {
const request = readOfficeRequest(meta);
return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick
? [{ id, dueTick: request.dueTick }]
: [];
})
),
snapshot( snapshot(
'auction-open-occurrence', 'auction-open-occurrence',
'KEEP', 'KEEP',
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => { const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks); const shift = BigInt(shiftTicks);
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift; const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([ const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }), db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql` db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick" SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
_max: { pickMoreFromTick: true }, _max: { pickMoreFromTick: true },
where: { pickMoreFromTick: { not: null } }, where: { pickMoreFromTick: { not: null } },
}), }),
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta #>> '{cityOfficeRequest,dueTick}')::bigint) AS "maxTick"
FROM general WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
`),
]); ]);
const values: Array<[string, bigint | null]> = [ const values: Array<[string, bigint | null]> = [
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
['general.turn_tick', general._max.turnTick], ['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null], ['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick], ['auction.close_tick', auction._max.closeTick],
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
AND (meta->>'next_change_tick')::bigint >= ${cutTick} AND (meta->>'next_change_tick')::bigint >= ${cutTick}
`) `)
); );
affected.set(
'city-office-request-deadline',
await db.$executeRaw(GamePrisma.sql`
UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}',
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks}))
WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick}
`)
);
affected.set( affected.set(
'auction-deadline', 'auction-deadline',
await db.$executeRaw(GamePrisma.sql` await db.$executeRaw(GamePrisma.sql`
@@ -185,6 +185,16 @@ const zKick = z.object({
destGeneralId: zFiniteNumber, destGeneralId: zFiniteNumber,
}); });
const zCityOfficeRequest = z.object({
type: z.literal('cityOfficeRequest'),
userId: z.string().min(1),
generalId: z.number().int().positive(),
action: z.enum(['request', 'approve', 'reject', 'withdraw']),
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
targetGeneralId: z.number().int().positive().optional(),
officeRequestId: z.string().min(1).optional(),
});
const zAppoint = z.object({ const zAppoint = z.object({
type: z.literal('appoint'), type: z.literal('appoint'),
userId: z.string().min(1), userId: z.string().min(1),
@@ -666,6 +676,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
return { ...command, requestId: envelope.requestId }; return { ...command, requestId: envelope.requestId };
}; };
const normalizeCityOfficeRequest: CommandNormalizer<'cityOfficeRequest'> = (envelope) => {
const command = parseWith(zCityOfficeRequest, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => { const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
const command = parseWith(zAppoint, envelope.command); const command = parseWith(zAppoint, envelope.command);
if (!command) { if (!command) {
@@ -883,6 +898,7 @@ const normalizers: CommandNormalizerMap = {
changePermission: normalizeChangePermission, changePermission: normalizeChangePermission,
kick: normalizeKick, kick: normalizeKick,
appoint: normalizeAppoint, appoint: normalizeAppoint,
cityOfficeRequest: normalizeCityOfficeRequest,
tournamentRefund: normalizeTournamentRefund, tournamentRefund: normalizeTournamentRefund,
tournamentBettingPayout: normalizeTournamentBettingPayout, tournamentBettingPayout: normalizeTournamentBettingPayout,
tournamentReward: normalizeTournamentReward, tournamentReward: normalizeTournamentReward,
+71 -13
View File
@@ -1,7 +1,8 @@
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js'; import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
@@ -78,6 +79,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
flushChanges(): Promise<void>; flushChanges(): Promise<void>;
restoreMissingAuditPolicyHeads(): Promise<boolean>;
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>; flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
@@ -206,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
permission: asRecord(general.meta).permission, permission: asRecord(general.meta).permission,
penalty: general.penalty, penalty: general.penalty,
}), }),
frontStatus: signature({ name: general.name, nationId: general.nationId }), frontStatus: signature({
name: general.name,
nationId: general.nationId,
officerLevel: general.officerLevel,
userId: general.userId,
penalty: general.penalty,
cityOfficeRequest: asRecord(general.meta).cityOfficeRequest,
}),
frontStatusGlobal: '', frontStatusGlobal: '',
lobbyCount: signature({ npcState: general.npcState }), lobbyCount: signature({ npcState: general.npcState }),
lobbyPersonal: signature({ lobbyPersonal: signature({
@@ -1235,6 +1244,7 @@ export const createDatabaseTurnHooks = async (
} }
const persistedClock = await prisma.$queryRaw< const persistedClock = await prisma.$queryRaw<
Array<{ Array<{
audit_gap: unknown;
clock_phase: string; clock_phase: string;
clock_revision: bigint; clock_revision: bigint;
deadline_generation: bigint; deadline_generation: bigint;
@@ -1242,7 +1252,7 @@ export const createDatabaseTurnHooks = async (
opening_reached: boolean; opening_reached: boolean;
}> }>
>(GamePrisma.sql` >(GamePrisma.sql`
SELECT clock_phase, SELECT meta->'playAuditGap' AS audit_gap, clock_phase,
clock_revision, clock_revision,
deadline_generation, deadline_generation,
clock_base_time IS NOT NULL clock_base_time IS NOT NULL
@@ -1258,6 +1268,21 @@ export const createDatabaseTurnHooks = async (
if (!durableClock) { if (!durableClock) {
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`); throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
} }
const durableGap = asRecord(durableClock.audit_gap);
if (durableGap.serverId && durableGap.serverId === state.meta.serverId) {
const localGap = asRecord(world.getState().meta.playAuditGap);
const gap =
localGap.serverId === durableGap.serverId
? {
...durableGap,
...localGap,
firstYear: durableGap.firstYear,
firstMonth: durableGap.firstMonth,
}
: durableGap;
world.updateWorldMeta({ playAuditGap: gap });
worldStateUpdate.meta = asJson(world.getState().meta);
}
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'); const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
const expectedRevision = BigInt(state.clockRevision ?? 1); const expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1); const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
@@ -1388,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
GamePrisma.sql` GamePrisma.sql`
UPDATE general UPDATE general
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END, SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second') turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second'),
meta = CASE WHEN meta #>> '{cityOfficeRequest,status}' = 'pending'
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
THEN jsonb_set(meta, '{cityOfficeRequest,dueTick}',
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${deltaTicks}))
ELSE meta END
` `
); );
await prisma.$executeRaw( await prisma.$executeRaw(
@@ -1894,11 +1924,25 @@ export const createDatabaseTurnHooks = async (
data: pendingLogRows, data: pendingLogRows,
}); });
} }
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand); if (
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy); pendingAuditPolicies.length ||
await persistAuditDecisions(prisma, pendingAuditDecisions); pendingAuditDiplomacy.length ||
for (const snapshot of pendingAuditMonths) { pendingAuditDecisions.length ||
await persistAuditMonth(prisma, snapshot); pendingAuditMonths.length
) {
const audit = await withPlayAuditSavepoint(prisma, async (auditDb) => {
await persistAuditPolicies(auditDb, pendingAuditPolicies, auditCommand);
await persistAuditDiplomacyEvents(auditDb, pendingAuditDiplomacy);
await persistAuditDecisions(auditDb, pendingAuditDecisions);
for (const snapshot of pendingAuditMonths) await persistAuditMonth(auditDb, snapshot);
});
if (!audit.ok) {
markAuditGap(world, 'persistence');
await prisma.worldState.update({
where: { id: state.id },
data: { meta: world.getState().meta as GamePrisma.InputJsonValue },
});
}
} }
for (const snapshot of pendingYearbookSnapshots) { for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot); await persistYearbookSnapshot(prisma, snapshot);
@@ -2093,8 +2137,8 @@ export const createDatabaseTurnHooks = async (
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite); enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
}; };
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => { const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
if (hasAuditDocumentBaseline(world)) { if (collectPlayAudit(world, 'document-boundary', () => hasAuditDocumentBaseline(world), false)) {
if (force || world.hasPendingAuditRecords()) await flushChanges(); if (force || world.hasPendingAuditRecords() || world.getState().meta.playAuditGap) await flushChanges();
return; return;
} }
const checkpoint = world.captureState(); const checkpoint = world.captureState();
@@ -2107,7 +2151,10 @@ export const createDatabaseTurnHooks = async (
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
await persistAuditDocumentBaseline(transaction, world, observedAt); const audit = await withPlayAuditSavepoint(transaction, (auditDb) =>
persistAuditDocumentBaseline(auditDb, world, observedAt)
);
if (!audit.ok) markAuditGap(world, 'document-baseline');
return persistChanges(transaction); return persistChanges(transaction);
}, transactionOptions); }, transactionOptions);
} catch (error) { } catch (error) {
@@ -2164,6 +2211,17 @@ export const createDatabaseTurnHooks = async (
hooks, hooks,
flushChanges, flushChanges,
flushInitialAudit, flushInitialAudit,
restoreMissingAuditPolicyHeads: async () => {
const result = await prisma.$transaction(
(tx) => withPlayAuditSavepoint(tx, (auditDb) => restoreMissingAuditPolicyHeads(auditDb, world)),
transactionOptions
);
if (!result.ok) {
markAuditGap(world, 'policy-head-recovery');
return false;
}
return result.value;
},
takeCommittedReadModelChanges: () => { takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null; return takeCommittedReceipt()?.changes ?? null;
}, },
+32
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from '../playAudit/bestEffort.js';
import type { PendingAuditDecision } from '../playAudit/decision.js'; import type { PendingAuditDecision } from '../playAudit/decision.js';
import { import {
recordTurnAuditDiplomacy, recordTurnAuditDiplomacy,
@@ -21,6 +22,7 @@ import type {
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { import {
GAME_TICKS_PER_TURN, GAME_TICKS_PER_TURN,
shiftOfficeRequestDeadline,
GameClock, GameClock,
assertGameplayCommitAllowed, assertGameplayCommitAllowed,
inferClockPhase, inferClockPhase,
@@ -818,6 +820,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks); const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
this.generals.set(generalId, { this.generals.set(generalId, {
...general, ...general,
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
turnTick, turnTick,
turnTime: clock.tickToDate(turnTick), turnTime: clock.tickToDate(turnTick),
}); });
@@ -1043,6 +1046,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks); const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
this.generals.set(generalId, { this.generals.set(generalId, {
...general, ...general,
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
turnTick, turnTick,
turnTime: clock.tickToDate(turnTick), turnTime: clock.tickToDate(turnTick),
}); });
@@ -1393,15 +1397,36 @@ export class InMemoryTurnWorld {
} }
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void { queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
collectPlayAudit(
this,
'queueDiplomacy',
() => {
this.pendingAuditDiplomacy.push(structuredClone(event)); this.pendingAuditDiplomacy.push(structuredClone(event));
},
undefined
);
} }
queueAuditDecision(decision: PendingAuditDecision): void { queueAuditDecision(decision: PendingAuditDecision): void {
collectPlayAudit(
this,
'queueDecision',
() => {
this.pendingAuditDecisions.push(structuredClone(decision)); this.pendingAuditDecisions.push(structuredClone(decision));
},
undefined
);
} }
queueAuditPolicy(policy: PendingAuditPolicy): void { queueAuditPolicy(policy: PendingAuditPolicy): void {
collectPlayAudit(
this,
'queuePolicy',
() => {
this.pendingAuditPolicies.push(structuredClone(policy)); this.pendingAuditPolicies.push(structuredClone(policy));
},
undefined
);
} }
hasPendingAuditRecords(): boolean { hasPendingAuditRecords(): boolean {
@@ -1414,7 +1439,14 @@ export class InMemoryTurnWorld {
} }
queueAuditMonth(snapshot: PendingAuditMonth): void { queueAuditMonth(snapshot: PendingAuditMonth): void {
collectPlayAudit(
this,
'queueMonth',
() => {
this.pendingAuditMonths.push(structuredClone(snapshot)); this.pendingAuditMonths.push(structuredClone(snapshot));
},
undefined
);
} }
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void { queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
@@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: {
} }
world.updateNation(nationId, { world.updateNation(nationId, {
chiefGeneralId: ruler.id, chiefGeneralId: ruler.id,
meta: { ...nation.meta, gennum: npcEachCount }, // addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
meta: { ...world.getNationById(nationId)!.meta, gennum: npcEachCount },
}); });
for (const officerLevel of [12, 11, 10, 9]) { for (const officerLevel of [12, 11, 10, 9]) {
options.reservedTurns.ensureNationTurns(nationId, officerLevel); options.reservedTurns.ensureNationTurns(nationId, officerLevel);
@@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: {
} }
world.updateNation(nationId, { world.updateNation(nationId, {
chiefGeneralId: ruler.id, chiefGeneralId: ruler.id,
meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length }, // addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
meta: { ...world.getNationById(nationId)!.meta, gennum: 1 + subordinateCandidates.length },
}); });
options.reservedTurns.ensureNationTurns(nationId, 12); options.reservedTurns.ensureNationTurns(nationId, 12);
options.reservedTurns.ensureNationTurns(nationId, 11); options.reservedTurns.ensureNationTurns(nationId, 11);
@@ -1,3 +1,4 @@
import { collectPlayAudit } from '../playAudit/bestEffort.js';
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js'; import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js'; import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js'; import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
@@ -1085,7 +1086,11 @@ export const createReservedTurnHandler = async (options: {
const onDecisionTrace: AiDecisionTraceObserver | undefined = const onDecisionTrace: AiDecisionTraceObserver | undefined =
collectDecisions || options.onDecisionTrace collectDecisions || options.onDecisionTrace
? (event) => { ? (event) => {
if (collectDecisions) { if (collectDecisions && worldRef)
collectPlayAudit(
worldRef,
'decision-trace',
() => {
if (event.kind === 'DECISION_START') { if (event.kind === 'DECISION_START') {
decisionSteps.set(event.phase, []); decisionSteps.set(event.phase, []);
const heads = asRecord(currentNation?.meta._playAuditPolicy); const heads = asRecord(currentNation?.meta._playAuditPolicy);
@@ -1094,7 +1099,8 @@ export const createReservedTurnHandler = async (options: {
Object.fromEntries( Object.fromEntries(
AUDIT_POLICY_AREAS.flatMap((area) => { AUDIT_POLICY_AREAS.flatMap((area) => {
const head = asRecord(heads[area]); const head = asRecord(heads[area]);
return head.serverId === auditServerId && typeof head.id === 'string' return head.serverId === auditServerId &&
typeof head.id === 'string'
? [[area, head.id]] ? [[area, head.id]]
: []; : [];
}) })
@@ -1104,11 +1110,18 @@ export const createReservedTurnHandler = async (options: {
decisionSteps decisionSteps
.get(event.phase) .get(event.phase)
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ }); ?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
} },
undefined
);
options.onDecisionTrace?.(event); options.onDecisionTrace?.(event);
} }
: undefined; : undefined;
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => { const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
if (!worldRef) return;
collectPlayAudit(
worldRef,
'decision-summary',
() => {
const steps = decisionSteps.get(phase); const steps = decisionSteps.get(phase);
const first = steps?.[0]; const first = steps?.[0];
if (!collectDecisions || !first || !steps) return; if (!collectDecisions || !first || !steps) return;
@@ -1125,6 +1138,9 @@ export const createReservedTurnHandler = async (options: {
month, month,
tick, tick,
}); });
},
undefined
);
}; };
const finishDecision = ( const finishDecision = (
phase: 'general' | 'nation', phase: 'general' | 'nation',
@@ -1135,6 +1151,11 @@ export const createReservedTurnHandler = async (options: {
blockedReason?: string; blockedReason?: string;
} }
): void => { ): void => {
if (!worldRef) return;
collectPlayAudit(
worldRef,
'decision-summary',
() => {
const steps = decisionSteps.get(phase); const steps = decisionSteps.get(phase);
const first = steps?.[0]; const first = steps?.[0];
const last = steps?.find((step) => step.kind === 'DECISION_END'); const last = steps?.find((step) => step.kind === 'DECISION_END');
@@ -1193,6 +1214,9 @@ export const createReservedTurnHandler = async (options: {
}, },
steps, steps,
}); });
},
undefined
);
}; };
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다. // Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
+4 -1
View File
@@ -1,3 +1,4 @@
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js'; import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
import { initializeAuditPolicies } from '../playAudit/policy.js'; import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
const calendarHandler = composeCalendarHandlers( const calendarHandler = composeCalendarHandlers(
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler, createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
options.monthlyEventHandler, options.monthlyEventHandler,
createCityOfficeRequestCalendarHandler(options.getWorld),
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler, options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler, createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
createPlayAuditHandler(options.getWorld), createPlayAuditHandler(options.getWorld),
@@ -937,10 +939,11 @@ const createTurnDaemonRuntimeWithLease = async (
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() }); await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다. // 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다. // 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
const policyHeadsRestored = await dbHooks.restoreMissingAuditPolicyHeads();
initializeAuditPolicies(world); initializeAuditPolicies(world);
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs())); const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
initializeAuditCollection(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs()));
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized); await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized || policyHeadsRestored);
dbHooks.takeCommittedReadModelChangeReceipt(); dbHooks.takeCommittedReadModelChangeReceipt();
} catch (error) { } catch (error) {
await Promise.allSettled([ await Promise.allSettled([
@@ -1,3 +1,4 @@
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
import type { import type {
TurnDaemonCommandHandler, TurnDaemonCommandHandler,
TurnDaemonCommand, TurnDaemonCommand,
@@ -187,6 +188,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'changePermission', 'changePermission',
'kick', 'kick',
'appoint', 'appoint',
'cityOfficeRequest',
'voteReward', 'voteReward',
'syncDiplomaticResponse', 'syncDiplomaticResponse',
] as const satisfies readonly TurnDaemonCommand['type'][]; ] as const satisfies readonly TurnDaemonCommand['type'][];
@@ -2587,6 +2589,7 @@ async function handleAppoint(
} else { } else {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' }; return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
} }
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
refreshActorKillturn(world, general); refreshActorKillturn(world, general);
return { type: 'appoint', ok: true, generalId: command.generalId }; return { type: 'appoint', ok: true, generalId: command.generalId };
} }
@@ -3243,6 +3246,12 @@ export const createTurnDaemonCommandHandler = (options: {
changePermission: (command) => changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>), handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>), kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
cityOfficeRequest: async (command) =>
handleCityOfficeRequest(
ctx.world,
command as Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
ctx.commandDb ? await resolveOperationalAcceptedAt(ctx.commandDb, command) : new Date()
),
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>), appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
tournamentRefund: (command) => tournamentRefund: (command) =>
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>), handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
@@ -72,6 +72,14 @@ const state: TurnWorldState = {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'), lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
clockBaseTime: new Date('2026-07-31T09:00:00.000Z'),
clockTick: 0,
lastTurnTick: 0,
clockMode: 'manual',
clockPhase: 'MANUAL',
clockWallAnchor: new Date('2026-07-31T09:00:00.000Z'),
clockRevision: 1,
deadlineGeneration: 1,
meta: { meta: {
killturn: 24, killturn: 24,
lastTurnTime: '2026-07-31T09:00:00.000Z', lastTurnTime: '2026-07-31T09:00:00.000Z',
@@ -209,6 +217,14 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
currentYear: state.currentYear, currentYear: state.currentYear,
currentMonth: state.currentMonth, currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds, tickSeconds: state.tickSeconds,
clockBaseTime: state.clockBaseTime,
clockTick: BigInt(state.clockTick ?? 0),
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
clockMode: state.clockMode,
clockPhase: state.clockPhase,
clockWallAnchor: state.clockWallAnchor,
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue, meta: state.meta as GamePrisma.InputJsonValue,
}, },
@@ -45,6 +45,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
destCityId: 1, destCityId: 1,
officerLevel: 4, officerLevel: 4,
}, },
{
type: 'cityOfficeRequest',
requestId: 'cityOfficeRequest',
userId,
generalId: 7,
action: 'request',
officerLevel: 4,
},
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] }, { type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
{ {
type: 'syncDiplomaticResponse', type: 'syncDiplomaticResponse',
@@ -0,0 +1,247 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { readOfficeRequest, type TurnDaemonCommand } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient, type GamePrisma } from '@sammo-ts/infra';
import { buildWorld } from './officeRequestFixture.js';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { createCityOfficeRequestCalendarHandler } from '../src/turn/cityOfficeRequests.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 992951;
const generalId = 992953;
const requestId = 'integration:city-office-request';
const constraint = 'city_office_request_rollback_test';
integration('city office request durable persistence', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let hooks: DatabaseTurnHooks | undefined;
beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema');
if (!schema || !/^(ci_|office_requests)/.test(schema)) throw new Error('Dedicated integration schema required');
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${constraint}`);
await db.inputEvent.deleteMany({ where: { requestId } });
await db.logEntry.deleteMany({
where: { OR: [{ nationId: worldId }, { generalId: { in: [worldId, worldId + 1, generalId] } }] },
});
await db.rankData.deleteMany({ where: { generalId: { in: [worldId, worldId + 1, generalId] } } });
await db.general.deleteMany({ where: { id: { in: [worldId, worldId + 1, generalId] } } });
await db.city.deleteMany({ where: { id: worldId } });
await db.nation.deleteMany({ where: { id: worldId } });
await db.worldState.deleteMany();
});
afterAll(async () => {
await hooks?.close();
await disconnect?.();
});
it('rolls back request and journal together, reloads pending state, then persists automatic appointment once', async () => {
const base = buildWorld({});
const state = {
...base.state,
id: worldId,
clockBaseTime: base.state.lastTurnTime,
clockTick: 0,
lastTurnTick: 0,
clockMode: 'manual' as const,
clockPhase: 'MANUAL' as const,
clockRevision: 1,
deadlineGeneration: 1,
clockWallAnchor: base.state.lastTurnTime,
};
const snapshot = {
...base.snapshot,
generals: base.snapshot.generals.map((g) => ({
...g,
id: 992950 + g.id,
nationId: worldId,
cityId: worldId,
userId: `user-${992950 + g.id}`,
})),
cities: base.snapshot.cities.map((c) => ({ ...c, id: worldId, nationId: worldId })),
nations: base.snapshot.nations.map((n) => ({
...n,
id: worldId,
capitalCityId: worldId,
chiefGeneralId: worldId,
})),
scenarioConfig: { ...base.snapshot.scenarioConfig!, environment: { mapName: 'che', unitSet: 'che' } },
};
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'office-requests',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: 600,
clockBaseTime: state.clockBaseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: 'manual',
clockPhase: 'MANUAL',
clockWallAnchor: state.clockWallAnchor,
clockRevision: 1n,
deadlineGeneration: 1n,
config: JSON.parse(JSON.stringify(snapshot.scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({
data: {
id: worldId,
name: '위',
color: '#777777',
level: 3,
gold: 10000,
rice: 10000,
tech: 0,
typeCode: 'che_중립',
capitalCityId: worldId,
meta: {},
},
});
await db.city.create({
data: {
id: worldId,
name: '허창',
nationId: worldId,
level: 7,
population: 1000,
populationMax: 2000,
agriculture: 1000,
agricultureMax: 2000,
commerce: 1000,
commerceMax: 2000,
security: 1000,
securityMax: 2000,
defence: 1000,
defenceMax: 2000,
wall: 1000,
wallMax: 2000,
trust: 80,
trade: 100,
region: 1,
meta: {},
},
});
await db.general.createMany({
data: snapshot.generals.map((g) => ({
id: g.id,
userId: g.userId,
name: g.name,
nationId: g.nationId,
cityId: g.cityId,
npcState: g.npcState,
officerLevel: g.officerLevel,
leadership: g.stats.leadership,
strength: g.stats.strength,
intel: g.stats.intelligence,
gold: g.gold,
rice: g.rice,
crew: g.crew,
crewTypeId: g.crewTypeId,
train: g.train,
atmos: g.atmos,
age: g.age,
turnTime: g.turnTime,
meta: g.meta,
})),
});
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const handler = createTurnDaemonCommandHandler({ world });
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const manager = new EngineStateManager();
manager.register('world', {
capture: () => world.captureState(),
restore: (saved) => world.restoreState(saved),
});
const command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }> = {
type: 'cityOfficeRequest',
requestId,
userId: `user-${generalId}`,
generalId,
action: 'request',
officerLevel: 4,
};
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
status: 'PROCESSING',
lockedBy: 'office-test',
leaseUntil: new Date('2099-01-01'),
attempts: 1,
payload: command,
acceptedGameTick: 0n,
acceptedClockRevision: 1n,
acceptedDeadlineGeneration: 1n,
processingGameTick: 0n,
processingClockRevision: 1n,
processingDeadlineGeneration: 1n,
},
});
const execute = () =>
manager.transaction(() =>
hooks!.hooks.executeCommand!(requestId, async (context) => {
const result = await handler.handle(command, context);
if (!result) throw new Error('Missing result');
return result;
})
);
await db.$executeRawUnsafe(
`ALTER TABLE input_event ADD CONSTRAINT ${constraint} CHECK (request_id <> '${requestId}' OR status <> 'SUCCEEDED')`
);
await expect(execute()).rejects.toThrow();
expect(readOfficeRequest(world.getGeneralById(generalId)?.meta)).toBeNull();
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)).toBeNull();
expect(hooks.takeCommittedReadModelChanges()).toBeNull();
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${constraint}`);
await expect(execute()).resolves.toMatchObject({ type: 'cityOfficeRequest', ok: true });
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('SUCCEEDED');
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)?.status).toBe(
'pending'
);
expect(hooks.takeCommittedReadModelChanges()).toMatchObject({ frontStatusChanged: true });
await hooks.close();
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const reloaded: InMemoryTurnWorld = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: createCityOfficeRequestCalendarHandler(() => reloaded),
});
const pending = readOfficeRequest(reloaded.getGeneralById(generalId)?.meta)!;
expect(pending.status).toBe('pending');
hooks = await createDatabaseTurnHooks(databaseUrl!, reloaded);
const turnTime = reloaded.gameTickToDate(pending.dueTick);
await reloaded.advanceMonth(turnTime);
await hooks.hooks.flushChanges?.({
lastTurnTime: turnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
const saved = await db.general.findUniqueOrThrow({ where: { id: generalId } });
expect(saved.officerLevel).toBe(4);
expect(readOfficeRequest(saved.meta)?.status).toBe('approved');
expect((await db.city.findUniqueOrThrow({ where: { id: worldId } })).meta).toMatchObject({ officer_set: 16 });
const count = await db.logEntry.count({ where: { generalId } });
await hooks.hooks.flushChanges?.({
lastTurnTime: turnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
expect(await db.logEntry.count({ where: { generalId } })).toBe(count);
});
});
@@ -0,0 +1,282 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN, readOfficeRequest, shiftOfficeRequestDeadline } from '@sammo-ts/common';
import { buildGeneral, buildWorld } from './officeRequestFixture.js';
import {
handleCityOfficeRequest,
settleCityOfficeRequests,
createCityOfficeRequestCalendarHandler,
} from '../src/turn/cityOfficeRequests.js';
const acceptedAt = new Date('0185-01-01T00:00:00Z');
const request = (world: ReturnType<typeof buildWorld>['world'], generalId = 3) =>
handleCityOfficeRequest(
world,
{ type: 'cityOfficeRequest', userId: `user-${generalId}`, generalId, action: 'request', officerLevel: 4 },
acceptedAt
);
const pending = (world: ReturnType<typeof buildWorld>['world']) => readOfficeRequest(world.getGeneralById(3)?.meta)!;
const decide = (
world: ReturnType<typeof buildWorld>['world'],
action: 'approve' | 'reject' | 'withdraw',
generalId = 2
) =>
handleCityOfficeRequest(
world,
{
type: 'cityOfficeRequest',
userId: `user-${generalId}`,
generalId,
action,
targetGeneralId: 3,
officeRequestId: pending(world).id,
},
acceptedAt
);
describe('city office request policy and lifecycle', () => {
it.each([null, 2, 3])('defaults vacancy / N / M (%s) to approval only at the deadline', (npc) => {
const { world } = buildWorld({
generals: [
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3),
...(npc === null
? []
: [
buildGeneral(4, {
userId: null,
npcState: npc,
officerLevel: 4,
meta: { killturn: 12, officerCity: 1 },
}),
]),
],
});
expect(request(world)).toMatchObject({ ok: true });
const entry = pending(world);
expect(entry).toMatchObject({ defaultDecision: 'approve', status: 'pending' });
expect(entry.dueTick - entry.createdTick).toBeGreaterThanOrEqual(GAME_TICKS_PER_TURN);
settleCityOfficeRequests(world, entry.dueTick - 1);
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
settleCityOfficeRequests(world, entry.dueTick);
expect(pending(world).status).toBe('approved');
expect(world.getGeneralById(3)).toMatchObject({ officerLevel: 4, meta: { officerCity: 1, officer_city: 1 } });
expect(Number(world.getCityById(1)?.meta.officer_set) & 16).toBe(16);
if (npc !== null) expect(world.getGeneralById(4)?.officerLevel).toBe(1);
settleCityOfficeRequests(world, entry.dueTick + GAME_TICKS_PER_TURN);
expect(pending(world).status).toBe('approved');
});
it.each(['absent', 'secret'] as const)('defaults %s to rejection but lets a chief explicitly approve', (reason) => {
for (const manual of [false, true]) {
const { world } = buildWorld({
generals: [
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3, reason === 'secret' ? { meta: { killturn: 12, belong: 0 } } : {}),
...(reason === 'absent'
? [buildGeneral(4, { cityId: 2, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } })]
: []),
],
});
expect(request(world)).toMatchObject({ ok: true });
expect(pending(world)).toMatchObject({ defaultDecision: 'reject', defaultReason: reason });
if (manual) expect(decide(world, 'approve')).toMatchObject({ ok: true });
else settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe(manual ? 'approved' : 'rejected');
}
});
it.each([0, 1])('does not replace a stationed user / possessed general (%s)', (npcState) => {
const { world } = buildWorld({
generals: [
buildGeneral(3),
buildGeneral(4, { npcState, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } }),
],
});
expect(request(world)).toMatchObject({ ok: false, reason: '재직자가 도시에 체류 중' });
});
it('lets chiefs approve or reject default approval, and permits owner withdrawal', () => {
for (const action of ['approve', 'reject', 'withdraw'] as const) {
const { world } = buildWorld({});
request(world);
expect(decide(world, action, action === 'withdraw' ? 3 : 2)).toMatchObject({ ok: true });
expect(pending(world).status).toBe(
{ approve: 'approved', reject: 'rejected', withdraw: 'withdrawn' }[action]
);
expect(request(world).ok).toBe(false);
}
});
it('rejects forged actors, ordinary approvers, foreign chiefs and stale request IDs', () => {
const { world } = buildWorld({});
request(world);
expect(decide(world, 'approve', 3).ok).toBe(false);
world.updateGeneral(2, { nationId: 9 });
expect(decide(world, 'approve').ok).toBe(false);
expect(
handleCityOfficeRequest(
world,
{
type: 'cityOfficeRequest',
userId: 'forged',
generalId: 3,
action: 'withdraw',
officeRequestId: pending(world).id,
},
acceptedAt
).ok
).toBe(false);
expect(
handleCityOfficeRequest(
world,
{
type: 'cityOfficeRequest',
userId: 'user-3',
generalId: 3,
action: 'withdraw',
officeRequestId: 'stale',
},
acceptedAt
).ok
).toBe(false);
expect(pending(world).status).toBe('pending');
});
it.each(['move', 'nation', 'owner', 'occupation', 'lock', 'stat', 'penalty'] as const)(
'cancels after %s changes before settlement',
(change) => {
const { world } = buildWorld({});
request(world);
const general = world.getGeneralById(3)!;
if (change === 'move') world.updateGeneral(3, { cityId: 2 });
if (change === 'nation') world.updateGeneral(3, { nationId: 2 });
if (change === 'owner') world.updateGeneral(3, { userId: 'other' });
if (change === 'occupation') world.updateCity(1, { nationId: 2 });
if (change === 'lock') world.updateCity(1, { meta: { officer_set: 16 } });
if (change === 'stat') world.updateGeneral(3, { stats: { ...general.stats, strength: 0 } });
if (change === 'penalty') world.updateGeneral(3, { penalty: { noChief: true } });
settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe('cancelled');
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
}
);
it('does not auto-grant newly restricted secrets or upgrade a stored rejection', () => {
const { world } = buildWorld({});
request(world);
world.updateNation(1, { meta: { secretlimit: 10 } });
settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe('rejected');
});
it('reserves a slot against other applicants but not a manual chief appointment', async () => {
const { world, handler } = buildWorld({
generals: [buildGeneral(2, { officerLevel: 5 }), buildGeneral(3), buildGeneral(4)],
});
request(world);
expect(request(world, 4).ok).toBe(false);
expect(
await handler.handle({
type: 'appoint',
userId: 'user-2',
generalId: 2,
destGeneralId: 4,
destCityId: 1,
officerLevel: 4,
})
).toMatchObject({ ok: true });
expect(pending(world).status).toBe('cancelled');
expect(world.getGeneralById(4)?.officerLevel).toBe(4);
});
it('uses the monthly calendar boundary and survives a world snapshot reload', async () => {
const { world } = buildWorld({});
request(world);
const saved = JSON.parse(JSON.stringify(world.getGeneralById(3)?.meta));
const { world: reloaded } = buildWorld({ generals: [buildGeneral(3, { meta: saved })] });
await createCityOfficeRequestCalendarHandler(() => reloaded).onMonthChanged?.({
previousYear: 185,
previousMonth: 1,
currentYear: 185,
currentMonth: 2,
turnTime: reloaded.gameTickToDate(pending(reloaded).dueTick),
});
expect(pending(reloaded).status).toBe('approved');
});
});
it('preserves a full review month for requests accepted just before a month boundary', () => {
const { world } = buildWorld({
clock: {
clockBaseTime: acceptedAt,
clockWallAnchor: acceptedAt,
clockMode: 'manual',
clockTick: GAME_TICKS_PER_TURN - 1,
lastTurnTick: 0,
},
});
expect(request(world)).toMatchObject({ ok: true });
expect(pending(world).dueTick).toBe(2 * GAME_TICKS_PER_TURN);
settleCityOfficeRequests(world, GAME_TICKS_PER_TURN);
expect(pending(world).status).toBe('pending');
});
it('releases the quarterly applicant limit next quarter while preserving same-quarter refusal', () => {
const { world } = buildWorld({});
request(world);
decide(world, 'reject');
expect(request(world).ok).toBe(false);
const previous = pending(world);
world.updateGeneral(3, {
meta: { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...previous, quarter: previous.quarter - 1 } },
});
expect(request(world).ok).toBe(true);
});
it('cancels pending requests before quarterly locks reset', async () => {
const { world } = buildWorld({});
request(world);
world.updateCity(1, { meta: { officer_set: 16 } });
await createCityOfficeRequestCalendarHandler(() => world).beforeMonthChanged?.({
previousYear: 185,
previousMonth: 3,
currentYear: 185,
currentMonth: 4,
turnTime: acceptedAt,
});
expect(pending(world).status).toBe('cancelled');
});
it('does not relax a stored secret rejection when tenure increases', () => {
const { world } = buildWorld({ generals: [buildGeneral(3, { meta: { killturn: 12, belong: 0 } })] });
request(world);
world.updateGeneral(3, { meta: { ...world.getGeneralById(3)!.meta, belong: 10 } });
settleCityOfficeRequests(world, pending(world).dueTick);
expect(pending(world).status).toBe('rejected');
});
it('moves pending deadlines on backlog rebase and preserves occurrence ticks', () => {
const anchor = new Date('2026-01-01T00:00:00Z');
const { world } = buildWorld({
clock: { clockBaseTime: anchor, clockWallAnchor: anchor, clockMode: 'realtime', clockTick: 0, lastTurnTick: 0 },
});
expect(
handleCityOfficeRequest(
world,
{ type: 'cityOfficeRequest', userId: 'user-3', generalId: 3, action: 'request', officerLevel: 4 },
anchor
)
).toMatchObject({ ok: true });
const before = pending(world);
const shifted = world.rebaseRealtimeBacklog(new Date(anchor.getTime() + 13 * 600_000));
expect(shifted?.shiftedTicks).toBe(12 * GAME_TICKS_PER_TURN);
expect(pending(world)).toMatchObject({
createdTick: before.createdTick,
dueTick: before.dueTick + shifted!.shiftedTicks,
});
const completed = { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...before, status: 'approved' } };
expect(shiftOfficeRequestDeadline(completed, 10)).toEqual(completed);
expect(shiftOfficeRequestDeadline(world.getGeneralById(3)!.meta, 10, pending(world).dueTick + 1)).toEqual(
world.getGeneralById(3)!.meta
);
});
it('uses the existing secret-limit fallback for malformed metadata', () => {
const { world } = buildWorld({
nationMeta: { secretlimit: 'invalid' },
generals: [buildGeneral(3, { meta: { killturn: 12, belong: 'invalid' } })],
});
expect(request(world)).toMatchObject({ ok: true });
expect(pending(world).defaultDecision).toBe('reject');
});
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common'; import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery, readOfficeRequest } from '@sammo-ts/common';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
readTurnRuntimeReady, readTurnRuntimeReady,
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
meta: meta:
index === 0 index === 0
? { ? {
cityOfficeRequest: {
id: '1:760',
generalId: 1,
userId: 'clock-user',
nationId: 1,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: initialTick - 100,
dueTick: reselectionTick,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
},
next_change_tick: reselectionTick, next_change_tick: reselectionTick,
next_change: clock.tickToDate(reselectionTick).toISOString(), next_change: clock.tickToDate(reselectionTick).toISOString(),
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(), nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
@@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => {
); );
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>; const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks); expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
expect(readOfficeRequest(shiftedReselectionMeta)).toMatchObject({
createdTick: initialTick - 100,
dueTick: reselectionTick + reconciled.shiftTicks,
status: 'pending',
});
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe( expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250 clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
); );
@@ -224,6 +224,14 @@ integration('monthly catalog boundary persistence', () => {
currentYear: 190, currentYear: 190,
currentMonth: 12, currentMonth: 12,
tickSeconds: 600, tickSeconds: 600,
clockBaseTime: new Date('2026-07-25T01:50:00.000Z'),
clockTick: 0n,
lastTurnTick: 0n,
clockMode: 'manual',
clockPhase: 'MANUAL',
clockWallAnchor: new Date('2026-07-25T01:50:00.000Z'),
clockRevision: 1n,
deadlineGeneration: 1n,
config: scenarioConfig, config: scenarioConfig,
meta: { meta: {
hiddenSeed: 'monthly-boundary-core-catalog', hiddenSeed: 'monthly-boundary-core-catalog',
@@ -157,6 +157,23 @@ integration('monthly diplomacy persistence', () => {
}); });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world); const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try { try {
// An API writer may have recorded a gap since this daemon loaded its world.
await db.worldState.update({
where: { id: worldRow.id },
data: {
meta: {
...world.getState().meta,
playAuditGap: {
serverId: scenarioCode,
firstYear: 192,
firstMonth: 12,
lastYear: 192,
lastMonth: 12,
stage: 'persistence',
},
},
},
});
const checkpoint = world.captureState(); const checkpoint = world.captureState();
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
const queued = world.peekDirtyState().pendingAuditDiplomacy; const queued = world.peekDirtyState().pendingAuditDiplomacy;
@@ -170,11 +187,11 @@ integration('monthly diplomacy persistence', () => {
await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`); FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
try { try {
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure'); await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0); expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
currentMonth: 1, currentMonth: 2,
}); });
expect( expect(
await db.diplomacy.findUniqueOrThrow({ await db.diplomacy.findUniqueOrThrow({
@@ -185,7 +202,7 @@ integration('monthly diplomacy persistence', () => {
}, },
}, },
}) })
).toMatchObject({ stateCode: 1, term: 1 }); ).toMatchObject({ stateCode: 0, term: 6 });
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
@@ -193,18 +210,25 @@ integration('monthly diplomacy persistence', () => {
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode); const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
world.queueAuditDecision(decision); world.queueAuditDecision(decision);
await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`); await db.$executeRawUnsafe(
await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`); `CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`
);
await db.$executeRawUnsafe(
`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`
);
try { try {
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback'); await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]); expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0); expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0); expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1); expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(2);
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk'); await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
} }
// New observations after recovery can be stored; discarded batches do not retry themselves.
for (const event of queued) world.queueAuditDiplomacy(event);
world.queueAuditDecision(decision);
await hooks.hooks.flushChanges?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: '0193-02-01T00:00:00.000Z', lastTurnTime: '0193-02-01T00:00:00.000Z',
processedGenerals: 0, processedGenerals: 0,
@@ -313,9 +337,9 @@ integration('monthly diplomacy persistence', () => {
await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`); FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
try { try {
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure'); await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1); expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
@@ -331,7 +355,7 @@ integration('monthly diplomacy persistence', () => {
await db.playAuditDiplomacyEvent.count({ await db.playAuditDiplomacyEvent.count({
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' }, where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
}) })
).toBe(6); ).toBe(0);
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true); expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
await hooks.flushChanges(); await hooks.flushChanges();
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1); expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
@@ -349,6 +373,18 @@ integration('monthly diplomacy persistence', () => {
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' }, where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' },
}) })
).toBe(6); ).toBe(6);
const savedDecision = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
const nation = world.getNationById(nationIds[0]!)!;
world.updateNation(nation.id, { gold: nation.gold + 1 });
world.queueAuditDecision({
...decision,
summary: { ...decision.summary, selectedReason: 'changed after update' },
});
await hooks.flushChanges();
expect(await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } })).toEqual(savedDecision);
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
expect(world.hasPendingAuditRecords()).toBe(false);
expect(world.getState().meta.playAuditGap).toMatchObject({ firstYear: 192, firstMonth: 12 });
} finally { } finally {
await hooks.close(); await hooks.close();
} }
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
@@ -186,6 +187,31 @@ describe('invader monthly actions', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('preserves new nation audit heads through ruler initialization and restart', async () => {
const harness = buildHarness();
const { world, environment } = harness;
world.updateWorldMeta({ serverId: 'new-nation-audit' });
initializeAuditPolicies(world);
const handler = createRaiseInvaderHandler({
getWorld: () => world,
reservedTurns: harness.reservedTurns,
env: buildCommandEnv(scenarioConfig),
});
await handler([10, 150, 100, 20], environment, event);
const created = world.peekDirtyState().createdNations;
expect(created.length).toBeGreaterThan(0);
for (const nation of created) {
expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4);
}
world.consumeDirtyState();
initializeAuditPolicies(world);
expect(
world
.peekDirtyState()
.pendingAuditPolicies.filter((policy) => created.some((nation) => nation.id === policy.nationId))
).toEqual([]);
});
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => { it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
const harness = buildHarness(); const harness = buildHarness();
const handler = createRaiseInvaderHandler({ const handler = createRaiseInvaderHandler({
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
parseScenarioGeneralPoolCandidate, parseScenarioGeneralPoolCandidate,
@@ -228,6 +229,21 @@ describe('RaiseNPCNation monthly action', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('preserves new nation audit heads through ruler initialization and restart', async () => {
const { world, handler, environment } = buildHarness();
world.updateWorldMeta({ serverId: 'new-nation-audit' });
initializeAuditPolicies(world);
await handler([], environment, event);
const created = world.peekDirtyState().createdNations;
expect(created.length).toBeGreaterThan(0);
for (const nation of created) {
expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4);
}
world.consumeDirtyState();
initializeAuditPolicies(world);
expect(world.peekDirtyState().pendingAuditPolicies).toEqual([]);
});
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => { it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
const info = { const info = {
generalName: '부장후보', generalName: '부장후보',
@@ -337,6 +353,32 @@ describe('RaiseNPCNation monthly action', () => {
"id": 2, "id": 2,
"level": 2, "level": 2,
"meta": { "meta": {
"_playAuditPolicy": {
"DEFENCE": {
"hash": "ffb1e0b6bbf1680af65800cfc1b166afbd68149217e2b039c95b4ce4db0d20aa",
"id": "6329bea23c4db57a8c6bfb7d4c81e9f5a3e59171c883e9d22c5ba97dfb7d486c",
"revision": 1,
"serverId": "fixture-server",
},
"NPC_GENERAL_PRIORITY": {
"hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062",
"id": "28bc6ff38cc5b1b250f6a738a6745b5d581badf34bbcaf191e5e8710c17263ec",
"revision": 1,
"serverId": "fixture-server",
},
"NPC_NATION_PRIORITY": {
"hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062",
"id": "feefa645ba10e0fb8c2c78e0c189f9effe8a1c3d2adf4c0d866300d4123f3748",
"revision": 1,
"serverId": "fixture-server",
},
"NPC_VALUES": {
"hash": "1906fb445a401a46f1279dbba3f3d572e9b213daf6c0f0d31cf8beeee3c77104",
"id": "c6fe336ffe5870cc90e2abab797d050810400404f7ad51b3ceea987b21eb3baa",
"revision": 1,
"serverId": "fixture-server",
},
},
"bill": 100, "bill": 100,
"can_국기변경": 1, "can_국기변경": 1,
"gennum": 1, "gennum": 1,
@@ -0,0 +1,140 @@
import { type GeneralActionModule, type TriggerValue, type TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
export const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
userId: `user-${id}`,
name: `장수${id}`,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
turnTime: new Date('0185-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 },
penalty: {},
officerLevel: 1,
experience: 1_000,
dedication: 2_000,
injury: 0,
gold: 1_500,
rice: 1_600,
crew: 100,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
...overrides,
});
export const buildWorld = (options: {
generals?: TurnGeneral[];
nationMeta?: Record<string, TriggerValue>;
nationChiefGeneralId?: number | null;
cityMeta?: Record<string, TriggerValue>;
currentYear?: number;
scenarioConst?: Record<string, unknown>;
generalActionModules?: ReadonlyArray<GeneralActionModule>;
clock?: Pick<TurnWorldState, 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'>;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: options.currentYear ?? 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
meta: { killturn: 24, scenarioMeta: { startYear: 180 } },
...options.clock,
};
const snapshot: TurnWorldSnapshot = {
generals: options.generals ?? [
buildGeneral(1, { officerLevel: 12 }),
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3),
],
cities: [
{
id: 1,
name: '허창',
nationId: 1,
level: 7,
state: 0,
population: 1_000,
populationMax: 2_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: options.cityMeta ?? {},
},
],
nations: [
{
id: 1,
name: '위',
color: '#777777',
capitalCityId: 1,
chiefGeneralId: options.nationChiefGeneralId === undefined ? 1 : options.nationChiefGeneralId,
gold: 10_000,
rice: 20_000,
power: 0,
level: 3,
typeCode: 'che_법가',
meta: options.nationMeta ?? {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: { defaultGold: 1000, defaultRice: 1000, ...options.scenarioConst },
environment: { mapName: 'test', unitSet: 'test' },
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
return {
snapshot,
state,
world,
handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }),
};
};
@@ -1,6 +1,6 @@
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js'; import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js'; import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
@@ -130,6 +130,29 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
return world; return world;
}; };
describe('play audit collection durability state', () => { describe('play audit collection durability state', () => {
it('discards collector failures without changing gameplay state and can collect again', () => {
const world = buildWorld();
const nations = structuredClone(world.listNations());
const broken = vi.spyOn(world, 'listGenerals').mockImplementationOnce(() => {
throw new Error('audit projection');
});
expect(() => queueAuditMonth(world)).not.toThrow();
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(0);
expect(world.listNations()).toEqual(nations);
expect(world.getState().meta.playAuditGap).toMatchObject({
stage: 'queueAuditMonth',
firstYear: 200,
firstMonth: 1,
});
broken.mockRestore();
queueAuditMonth(world);
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(1);
expect(world.getState().meta.playAuditGap).toBeDefined();
world.updateWorldMeta({ playAuditCollection: { serverId: 'yearbook-projection-test', schemaVersion: 999 } });
expect(initializeAuditCollection(world)).toBe(false);
expect(world.getState().meta.playAuditGap).toMatchObject({ stage: 'initializeAuditCollection' });
});
it('restores decision buffers and acknowledges only the committed prefix', () => { it('restores decision buffers and acknowledges only the committed prefix', () => {
const world = buildWorld(); const world = buildWorld();
const first = buildAuditDecisionFixture('first'); const first = buildAuditDecisionFixture('first');
@@ -207,7 +230,8 @@ describe('play audit collection durability state', () => {
world.removeNation(1); world.removeNation(1);
world.removeNation(2); world.removeNation(2);
world.updateWorldMeta({ serverId: 'yearbook-projection-test' }); world.updateWorldMeta({ serverId: 'yearbook-projection-test' });
expect(() => initializeAuditDiplomacy(world, new Date('invalid'))).toThrow(RangeError); expect(initializeAuditDiplomacy(world, new Date('invalid'))).toBe(false);
expect(world.getState().meta.playAuditGap).toMatchObject({ stage: 'initializeAuditDiplomacy' });
expect(world.getState().meta.playAuditDiplomacy).toBeUndefined(); expect(world.getState().meta.playAuditDiplomacy).toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(initializeAuditDiplomacy(world)).toBe(true); expect(initializeAuditDiplomacy(world)).toBe(true);
@@ -76,13 +76,15 @@ describe('play audit monthly projection', () => {
it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => { it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => {
const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }]; const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }];
humans[0]!.meta.dex1 = 10; humans[0]!.meta.dex1 = 10;
humans[0]!.crew = 3210;
humans[1]!.crew = 790;
const result = buildAuditSnapshot({ const result = buildAuditSnapshot({
nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})], nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})],
cities: [buildCity(1, 2)], cities: [buildCity(1, 2)],
generals: [ generals: [
...humans, ...humans,
{ ...buildGeneral(3, 1), npcState: 2 }, { ...buildGeneral(3, 1), npcState: 2, crew: 1200 },
{ ...buildGeneral(4, 1), npcState: 5 }, { ...buildGeneral(4, 1), npcState: 5, crew: 800 },
buildGeneral(5, 0), buildGeneral(5, 0),
], ],
settlements: [], settlements: [],
@@ -91,12 +93,13 @@ describe('play audit monthly projection', () => {
const nation = result.nations.find((row) => row.id === 1)!; const nation = result.nations.find((row) => row.id === 1)!;
expect(nation.populations.human).toMatchObject({ expect(nation.populations.human).toMatchObject({
count: 2, count: 2,
crew: 4000,
gold: 2000, gold: 2000,
averageGold: 1000, averageGold: 1000,
averageDex: { dex1: 5 }, averageDex: { dex1: 5 },
}); });
expect(nation.populations.npc.count).toBe(1); expect(nation.populations.npc).toMatchObject({ count: 1, crew: 1200 });
expect(nation.populations.troopNpc.count).toBe(1); expect(nation.populations.troopNpc).toMatchObject({ count: 1, crew: 800 });
expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull(); expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull();
expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1); expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1);
expect(result.cities[0]!.nationId).toBe(2); expect(result.cities[0]!.nationId).toBe(2);
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
withPlayAuditSavepoint,
hashAuditDiplomacyDocument, hashAuditDiplomacyDocument,
type GamePrismaClient, type GamePrismaClient,
type GamePrisma, type GamePrisma,
@@ -92,7 +93,7 @@ integration('initial audit durability before runtime readiness', () => {
await closeDb?.(); await closeDb?.();
}); });
it('rolls initial policies, sample and collection marker back before readiness on persistence failure', async () => { it('keeps readiness and gameplay when initial audit persistence fails', async () => {
const before = await clock(); const before = await clock();
await db.$executeRawUnsafe( await db.$executeRawUnsafe(
"CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$" "CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$"
@@ -100,28 +101,34 @@ integration('initial audit durability before runtime readiness', () => {
await db.$executeRawUnsafe( await db.$executeRawUnsafe(
"CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()" "CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()"
); );
try {
let error: unknown;
try { try {
runtime = await start(); runtime = await start();
} catch (cause) {
error = cause;
}
expect(String(error)).toContain('fixture initial audit failure');
expect(await db.playAuditMonth.count()).toBe(0); expect(await db.playAuditMonth.count()).toBe(0);
expect(await db.playAuditPolicy.count()).toBe(0); expect(await db.playAuditPolicy.count()).toBe(0);
expect(await db.playAuditDiplomacyEvent.count()).toBe(0); expect(runtime.world.hasPendingAuditRecords()).toBe(false);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditGap).toMatchObject({
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined(); serverId,
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined(); stage: 'persistence',
expect( });
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined) expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
).toBe(true);
expect((await db.turnDaemonLease.findMany()).every((lease) => !lease.clockReady)).toBe(true);
expect(await clock()).toEqual(before); expect(await clock()).toEqual(before);
await runtime.close();
runtime = undefined;
// Restart under the same broken audit table must not pause or accumulate retries.
runtime = await start();
expect(runtime.world.hasPendingAuditRecords()).toBe(false);
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
} finally { } finally {
await runtime?.close();
runtime = undefined;
await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month'); await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month');
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()'); await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()');
// Independent baseline fixture for the following normal-start tests.
await db.playAuditDiplomacyEvent.deleteMany();
await db.$executeRawUnsafe(
"UPDATE world_state SET meta = meta - 'playAuditCollection' - 'playAuditDocuments' - 'playAuditDiplomacy' - 'playAuditGap'"
);
await db.$executeRawUnsafe("UPDATE nation SET meta = meta - '_playAuditPolicy'");
} }
}); });
@@ -235,6 +242,64 @@ integration('initial audit durability before runtime readiness', () => {
).rejects.toMatchObject({ code: 'P2002' }); ).rejects.toMatchObject({ code: 'P2002' });
}, 30_000); }, 30_000);
it('restores missing policy heads from immutable history before restart without rewriting history', async () => {
await runtime?.close();
runtime = undefined;
const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
const { _playAuditPolicy: heads, ...meta } = asRecord(nation.meta);
await db.nation.update({ where: { id: nation.id }, data: { meta: meta as GamePrisma.InputJsonObject } });
runtime = await start();
expect(
asRecord((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).meta)._playAuditPolicy
).toEqual(heads);
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
await runtime.close();
runtime = undefined;
runtime = await start();
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
}, 30_000);
it('links an observed policy gap to the recovered head and keeps it idempotent', async () => {
await runtime?.close();
runtime = undefined;
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
const meta = asRecord(nation.meta);
const heads = asRecord(meta._playAuditPolicy);
const previous = asRecord(heads.DEFENCE);
const { DEFENCE: _lost, ...remainingHeads } = heads;
await db.nation.update({
where: { id: nation.id },
data: {
meta: { ...meta, scout: 1, _playAuditPolicy: remainingHeads } as GamePrisma.InputJsonObject,
},
});
runtime = await start();
const rows = await db.playAuditPolicy.findMany({
where: { serverId, nationId: nation.id, area: 'DEFENCE' },
orderBy: { revision: 'asc' },
});
expect(rows.at(-1)).toMatchObject({
source: 'OBSERVED_GAP',
previousId: previous.id,
revision: Number(previous.revision) + 1,
actor: null,
before: null,
after: { scout: 1 },
});
expect(asRecord((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).meta).scout).toBe(1);
await runtime.close();
runtime = undefined;
runtime = await start();
expect(
await db.playAuditPolicy.findMany({
where: { serverId, nationId: nation.id, area: 'DEFENCE' },
orderBy: { revision: 'asc' },
})
).toEqual(rows);
}, 30_000);
it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => { it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => {
await runtime?.close(); await runtime?.close();
runtime = undefined; runtime = undefined;
@@ -279,4 +344,34 @@ integration('initial audit durability before runtime readiness', () => {
runtime = await start(); runtime = await start();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker);
}, 30_000); }, 30_000);
it('isolates missing audit tables and bounded SQL waits but propagates core write failures', async () => {
await runtime?.close();
runtime = undefined;
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
await db.$transaction(async (tx) => {
await tx.nation.update({ where: { id: nation.id }, data: { gold: nation.gold + 1 } });
const missing = await withPlayAuditSavepoint(tx, () =>
tx.$queryRawUnsafe('SELECT * FROM nonexistent_audit_fixture_table')
);
expect(missing.ok).toBe(false);
const [settings] = await tx.$queryRaw<
Array<{ timeout: string }>
>`SELECT current_setting('statement_timeout') AS timeout`;
const slow = await withPlayAuditSavepoint(tx, () => tx.$queryRawUnsafe('SELECT pg_sleep(2)::text'));
expect(slow.ok).toBe(false);
const [restored] = await tx.$queryRaw<
Array<{ timeout: string }>
>`SELECT current_setting('statement_timeout') AS timeout`;
expect(restored).toEqual(settings);
});
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
await expect(
db.$transaction(async (tx) => {
await tx.nation.update({ where: { id: nation.id }, data: { gold: nation.gold + 2 } });
expect((await withPlayAuditSavepoint(tx, async () => 'audit-ok')).ok).toBe(true);
throw new Error('core game write failed');
})
).rejects.toThrow('core game write failed');
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
});
}); });
@@ -504,11 +504,11 @@ describe('summarizeRealtimeReadModelChanges', () => {
mapGeneralIds: [], mapGeneralIds: [],
mapCityIds: [1], mapCityIds: [1],
mapNationIds: [], mapNationIds: [],
frontStatusGeneralIds: [], frontStatusGeneralIds: [7],
frontStatusNationIds: [1], frontStatusNationIds: [1],
lobbyGeneralIds: [], lobbyGeneralIds: [],
contactsChanged: true, contactsChanged: true,
frontStatusChanged: false, frontStatusChanged: true,
lobbyChanged: false, lobbyChanged: false,
}); });
}); });
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
@@ -618,3 +619,18 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
}); });
expect(workerUrls.some((url) => url.includes('battleSimulator.worker'))).toBe(true); expect(workerUrls.some((url) => url.includes('battleSimulator.worker'))).toBe(true);
}); });
for (const width of [500, 1000]) {
test(`전투 시뮬레이터 요약표 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await installApi(page, {
hasGeneral: true,
requests: [],
preparedPayloads: [],
serverResults: [],
prepareResponseBytes: [],
});
await page.setViewportSize({ width, height: 900 });
await gotoSimulator(page);
await verifyVerticalAlignment(page, testInfo, ['.summary-table th', '.summary-table td'], 'table');
});
}
@@ -139,7 +139,7 @@ test('persists one chief command and exposes it to a normal nation user and anot
selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id); selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id);
if (!selectedTargetId) throw new Error('No reward target is available in the live command table.'); if (!selectedTargetId) throw new Error('No reward target is available in the live command table.');
await argumentForm.locator('select').selectOption(String(selectedTargetId)); await argumentForm.locator('select').selectOption(String(selectedTargetId));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect( await expect(
editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first() editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()
).toHaveText('포상'); ).toHaveText('포상');
@@ -245,10 +245,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an
expect(geometry.display).not.toBe('none'); expect(geometry.display).not.toBe('none');
await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true });
await page await page.getByRole('button', { name: '명령 취소', exact: true }).click();
.getByRole('button', { name: '명령 입력 닫기', exact: true })
.click({ timeout: 2_000 })
.catch(() => undefined);
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
const nationPicker = page.getByTestId('command-picker'); const nationPicker = page.getByTestId('command-picker');
await nationPicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); await nationPicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
@@ -264,10 +261,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an
); );
await page.screenshot({ path: testInfo.outputPath('chief-command-nation-map-live.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('chief-command-nation-map-live.png'), fullPage: true });
await page await page.getByRole('button', { name: '명령 취소', exact: true }).click();
.getByRole('button', { name: '명령 입력 닫기', exact: true })
.click({ timeout: 2_000 })
.catch(() => undefined);
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
const assignmentPicker = page.getByTestId('command-picker'); const assignmentPicker = page.getByTestId('command-picker');
await assignmentPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); await assignmentPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
+1153 -38
View File
@@ -1,6 +1,12 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { buildRefGeneralTargetOptions } from '../../game-api/src/turns/commandTargets.js'; import {
buildRefGeneralTargetOptions,
buildRefNationTargetOptions,
type NationTargetSource,
} from '../../game-api/src/turns/commandTargets.js';
import { purifyNationHtml } from '../../game-api/src/security/nationHtml.js';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -848,7 +854,8 @@ const install = async (
clockRecovery: { startsAt: string; endsAt: string } | null; clockRecovery: { startsAt: string; endsAt: string } | null;
turnEngineRunning: boolean; turnEngineRunning: boolean;
}, },
decoratedNpcChiefs = false decoratedNpcChiefs = false,
chiefOfficerLevel = 5
) => { ) => {
const requests: unknown[] = []; const requests: unknown[] = [];
const currentGeneralContext = { const currentGeneralContext = {
@@ -994,6 +1001,18 @@ const install = async (
npcState: [4, 6, 9, 7, 5, 3, 2, 0][index]!, npcState: [4, 6, 9, 7, 5, 3, 2, 0][index]!,
})), })),
} }
: chiefOfficerLevel !== chiefCenter.me.officerLevel
? {
...chiefCenter,
me: { ...chiefCenter.me, officerLevel: chiefOfficerLevel },
chiefs: chiefCenter.chiefs.map((chief) => ({
...chief,
name:
chief.officerLevel === chiefOfficerLevel
? '장수'
: `수뇌${chief.officerLevel}`,
})),
}
: chiefCenter : chiefCenter
); );
if (name === 'turns.reserved.getGeneral') if (name === 'turns.reserved.getGeneral')
@@ -1237,7 +1256,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
); );
await form.locator('select').selectOption('2'); await form.locator('select').selectOption('2');
await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') }); await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') });
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행'); await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행');
expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}'); expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}');
@@ -1355,7 +1374,7 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click(); await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차'); await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차');
await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible(); await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible();
await expect(mobilePicker.getByRole('button', { name: '입력', exact: true })).toBeDisabled(); await expect(mobilePicker.getByRole('button', { name: / 입력$/ })).toBeDisabled();
await mobilePicker.getByLabel('국가명').fill('신국'); await mobilePicker.getByLabel('국가명').fill('신국');
const mobileColorType = mobilePicker.getByLabel('국기 색상'); const mobileColorType = mobilePicker.getByLabel('국기 색상');
await mobileColorType.click(); await mobileColorType.click();
@@ -1384,7 +1403,7 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
expect(colorSelectRect.right).toBeLessThanOrEqual(500); expect(colorSelectRect.right).toBeLessThanOrEqual(500);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') }); await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') });
await mobilePicker.getByRole('button', { name: '입력', exact: true }).click(); await mobilePicker.getByRole('button', { name: / 입력$/ }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toContainText('신국'); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toContainText('신국');
await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15'); await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15');
}); });
@@ -1499,7 +1518,7 @@ test('shows speciality reset cooldowns and reserves special user commands from t
const forceMoveForm = picker.getByTestId('command-argument-form'); const forceMoveForm = picker.getByTestId('command-argument-form');
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
await forceMoveForm.locator('select').selectOption('2'); await forceMoveForm.locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행'); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
const serialized = JSON.stringify(requests); const serialized = JSON.stringify(requests);
@@ -1585,6 +1604,98 @@ test('shows general command durations from metadata on desktop and mobile', asyn
expect(JSON.stringify(requests)).toContain('"action":"che_내정특기초기화","args":{}'); expect(JSON.stringify(requests)).toContain('"action":"che_내정특기초기화","args":{}');
}); });
for (const { officerLevel, row, column } of [
{ officerLevel: 12, row: 0, column: 0 },
{ officerLevel: 6, row: 0, column: 3 },
{ officerLevel: 9, row: 1, column: 1 },
{ officerLevel: 5, row: 1, column: 3 },
]) {
test(`anchors the chief command list to officer ${officerLevel} column and clicked turn`, async ({ page }) => {
await install(page, false, refChiefCommandTable, 1, undefined, false, officerLevel);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
const editor = page.locator('[data-command-scope="nation"]');
const gridRow = page.locator('.chief-grid-row').nth(row);
await expect(gridRow.locator('[data-command-scope="nation"]')).toHaveCount(1);
const picker = page.getByTestId('command-picker');
const pageBox = (await page.locator('.layout-desktop').boundingBox())!;
const artifacts: Record<string, unknown>[] = [];
for (const turn of [1, 7, 12]) {
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
await expect(picker.locator('.category-btn').first()).toBeVisible();
const geometry = await page.evaluate((turnIndex) => {
const editorElement = document.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
const pickerElement = document.querySelector<HTMLElement>('[data-testid="command-picker"]')!;
const rowElement = editorElement.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`)!;
const rect = (element: Element) => {
const { left, top, right, bottom, width, height } = element.getBoundingClientRect();
return { left, top, right, bottom, width, height };
};
return {
editor: rect(editorElement),
picker: rect(pickerElement),
row: rect(rowElement),
position: getComputedStyle(pickerElement).position,
horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
pickerOverflow: pickerElement.scrollWidth - pickerElement.clientWidth,
};
}, turn - 1);
artifacts.push({ turn, ...geometry });
// Ref PageChiefCenter는 24px 턴 번호 열 뒤에 238px 수뇌 열 4개를 둔다.
expect(geometry.editor.left - pageBox.x).toBeCloseTo(24 + column * 238, 0);
expect(geometry.position).toBe('absolute');
expect(geometry.picker.left).toBeCloseTo(geometry.editor.left, 0);
expect(geometry.picker.width).toBeCloseTo(geometry.editor.width, 0);
expect(geometry.picker.top).toBeCloseTo(geometry.row.bottom, 0);
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(geometry.pickerOverflow).toBeLessThanOrEqual(0);
// 둘째 줄 마지막 턴에서도 목록이 문서 밖으로 잘리지 않고 스크롤로 전부 보여야 한다.
await picker.evaluate((element) => element.scrollIntoView({ block: 'nearest' }));
const visible = await picker.evaluate((element) => {
const { top, bottom } = element.getBoundingClientRect();
return { top, bottom, viewportHeight: window.innerHeight };
});
expect(visible.top).toBeGreaterThanOrEqual(0);
expect(visible.bottom).toBeLessThanOrEqual(visible.viewportHeight);
if (turn !== 1) {
await page.screenshot({
path: test.info().outputPath(`chief-picker-officer-${officerLevel}-turn-${turn}-1200.png`),
});
}
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
await expect(picker).toHaveCount(0);
}
// 고급 모드의 `명령 선택 ▾`은 Ref 편집 모드(`bottom: 0`)처럼 자기 열 턴 표 하단에 맞춰 위로 펼친다.
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
await expect(picker.locator('.category-btn').first()).toBeVisible();
const advanced = await page.evaluate(() => {
const rect = (selector: string) => {
const { left, top, bottom, width } = document.querySelector(selector)!.getBoundingClientRect();
return { left, top, bottom, width };
};
return {
editor: rect('[data-command-scope="nation"]'),
picker: rect('[data-testid="command-picker"]'),
queue: rect('[data-command-scope="nation"] .queue-area'),
};
});
artifacts.push({ turn: 'advanced', ...advanced });
expect(advanced.picker.left).toBeCloseTo(advanced.editor.left, 0);
expect(advanced.picker.width).toBeCloseTo(advanced.editor.width, 0);
expect(advanced.picker.top).toBeGreaterThanOrEqual(advanced.editor.top + 24);
expect(advanced.picker.bottom).toBeCloseTo(advanced.queue.bottom, 0);
await page.screenshot({
path: test.info().outputPath(`chief-picker-officer-${officerLevel}-advanced-1200.png`),
});
await writeFile(
test.info().outputPath(`chief-picker-officer-${officerLevel}-geometry.json`),
JSON.stringify(artifacts, null, 2)
);
});
}
test('shows every Ref chief command in the exact category and command order', async ({ page }) => { test('shows every Ref chief command in the exact category and command order', async ({ page }) => {
await install(page, false, refChiefCommandTable); await install(page, false, refChiefCommandTable);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
@@ -1669,7 +1780,7 @@ test('keeps general and chief command categories after input and across page rel
await picker.getByRole('button', { name: '군사', exact: true }).click(); await picker.getByRole('button', { name: '군사', exact: true }).click();
await picker.getByRole('button', { name: '출병', exact: true }).click(); await picker.getByRole('button', { name: '출병', exact: true }).click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('3'); await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(generalEditor.locator('.action-column > div').first()).toHaveText('【단양】으로 출병'); await expect(generalEditor.locator('.action-column > div').first()).toHaveText('【단양】으로 출병');
await generalEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); await generalEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
@@ -1845,8 +1956,8 @@ test('shows all 12 advanced chief turns before the actions and uses the full mob
expect(geometry.frameWidth).toBe(500); expect(geometry.frameWidth).toBe(500);
expect(geometry.rowWidth).toBe(500); expect(geometry.rowWidth).toBe(500);
expect(geometry.rowEdges).toEqual([ expect(geometry.rowEdges).toEqual([
{ top: 0, bottom: 155 }, { top: 0, bottom: 224 },
{ top: 155, bottom: 310 }, { top: 224, bottom: 448 },
]); ]);
expect(geometry.gutterWidths).toEqual([12, 12]); expect(geometry.gutterWidths).toEqual([12, 12]);
expect(geometry.gutterEdges).toEqual([ expect(geometry.gutterEdges).toEqual([
@@ -2032,7 +2143,10 @@ test('enters general and nation command arguments and sends exact values', async
const rect = area.getBoundingClientRect(); const rect = area.getBoundingClientRect();
return { width: rect.width, height: rect.height }; return { width: rect.width, height: rect.height };
}); });
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page
.getByTestId('command-picker')
.getByRole('button', { name: / 입력$/ })
.click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
'【허창】에 화계실행' '【허창】에 화계실행'
); );
@@ -2059,7 +2173,7 @@ test('enters general and nation command arguments and sends exact values', async
fontSize: style.fontSize, fontSize: style.fontSize,
}; };
}); });
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click(); await chiefPicker.getByRole('button', { name: / 입력$/ }).click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
'【여포NPC】 쌀 300 포상' '【여포NPC】 쌀 300 포상'
); );
@@ -2124,8 +2238,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await picker.getByRole('button', { name: '징병', exact: true }).click(); await picker.getByRole('button', { name: '징병', exact: true }).click();
await expect(picker).toHaveAttribute('role', 'dialog'); await expect(picker).toHaveAttribute('role', 'dialog');
await expect(picker).toHaveAttribute('aria-modal', 'true'); await expect(picker).toHaveAttribute('aria-modal', 'true');
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused(); await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
const form = picker.getByTestId('recruitment-command-form'); const form = picker.getByTestId('recruitment-command-form');
await verifyArgumentActionPlacement(page, picker);
await expect(form).toContainText('현재 기술력 : 1등급'); await expect(form).toContainText('현재 기술력 : 1등급');
await expect(form).toContainText('공격'); await expect(form).toContainText('공격');
await expect(form).toContainText('방어'); await expect(form).toContainText('방어');
@@ -2184,7 +2299,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await infantry.getByRole('button', { name: '절반', exact: true }).click(); await infantry.getByRole('button', { name: '절반', exact: true }).click();
await expect(estimatedGold).toHaveText('363금'); await expect(estimatedGold).toHaveText('363금');
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') }); await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
'【보병】 3500명 징병' '【보병】 3500명 징병'
); );
@@ -2196,7 +2311,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
picker = page.getByTestId('command-picker'); picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click(); await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click(); await picker.getByRole('button', { name: '징병', exact: true }).click();
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused(); await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
const referenceWidthGeometry = await picker.evaluate((element) => { const referenceWidthGeometry = await picker.evaluate((element) => {
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!; const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
const row = formElement.querySelector<HTMLElement>('.crew-row')!; const row = formElement.querySelector<HTMLElement>('.crew-row')!;
@@ -2243,7 +2358,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
infoWidth: info?.getBoundingClientRect().width ?? 0, infoWidth: info?.getBoundingClientRect().width ?? 0,
selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0, selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0,
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '', selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')?.getBoundingClientRect().bottom ?? 0, formBottom: formElement.getBoundingClientRect().bottom,
actionsHeight: element.querySelector<HTMLElement>('.picker-actions')!.getBoundingClientRect().height,
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')!.getBoundingClientRect().bottom,
viewportHeight: window.innerHeight, viewportHeight: window.innerHeight,
scrollWidth: element.scrollWidth, scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth, clientWidth: element.clientWidth,
@@ -2260,10 +2377,13 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
imageWidth: 64, imageWidth: 64,
selectedWidth: 390, selectedWidth: 390,
selectedDisplay: 'grid', selectedDisplay: 'grid',
actionsBottom: 844,
viewportHeight: 844, viewportHeight: 844,
bodyOverflow: 'hidden', bodyOverflow: 'hidden',
}); });
expect(mobileGeometry.actionsBottom).toBeCloseTo(
Math.min(mobileGeometry.formBottom + mobileGeometry.actionsHeight, 844),
0
);
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64); expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150); expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150);
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth); expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
@@ -2277,6 +2397,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
return { return {
scrollTop: element.scrollTop, scrollTop: element.scrollTop,
headerTop: header.getBoundingClientRect().top, headerTop: header.getBoundingClientRect().top,
headerBottom: header.getBoundingClientRect().bottom,
listFrontTop: listFront.getBoundingClientRect().top, listFrontTop: listFront.getBoundingClientRect().top,
actionsBottom: actions.getBoundingClientRect().bottom, actionsBottom: actions.getBoundingClientRect().bottom,
viewportHeight: window.innerHeight, viewportHeight: window.innerHeight,
@@ -2284,7 +2405,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
}); });
expect(stickyGeometry.scrollTop).toBeGreaterThan(0); expect(stickyGeometry.scrollTop).toBeGreaterThan(0);
expect(stickyGeometry.headerTop).toBe(0); expect(stickyGeometry.headerTop).toBe(0);
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44); expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(stickyGeometry.headerBottom);
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight); expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -2306,7 +2427,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await unavailableMercenary.click(); await unavailableMercenary.click();
await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25'); await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25');
await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled(); await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled();
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText( await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText(
'【정예병】 2500명 모병' '【정예병】 2500명 모병'
); );
@@ -2592,7 +2713,7 @@ test('limits non-aggression end years to the Ref twenty-year window on desktop a
await expect(yearInput).toHaveAttribute('max', '210'); await expect(yearInput).toHaveAttribute('max', '210');
await expect(yearInput).toHaveValue('191'); await expect(yearInput).toHaveValue('191');
const submit = picker.getByRole('button', { name: '입력', exact: true }); const submit = picker.getByRole('button', { name: / 입력$/ });
await yearInput.fill('211'); await yearInput.fill('211');
await expect(submit).toBeDisabled(); await expect(submit).toBeDisabled();
await yearInput.fill('210'); await yearInput.fill('210');
@@ -2880,7 +3001,7 @@ test('offers Ref amount presets and rich, command-specific general lists', async
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(); await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk')); const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await awardResponse; await awardResponse;
await page.goto('/che/chief-center'); await page.goto('/che/chief-center');
@@ -2945,7 +3066,7 @@ test('offers Ref amount presets and rich, command-specific general lists', async
await expect(tupleInputs.nth(0)).toHaveValue('20000'); await expect(tupleInputs.nth(0)).toHaveValue('20000');
await expect(tupleInputs.nth(1)).toHaveValue('1370'); await expect(tupleInputs.nth(1)).toHaveValue('1370');
const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk')); const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await aidResponse; await aidResponse;
const serialized = JSON.stringify(requests); const serialized = JSON.stringify(requests);
@@ -3087,7 +3208,7 @@ for (const width of [1200, 500]) {
); );
await picker.screenshot({ path: testInfo.outputPath('assignment-details.png') }); await picker.screenshot({ path: testInfo.outputPath('assignment-details.png') });
const saved = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk')); const saved = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await saved; await saved;
expect(JSON.stringify(requests)).toContain('"destGeneralId":3'); expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
expect(JSON.stringify(requests)).toContain('"destCityId":2'); expect(JSON.stringify(requests)).toContain('"destCityId":2');
@@ -3162,7 +3283,7 @@ test('keeps the entered command visible and reports a server validation error',
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
await page.getByTestId('command-argument-form').locator('select').selectOption('2'); await page.getByTestId('command-argument-form').locator('select').selectOption('2');
const submit = page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }); const submit = page.getByTestId('command-picker').getByRole('button', { name: / 입력$/ });
await submit.click(); await submit.click();
await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.'); await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.');
@@ -3211,7 +3332,7 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
await picker.getByRole('button', { name: '군사', exact: true }).click(); await picker.getByRole('button', { name: '군사', exact: true }).click();
await picker.getByRole('button', { name: '출병', exact: true }).click(); await picker.getByRole('button', { name: '출병', exact: true }).click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('3'); await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(firstRow).toHaveText('【단양】으로 출병'); await expect(firstRow).toHaveText('【단양】으로 출병');
await expect(firstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u); await expect(firstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u);
@@ -3299,12 +3420,12 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await drag(0, 2); await drag(0, 2);
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3); await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click(); await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
const picker = editor.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
const blockedFire = picker.getByRole('button', { name: '화계', exact: true }); const blockedFire = picker.getByRole('button', { name: '화계', exact: true });
await expect(blockedFire).toBeEnabled(); await expect(blockedFire).toBeEnabled();
await blockedFire.click(); await blockedFire.click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('2'); await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행'); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) }); const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) });
@@ -3494,7 +3615,10 @@ test('keeps the shared main and chief shell geometry and interaction states', as
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click(); await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
await chiefArgumentForm.locator('input[type=number]').fill('300'); await chiefArgumentForm.locator('input[type=number]').fill('300');
await chiefArgumentForm.locator('select').selectOption('2'); await chiefArgumentForm.locator('select').selectOption('2');
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page
.getByTestId('command-picker')
.getByRole('button', { name: / 입력$/ })
.click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
'【관우】 쌀 300 포상' '【관우】 쌀 300 포상'
); );
@@ -3717,7 +3841,7 @@ for (const width of [1200, 390]) {
let picker = page.getByTestId('command-picker'); let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /화계/ }).click(); await picker.getByRole('button', { name: /화계/ }).click();
let form = picker.getByTestId('command-argument-form'); let form = picker.getByTestId('command-argument-form');
const toggle = form.getByRole('button', { name: '검색 꺼짐', exact: true }); const toggle = picker.getByRole('button', { name: '검색 꺼짐', exact: true });
await expect(form.locator('input[type=search]')).toHaveCount(0); await expect(form.locator('input[type=search]')).toHaveCount(0);
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3); await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
await form.getByTestId('city-target-list').getByRole('button', { name: /허창/ }).click(); await form.getByTestId('city-target-list').getByRole('button', { name: /허창/ }).click();
@@ -3743,11 +3867,11 @@ for (const width of [1200, 390]) {
await input.press('Enter'); await input.press('Enter');
await expect(input).not.toBeFocused(); await expect(input).not.toBeFocused();
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
await expect(input).toHaveCount(0); await expect(input).toHaveCount(0);
await expect(results.locator('button')).toHaveCount(3); await expect(results.locator('button')).toHaveCount(3);
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2'); await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"destCityId":2'); await expect.poll(() => JSON.stringify(requests)).toContain('"destCityId":2');
await page.goto(gamePath('/chief-center')); await page.goto(gamePath('/chief-center'));
@@ -3758,7 +3882,7 @@ for (const width of [1200, 390]) {
form = picker.getByTestId('command-argument-form'); form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3); await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3);
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3); await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
const generalSearch = form.locator('#command-search-destGeneralId'); const generalSearch = form.locator('#command-search-destGeneralId');
const citySearch = form.locator('#command-search-destCityId'); const citySearch = form.locator('#command-search-destCityId');
for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) { for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) {
@@ -3793,7 +3917,7 @@ for (const width of [1200, 390]) {
expect(geometry.viewport.scale).toBe(initialScale); expect(geometry.viewport.scale).toBe(initialScale);
await writeFile(testInfo.outputPath(`search-${width}.json`), JSON.stringify(geometry, null, 2)); await writeFile(testInfo.outputPath(`search-${width}.json`), JSON.stringify(geometry, null, 2));
await form.screenshot({ path: testInfo.outputPath(`search-${width}.png`) }); await form.screenshot({ path: testInfo.outputPath(`search-${width}.png`) });
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"destGeneralId":2,"destCityId":2'); await expect.poll(() => JSON.stringify(requests)).toContain('"destGeneralId":2,"destCityId":2');
await page.reload(); await page.reload();
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
@@ -3801,7 +3925,7 @@ for (const width of [1200, 390]) {
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
await picker.getByRole('button', { name: /선전포고/ }).click(); await picker.getByRole('button', { name: /선전포고/ }).click();
form = picker.getByTestId('command-argument-form'); form = picker.getByTestId('command-argument-form');
await expect(form.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute( await expect(picker.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
'aria-pressed', 'aria-pressed',
'true' 'true'
); );
@@ -3821,7 +3945,7 @@ for (const width of [1200, 390]) {
await input.press('Escape'); await input.press('Escape');
await expect(input).toHaveValue(''); await expect(input).toHaveValue('');
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
await expect(input).toHaveCount(0); await expect(input).toHaveCount(0);
await expect(results.locator('button')).toHaveCount(3); await expect(results.locator('button')).toHaveCount(3);
}); });
@@ -3856,7 +3980,7 @@ test('gift search indexes nullable names without matching the displayed no-troop
const results = form.getByTestId('general-target-list'); const results = form.getByTestId('general-target-list');
await expect(results.locator('button')).toHaveCount(3); await expect(results.locator('button')).toHaveCount(3);
await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음'); await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음');
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
const search = form.locator('input[type=search]'); const search = form.locator('input[type=search]');
await search.fill('ㅇㅇ'); await search.fill('ㅇㅇ');
await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']); await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']);
@@ -3896,7 +4020,7 @@ test('search reflects an active Korean IME composition after debounce without co
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /증여/ }).click(); await picker.getByRole('button', { name: /증여/ }).click();
const form = picker.getByTestId('command-argument-form'); const form = picker.getByTestId('command-argument-form');
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
const input = form.locator('input[type=search]'); const input = form.locator('input[type=search]');
const results = form.getByTestId('general-target-list'); const results = form.getByTestId('general-target-list');
await input.focus(); await input.focus();
@@ -3928,11 +4052,1002 @@ test('search reflects an active Korean IME composition after debounce without co
await form.getByRole('button', { name: '지우기', exact: true }).click(); await form.getByRole('button', { name: '지우기', exact: true }).click();
await expect(results.locator('button')).toHaveCount(3); await expect(results.locator('button')).toHaveCount(3);
await input.fill('ㄱㅇ'); await input.fill('ㄱㅇ');
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click();
await page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다. await page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다.
await expect(results.locator('button')).toHaveCount(3); await expect(results.locator('button')).toHaveCount(3);
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
await expect(input).toHaveValue(''); await expect(input).toHaveValue('');
await expect(results.locator('button')).toHaveCount(3); await expect(results.locator('button')).toHaveCount(3);
await cdp.detach(); await cdp.detach();
}); });
// 짧은 폼은 바로 뒤에 확인 영역을 두고, 긴 폼은 스크롤 중에도 하단에서 확인할 수 있다.
const verifyArgumentActionPlacement = async (page: Page, overlay: ReturnType<Page['getByRole']>) => {
const viewport = page.viewportSize()!;
for (const height of [1600, 320]) {
await page.setViewportSize({ width: viewport.width, height });
await overlay.evaluate((element) => (element.scrollTop = 0));
const measure = () =>
overlay.evaluate((element) => {
const form = element.querySelector<HTMLElement>('.command-argument-form, .recruitment-command-form')!;
const actions = element.querySelector<HTMLElement>('.picker-actions')!;
return {
formBottom: form.getBoundingClientRect().bottom,
actionsTop: actions.getBoundingClientRect().top,
actionsBottom: actions.getBoundingClientRect().bottom,
scrollTop: element.scrollTop,
};
});
if (height === 1600) {
const geometry = await measure();
expect(geometry.actionsTop).toBeCloseTo(geometry.formBottom, 0);
expect(geometry.actionsBottom).toBeLessThan(height - 100);
} else {
expect(Math.abs((await measure()).actionsBottom - height)).toBeLessThanOrEqual(1);
await overlay.evaluate((element) => (element.scrollTop = 120));
const geometry = await measure();
expect(geometry.scrollTop).toBeGreaterThan(0);
expect(Math.abs(geometry.actionsBottom - height)).toBeLessThanOrEqual(1);
}
}
await page.setViewportSize(viewport);
await overlay.evaluate((element) => (element.scrollTop = 0));
};
// Ref v_processing처럼 인자 입력은 화면 전체를 차지하되, Core는 URL을 바꾸지 않고
// Back·명령 취소·명령 다시 선택·Esc로 같은 문서의 overlay만 닫는다.
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 500, height: 900 },
{ width: 390, height: 844 },
] as const) {
test(`opens every argument input as a full-screen overlay with a top control bar at ${viewport.width}px`, async ({
page,
}, testInfo) => {
const requests = await install(page);
await page.setViewportSize(viewport);
await page.goto(gamePath('/'));
const mainUrl = page.url();
const initialHistoryLength = await page.evaluate(() => history.length);
const editor = page.locator('[data-command-scope="general"]:visible');
const openFireAttack = async (turn: number) => {
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
const listPicker = page.getByTestId('command-picker');
await expect(listPicker).not.toHaveAttribute('role', 'dialog');
await listPicker.getByRole('button', { name: /화계/ }).click();
const overlay = page.getByRole('dialog', { name: `화계 ${turn}턴 명령 입력` });
await expect(overlay).toBeVisible();
return overlay;
};
const measure = (overlay: ReturnType<Page['getByRole']>) =>
overlay.evaluate((element) => {
const bar = element.querySelector<HTMLElement>('[data-testid="command-input-top-bar"]')!;
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"]');
const fields = element.querySelector<HTMLElement>('.argument-fields');
const rect = (target: Element | null) => target?.getBoundingClientRect().toJSON() ?? null;
return {
parentIsBody: element.parentElement === document.body,
overlay: rect(element),
bar: rect(bar),
buttons: [...bar.querySelectorAll('button')].map((button) => ({
text: button.textContent?.trim(),
background: getComputedStyle(button).backgroundColor,
...button.getBoundingClientRect().toJSON(),
})),
submit: (() => {
const submit = element.querySelector<HTMLButtonElement>('.picker-actions > .command-submit');
if (!submit) return null;
return {
text: submit.textContent?.trim(),
disabled: submit.disabled,
background: getComputedStyle(submit).backgroundColor,
fontWeight: getComputedStyle(submit).fontWeight,
...submit.getBoundingClientRect().toJSON(),
};
})(),
title: bar.querySelector('h2')?.textContent?.replace(/\s+/g, ' ').trim(),
map: rect(map),
fields: rect(fields),
form: rect(element.querySelector('.command-argument-form')),
actions: rect(element.querySelector('.picker-actions')),
bodyOverflow: getComputedStyle(document.body).overflow,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
let overlay = await openFireAttack(1);
await expect(overlay).toHaveAttribute('aria-modal', 'true');
await expect(overlay.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused();
await page.evaluate(() => document.fonts.ready);
const geometry = await measure(overlay);
await writeFile(
testInfo.outputPath(`argument-overlay-${viewport.width}.json`),
JSON.stringify(geometry, null, 2)
);
await page.screenshot({ path: testInfo.outputPath(`argument-overlay-${viewport.width}.png`) });
expect(geometry.parentIsBody).toBe(true);
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: viewport.width, height: viewport.height });
expect(geometry.bar?.y).toBe(0);
expect(geometry.buttons.map((button) => button.text)).toEqual(['명령 취소', '명령 다시 선택', '검색 꺼짐']);
expect(geometry.title).toBe('화계1턴');
// 녹색은 Ref 돌아가기와 같은 이동 버튼(명령 취소)에만 쓰고, 제출은 명령명을 붙인 primary(파랑) 버튼이다.
expect(geometry.buttons.map((button) => button.background)).toEqual([
'rgb(0, 88, 44)',
'rgb(68, 68, 68)',
'rgb(68, 68, 68)',
]);
expect(geometry.submit).toMatchObject({
text: '화계 입력',
disabled: false,
background: 'rgb(55, 90, 127)',
fontWeight: '700',
});
expect(geometry.submit!.width).toBe(Math.min(360, viewport.width - 12));
expect(geometry.submit!.x + geometry.submit!.width / 2).toBeCloseTo(viewport.width / 2, 0);
expect(geometry.submit!.height).toBeGreaterThanOrEqual(40);
expect(geometry.submit!.bottom).toBeLessThanOrEqual(viewport.height);
expect(geometry.actions!.top).toBeCloseTo(
Math.min(geometry.form!.bottom, viewport.height - geometry.actions!.height),
0
);
await verifyArgumentActionPlacement(page, overlay);
expect(geometry.bodyOverflow).toBe('hidden');
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
for (const button of geometry.buttons) {
expect(button.x).toBeGreaterThanOrEqual(0);
expect(button.right).toBeLessThanOrEqual(viewport.width);
}
if (viewport.width >= 1120) {
// 원본 700px 지도 오른쪽에 대상 목록 열을 두고 1120px 폭(1px 테두리 포함) 안에서 중앙 정렬한다.
expect(geometry.map).toMatchObject({ x: (viewport.width - 1120) / 2 + 1, width: 700 });
expect(geometry.fields?.x).toBe(geometry.map!.right);
expect(geometry.fields?.y).toBe(geometry.map!.y);
expect(geometry.fields?.right).toBe((viewport.width + 1120) / 2 - 1);
} else {
expect(geometry.fields!.y).toBeGreaterThanOrEqual(geometry.map!.bottom);
expect(geometry.map!.width).toBeLessThanOrEqual(viewport.width);
}
// 검색 토글은 상단 바에 있고 인자 폼의 검색 입력을 제어한다.
await overlay.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
await expect(overlay.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(overlay.locator('input[type=search]')).toHaveCount(1);
await overlay.getByRole('button', { name: '검색 켜짐', exact: true }).click();
await expect(overlay.locator('input[type=search]')).toHaveCount(0);
// 명령 취소: overlay와 명령 목록을 함께 닫고 Back entry를 소비한다.
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
await expect(page.getByTestId('command-picker')).toHaveCount(0);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1);
expect(page.url()).toBe(mainUrl);
// 브라우저 Back: 페이지를 떠나지 않고 overlay만 닫는다.
await openFireAttack(2);
await page.goBack();
await expect(page.getByTestId('command-picker')).toHaveCount(0);
expect(page.url()).toBe(mainUrl);
await expect(editor).toBeVisible();
// Esc
await openFireAttack(3);
await page.keyboard.press('Escape');
await expect(page.getByTestId('command-picker')).toHaveCount(0);
expect(page.url()).toBe(mainUrl);
// 명령 다시 선택: 명령 목록 popup으로 돌아가고 이후 Back은 남은 history를 소비하지 않는다.
overlay = await openFireAttack(4);
await overlay.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
const listPicker = page.getByTestId('command-picker');
await expect(listPicker).toBeVisible();
await expect(listPicker).not.toHaveAttribute('role', 'dialog');
await expect(listPicker.getByRole('button', { name: /화계/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
// 입력 완료 뒤에도 같은 URL과 원래 history 길이를 유지하고 payload·turnList는 그대로다.
await listPicker.getByRole('button', { name: /화계/ }).click();
overlay = page.getByRole('dialog', { name: '화계 4턴 명령 입력' });
await overlay.getByTestId('command-argument-form').locator('select').selectOption('2');
await overlay.getByRole('button', { name: / 입력$/ }).click();
await expect(page.getByTestId('command-picker')).toHaveCount(0);
await expect(editor.locator('.action-column > div').nth(3)).toHaveText('【허창】에 화계실행');
await expect
.poll(() => JSON.stringify(requests))
.toContain('"turnList":[3],"action":"che_화계","args":{"destCityId":2}');
await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1);
expect(page.url()).toBe(mainUrl);
await page.goBack();
await expect(page).not.toHaveURL(mainUrl);
});
}
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
test(`opens chief argument input as the same full-screen overlay with selected turns at ${viewport.width}px`, async ({
page,
}, testInfo) => {
await install(page);
await page.setViewportSize(viewport);
await page.goto(gamePath('/chief-center'));
const mainUrl = page.url();
const editor = page.locator('[data-command-scope="nation"]');
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const listPicker = page.getByTestId('command-picker');
await listPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await listPicker.getByRole('button', { name: /발령/ }).click();
const overlay = page.getByRole('dialog', { name: '발령 1턴 명령 입력' });
await expect(overlay).toBeVisible();
await expect(overlay.getByTestId('command-input-top-bar').locator('h2')).toHaveText('발령1턴');
const geometry = await overlay.evaluate((element) => ({
parentIsBody: element.parentElement === document.body,
overlay: element.getBoundingClientRect().toJSON(),
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
}));
expect(geometry.parentIsBody).toBe(true);
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, ...viewport });
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
await expect(overlay.getByTestId('command-argument-map')).toBeVisible();
await verifyArgumentActionPlacement(page, overlay);
// Ref che_발령.vue는 distanceList를 받지만 표시하지 않는다.
await expect(overlay.getByTestId('city-distance-list')).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath(`chief-argument-overlay-${viewport.width}.png`) });
await page.goBack();
await expect(page.getByTestId('command-picker')).toHaveCount(0);
expect(page.url()).toBe(mainUrl);
});
}
// Ref ProcessCity + CitiesBasedOnDistance: 화계 계열·강행·첩보는 3칸, 이동·출병은 1칸까지 현재 도시 기준
// 거리별 도시를 magenta/orange/yellow 밑줄 글자로 보여 주고, 누르면 대상 도시로 고른다.
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
] as const) {
test(`lists Ref-style cities by distance for city target commands at ${viewport.width}px`, async ({
page,
}, testInfo) => {
const requests = await install(page);
await page.setViewportSize(viewport);
await page.goto(gamePath('/'));
const editor = page.locator('[data-command-scope="general"]:visible');
const openCommand = async (turn: number, category: string, name: string) => {
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
const listPicker = page.getByTestId('command-picker');
await listPicker.getByRole('button', { name: category, exact: true }).click();
await listPicker.getByRole('button', { name, exact: true }).click();
const overlay = page.getByRole('dialog', { name: `${name} ${turn}턴 명령 입력` });
await expect(overlay).toBeVisible();
return overlay;
};
let overlay = await openCommand(1, '계략', '화계');
const list = overlay.getByTestId('city-distance-list');
await expect(list).toBeVisible();
await expect(list.locator('.city-distance-row')).toHaveText([
'1칸 떨어진 도시: 허창',
'2칸 떨어진 도시:',
'3칸 떨어진 도시:',
]);
const link = list.getByRole('button', { name: '허창', exact: true });
const style = await link.evaluate((element) => {
const computed = getComputedStyle(element);
return {
color: computed.color,
textDecorationLine: computed.textDecorationLine,
fontWeight: computed.fontWeight,
};
});
expect(style).toEqual({ color: 'rgb(255, 0, 255)', textDecorationLine: 'underline', fontWeight: '400' });
await expect(link).toHaveAttribute('aria-pressed', 'false');
await link.click();
await expect(link).toHaveAttribute('aria-pressed', 'true');
await expect(overlay.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
await expect(overlay.getByTestId('command-map-selection-status')).toContainText('허창');
const geometry = await overlay.evaluate((element) => {
const fields = element.querySelector<HTMLElement>('.argument-fields')!.getBoundingClientRect();
const distance = element.querySelector<HTMLElement>('[data-testid="city-distance-list"]')!;
const rect = distance.getBoundingClientRect();
return {
fields: fields.toJSON(),
list: rect.toJSON(),
fontSize: getComputedStyle(distance).fontSize,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
};
});
await writeFile(testInfo.outputPath(`city-distance-${viewport.width}.json`), JSON.stringify(geometry, null, 2));
await page.screenshot({ path: testInfo.outputPath(`city-distance-${viewport.width}.png`) });
expect(geometry.fontSize).toBe('14px');
expect(geometry.list.x).toBeGreaterThanOrEqual(geometry.fields.x);
expect(geometry.list.right).toBeLessThanOrEqual(geometry.fields.right);
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
await overlay.getByRole('button', { name: '화계 입력', exact: true }).click();
await expect
.poll(() => JSON.stringify(requests))
.toContain('"turnList":[0],"action":"che_화계","args":{"destCityId":2}');
overlay = await openCommand(2, '군사', '출병');
await expect(overlay.getByTestId('city-distance-list').locator('.city-distance-row')).toHaveText([
'1칸 떨어진 도시: 허창',
]);
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
});
}
for (const width of [1200, 390]) {
test(`groups recruitment targets by nation color without cross-nation resources at ${width}px`, async ({
page,
}, testInfo) => {
const targets = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: [
{ id: 1, name: '장수', nationId: 1, cityId: 1, npcState: 0, officerLevel: 5 },
{
id: 4,
name: '적장',
nationId: 2,
cityId: 2,
npcState: 0,
officerLevel: 1,
gold: 9100,
rice: 8200,
crew: 7300,
train: 90,
atmos: 80,
troopId: 4,
leadership: 75,
strength: 60,
intel: 45,
},
{ id: 5, name: '떠돌이', nationId: 0, cityId: 3, npcState: 0, officerLevel: 0, gold: 600 },
{ id: 6, name: '빙의장', nationId: 2, cityId: 2, npcState: 1, officerLevel: 1 },
{ id: 7, name: '아군', nationId: 1, cityId: 1, npcState: 0, officerLevel: 1 },
],
nationNames: new Map([
[1, '아국'],
[2, '적국'],
]),
cityNames: new Map([
[1, '업'],
[2, '허창'],
[3, '단양'],
]),
troopNames: new Map([[4, '비밀부대']]),
});
const requests = await install(page, false, {
...commandTable,
general: [{ category: '인사', values: [buildGeneralCommand('che_등용', '등용')] }],
inputOptions: { ...inputOptions, ...targets },
});
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto(gamePath('/'));
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /등용/ }).click();
const form = picker.getByTestId('command-argument-form');
const list = form.getByTestId('general-target-list');
const headers = list.getByTestId('general-nation-group');
// Ref ORDER BY npc,name 순서에서 국가가 처음 나타난 순서로 묶는다.
await expect(headers).toHaveText(['적국', '재야', '아국']);
// 같은 국가 장수는 한 묶음에 모이므로 빙의장(npc=1)이 적국 묶음 안에 들어간다.
await expect(list.locator('.target-option strong')).toHaveText(['적장', '빙의장', '떠돌이', '아군']);
await expect(form.locator('#command-arg-destGeneralId optgroup')).toHaveCount(3);
await expect(form.locator('#command-arg-destGeneralId optgroup').first()).toHaveAttribute('label', '적국');
await expect(form.locator('#command-arg-destGeneralId option').first()).toHaveText('적장 (적국)');
const text = await list.innerText();
for (const hidden of ['9,100', '8,200', '7,300', '허창', '단양', '비밀부대', '훈련', '사기']) {
expect(text).not.toContain(hidden);
}
await expect(list.locator('.target-option').first()).toContainText('통솔 75 · 무력 60 · 지력 45');
const style = await headers.evaluateAll((nodes) =>
nodes.map((node) => {
const computed = getComputedStyle(node);
return [computed.backgroundColor, computed.color, computed.position];
})
);
expect(style).toEqual([
['rgb(128, 0, 0)', 'rgb(255, 255, 255)', 'sticky'],
['rgb(0, 0, 0)', 'rgb(255, 255, 255)', 'sticky'],
['rgb(0, 128, 0)', 'rgb(255, 255, 255)', 'sticky'],
]);
// 빙의장(npc=1)은 Ref getNPCColor처럼 skyblue다.
await expect(list.locator('.target-option').nth(1).locator('strong')).toHaveCSS('color', 'rgb(135, 206, 235)');
await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-all-${width}.png`) });
await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
await form.locator('#command-search-destGeneralId').fill('ㄸㄷ');
await expect(headers).toHaveText(['재야']);
await expect(list.locator('.target-option')).toHaveCount(1);
await list.locator('.target-option').click();
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('5');
await page.evaluate(() => document.fonts.ready);
const geometry = await picker.evaluate((element) => ({
url: location.href,
viewport: [innerWidth, innerHeight],
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
overlayOverflow: element.scrollWidth - element.clientWidth,
list: element.querySelector('[data-testid=general-target-list]')?.getBoundingClientRect().toJSON(),
headers: [...element.querySelectorAll('[data-testid=general-nation-group]')].map((node) =>
node.getBoundingClientRect().toJSON()
),
}));
// 메인 화면 자체는 Ref처럼 최소 500px 폭이라 390px에서 문서가 넓다. 전체화면 overlay 안만 확인한다.
expect(geometry.overlayOverflow).toBe(0);
await writeFile(testInfo.outputPath(`recruit-groups-${width}.json`), JSON.stringify(geometry, null, 2));
await picker.screenshot({ path: testInfo.outputPath(`recruit-groups-${width}.png`) });
await picker.getByRole('button', { name: / 입력$/ }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_등용","args":{"destGeneralId":5}');
});
}
// 2-3 국가 대상: Ref che_임관·che_장수대상임관 권유문 목록, che_물자원조 작위별 한도표, che_피장파장 전략 재사용 대기.
const nationTargetSources: NationTargetSource[] = [
{
id: 1,
name: '아국',
color: '#008000',
capitalName: '업',
level: 1,
power: 1000,
generalCount: 5,
cityCount: 2,
diplomacyState: 2,
diplomacyTerm: 0,
adjacent: false,
scoutMessage: purifyNationHtml('<p>아국 권유문</p>'),
},
{
id: 2,
name: '위',
color: '#0000FF',
capitalName: '허창',
level: 2,
power: 800,
generalCount: 4,
cityCount: 2,
diplomacyState: 0,
diplomacyTerm: 6,
adjacent: true,
// 저장·조회 경로와 같은 정제 결과만 화면에 전달한다. script와 on* 속성은 남지 않는다.
scoutMessage: purifyNationHtml(
'<div style="width: 870px; height: 300px; background-color: #334455"><b>천하를 함께</b>' +
'<script>window.__scoutXss = 1</script><span onclick="window.__scoutXss = 2">함께</span></div>'
),
},
{
id: 3,
name: '오',
color: '#FFFF00',
capitalName: '건업',
level: 2,
power: 900,
generalCount: 3,
cityCount: 1,
diplomacyState: 1,
diplomacyTerm: 12,
adjacent: false,
blockScout: true,
},
];
for (const width of [1200, 390]) {
test(`shows Ref scout messages for appointment commands at ${width}px`, async ({ page }, testInfo) => {
const nationTargets = buildRefNationTargetOptions({
actorNationId: 0,
nations: nationTargetSources,
join: { actorNpcState: 0, relYear: 5, openingPartYear: 3, initialNationGenLimit: 10 },
});
const requests = await install(page, false, {
...commandTable,
general: [
{
category: '인사',
values: [
{
...buildNationCommand('che_임관', '임관'),
},
buildGeneralCommand('che_장수대상임관', '장수대상임관'),
],
},
],
inputOptions: {
...inputOptions,
nations: nationTargets.nations,
nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets },
nationScoutMessages: nationTargets.nationScoutMessages,
},
});
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto(gamePath('/'));
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^임관/ }).click();
const form = picker.getByTestId('command-argument-form');
const list = form.getByTestId('nation-scout-list');
const rows = list.getByTestId('nation-scout-row');
await expect(list.locator('.nation-scout-header > div')).toHaveText(['국가명', '임관권유문']);
await expect(rows.locator('.nation-scout-name')).toHaveText(['아국', '위', '오']);
await expect(rows.nth(1).locator('.nation-scout-msg b')).toHaveText('천하를 함께');
expect(await list.locator('script, [onclick]').count()).toBe(0);
expect(await page.evaluate(() => (window as unknown as { __scoutXss?: number }).__scoutXss)).toBeUndefined();
// 국가명 칸은 국가색 배경이고 Ref isBrightColor 기준으로 글자색을 고른다.
const nameStyles = await rows
.locator('.nation-scout-name')
.evaluateAll((nodes) =>
nodes.map((node) => [getComputedStyle(node).backgroundColor, getComputedStyle(node).color])
);
expect(nameStyles).toEqual([
['rgb(0, 128, 0)', 'rgb(255, 255, 255)'],
['rgb(0, 0, 255)', 'rgb(255, 255, 255)'],
['rgb(255, 255, 0)', 'rgb(0, 0, 0)'],
]);
// 임관 금지 국가는 대상 카드에서 현재 불가로 남는다.
await expect(form.getByTestId('nation-target-list').locator('.target-option').nth(2)).toContainText(
'임관이 금지되어 있습니다.'
);
const toggle = list.getByRole('button', { name: /보기$/ });
const plate = rows.nth(1).locator('.nation-scout-plate');
if (width >= 940) {
// Ref media-1000px(940px 이상): 130px + 870px 두 열, 크게/작게 보기 버튼 없음.
await expect(toggle).toBeHidden();
await expect(rows.nth(1)).toHaveCSS('grid-template-columns', '130px 870px');
} else {
await expect(toggle).toHaveText('작게 보기');
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
await expect(plate).toHaveCSS('max-height', '200px');
await expect(plate).toHaveCSS('overflow-x', 'auto');
await toggle.click();
await expect(toggle).toHaveText('크게 보기');
await expect(rows.nth(1)).toHaveClass(/on-fit/);
}
await rows.nth(1).click();
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
await expect(rows.nth(1)).toHaveClass(/selected/);
await page.evaluate(() => document.fonts.ready);
const geometry = await picker.evaluate((element) => {
const row = element.querySelectorAll<HTMLElement>('[data-testid=nation-scout-row]')[1]!;
const message = row.querySelector<HTMLElement>('.nation-scout-msg')!;
return {
url: location.href,
viewport: [innerWidth, innerHeight],
overlayOverflow: element.scrollWidth - element.clientWidth,
list: element.querySelector('[data-testid=nation-scout-list]')?.getBoundingClientRect().toJSON(),
row: row.getBoundingClientRect().toJSON(),
plate: row.querySelector('.nation-scout-plate')!.getBoundingClientRect().toJSON(),
message: message.getBoundingClientRect().toJSON(),
transform: getComputedStyle(message).transform,
};
});
expect(geometry.overlayOverflow).toBe(0);
if (width < 940) {
// on-fit: 870px 권유문을 목록 폭/870으로 줄이고, 높이는 200px × 같은 비율까지만 보인다.
const scale = geometry.list!.width / 870;
expect(geometry.message.width).toBeCloseTo(870 * scale, 0);
expect(geometry.plate.height).toBeLessThanOrEqual(200 * scale + 1);
expect(geometry.plate.right).toBeLessThanOrEqual(width);
} else {
expect(geometry.plate.width).toBeCloseTo(870, 0);
expect(geometry.transform).toBe('none');
}
await writeFile(testInfo.outputPath(`scout-list-${width}.json`), JSON.stringify(geometry, null, 2));
await picker.screenshot({ path: testInfo.outputPath(`scout-list-${width}.png`) });
await picker.getByRole('button', { name: '임관 입력', exact: true }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_임관","args":{"destNationId":2}');
// 장수대상임관은 재야를 앞에 둔 같은 목록을 보이며 행을 눌러도 국가를 고르지 않는다.
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
await picker.getByRole('button', { name: /장수대상임관/ }).click();
await expect(rows.locator('.nation-scout-name')).toHaveText(['재야', '아국', '위', '오']);
await expect(rows.first().locator('.nation-scout-name')).toHaveCSS('background-color', 'rgb(0, 0, 0)');
await expect(rows.first().locator('.nation-scout-name button')).toHaveCount(0);
});
test(`shows Ref aid limits and counter-strategy cooldowns at ${width}px`, async ({ page }, testInfo) => {
const nationTargets = buildRefNationTargetOptions({ actorNationId: 1, nations: nationTargetSources });
const counterStrategy = {
...buildNationCommand('che_피장파장', '피장파장'),
inputFields: [
...buildNationCommand('che_피장파장', '피장파장').inputFields,
{
key: 'commandType',
label: '전략',
kind: 'select',
required: true,
options: [
{ value: 'che_필사즉생', label: '필사즉생' },
{ value: 'che_수몰', label: '수몰' },
{ value: 'che_허보', label: '허보' },
],
},
],
};
const aid = commandTable.nation.flatMap((group) => group.values).find((entry) => entry.key === 'che_물자원조')!;
const requests = await install(page, false, {
...commandTable,
nation: [{ category: '전략', values: [counterStrategy, aid] }],
inputOptions: {
...inputOptions,
nations: nationTargets.nations,
nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets },
strategyCooldowns: { che_수몰: 12 },
context: { ...inputOptions.context, nationLevel: 3 },
},
});
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto(gamePath('/chief-center'));
const editor = page.locator('[data-command-scope="nation"]');
await editor.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 strategy = form.locator('#command-arg-commandType');
await expect(strategy.locator('option')).toHaveText(['필사즉생', '수몰 (불가, 12턴)', '허보']);
await expect(strategy.locator('option').nth(1)).toHaveCSS('color', 'rgb(255, 0, 0)');
await strategy.selectOption('che_수몰');
await expect(strategy).toHaveCSS('color', 'rgb(255, 0, 0)');
await strategy.selectOption('che_허보');
await expect(strategy).not.toHaveCSS('color', 'rgb(255, 0, 0)');
// 선포·전쟁 중인 국가만 가능하고 아국은 불가다.
const cards = form.getByTestId('nation-target-list').locator('.target-option');
await expect(cards.locator('strong')).toHaveText(['오', '위', '아국']);
await expect(cards.nth(2)).toHaveClass(/unavailable/);
await expect(form.getByTestId('command-argument-guidance')).toContainText('60턴 동안');
await form.locator('#command-arg-destNationId').selectOption('2');
await picker.screenshot({ path: testInfo.outputPath(`counter-strategy-${width}.png`) });
await picker.getByRole('button', { name: '피장파장 입력', exact: true }).click();
await expect
.poll(() => JSON.stringify(requests))
.toContain('"action":"che_피장파장","args":{"destNationId":2,"commandType":"che_허보"}');
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
await picker.getByRole('button', { name: /원조/ }).click();
const table = picker.getByTestId('aid-level-table');
await expect(table.locator('li')).toHaveText([
'방랑군: 0',
'호족: 10,000',
'군벌: 20,000',
'주자사: 30,000',
'주목: 40,000',
'공: 50,000',
'왕: 60,000',
'황제: 70,000',
]);
await expect(picker.getByTestId('command-resource-summary')).toContainText('국가 작위 주자사');
const current = table.locator('li.current .aid-level-name');
await expect(current).toHaveText('주자사');
await expect(current).toHaveCSS('font-weight', '700');
await expect(current).toHaveCSS('text-decoration-line', 'underline');
const geometry = await picker.evaluate((element) => ({
url: location.href,
viewport: [innerWidth, innerHeight],
overlayOverflow: element.scrollWidth - element.clientWidth,
table: element.querySelector('[data-testid=aid-level-table]')?.getBoundingClientRect().toJSON(),
nameWidths: [...element.querySelectorAll('.aid-level-name')].map(
(node) => node.getBoundingClientRect().width
),
fontSize: getComputedStyle(element.querySelector('[data-testid=aid-level-table]')!).fontSize,
}));
expect(geometry.overlayOverflow).toBe(0);
expect(geometry.fontSize).toBe('14px');
// Ref: width 4em inline-block.
expect(new Set(geometry.nameWidths)).toEqual(new Set([56]));
await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2));
await picker.screenshot({ path: testInfo.outputPath(`aid-levels-${width}.png`) });
});
test(`shows Ref equipment, dex conversion and nation type details at ${width}px`, async ({ page }, testInfo) => {
const requests = await install(page, false, {
...commandTable,
general: [
{
category: '개인',
values: [
{
key: 'che_장비매매',
name: '장비매매',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{
key: 'itemType',
label: '장비 종류',
kind: 'select',
required: true,
options: [
{ value: 'horse', label: '명마' },
{ value: 'weapon', label: '무기' },
],
},
{
key: 'itemCode',
label: '장비',
kind: 'select',
required: true,
optionSource: 'items',
},
],
},
{
key: 'che_숙련전환',
name: '숙련전환',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{
key: 'srcArmType',
label: '감소 대상 숙련',
kind: 'select',
required: true,
optionSource: 'armTypes',
},
{
key: 'destArmType',
label: '전환 대상 숙련',
kind: 'select',
required: true,
optionSource: 'armTypes',
},
],
},
{
key: 'che_건국',
name: '건국',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
{
key: 'nationType',
label: '국가 성향',
kind: 'select',
required: true,
optionSource: 'nationTypes',
},
{
key: 'colorType',
label: '국기 색상',
kind: 'select',
required: true,
optionSource: 'colors',
},
],
},
],
},
],
inputOptions: {
...inputOptions,
armTypes: [
{ value: 1, label: '보병' },
{ value: 2, label: '궁병' },
{ value: 3, label: '기병' },
],
nationTypes: [
{ value: 'che_도적', label: '도적', description: '계략↑ 금수입↓ 치안↓ 민심↓' },
{ value: 'che_덕가', label: '덕가', description: '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓' },
{ value: 'che_묵가', label: '묵가', description: '수성↑ 기술↓' },
],
items: {
horse: [
{ value: 'None', label: '노기 판매', description: '소유 물품 판매 · 판매가 500금' },
{
value: 'che_명마_갈색마',
label: '갈색마',
availableNow: true,
description: '현재 구입 가능 · 가격 1,000 · 통솔 +1',
},
{
value: 'che_명마_적토마',
label: '적토마',
availableNow: false,
description: '현재 구입 불가: 치안 5,000 필요 · 가격 6,000 · 통솔 +6',
},
],
weapon: [
{
value: 'None',
label: '무기 판매',
availableNow: false,
description: '현재 보유한 장비가 없습니다.',
},
],
},
context: {
...inputOptions.context,
citySecurity: 2_000,
dexterity: { '1': 1001, '2': 3000, '3': 500_000 },
},
},
});
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto(gamePath('/'));
const picker = page.getByTestId('command-picker');
const form = picker.getByTestId('command-argument-form');
// 장비매매: Ref처럼 불가 항목은 붉은색 `(불가)`이고 치안·자금을 함께 보인다.
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await picker.getByRole('button', { name: /장비매매/ }).click();
await expect(form.getByTestId('command-argument-guidance')).toContainText(
'현재 구입 불가능한 것은 붉은색으로 표시됩니다.'
);
await expect(form.getByTestId('command-resource-summary')).toContainText('현재 도시 치안 2,000');
await expect(form.getByTestId('command-resource-summary')).toContainText('현재 자금');
const itemType = form.locator('#command-arg-itemType');
const itemCode = form.locator('#command-arg-itemCode');
await itemType.selectOption('horse');
await expect(itemCode.locator('option')).toHaveText(['노기 판매', '갈색마', '적토마 (불가)']);
await expect(itemCode.locator('option').nth(2)).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(itemCode.locator('option').nth(1)).not.toHaveCSS('color', 'rgb(255, 0, 0)');
await itemCode.selectOption('che_명마_적토마');
await expect(itemCode).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(form.locator('.option-detail')).toContainText('치안 5,000 필요');
await itemType.selectOption('weapon');
await expect(itemCode.locator('option')).toHaveText(['무기 판매 (불가)']);
await itemType.selectOption('horse');
await itemCode.selectOption('che_명마_갈색마');
await picker.screenshot({ path: testInfo.outputPath(`equipment-${width}.png`) });
await picker.getByRole('button', { name: '장비매매 입력', exact: true }).click();
await expect
.poll(() => JSON.stringify(requests))
.toContain('"action":"che_장비매매","args":{"itemType":"horse","itemCode":"che_명마_갈색마"}');
// 숙련전환: Ref 라벨·`병과 (등급)`과 감소/전환 대상 전후 두 줄.
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
await picker.getByRole('button', { name: /숙련전환/ }).click();
const src = form.getByLabel('감소 대상 숙련');
const dest = form.getByLabel('전환 대상 숙련');
await expect(src.locator('option')).toHaveText(['보병 (F)', '궁병 (F+)', '기병 (S-)']);
await expect(form.getByTestId('dex-conversion-same')).toBeVisible();
await src.selectOption('3');
await dest.selectOption('2');
const preview = form.getByTestId('dex-conversion-preview');
const previewRows = preview.locator('.dex-conversion-row');
// cut = trunc(500,000 × 0.4) = 200,000, add = trunc(200,000 × 0.9) = 180,000.
await expect(previewRows.nth(0).locator('> div')).toHaveText([
'기병',
'[',
'S-',
'500,000',
']',
'→',
'[',
'A-',
'300,000',
']',
]);
await expect(previewRows.nth(1).locator('> div')).toHaveText([
'궁병',
'[',
'F+',
'3,000',
']',
'→',
'[',
'B',
'183,000',
']',
]);
await expect(previewRows.nth(0).locator('> div').nth(2)).toHaveCSS('color', 'rgb(255, 99, 71)');
await expect(previewRows.nth(0).locator('> div').nth(7)).toHaveCSS('color', 'rgb(255, 140, 0)');
await expect(previewRows.nth(1).locator('> div').nth(2)).toHaveCSS('color', 'rgb(0, 0, 128)');
await expect(previewRows.nth(1).locator('> div').nth(7)).toHaveCSS('color', 'rgb(50, 205, 50)');
const dexGeometry = await picker.evaluate((element) => ({
url: location.href,
viewport: [innerWidth, innerHeight],
overlayOverflow: element.scrollWidth - element.clientWidth,
rows: [...element.querySelectorAll('.dex-conversion-row')].map((row) => ({
columns: getComputedStyle(row).gridTemplateColumns,
cells: [...row.children].map((cell) => {
const rect = cell.getBoundingClientRect();
return {
text: cell.textContent?.trim(),
left: rect.left,
right: rect.right,
scroll: cell.scrollWidth,
};
}),
})),
}));
expect(dexGeometry.overlayOverflow).toBe(0);
for (const row of dexGeometry.rows) {
// 병과명 4em(56px) 칸 안에 이름이 들어가고 칸끼리 겹치지 않는다.
expect(row.columns.split(' ')[0]).toBe('56px');
for (let index = 1; index < row.cells.length; index += 1) {
expect(row.cells[index]!.left).toBeGreaterThanOrEqual(row.cells[index - 1]!.right - 0.5);
}
}
await writeFile(testInfo.outputPath(`dex-conversion-${width}.json`), JSON.stringify(dexGeometry, null, 2));
await picker.screenshot({ path: testInfo.outputPath(`dex-conversion-${width}.png`) });
await picker.getByRole('button', { name: '숙련전환 입력', exact: true }).click();
await expect
.poll(() => JSON.stringify(requests))
.toContain('"action":"che_숙련전환","args":{"srcArmType":3,"destArmType":2}');
// 건국: Ref 성향표(`- 이름 : 장점, 단점`)를 입력 위에 보이고, 행을 누르면 성향을 고른다(Core 보조).
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
await picker.getByRole('button', { name: /^건국/ }).click();
const typeList = form.getByTestId('nation-type-list');
const typeRows = typeList.locator('li');
await expect(typeRows).toHaveText([
/- 도적\s*: 계략↑,\s*금수입↓ 치안↓ 민심↓/,
/- 덕가\s*: 치안↑ 인구↑ 민심↑,\s*쌀수입↓ 수성↓/,
/- 묵가\s*: 수성↑,\s*기술↓/,
]);
await expect(typeRows.first().locator('.nation-type-pros')).toHaveCSS('color', 'rgb(0, 255, 255)');
await expect(typeRows.first().locator('.nation-type-cons')).toHaveCSS('color', 'rgb(255, 0, 255)');
await expect(typeRows.first()).toHaveClass(/selected/);
await typeRows.nth(1).locator('.nation-type-name').click();
await expect(form.getByLabel('국가 성향')).toHaveValue('che_덕가');
await expect(typeRows.nth(1).locator('.nation-type-name')).toHaveAttribute('aria-pressed', 'true');
const typeGeometry = await picker.evaluate((element) => {
const list = element.querySelector('[data-testid=nation-type-list]')!;
const row = list.querySelector('li')!;
return {
url: location.href,
viewport: [innerWidth, innerHeight],
overlayOverflow: element.scrollWidth - element.clientWidth,
list: list.getBoundingClientRect().toJSON(),
rowWidth: row.getBoundingClientRect().width,
rowHeights: [...list.querySelectorAll('li')].map((node) => node.getBoundingClientRect().height),
lineHeight: Number.parseFloat(getComputedStyle(list).lineHeight),
columns: getComputedStyle(row).gridTemplateColumns.split(' ').map(Number.parseFloat),
listBeforeFields:
list.compareDocumentPosition(element.querySelector('#command-arg-nationType')!) &
Node.DOCUMENT_POSITION_FOLLOWING,
};
});
expect(typeGeometry.overlayOverflow).toBe(0);
expect(typeGeometry.listBeforeFields).toBeTruthy();
if (width >= 992) {
// Ref bootstrap: lg(992px) 이상 col-lg-1/2/2.
const [first, second, third] = typeGeometry.columns;
const unit = typeGeometry.rowWidth / 12;
expect(first).toBeCloseTo(unit, 0);
expect(second).toBeCloseTo(unit * 2, 0);
expect(third).toBeCloseTo(unit * 2, 0);
}
// 390px은 Ref 최소 폭보다 좁아 내용 폭 열을 쓴다. 어느 폭에서도 한 성향이 한 줄이다.
for (const height of typeGeometry.rowHeights) {
expect(height).toBeLessThan(typeGeometry.lineHeight * 1.5);
}
await writeFile(testInfo.outputPath(`nation-types-${width}.json`), JSON.stringify(typeGeometry, null, 2));
await picker.screenshot({ path: testInfo.outputPath(`nation-types-${width}.png`) });
await form.getByLabel('국가명').fill('신국');
await picker.getByRole('button', { name: '건국 입력', exact: true }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"nationType":"che_덕가"');
});
}
for (const width of [500, 1000]) {
test(`징병 현황 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await install(page);
await page.setViewportSize({ width, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
await verifyVerticalAlignment(page, testInfo, ['.recruitment-status > span']);
});
}
@@ -70,7 +70,8 @@ test('reserves every requested general and chief command through Chromium and re
fill?: (form: Locator) => Promise<void> fill?: (form: Locator) => Promise<void>
) => { ) => {
await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click(); await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click();
const picker = editor.getByTestId('command-picker'); // 명령 목록은 편집기 안 popup이고, 인자 입력은 body 위 전체화면 overlay로 옮겨진다.
const picker = editor.page().getByTestId('command-picker');
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${category}$`) }).click(); await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${category}$`) }).click();
const commandButton = picker.getByRole('button', { name: command }).first(); const commandButton = picker.getByRole('button', { name: command }).first();
await expect(commandButton).toBeEnabled(); await expect(commandButton).toBeEnabled();
@@ -78,7 +79,7 @@ test('reserves every requested general and chief command through Chromium and re
if (fill) { if (fill) {
const form = picker.getByTestId('command-argument-form'); const form = picker.getByTestId('command-argument-form');
await fill(form); await fill(form);
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
} }
await expect(editor.locator('.action-column > div').nth(turn)).toHaveText(command); await expect(editor.locator('.action-column > div').nth(turn)).toHaveText(command);
}; };
@@ -90,7 +90,7 @@ const reserveFirstTurn = async (page: Page, commandName: string, targetName: str
const targetValue = await targetOption.getAttribute('value'); const targetValue = await targetOption.getAttribute('value');
if (!targetValue) throw new Error(`Missing target value for ${targetName}.`); if (!targetValue) throw new Error(`Missing target value for ${targetName}.`);
await picker.locator('select').selectOption(targetValue); await picker.locator('select').selectOption(targetValue);
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
await expect(picker).toBeHidden(); await expect(picker).toBeHidden();
}; };
+10
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
@@ -161,3 +162,12 @@ test('keeps diplomacy detail redacted below permission three', async ({ page })
await expect(page.getByText('문서 작성 권한은 군주/수뇌에게만 제공됩니다.')).toBeVisible(); await expect(page.getByText('문서 작성 권한은 군주/수뇌에게만 제공됩니다.')).toBeVisible();
await expect(page.getByRole('button', { name: '전송' })).toHaveCount(0); await expect(page.getByRole('button', { name: '전송' })).toHaveCount(0);
}); });
for (const width of [500, 1000]) {
test(`외교 제목 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await installFixture(page, 4);
await page.setViewportSize({ width, height: 900 });
await page.goto('diplomacy');
await verifyVerticalAlignment(page, testInfo, ['.panel-header']);
});
}
@@ -2659,6 +2659,40 @@
} }
} }
} }
},
"nation.getCityOfficeRequests": {
"result": {
"data": {
"canManage": true,
"generalId": 1,
"nationId": 1,
"serverId": "typography-fixture",
"city": {
"id": 1,
"name": "허창"
},
"options": [],
"requests": [
{
"id": "typography-request",
"generalId": 3,
"generalName": "가나다라마바사아자",
"cityId": 1,
"cityName": "허창",
"officerLevel": 4,
"status": "pending",
"defaultDecision": "approve",
"defaultReason": "vacant",
"dueYear": 190,
"dueMonth": 2,
"remainingSeconds": 540,
"invalidReason": null
}
],
"running": true,
"tickSeconds": 600
}
}
} }
}, },
"possession": { "possession": {
@@ -2910,5 +2944,126 @@
} }
} }
} }
},
"best": {
"lobby.info": {
"result": {
"data": {
"year": 197,
"month": 7,
"userCnt": 17,
"npcCnt": 31,
"nationCnt": 5,
"maxUserCnt": 50,
"turnTerm": 60,
"fictionMode": "가상",
"otherTextInfo": "표준",
"starttime": "0190-01-01T00:00:00.000Z",
"myGeneral": {
"id": 1,
"name": "가나다라마바사아자",
"troopId": 0
}
}
}
},
"auth.status": {
"result": {
"data": {
"userId": "frontend-legacy-fixture-user"
}
}
},
"ranking.getBestGeneral": {
"result": {
"data": {
"isUnited": true,
"sections": [
{
"title": "명 성",
"valueType": "int",
"entries": [
{
"id": 1,
"name": "가나다라마바사아자",
"ownerName": "시각검증",
"nationName": "대한민국천하통일국",
"bgColor": "#006400",
"fgColor": "#ffffff",
"picture": "default.jpg",
"imageServer": 0,
"value": 12000,
"printValue": "12,000"
},
{
"id": 2,
"name": "가나다라마바사아자",
"ownerName": "검증계정",
"nationName": "대한민국천하통일국",
"bgColor": "#8b0000",
"fgColor": "#ffffff",
"picture": "default.jpg",
"imageServer": 0,
"value": 11000,
"printValue": "11,000"
}
]
},
{
"title": "계 급",
"valueType": "int",
"entries": []
}
],
"uniqueItems": [
{
"title": "명 마",
"slot": "horse",
"entries": [
{
"itemKey": "che_명마_15_적토마",
"itemName": "적토마",
"itemInfo": "최고의 명마",
"owner": {
"id": 1,
"name": "가나다라마바사아자",
"nationName": "대한민국천하통일국",
"bgColor": "#006400",
"fgColor": "#ffffff",
"picture": "default.jpg",
"imageServer": 0
}
},
{
"itemKey": "che_명마_15_적토마",
"itemName": "적토마",
"itemInfo": "최고의 명마",
"owner": {
"id": 0,
"name": "가나다라마바사아자",
"nationName": "대한민국천하통일국",
"bgColor": "#00582c",
"fgColor": "#ffffff",
"picture": null,
"imageServer": 0
}
}
]
}
]
}
}
}
},
"join": {
"lobby.info": "result",
"auth.status": {
"result": {
"data": {
"ok": true
}
}
},
"join.getConfig": "result"
} }
} }
+34 -7
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path'; import { basename, resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
@@ -591,11 +592,17 @@ const install = async (page: Page, state: FixtureState) => {
if (type === 'generalHistory') { if (type === 'generalHistory') {
return response({ return response({
type, type,
logs: Array.from({ length: 25 }, (_, index) => ({ id: 100 - index, text: `열전-${index}` })), logs: Array.from({ length: 25 }, (_, index) => ({
id: 100 - index,
text: `열전-${index}`,
})),
}); });
} }
if (type === 'generalAction') { if (type === 'generalAction') {
const firstPage = Array.from({ length: 24 }, (_, index) => ({ id: 50 - index, text: `행동-${index}` })); const firstPage = Array.from({ length: 24 }, (_, index) => ({
id: 50 - index,
text: `행동-${index}`,
}));
return response({ return response({
type, type,
logs: jsonInput.beforeId ? [{ id: 26, text: '이전 행동' }] : firstPage, logs: jsonInput.beforeId ? [{ id: 26, text: '이전 행동' }] : firstPage,
@@ -1033,10 +1040,10 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·
expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0); expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0);
expect(geometry.styles).toEqual({ expect(geometry.styles).toEqual({
diamondColor: 'rgb(135, 206, 235)', diamondColor: 'rgb(135, 206, 235)',
namePlateFontSize: '10.5px', namePlateFontSize: '12px',
nameCoverColor: 'rgb(255, 255, 0)', nameCoverColor: 'rgb(255, 255, 0)',
crewPlateColor: 'rgb(255, 69, 0)', crewPlateColor: 'rgb(255, 69, 0)',
crewPlateFontSize: '12.6px', crewPlateFontSize: '12px',
defenseArrowColor: 'rgb(255, 0, 255)', defenseArrowColor: 'rgb(255, 0, 255)',
}); });
}; };
@@ -1316,7 +1323,7 @@ test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']); expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']); expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]); expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']); expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12px', '12px', '12px']);
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]); expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
expect(measurements[0]?.nameFontSize).toBe('14px'); expect(measurements[0]?.nameFontSize).toBe('14px');
expect(measurements[0]?.actionFontSize).toBe('14px'); expect(measurements[0]?.actionFontSize).toBe('14px');
@@ -1386,7 +1393,12 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다음 페이지를 요청한다', async ({ page }) => { test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다음 페이지를 요청한다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', myset: 3, settingMutations: [], accessPages: [], pagedMyLogs: true, myLogInputs: [], permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
pagedMyLogs: true,
myLogInputs: [],
}; };
await install(page, state); await install(page, state);
await page.goto('my-page'); await page.goto('my-page');
@@ -1413,7 +1425,8 @@ test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다
await expect(action.getByRole('button', { name: '이전 로그 불러오기' })).toHaveCount(0); await expect(action.getByRole('button', { name: '이전 로그 불러오기' })).toHaveCount(0);
expect(state.myLogInputs?.filter((input) => input.type === 'generalHistory')).toHaveLength(1); expect(state.myLogInputs?.filter((input) => input.type === 'generalHistory')).toHaveLength(1);
expect(state.myLogInputs?.filter((input) => input.type === 'generalAction')).toEqual([ expect(state.myLogInputs?.filter((input) => input.type === 'generalAction')).toEqual([
{ type: 'generalAction' }, { type: 'generalAction', beforeId: 27 }, { type: 'generalAction' },
{ type: 'generalAction', beforeId: 27 },
]); ]);
}); });
@@ -2680,3 +2693,17 @@ test('감찰부 사관 연수를 채운 일반 장수는 개인 기록 거부에
await expect(page.locator('[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록'); await expect(page.locator('[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록');
} }
}); });
for (const width of [500, 1000]) {
test(`내 정보의 오른쪽 수치와 왼쪽 날짜 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await install(page, { permission: 'head', myset: 3, settingMutations: [], accessPages: [] });
await page.setViewportSize({ width, height: 900 });
await page.goto('my-page');
await verifyVerticalAlignment(page, testInfo, [
'.battle-general-extra > span',
'.battle-general-extra > strong',
'.dex-row > span',
'.dex-row > strong',
]);
});
}
@@ -1,3 +1,4 @@
import { writeFile } from 'node:fs/promises';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -156,7 +157,12 @@ const installArchiveViews = async (page: Page) => {
}); });
} }
if (operation === 'yearbook.getRange') { if (operation === 'yearbook.getRange') {
return response({ firstYearMonth: 22001, lastYearMonth: 22001, currentYearMonth: 22001 }); return response({
firstYearMonth: 22001,
lastYearMonth: 22001,
currentYearMonth: 22001,
mapLayout: { mapName: 'miniche', cityList: [], regionMap: {}, levelMap: {} },
});
} }
if (operation === 'public.getMapLayout') { if (operation === 'public.getMapLayout') {
return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} }); return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} });
@@ -293,3 +299,48 @@ test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰
await expect(labels.filter({ hasText: '명국' })).toHaveCSS('color', 'rgb(0, 0, 0)'); await expect(labels.filter({ hasText: '명국' })).toHaveCSS('color', 'rgb(0, 0, 0)');
await page.screenshot({ path: testInfo.outputPath('yearbook-nation-contrast-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('yearbook-nation-contrast-mobile.png'), fullPage: true });
}); });
test('왕조의 역사 보기는 해당 기수의 지도 레이아웃으로 연감을 렌더한다', async ({ page }, testInfo) => {
await installArchiveViews(page);
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('dynasty');
const archivedHistory = page.getByRole('link', { name: '역사 보기' }).last();
await expect(archivedHistory).toHaveAttribute('href', /yearbook\?serverID=che-current-1$/);
await archivedHistory.click();
await expect(page.locator('.history-grid')).toBeVisible();
await expect(page.locator('.map-area')).toHaveClass(/map-theme-miniche/);
await expect(page.locator('.map-bgroad')).toHaveCSS('background-image', /miniche_road\.png/);
await expect(page.locator('.nation-position tbody tr')).toHaveCount(2);
const mapEvidence = await page.locator('.map-area').evaluate((area) => {
const road = area.querySelector<HTMLElement>('.map-bgroad');
const background = area.querySelector<HTMLImageElement>('.map-background-image');
return {
mapClass: area.className,
mapRect: area.getBoundingClientRect().toJSON(),
backgroundRect: background?.getBoundingClientRect().toJSON(),
backgroundNaturalSize: background
? { width: background.naturalWidth, height: background.naturalHeight }
: null,
backgroundObjectFit: background ? getComputedStyle(background).objectFit : null,
roadBackground: road ? getComputedStyle(road).backgroundImage : null,
};
});
expect(mapEvidence.mapRect.width / mapEvidence.mapRect.height).toBeCloseTo(7 / 5, 2);
expect(mapEvidence.backgroundNaturalSize?.width).toBeGreaterThan(0);
await page.screenshot({ path: testInfo.outputPath('dynasty-history-map-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 800 });
await expect(page.locator('.map-area')).toHaveClass(/map-theme-miniche/);
const mobileEvidence = await page.locator('.map-area').evaluate((area) => ({
mapClass: area.className,
mapRect: area.getBoundingClientRect().toJSON(),
roadBackground: getComputedStyle(area.querySelector<HTMLElement>('.map-bgroad')!).backgroundImage,
}));
expect(mobileEvidence.mapRect.width / mobileEvidence.mapRect.height).toBeCloseTo(7 / 5, 2);
await writeFile(
testInfo.outputPath('dynasty-history-map-geometry.json'),
JSON.stringify({ desktop: mapEvidence, mobile: mobileEvidence }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('dynasty-history-map-mobile.png'), fullPage: true });
});
+152 -44
View File
@@ -33,6 +33,7 @@ const operationNames = (route: Route) =>
type NavigationFixture = { type NavigationFixture = {
pageExitAudit?: boolean; pageExitAudit?: boolean;
cityOfficeRequestIds?: string[];
decoratedNpcChiefs?: boolean; decoratedNpcChiefs?: boolean;
officerLevel: number; officerLevel: number;
permission: number; permission: number;
@@ -646,6 +647,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
return response({ return response({
myGeneral: { id: 7, name: '메뉴검증장수' }, myGeneral: { id: 7, name: '메뉴검증장수' },
serverId: state.serverId ?? 'che_fixture_season', serverId: state.serverId ?? 'che_fixture_season',
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
profile: state.profile ?? 'che', profile: state.profile ?? 'che',
gameIdx: state.gameIdx ?? 101, gameIdx: state.gameIdx ?? 101,
year: state.currentYear ?? 185, year: state.currentYear ?? 185,
@@ -815,6 +817,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
if (operation === 'general.getFrontStatus') { if (operation === 'general.getFrontStatus') {
return response({ return response({
serverId: state.serverId ?? 'che_fixture_season', serverId: state.serverId ?? 'che_fixture_season',
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
onlineUserCount: 1, onlineUserCount: 1,
onlineNations: '위(1)', onlineNations: '위(1)',
onlineGenerals: '메뉴검증장수', onlineGenerals: '메뉴검증장수',
@@ -877,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
const installRealtimeHarness = async (page: Page) => { const installRealtimeHarness = async (page: Page) => {
await page.addInitScript(() => { await page.addInitScript(() => {
class TestEventSource extends EventTarget { const originalFetch = window.fetch.bind(window);
static latest: TestEventSource | null = null; let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null;
static created = 0; let created = 0;
static closed = 0; let closed = 0;
readonly url: string; let authenticated = false;
let credentialInUrl = false;
constructor(url: string | URL) { window.fetch = (input, init) => {
super(); const url = new URL(input instanceof Request ? input.url : String(input), location.href);
this.url = url.toString(); if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
TestEventSource.created += 1; if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
TestEventSource.latest = this; authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false;
queueMicrotask(() => this.dispatchEvent(new Event('open'))); credentialInUrl = url.searchParams.has('token');
} let controller!: ReadableStreamDefaultController<Uint8Array>;
let ended = false;
close() { const source = {
if (TestEventSource.latest === this) { close: () => {
TestEventSource.latest = null; if (ended) return;
TestEventSource.closed += 1; ended = true;
} closed += 1;
} if (latest === source) latest = null;
} controller.close();
},
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource }); emit: (type: string, payload: unknown) => {
Object.defineProperty(window, '__emitMainRealtime', { if (ended) return;
configurable: true, controller.enqueue(
value: (type: string, payload: unknown) => { new TextEncoder().encode(
TestEventSource.latest?.dispatchEvent( `event: ${type}\ndata: ${JSON.stringify({
new MessageEvent(type, {
data: JSON.stringify({
type, type,
...(type === 'readModelInvalidated' || type === 'messagesInvalidated' ...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
? { refreshGrant: 'fixture-realtime-grant' } ? { refreshGrant: 'fixture-realtime-grant' }
: {}), : {}),
...((payload as object) ?? {}), ...((payload as object) ?? {}),
}), })}\n\n`
}) )
); );
}, },
};
const body = new ReadableStream<Uint8Array>({
start: (next) => {
controller = next;
},
cancel: () => {
if (!ended) {
ended = true;
closed += 1;
if (latest === source) latest = null;
}
},
}); });
Object.defineProperty(window, '__hasMainRealtime', { created += 1;
configurable: true, latest = source;
value: () => TestEventSource.latest !== null, init?.signal?.addEventListener('abort', source.close, { once: true });
}); return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
Object.defineProperty(window, '__mainRealtimeStats', { };
configurable: true, Object.assign(window, {
value: () => ({ __emitMainRealtime: (type: string, payload: unknown) => {
active: TestEventSource.latest !== null, latest?.emit(type, payload);
created: TestEventSource.created, },
closed: TestEventSource.closed, __hasMainRealtime: () => latest !== null,
}), __mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }),
}); });
}); });
}; };
@@ -5283,7 +5296,7 @@ for (const viewport of [
); );
await picker.screenshot({ path: test.info().outputPath(`equipment-order-${viewport.name}.png`) }); await picker.screenshot({ path: test.info().outputPath(`equipment-order-${viewport.name}.png`) });
const request = page.waitForRequest((entry) => entry.url().includes('turns.reserved.setGeneral')); const request = page.waitForRequest((entry) => entry.url().includes('turns.reserved.setGeneral'));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
expect(JSON.stringify((await request).postDataJSON())).toContain('"itemCode":"che_훈련_청주"'); expect(JSON.stringify((await request).postDataJSON())).toContain('"itemCode":"che_훈련_청주"');
}); });
@@ -5351,7 +5364,7 @@ for (const viewport of [
await writeFile(test.info().outputPath(`owned-sale-${viewport.name}.json`), JSON.stringify(geometry, null, 2)); await writeFile(test.info().outputPath(`owned-sale-${viewport.name}.json`), JSON.stringify(geometry, null, 2));
await picker.screenshot({ path: test.info().outputPath(`owned-sale-${viewport.name}.png`) }); await picker.screenshot({ path: test.info().outputPath(`owned-sale-${viewport.name}.png`) });
const request = page.waitForRequest((request) => request.url().includes('turns.reserved.setGeneralBulk')); const request = page.waitForRequest((request) => request.url().includes('turns.reserved.setGeneralBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: / 입력$/ }).click();
const payload = (await request).postDataJSON(); const payload = (await request).postDataJSON();
expect(JSON.stringify(payload)).toContain('"itemCode":"None"'); expect(JSON.stringify(payload)).toContain('"itemCode":"None"');
expect(JSON.stringify(payload)).toContain('"itemType":"item"'); expect(JSON.stringify(payload)).toContain('"itemType":"item"');
@@ -5427,10 +5440,53 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
) )
.toBe(true); .toBe(true);
expect(
await page.evaluate(() =>
(
window as unknown as {
__mainRealtimeStats: () => { authenticated: boolean; credentialInUrl: boolean };
}
).__mainRealtimeStats()
)
).toMatchObject({ authenticated: true, credentialInUrl: false });
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u); await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders).toContain(null); expect(state.dashboardGrantHeaders).toContain(null);
const captureRefreshLayout = async (phase: string): Promise<void> => {
if (!autoRefreshArtifactRoot) return;
await page.evaluate(() => document.fonts.ready);
const layout = await page.evaluate(() => ({
url: location.href,
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
cards: Array.from(document.querySelectorAll('[data-main-target="general"], [data-main-target="city"]')).map(
(element) => {
const style = getComputedStyle(element);
return {
target: element.getAttribute('data-main-target'),
bounds: element.getBoundingClientRect().toJSON(),
fontFamily: style.fontFamily,
fontSize: style.fontSize,
color: style.color,
backgroundColor: style.backgroundColor,
display: style.display,
};
}
),
images: Array.from(document.images).map((image) => ({
src: image.getAttribute('src'),
width: image.naturalWidth,
height: image.naturalHeight,
complete: image.complete,
objectFit: getComputedStyle(image).objectFit,
})),
}));
await Promise.all([
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-layout.json`), JSON.stringify(layout, null, 2)),
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-dom.html`), await page.content()),
]);
};
const operationsBeforeTournament = state.operations.length; const operationsBeforeTournament = state.operations.length;
state.stage = 1; state.stage = 1;
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true })); await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
@@ -5470,6 +5526,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
}).observe({ entryTypes: ['measure'] }); }).observe({ entryTypes: ['measure'] });
}); });
await captureRefreshLayout('before-refresh');
const callsBeforeRefresh = state.generalMeCalls; const callsBeforeRefresh = state.generalMeCalls;
const operationsBeforeClockOnly = state.operations.length; const operationsBeforeClockOnly = state.operations.length;
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
@@ -5613,6 +5670,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)), vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
}; };
}); });
await captureRefreshLayout('after-refresh');
expect(profile.generalMounted).toBe(true); expect(profile.generalMounted).toBe(true);
expect(profile.cityMounted).toBe(true); expect(profile.cityMounted).toBe(true);
expect(profile.generalMutations).toBeGreaterThan(0); expect(profile.generalMutations).toBeGreaterThan(0);
@@ -7135,3 +7193,53 @@ test.describe('page exit navigation', () => {
expect(context.pages()).toHaveLength(1); expect(context.pages()).toHaveLength(1);
}); });
}); });
for (const width of [1000, 390]) {
test(`city office request toast announces new IDs without repeat at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
cityOfficeRequestIds: [],
latestVote: null,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await waitForMain(page);
await waitForMainRealtime(page);
await expect(page.locator('.office-request-notice')).toHaveCount(0);
state.cityOfficeRequestIds = ['30:760'];
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
const toast = page.locator('.office-request-notice');
await expect(toast).toContainText('새 관직 요청');
const rect = await toast.boundingBox();
expect(rect).not.toBeNull();
expect(rect!.x).toBeGreaterThanOrEqual(0);
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
await page.screenshot({ path: testInfo.outputPath(`office-request-toast-${width}.png`), fullPage: true });
await writeFile(testInfo.outputPath(`office-request-toast-${width}.json`), JSON.stringify(rect));
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
await expect(toast).toBeVisible();
await toast.getByRole('button', { name: '관직 요청 알림 닫기' }).click();
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
await expect(toast).toHaveCount(0);
await expect(page.locator('.office-request-link')).toContainText('관직 요청 1건');
await page.reload();
await waitForMainRealtime(page);
await expect(toast).toHaveCount(0);
state.cityOfficeRequestIds = ['30:760', '31:760'];
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
await expect(toast).toContainText('대기 2건');
await writeFile(
testInfo.outputPath(`office-request-toast-${width}.html`),
await toast.evaluate((element) => element.outerHTML)
);
await toast.getByRole('link').click();
await expect(page).toHaveURL(/\/nation\/personnel#city-office-requests$/);
});
}
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -115,3 +116,13 @@ test('keeps the nation-betting amount while changing candidates, submitting, and
await expect(amount).toHaveValue('100'); await expect(amount).toHaveValue('100');
await page.screenshot({ path: testInfo.outputPath('nation-betting-amount-retained.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('nation-betting-amount-retained.png'), fullPage: true });
}); });
for (const width of [500, 1000]) {
test(`국가 베팅 지급표 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await installFixture(page);
await page.setViewportSize({ width, height: 900 });
await page.goto('nation-betting');
await page.getByRole('button', { name: /두 번째 천통국 베팅/u }).click();
await verifyVerticalAlignment(page, testInfo, ['.payout-row > div']);
});
}
+216 -3
View File
@@ -1,5 +1,6 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -9,8 +10,11 @@ import { acceptAppConfirmation } from './appConfirmation.js';
type Role = 'leader' | 'head' | 'member'; type Role = 'leader' | 'head' | 'member';
type FixtureState = { type FixtureState = {
role: Role; role: Role;
officeRequests?: Record<string, unknown>;
officeMutation?: Record<string, unknown>;
failNextRate?: boolean; failNextRate?: boolean;
failPersonnelLoad?: boolean; failPersonnelLoad?: boolean;
testIcons?: boolean;
rate: number; rate: number;
appointedGeneralId?: number; appointedGeneralId?: number;
appointedCityId?: number; appointedCityId?: number;
@@ -98,8 +102,8 @@ const personnelFixture = (state: FixtureState) => {
const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1; const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1;
const fullGenerals = [ const fullGenerals = [
general(1, '조조', 12), general(1, '조조', 12),
general(2, '순욱', 11), general(2, '순욱', 11, state.testIcons ? { picture: 'transparent.png', imageServer: 1 } : {}),
general(3, '하후돈', 4), general(3, '하후돈', 4, state.testIcons ? { picture: 'missing.png', imageServer: 1 } : {}),
general(4, '곽가', 3), general(4, '곽가', 3),
general(5, '정욱', 2), general(5, '정욱', 2),
general(6, '장료', 1, { npcState: 2 }), general(6, '장료', 1, { npcState: 2 }),
@@ -236,6 +240,24 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } });
if (operation === 'join.getConfig') return response({}); if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getCityOfficeRequests')
return response(
state.officeRequests ?? {
canManage: state.role !== 'member',
generalId: 6,
nationId: 1,
serverId: 'office-season',
city: { id: 1, name: '허창' },
options: [],
requests: [],
running: true,
tickSeconds: 600,
}
);
if (operation === 'nation.cityOfficeRequest') {
state.officeMutation = jsonInput;
return response({ ok: true });
}
if (operation === 'nation.getPersonnelInfo') { if (operation === 'nation.getPersonnelInfo') {
return state.failPersonnelLoad return state.failPersonnelLoad
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
@@ -1022,3 +1044,194 @@ test('finance adopts the server-purified notice and scout message before renderi
) )
.toBeUndefined(); .toBeUndefined();
}); });
for (const width of [1365, 390]) {
test(`personnel custom icons never layer the default at ${width}px`, async ({ page }, testInfo) => {
await installFixture(page, { role: 'leader', rate: 20, testIcons: true });
await page.setViewportSize({ width, height: 900 });
await page.route('**/transparent.png', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="12" fill="red"/></svg>',
})
);
await page.route('**/missing.png', (route) => route.fulfill({ status: 404, body: '' }));
await page.route('**/icons/default.jpg', async (route) =>
route.fulfill({
contentType: 'image/jpeg',
body: await referenceAsset('icons/default.jpg'),
})
);
await gotoOffice(page, 'nation/personnel');
const chief = page.locator('.chief-entry').filter({ hasText: '순욱' }).locator('.general-icon');
await expect(chief).toHaveCSS('background-image', 'none');
await expect(chief).toHaveAttribute('src', /transparent\.png$/);
await expect(page.locator('.chief-entry').filter({ hasText: '조조' }).locator('.general-icon')).toHaveAttribute(
'src',
/default\.jpg$/
);
await page.getByRole('button', { name: '주부 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog');
const custom = picker
.locator('.personnel-picker-card')
.filter({ hasText: '순욱' })
.locator('.personnel-picker-portrait');
const missing = picker
.locator('.personnel-picker-card')
.filter({ hasText: '하후돈' })
.locator('.personnel-picker-portrait');
await expect(custom).toHaveCSS('background-image', 'none');
await expect(custom).toHaveAttribute('src', /transparent\.png$/);
await expect(missing).toHaveAttribute('src', /default\.jpg$/);
await expect.poll(() => custom.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBe(64);
await page.evaluate(() => document.fonts.ready);
const metrics = await page.locator('.general-icon, .personnel-picker-portrait').evaluateAll((elements) =>
elements.map((el) => ({
src: (el as HTMLImageElement).currentSrc,
rect: el.getBoundingClientRect().toJSON(),
background: getComputedStyle(el).backgroundImage,
objectFit: getComputedStyle(el).objectFit,
naturalWidth: (el as HTMLImageElement).naturalWidth,
naturalHeight: (el as HTMLImageElement).naturalHeight,
}))
);
expect(metrics.every((entry) => entry.background === 'none')).toBe(true);
await writeFile(testInfo.outputPath('icon-geometry.json'), JSON.stringify(metrics, null, 2));
await writeFile(testInfo.outputPath('icon-dom.html'), await page.content());
await testInfo.attach('icon-geometry', {
body: JSON.stringify(metrics, null, 2),
contentType: 'application/json',
});
await page.screenshot({
path: testInfo.outputPath('personnel-icons.png'),
fullPage: true,
animations: 'disabled',
});
await testInfo.attach('icon-dom', { body: await page.content(), contentType: 'text/html' });
});
}
for (const width of [500, 1000]) {
test(`재정 격자 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await installFixture(page, { role: 'head', rate: 20 });
await page.setViewportSize({ width, height: 900 });
await gotoOffice(page, 'nation/finance');
await verifyVerticalAlignment(page, testInfo, ['.diplomacy-row > div', '.budget-row > span', '.blue-heading']);
});
}
for (const width of [1000, 390]) {
test(`city office requests show defaults and both decisions at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const state: FixtureState = {
role: 'head',
rate: 20,
officeRequests: {
canManage: true,
generalId: 2,
nationId: 1,
serverId: 'office-season',
city: { id: 1, name: '허창' },
options: [],
running: true,
tickSeconds: 600,
requests: ['approve', 'reject'].map((defaultDecision, index) => ({
id: `3:${index}`,
generalId: 3 + index,
generalName: index ? '장료' : '허저',
cityId: 1,
cityName: '허창',
officerLevel: index ? 3 : 4,
status: 'pending',
defaultDecision,
defaultReason: index ? 'secret' : 'vacant',
dueYear: 190,
dueMonth: 2,
remainingSeconds: 540,
invalidReason: null,
})),
},
};
await installFixture(page, state);
await page.goto('nation/personnel');
const panel = page.getByRole('region', { name: '도시 관직 자원' });
await expect(panel.getByText('미처리 시 자동 승인', { exact: true })).toBeVisible();
await expect(panel.getByText('미처리 시 자동 거부', { exact: true })).toBeVisible();
await expect(panel.getByRole('button', { name: '승인', exact: true })).toHaveCount(2);
await expect(panel.getByRole('button', { name: '거부', exact: true })).toHaveCount(2);
await page.evaluate(() => document.fonts.ready);
const geometry = await panel.evaluate((element) => ({
width: element.getBoundingClientRect().width,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
font: getComputedStyle(element).fontSize,
}));
expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth);
await page.screenshot({ path: testInfo.outputPath(`office-requests-${width}.png`), fullPage: true });
await writeFile(testInfo.outputPath(`office-requests-${width}.json`), JSON.stringify(geometry));
await writeFile(
testInfo.outputPath(`office-requests-${width}.html`),
await panel.evaluate((element) => element.outerHTML)
);
const approval = panel.getByRole('button', { name: '승인', exact: true }).first();
await approval.hover();
await approval.focus();
await expect(approval).toBeFocused();
await writeFile(
testInfo.outputPath(`office-requests-button-${width}.json`),
JSON.stringify(
await approval.evaluate((element) => ({
color: getComputedStyle(element).color,
background: getComputedStyle(element).backgroundColor,
lineHeight: getComputedStyle(element).lineHeight,
pointer: getComputedStyle(element).cursor,
focused: document.activeElement === element,
}))
)
);
await panel.getByRole('button', { name: '승인', exact: true }).nth(1).click();
await expect(page.getByTestId('game-notice-dialog')).toContainText('승인 시 기밀 열람 가능');
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '승인', exact: true }).click();
await expect.poll(() => state.officeMutation?.action).toBe('approve');
expect(state.officeMutation).toMatchObject({ targetGeneralId: 4, officeRequestId: '3:1' });
await panel.getByRole('button', { name: '거부', exact: true }).first().click();
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '거부', exact: true }).click();
await expect.poll(() => state.officeMutation?.action).toBe('reject');
});
test(`city office volunteer confirms the default at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const state: FixtureState = {
role: 'member',
rate: 20,
officeRequests: {
canManage: false,
generalId: 6,
nationId: 1,
serverId: 'office-season',
city: { id: 1, name: '허창' },
requests: [],
running: true,
tickSeconds: 600,
options: [
{ level: 4, allowed: true, reason: null, defaultDecision: 'approve', defaultReason: 'vacant' },
{
level: 3,
allowed: false,
reason: '지력 부족',
defaultDecision: 'approve',
defaultReason: 'vacant',
},
],
},
};
await installFixture(page, state);
await page.goto('nation/personnel');
const panel = page.getByRole('region', { name: '도시 관직 자원' });
await expect(panel.getByRole('button', { name: '자원', exact: true }).nth(1)).toBeDisabled();
await panel.getByRole('button', { name: '자원', exact: true }).first().click();
await expect(page.getByTestId('game-notice-dialog')).toContainText('미처리 시 자동 승인');
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '자원', exact: true }).click();
await expect.poll(() => state.officeMutation?.action).toBe('request');
expect(state.officeMutation).toEqual({ action: 'request', officerLevel: 4 });
});
}
+10
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
@@ -454,3 +455,12 @@ test('a user below secret read permission receives a recoverable page error', as
await expect(page.locator('#container')).toHaveCount(0); await expect(page.locator('#container')).toHaveCount(0);
await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible();
}); });
for (const width of [500, 1000]) {
test(`NPC 정책 제목 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await installFixture(page, { permissionLevel: 4, mutations: [] });
await page.setViewportSize({ width, height: 900 });
await gotoPolicy(page);
await verifyVerticalAlignment(page, testInfo, ['.section_bar', '.sub_bar']);
});
}
+11
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
@@ -378,3 +379,13 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
expect(detailMetrics.hallColumns.split(' ')).toHaveLength(3); expect(detailMetrics.hallColumns.split(' ')).toHaveLength(3);
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom); expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
}); });
for (const width of [500, 1000]) {
test(`지난 플레이 전투 수치 세로 정렬 ${width}px`, async ({ page }, testInfo) => {
await installArchive(page);
await page.setViewportSize({ width, height: 900 });
await page.goto('past-plays');
await page.locator('.detail-toggle').click();
await verifyVerticalAlignment(page, testInfo, ['.hall-battle-record dt', '.hall-battle-record dd']);
});
}
+301 -9
View File
@@ -14,7 +14,16 @@ const world = {
collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' }, collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' },
}; };
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 }; const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 };
const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex }; const population = {
crew: 5000,
count: 2,
gold: 200,
rice: 400,
dex,
averageGold: 100,
averageRice: 200,
averageDex: dex,
};
const general = { const general = {
id: 1, id: 1,
name: '감사장수', name: '감사장수',
@@ -76,7 +85,9 @@ const install = async (
page: Page, page: Page,
denied = false, denied = false,
baseline: boolean | 'document' | 'created' | 'removed' = false, baseline: boolean | 'document' | 'created' | 'removed' = false,
executionStatus?: 'PREPARING' | 'BLOCKED' executionStatus?: 'PREPARING' | 'BLOCKED',
dashboard = false,
historyGap = false
) => { ) => {
const requests: { operation: string; input: Record<string, unknown> }[] = []; const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
@@ -136,7 +147,9 @@ const install = async (
case 'playAudit.decisionHistory': case 'playAudit.decisionHistory':
return result({ return result({
...world, ...world,
month: input.month ?? { year: 190, month: 7 }, month: input.month ?? { year: 190, month: 6 },
currentMonth: { year: 190, month: 7 },
selection: input.month ? 'MONTH' : 'LATEST',
coverage: 'PROCEDURES_ONLY', coverage: 'PROCEDURES_ONLY',
items: [ items: [
{ {
@@ -156,7 +169,59 @@ const install = async (
{ {
ordinal: input.cursor === undefined ? 0 : 1, ordinal: input.cursor === undefined ? 0 : 1,
steps: [ steps: [
...(input.cursor === undefined ? [{ phase: 'general', generalId: 1, nationId: 2, cityId: 3, npcState: 2, year: 190, month: 6, tick: 100, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}} }] : []), ...(input.cursor === undefined
? [
{
phase: 'general',
generalId: 1,
nationId: 2,
cityId: 3,
npcState: 2,
year: 190,
month: 6,
tick: 100,
sequence: 0,
kind: 'DECISION_START',
reservedAction: '휴식',
effectivePolicy: {
schemaVersion: 1,
general: {
priority: ['징병'],
flags: { 징병: true, 출병: false },
},
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
},
},
},
]
: []),
{ {
phase: 'general', phase: 'general',
generalId: 1, generalId: 1,
@@ -431,7 +496,13 @@ const install = async (
}); });
} }
case 'playAudit.coverage': case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null }); return result({
...world,
historyGap: historyGap ? { firstYear: 190, firstMonth: 4 } : null,
status: 'COLLECTED',
samples: [],
nextCursor: null,
});
case 'playAudit.nations': case 'playAudit.nations':
return result({ return result({
...world, ...world,
@@ -442,6 +513,13 @@ const install = async (
case 'playAudit.nationSeries': case 'playAudit.nationSeries':
return result({ return result({
...world, ...world,
currentSettlement: {
year: 190,
month: 7,
complete: true,
gold: null,
rice: { income: 4321, paid: 1234 },
},
nextCursor: null, nextCursor: null,
items: [ items: [
{ {
@@ -471,7 +549,33 @@ const install = async (
settlementsComplete: true, settlementsComplete: true,
})), })),
}, },
], ].flatMap((point) =>
dashboard
? Array.from({ length: 6 }, (_, index) => ({
...point,
month: index + 1,
periodMonths: 1,
from: { year: 190, month: index + 1 },
to: { year: 190, month: index + 1 },
stockAsOf: { year: 190, month: index + 1 },
stock:
index === 2
? null
: {
...point.stock,
gold: 600 + index * 150,
rice: 1200 - index * 90,
populations: {
human: { ...population, crew: 5000 + index * 1000 },
npc: population,
troopNpc: population,
},
},
flows: { ...point.flows, incomeGold: index === 0 ? 2100 : 0 },
complete: index !== 2,
}))
: [point]
),
}); });
case 'playAudit.nationSnapshot': case 'playAudit.nationSnapshot':
return result({ return result({
@@ -503,7 +607,11 @@ const install = async (
...world, ...world,
collected: !input.at || (input.at as { month: number }).month !== 7, collected: !input.at || (input.at as { month: number }).month !== 7,
sample: input.at ?? null, sample: input.at ?? null,
nextCursor: input.cursor ? null : 1, nextCursor: input.cursor
? null
: input.sort && input.sort !== 'id'
? { id: 1, value: 1200 }
: 1,
items: items:
input.at && (input.at as { month: number }).month === 7 input.at && (input.at as { month: number }).month === 7
? [] ? []
@@ -524,6 +632,27 @@ const install = async (
nation: { id: 2, name: '촉' }, nation: { id: 2, name: '촉' },
city: { id: 3, name: '성도' }, city: { id: 3, name: '성도' },
}); });
case 'playAudit.cityMap':
return result({
...world,
collected: true,
sample: input.at ?? null,
nextCursor: null,
unmappedCityIds: [],
layout: {
mapName: 'che',
cityList: [{ id: 3, name: '성도', level: 4, region: 1, x: 200, y: 180, path: [] }],
regionMap: { 1: '익주' },
levelMap: { 4: '이' },
},
map: {
year: 190,
month: (input.at as { month: number } | undefined)?.month ?? 7,
startYear: 190,
cityList: [[3, 4, 0, 2, 1, 1]],
nationList: [[2, '촉', '#ff0000', 0]],
},
});
case 'playAudit.cityDetail': case 'playAudit.cityDetail':
return result({ return result({
...world, ...world,
@@ -952,7 +1081,9 @@ test('policy filter drafts do not read until applied, including default dates',
await page.goto(gamePath('/play-audit?tab=policies&nation=2')); await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
await page.getByRole('link', { name: '국방 설정', exact: true }).click(); await page.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe('DEFENCE'); await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area)
.toBe('DEFENCE');
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length; const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3'); await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByRole('button', { name: '조회', exact: true }).focus(); await page.getByRole('button', { name: '조회', exact: true }).focus();
@@ -1337,7 +1468,10 @@ for (const width of [1280, 390]) {
await menu.getByRole('link', { name: '국방 설정', exact: true }).click(); await menu.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect(page).toHaveURL(/policyArea=DEFENCE/); await expect(page).toHaveURL(/policyArea=DEFENCE/);
await page.goBack(); await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute(
'aria-current',
'page'
);
await page.goBack(); await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
await menu.getByRole('link', { name: '도시', exact: true }).hover(); await menu.getByRole('link', { name: '도시', exact: true }).hover();
@@ -1357,3 +1491,161 @@ for (const width of [1280, 390]) {
await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true }); await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true });
}); });
} }
for (const width of [1440, 390]) {
test(`audit dashboard charts, nation navigation and inspector at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 960 });
const requests = await install(page, false, false, undefined, true);
await page.goto(gamePath('/play-audit'));
await page.getByRole('button', { name: '촉 #2', exact: true }).click();
await expect(page).toHaveURL(/nation=2/);
await expect(page.getByRole('region', { name: '당월 정산', exact: true })).toContainText('4,321');
const charts = page.locator('.audit-chart canvas');
await expect(charts).toHaveCount(4);
const sizes = await charts.evaluateAll((nodes) =>
nodes.map((node) => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
}))
);
for (const size of sizes) {
expect(size.width).toBeGreaterThan(width === 390 ? 260 : 600);
expect(size.height).toBeGreaterThanOrEqual(260);
}
await page.getByLabel('지표', { exact: true }).selectOption('crew');
await expect(page.getByRole('cell', { name: '15,000', exact: true })).toBeVisible();
const query = requests.findLast((request) => request.operation === 'playAudit.nationSeries')?.input;
expect(query).toMatchObject({ nationId: 2, resolution: 'month' });
await capture(page, `dashboard-${width}`);
await page.getByRole('button', { name: '최근 6개월', exact: true }).click();
await expect(page).toHaveURL(/fromMonth=2/);
await page
.getByRole('navigation', { name: '플레이 감사 메뉴' })
.getByRole('link', { name: '장수', exact: true })
.click();
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
const detail = page.getByRole('complementary', { name: '선택한 대상 상세' });
await expect(detail).toContainText('병력 5,000');
await expect(detail).toBeFocused();
if (width >= 1200) {
const left = await page.getByRole('region', { name: '감사 분석', exact: true }).boundingBox();
const right = await detail.boundingBox();
expect(right!.x).toBeGreaterThanOrEqual(left!.x + left!.width);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await capture(page, `inspector-${width}`);
await page.reload();
await expect(detail).toContainText('병력 5,000');
await expect(detail).toBeFocused();
if (width < 1200) await expect(detail).toBeInViewport();
});
}
for (const width of [1280, 390]) {
test(`audit historical map and ranked generals at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.route('https://sam-image.hided.net/**', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="700" height="500"><rect width="700" height="500" fill="#203c2d"/></svg>',
})
);
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6'));
expect(requests.some((request) => request.operation === 'playAudit.cityMap')).toBe(false);
await page.getByRole('link', { name: '도시 지도', exact: true }).click();
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
await page.getByRole('button', { name: '성도', exact: true }).click();
await expect(page).toHaveURL(/cityRecord=3/);
await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible();
await capture(page, `audit-map-${width}`);
await page.reload();
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click();
await expect(page).toHaveURL(/city=3/);
await page.getByLabel('정렬 기준', { exact: true }).selectOption('gold');
await page.getByLabel('정렬 방향', { exact: true }).selectOption('desc');
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect(page).toHaveURL(/sort=gold/);
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({
sort: 'gold',
order: 'desc',
cityId: 3,
at: { year: 190, month: 6, kind: 'MONTH_END' },
cursor: { id: 1, value: 1200 },
});
await page.reload();
await expect(page.getByLabel('정렬 기준', { exact: true })).toHaveValue('gold');
await capture(page, `audit-ranked-${width}`);
});
}
for (const width of [390, 1280]) {
test(`audit history gap stays visible while browsing at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await install(page, false, false, undefined, false, true);
await page.goto(gamePath('/play-audit'));
const notice = page.getByRole('status').filter({ hasText: '감사 이력에 누락된 구간' });
await expect(notice).toContainText('190년 4월');
await page.getByRole('link', { name: '장수', exact: true }).click();
await expect(notice).toBeVisible();
await page.evaluate(() => document.fonts.ready);
const geometry = await notice.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
viewport: innerWidth,
};
});
expect(geometry.x).toBeGreaterThanOrEqual(0);
expect(geometry.x + geometry.width).toBeLessThanOrEqual(width);
await writeFile(testInfo.outputPath('audit-gap-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('audit-gap.png'), fullPage: true });
});
}
for (const width of [390, 1280]) {
test(`NPC decision month navigation shows latest recorded month at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
await expect(page.getByText('가장 최근 결정이 수집된 월입니다.', { exact: false })).toContainText('190년 7월');
const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
await expect(panel).toContainText('190년 6월');
await panel.getByRole('button', { name: '다음 월', exact: true }).click();
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input)
.toMatchObject({ month: { year: 190, month: 7 } });
await expect(panel.getByRole('button', { name: '다음 월', exact: true })).toBeDisabled();
await panel.getByRole('button', { name: '이전 월', exact: true }).click();
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input)
.toMatchObject({ month: { year: 190, month: 6 } });
await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click();
await expect(page.getByText('가장 최근 결정이 수집된 월입니다.', { exact: false })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).not.toHaveProperty(
'month'
);
await capture(page, `npc-decision-latest-${width}`);
});
}
test('direct decision URL keeps the history panel open when changing month', async ({ page }) => {
await install(page);
await page.goto(gamePath(`/play-audit?tab=generals&general=1&decision=${decision.id}`));
const panel = page.getByRole('region', { name: 'NPC 결정 기록', exact: true });
await expect(panel.getByRole('list', { name: '판단 절차' })).toBeVisible();
await panel.getByRole('button', { name: '이전 월', exact: true }).click();
await expect(panel).toBeVisible();
await expect(panel.getByRole('region', { name: '선택 결정 상세' })).toHaveCount(0);
await panel.getByRole('button', { name: '최근 결정 조회', exact: true }).click();
await expect(panel).toContainText('가장 최근 결정이 수집된 월입니다.');
});

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