Author SHA1 Message Date
Hide_D 4e668b9fb6 fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다 2026-10-10 01:56:31 +09:00
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
333 changed files with 20619 additions and 4642 deletions
+13
View File
@@ -38,6 +38,15 @@ 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
# Browser origins default to GATEWAY_PUBLIC_URL (Game API also accepts GAME_PUBLIC_URL).
# Add exact HTTP(S) origins for a separate frontend; wildcards/null/path entries are rejected.
# Requests with a different Origin fail with 403 before mutation. No-Origin clients still need auth.
# API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
# GAME_PUBLIC_URL=http://localhost:14000
# 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 +112,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>;
+6 -1
View File
@@ -1,4 +1,4 @@
import { parseNumberWithFallback } from '@sammo-ts/common'; import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
const parseReconcileInterval = (value: string | undefined): number => { const parseReconcileInterval = (value: string | undefined): number => {
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS'); const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
export interface GameApiConfig { export interface GameApiConfig {
host: string; host: string;
corsAllowedOrigins: string[];
port: number; port: number;
trpcPath: string; trpcPath: string;
eventsPath: string; eventsPath: string;
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
return { return {
host: env.GAME_API_HOST ?? '0.0.0.0', host: env.GAME_API_HOST ?? '0.0.0.0',
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
env.GATEWAY_PUBLIC_URL,
env.GAME_PUBLIC_URL,
]),
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'), port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc', trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events', eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
+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(),
}; };
+50 -65
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic'; import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js'; import type { TournamentState } from '../../tournament/types.js';
@@ -18,6 +18,16 @@ import {
} from '../../services/redisClockFence.js'; } from '../../services/redisClockFence.js';
import { loadClockAdminStatus } from '../../services/clockReadiness.js'; import { loadClockAdminStatus } from '../../services/clockReadiness.js';
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
if (result?.type === 'commandRejected') {
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
}
if (!result || result.type !== 'tournamentAdjustGeneral') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
}
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
};
const hasAdminRole = (roles: string[], profileName: string): boolean => { const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true; return true;
@@ -197,6 +207,7 @@ const zMatch = z.object({
const zBetEntry = z.object({ const zBetEntry = z.object({
generalId: z.number().int().positive(), generalId: z.number().int().positive(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int().positive(), targetId: z.number().int().positive(),
amount: z.number().int().positive(), amount: z.number().int().positive(),
}); });
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
}); });
}), }),
getBettingSummary: authedProcedure.query(async ({ ctx }) => { getBettingSummary: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx); await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, entries, matches] = await Promise.all([ const [state, entries, matches] = await Promise.all([
store.getState(), store.getState(),
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
} }
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount; totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
totalAmount += entry.amount; totalAmount += entry.amount;
if (entry.generalId === general.id) { if (entry.userId === ctx.auth!.user.id) {
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount; myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
myAmount += entry.amount; myAmount += entry.amount;
} }
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState); const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({ const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'), requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentJoin', reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }], userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: -develCost,
minGoldAfter: 0,
}); });
if (!feeResult || feeResult.type !== 'adjustGeneralResources') { assertTournamentAdjustment(feeResult);
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!feeResult.ok || feeResult.processed !== 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
});
}
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0; const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
try { try {
await store.setParticipants(next); await store.setParticipants(next);
} catch (error) { } catch (error) {
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'), requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback', reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }], userId: ctx.auth!.user.id,
}); generalId: general.id,
goldDelta: develCost,
minGoldAfter: 0,
})
);
throw error; throw error;
} }
return { ok: true, count: next.length }; return { ok: true, count: next.length };
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
} }
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
if (entries.some((entry) => entry.userId === undefined)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
}
const previousBetAmount = entries const previousBetAmount = entries
.filter((entry) => entry.generalId === general.id) .filter((entry) => entry.userId === ctx.auth!.user.id)
.reduce((sum, entry) => sum + entry.amount, 0); .reduce((sum, entry) => sum + entry.amount, 0);
if (previousBetAmount + input.amount > 1_000) { if (previousBetAmount + input.amount > 1_000) {
throw new TRPCError({ throw new TRPCError({
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
} }
const adjustResult = await ctx.turnDaemon.requestCommand({ const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'), requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet', reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], userId: ctx.auth!.user.id,
});
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!adjustResult.ok || adjustResult.processed !== 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
});
}
const rankResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
reason: 'tournamentBet',
adjustments: [
{
generalId: general.id, generalId: general.id,
metaDelta: { betgold: input.amount }, goldDelta: -input.amount,
}, betGoldDelta: input.amount,
], minGoldAfter: 500,
}); });
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) { assertTournamentAdjustment(adjustResult);
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
});
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
}
try { try {
await store.appendBettingEntry({ await store.appendBettingEntry({
generalId: general.id, generalId: general.id,
userId: ctx.auth!.user.id,
targetId: input.targetId, targetId: input.targetId,
amount: input.amount, amount: input.amount,
}); });
} catch (error) { } catch (error) {
await Promise.all([ assertTournamentAdjustment(
ctx.turnDaemon.requestCommand({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'), requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }], userId: ctx.auth!.user.id,
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
generalId: general.id, generalId: general.id,
metaDelta: { betgold: -input.amount }, goldDelta: input.amount,
}, betGoldDelta: -input.amount,
], minGoldAfter: 0,
}), })
]); );
throw error; throw error;
} }
return { ok: true }; return { ok: true };
+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
+147 -72
View File
@@ -1,8 +1,10 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } 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) {
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
const resolveAuthFromToken = async ( const resolveAuthFromToken = async (
token: string | null, token: string | null,
accessTokenStore: RedisAccessTokenStore, accessTokenStore: RedisAccessTokenStore,
flushStore: FlushStore flushStore: FlushStore,
profileName: string
): Promise<GameSessionTokenPayload | null> => { ): Promise<GameSessionTokenPayload | null> => {
if (!token) { if (!token) {
return null; return null;
} }
const stored = await accessTokenStore.get(token); const stored = await accessTokenStore.get(token);
if (!stored) { if (!stored || stored.profile !== profileName) {
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 +110,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 +152,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 +179,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) => {
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
app.addHook('onClose', closeResources); app.addHook('onClose', closeResources);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, { await app.register(cors, {
origin: true, origin: config.corsAllowedOrigins,
credentials: true, credentials: true,
}); });
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
...trpcJsonBodyHttpServerOptions, ...trpcJsonBodyHttpServerOptions,
createContext: async ({ req }: { req: FastifyRequest }) => { createContext: async ({ req }: { req: FastifyRequest }) => {
const token = extractBearerToken(req.headers.authorization); const token = extractBearerToken(req.headers.authorization);
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key']) const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
? req.headers['idempotency-key'][0] ? req.headers['idempotency-key'][0]
: req.headers['idempotency-key']; : req.headers['idempotency-key'];
@@ -299,17 +335,68 @@ export const createGameApiServer = async () => {
}, },
}); });
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
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 +421,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 +439,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 +461,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}` };
} }
} }
+5 -1
View File
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
const zTournamentBet = z const zTournamentBet = z
.object({ .object({
generalId: z.number().int(), generalId: z.number().int(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int(), targetId: z.number().int(),
amount: z.number(), amount: z.number(),
}) })
@@ -286,7 +287,10 @@ export class TournamentStore {
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> { async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries(); const entries = await this.getBettingEntries();
const existing = entries.find( const existing = entries.find(
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId (candidate) =>
candidate.generalId === entry.generalId &&
candidate.targetId === entry.targetId &&
candidate.userId === entry.userId
); );
if (existing) { if (existing) {
existing.amount += entry.amount; existing.amount += entry.amount;
+2
View File
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
export interface TournamentBetEntry { export interface TournamentBetEntry {
generalId: number; generalId: number;
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
userId?: string | null;
targetId: number; targetId: number;
amount: number; amount: number;
} }
+18 -13
View File
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
for (const npc of npcBetList) { for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds); const targetId = rng.choice(candidateIds);
if (!existingBettors.has(npc.id as number)) { if (!existingBettors.has(npc.id as number)) {
entries.push({ generalId: npc.id as number, targetId, amount: betGold }); entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
} }
} }
return entries; return entries;
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
} }
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`; const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
await daemonTransport.sendCommand({ // Wait for one durable ENGINE result before publishing entries or opening stage 6.
type: 'adjustGeneralResources', // The engine skips NPCs whose owner changed after this deterministic plan was stored.
requestId: `${requestPrefix}:resources`, const result = await daemonTransport.requestCommand({
reason: 'tournamentNpcBet', type: 'tournamentSeedNpcBets',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })), requestId: `${requestPrefix}:atomic`,
}); bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
await daemonTransport.sendCommand({
type: 'adjustGeneralMeta',
requestId: `${requestPrefix}:meta`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
}); });
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
throw new Error('NPC tournament betting did not commit.');
}
const processed = new Set(result.processedGeneralIds);
const existing = await store.getBettingEntries(); const existing = await store.getBettingEntries();
const existingBettors = new Set(existing.map((entry) => entry.generalId)); const existingBettors = new Set(existing.map((entry) => entry.generalId));
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId)))); await store.setBettingEntries(
existing.concat(
plan
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
.map((entry) => ({ ...entry, userId: null }))
)
);
}; };
+3 -2
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,10 +21,10 @@ 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 || ctx.auth.profile !== ctx.profile.name) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'Unauthorized', message: 'Unauthorized',
+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())));
}
});
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
db, db,
generalAccessTracking: true, generalAccessTracking: true,
requestId: 'access-boundary-test', requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' }, profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource, profileStatusSource,
} as unknown as GameApiContext; } as unknown as GameApiContext;
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
auth: token, auth: token,
db: { $executeRaw: executeRaw }, db: { $executeRaw: executeRaw },
generalAccessTracking: true, generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' }, profile: { id: 'che', name: 'che:default', scenario: 'default' },
}) as unknown as GameApiContext; }) as unknown as GameApiContext;
const activityRouter = router({ const activityRouter = router({
@@ -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,23 +21,35 @@ 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,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: { db: {
general: { general: {
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 +121,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 +186,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([]);
});
@@ -36,6 +36,7 @@ type LogQuery = {
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) => const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
({ ({
auth, auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: { db: {
general: { general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({ findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
@@ -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);
});
});
+2 -2
View File
@@ -20,7 +20,7 @@ const profile: GameProfile = {
const auth: GameSessionTokenPayload = { const auth: GameSessionTokenPayload = {
version: 1, version: 1,
profile: 'che', profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z', issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z', expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session', sessionId: 'ranking-session',
@@ -239,7 +239,7 @@ const buildContext = (options?: {
turnDaemon: new InMemoryTurnDaemonTransport(), turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(), battleSim: new InMemoryBattleSimTransport(),
profile: selectedProfile, profile: selectedProfile,
auth: options?.authenticated === false ? null : auth, auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
uploadDir: 'uploads', uploadDir: 'uploads',
uploadPath: '/uploads', uploadPath: '/uploads',
uploadPublicUrl: null, uploadPublicUrl: null,
@@ -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';
@@ -68,6 +70,7 @@ const envKeys = [
'GATEWAY_REDIS_PREFIX', 'GATEWAY_REDIS_PREFIX',
'GATEWAY_INTERNAL_API_URL', 'GATEWAY_INTERNAL_API_URL',
'GAME_UPLOAD_DIR', 'GAME_UPLOAD_DIR',
'API_ALLOWED_ORIGINS',
] as const; ] as const;
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
@@ -172,7 +175,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 +419,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);
} }
@@ -640,6 +643,7 @@ integration('game API security over HTTP transport', () => {
process.env.GATEWAY_REDIS_PREFIX = redisPrefix; process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub(); process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
process.env.GAME_UPLOAD_DIR = uploadDir; process.env.GAME_UPLOAD_DIR = uploadDir;
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl }); const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
await connector.connect(); await connector.connect();
@@ -742,6 +746,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 +824,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 +2345,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 +2422,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 +2949,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 +3342,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 +3390,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 +3425,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 +3600,414 @@ 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('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
const token = await createAccessToken('origin-mutation', {});
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
origin: 'https://untrusted.example',
'content-type': 'application/json',
},
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(await accessTokenStore.get(token)).not.toBeNull();
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin,
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization,content-type',
},
});
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
expect(preflight.headers.get('access-control-allow-origin')).toBe(
origin === 'https://sam.hided.net' ? origin : null
);
}
await accessTokenStore.revoke(token);
});
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
const foreign = await accessTokenStore.create({
...buildPayload('trpc-foreign', {}),
profile: 'other:default',
});
if (!foreign) throw new Error('Foreign token fixture failed');
const before = await readReservedMutationState();
try {
const response = await fetch(
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
{
headers: { authorization: `Bearer ${foreign.accessToken}` },
}
);
expect(response.status).toBe(401);
expect(await readReservedMutationState()).toEqual(before);
} finally {
await accessTokenStore.revoke(foreign.accessToken);
}
});
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.
+99 -35
View File
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> { async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
this.commands.push(command); this.commands.push(command);
if (command.type === 'adjustGeneralResources') { if (command.type === 'tournamentAdjustGeneral') {
const adjustment = command.adjustments[0]; if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
if (!adjustment) { this.failNextRankUpdate = false;
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
} }
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0); const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
if (nextGold < (adjustment.minGoldAfter ?? 0)) { if (nextGold < command.minGoldAfter) {
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' }; return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
} }
this.gold.set(adjustment.generalId, nextGold); this.gold.set(command.generalId, nextGold);
return { return { type: command.type, ok: true, generalId: command.generalId };
type: 'adjustGeneralResources',
ok: true,
processed: 1,
missing: 0,
totalGoldDelta: adjustment.goldDelta ?? 0,
totalRiceDelta: 0,
};
} }
if (command.type === 'setMySetting') { if (command.type === 'setMySetting') {
return { type: 'setMySetting', ok: true, generalId: command.generalId }; return { type: 'setMySetting', ok: true, generalId: command.generalId };
} }
if (command.type === 'adjustGeneralMeta') {
if (this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
}
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
}
return null; return null;
} }
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800); expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
expect(transport.commands).toContainEqual( expect(transport.commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: 'http:tournament-join:tournamentJoin:resources', requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin', userId: 'user-1',
}) })
); );
expect(outerApiTransaction).not.toHaveBeenCalled(); expect(outerApiTransaction).not.toHaveBeenCalled();
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_936); expect(transport.gold.get(general.id)).toBe(1_936);
expect(transport.commands).toContainEqual({ expect(transport.commands).toContainEqual({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
userId: 'user-1',
reason: 'tournamentJoin', reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }], generalId: general.id,
goldDelta: -64,
minGoldAfter: 0,
}); });
}); });
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600); expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual( expect(transport.commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralResources', type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources', requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet', userId: 'user-1',
}) })
); );
expect(outerApiTransaction).not.toHaveBeenCalled(); expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual( expect(transport.commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralMeta', type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank', userId: 'user-1',
reason: 'tournamentBet', betGoldDelta: 600,
}) })
); );
}); });
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 800,
myTotals: { 12: 800 },
totalAmount: 1_800,
});
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 1_000,
myTotals: { 11: 200, 12: 800 },
});
expect(transport.gold.get(1)).toBe(2_800);
});
it('does not infer a legacy bet actor from the current general owner', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(1, 3_000);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
expect(transport.gold.get(1)).toBe(3_000);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => { it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
expect(snapshot.sourceRevision).toBe('41'); expect(snapshot.sourceRevision).toBe('41');
}); });
it('refunds gold when the tournament bet rank update fails', async () => { it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 2_000); const general = buildGeneral(1, 'user-1', 2_000);
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
); );
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({ await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR', code: 'BAD_REQUEST',
}); });
expect(transport.gold.get(general.id)).toBe(2_000); expect(transport.gold.get(general.id)).toBe(2_000);
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({ await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
+55 -30
View File
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
const createNoopDaemonTransport = (): TurnDaemonTransport => ({ const createNoopDaemonTransport = (): TurnDaemonTransport => ({
sendCommand: async () => 'ok', sendCommand: async () => 'ok',
requestCommand: async () => null, requestCommand: async (command) =>
command.type === 'tournamentSeedNpcBets'
? {
type: command.type,
ok: true,
processedGeneralIds: command.bets.map((bet) => bet.generalId),
skippedGeneralIds: [],
}
: null,
requestStatus: async () => null, requestStatus: async () => null,
}); });
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
const commands: TurnDaemonCommand[] = []; const commands: TurnDaemonCommand[] = [];
const daemonTransport: TurnDaemonTransport = { const daemonTransport: TurnDaemonTransport = {
...createNoopDaemonTransport(), ...createNoopDaemonTransport(),
sendCommand: async (command) => { requestCommand: async (command) => {
expect((await store.getState())?.stage).toBe(5); expect((await store.getState())?.stage).toBe(5);
commands.push(command); commands.push(command);
return 'ok'; return createNoopDaemonTransport().requestCommand(command);
}, },
}; };
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport); const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1); expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
expect(commands).toEqual([ expect(commands).toEqual([
{ {
type: 'adjustGeneralResources', type: 'tournamentSeedNpcBets',
requestId: 'tournament:123:npc-bet:resources', requestId: 'tournament:123:npc-bet:atomic',
reason: 'tournamentNpcBet', bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
},
{
type: 'adjustGeneralMeta',
requestId: 'tournament:123:npc-bet:meta',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
}, },
]); ]);
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport }); await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
expect(await store.getBettingEntries()).toEqual(bets); expect(await store.getBettingEntries()).toEqual(bets);
expect(commands).toHaveLength(2); expect(commands).toHaveLength(1);
// A fresh projection with the same seed and DB inputs has the same choices. // A fresh projection with the same seed and DB inputs has the same choices.
await store.setBettingEntries([bets[0]!]); await store.setBettingEntries([bets[0]!]);
await seedNpcBets({ await seedNpcBets({
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toEqual(bets); expect(await store.getBettingEntries()).toEqual(bets);
}); });
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
const state = createTournamentState({
stage: 5,
bettingId: 789,
npcBettingPlan: [
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
],
});
await store.setState(state);
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
requestCommand: async (command) => {
expect(command.type).toBe('tournamentSeedNpcBets');
expect((await store.getState())?.stage).toBe(5);
expect(await store.getBettingEntries()).toEqual([]);
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
},
};
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
expect(opened.stage).toBe(6);
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
});
it('retries an opening query failure without advancing stage or changing the betting identity', async () => { it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry')); const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
const state = createTournamentState({ stage: 5 }); const state = createTournamentState({ stage: 5 });
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toHaveLength(1); expect(await store.getBettingEntries()).toHaveLength(1);
}); });
it('reuses durable command identities after a partial enqueue failure', async () => { it('reuses durable command identities after an uncertain atomic command response', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry')); const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
const state = createTournamentState({ stage: 5, bettingId: 456 }); const state = createTournamentState({ stage: 5, bettingId: 456 });
await store.setState(state); await store.setState(state);
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
let fail = true; let fail = true;
const transport: TurnDaemonTransport = { const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(), ...createNoopDaemonTransport(),
sendCommand: async (command) => { requestCommand: async (command) => {
commands.push(command); commands.push(command);
if (command.type === 'adjustGeneralMeta' && fail) { if (command.type === 'tournamentSeedNpcBets' && fail) {
fail = false; fail = false;
// The accepted debit may already have committed before retry. // The accepted debit may already have committed before retry.
prisma.general.findMany = async () => []; prisma.general.findMany = async () => [];
throw new Error('enqueue unavailable'); throw new Error('enqueue unavailable');
} }
return 'ok'; return createNoopDaemonTransport().requestCommand(command);
}, },
}; };
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow( await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
); );
expect(await store.getState()).toMatchObject({ expect(await store.getState()).toMatchObject({
stage: 5, stage: 5,
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }], npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
}); });
expect(await store.getBettingEntries()).toEqual([]); expect(await store.getBettingEntries()).toEqual([]);
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport); await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
expect(commands.slice(2)).toEqual(commands.slice(0, 2)); expect(commands.slice(1)).toEqual(commands.slice(0, 1));
expect(commands.map((command) => command.requestId)).toEqual([ expect(commands.map((command) => command.requestId)).toEqual([
'tournament:456:npc-bet:resources', 'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:meta', 'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
]); ]);
expect(await store.getBettingEntries()).toHaveLength(1); expect(await store.getBettingEntries()).toHaveLength(1);
}); });
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
commands.push(command); commands.push(command);
return 'ok'; return 'ok';
}, },
requestCommand: async () => null, requestCommand: async (command) => {
commands.push(command);
return createNoopDaemonTransport().requestCommand(command);
},
requestStatus: async () => null, requestStatus: async () => null,
}; };
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10); expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
expect(commands).toContainEqual( expect(commands).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'adjustGeneralMeta', type: 'tournamentSeedNpcBets',
reason: 'tournamentNpcBet', bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
adjustments: expect.arrayContaining([
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
]),
}) })
); );
} }
@@ -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' },
}); });
}); });
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const claimCoordinate = await readInputEventClockCoordinate(transaction); const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({ const world = await transaction.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true }, select: {
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
clockTick: true,
meta: true,
},
}); });
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다. // 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다. // 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
OR (${postUnificationSurvey} AND "event_type" = 'voteReward') OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
OR ( OR (
${suspendedTournamentBetCommand} ${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback') AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
) )
OR ( OR (
@@ -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),
@@ -259,6 +269,21 @@ const zSetNpcPolicy = z.object({
]), ]),
}); });
const zTournamentAdjustGeneral = z.object({
type: z.literal('tournamentAdjustGeneral'),
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
userId: z.string().min(1),
generalId: zSafeInteger.positive(),
goldDelta: zFiniteNumber,
betGoldDelta: zFiniteNumber.optional(),
minGoldAfter: zFiniteNumber.nonnegative(),
});
const zTournamentSeedNpcBets = z.object({
type: z.literal('tournamentSeedNpcBets'),
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
});
const zAdjustGeneralResources = z.object({ const zAdjustGeneralResources = z.object({
type: z.literal('adjustGeneralResources'), type: z.literal('adjustGeneralResources'),
reason: z.string().optional(), reason: z.string().optional(),
@@ -666,6 +691,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) {
@@ -722,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
return { ...command, requestId: envelope.requestId }; return { ...command, requestId: envelope.requestId };
}; };
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
return command ? { ...command, requestId: envelope.requestId } : null;
};
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => { const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
const command = parseWith(zAdjustGeneralResources, envelope.command); const command = parseWith(zAdjustGeneralResources, envelope.command);
if (!command) { if (!command) {
@@ -883,12 +923,15 @@ 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,
voteReward: normalizeVoteReward, voteReward: normalizeVoteReward,
setNationSetting: normalizeSetNationSetting, setNationSetting: normalizeSetNationSetting,
setNpcPolicy: normalizeSetNpcPolicy, setNpcPolicy: normalizeSetNpcPolicy,
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
adjustGeneralResources: normalizeAdjustGeneralResources, adjustGeneralResources: normalizeAdjustGeneralResources,
adjustGeneralMeta: normalizeAdjustGeneralMeta, adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult, tournamentMatchResult: normalizeTournamentMatchResult,
+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,
@@ -174,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
}; };
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'tournamentAdjustGeneral',
'troopCreate', 'troopCreate',
'troopJoin', 'troopJoin',
'troopExit', 'troopExit',
@@ -187,6 +189,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'][];
@@ -625,11 +628,84 @@ async function handleSetNpcPolicy(
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt }); return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
} }
async function handleTournamentAdjustGeneral(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
): Promise<TurnDaemonCommandResult> {
const general = ctx.world.getGeneralById(command.generalId);
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
if (!general || general.userId !== command.userId) {
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
}
const gold = general.gold + command.goldDelta;
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
}
const meta = { ...general.meta };
if (command.betGoldDelta !== undefined) {
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
const betgold = current + command.betGoldDelta;
if (!Number.isFinite(betgold) || betgold < 0) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
}
meta.betgold = betgold;
}
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
ctx.world.updateGeneral(command.generalId, { gold, meta });
return { type: command.type, ok: true, generalId: command.generalId };
}
async function handleTournamentSeedNpcBets(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
): Promise<TurnDaemonCommandResult> {
if (ctx.commandDb) {
const event = command.requestId
? await ctx.commandDb.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { actorUserId: true, target: true, eventType: true },
})
: null;
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
}
}
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
}
const processedGeneralIds: number[] = [];
const skippedGeneralIds: number[] = [];
for (const bet of command.bets) {
const general = ctx.world.getGeneralById(bet.generalId);
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
skippedGeneralIds.push(bet.generalId);
continue;
}
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
ctx.world.updateGeneral(bet.generalId, {
gold: general.gold - bet.amount,
meta: { ...general.meta, betgold: current + bet.amount },
});
processedGeneralIds.push(bet.generalId);
}
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
}
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
reason !== undefined &&
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
reason
);
async function handleAdjustGeneralResources( async function handleAdjustGeneralResources(
ctx: CommandHandlerContext, ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world } = ctx; const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) { if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
} }
@@ -686,6 +762,9 @@ async function handleAdjustGeneralMeta(
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }> command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> { ): Promise<TurnDaemonCommandResult> {
const { world } = ctx; const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) { if (!command.adjustments || command.adjustments.length === 0) {
return { return {
type: 'adjustGeneralMeta', type: 'adjustGeneralMeta',
@@ -2587,6 +2666,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 +3323,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' }>),
@@ -3258,6 +3344,13 @@ export const createTurnDaemonCommandHandler = (options: {
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>), handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
setNpcPolicy: (command) => setNpcPolicy: (command) =>
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>), handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
tournamentAdjustGeneral: (command) =>
handleTournamentAdjustGeneral(
ctx,
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
),
tournamentSeedNpcBets: (command) =>
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
adjustGeneralResources: (command) => adjustGeneralResources: (command) =>
handleAdjustGeneralResources( handleAdjustGeneralResources(
ctx, ctx,
@@ -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,
}, },
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
{
type: 'tournamentAdjustGeneral',
reason: 'tournamentBet',
requestId: 'tournamentAdjustGeneral',
userId,
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' }, { type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 }, { type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 }, { type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
@@ -45,6 +55,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
); );
@@ -274,13 +274,22 @@ integration('database command queue', () => {
}); });
}); });
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => { it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
const requestId = 'integration:engine:stale-owner-replay'; 'stores a stale-owner %s rejection once and never redispatches it',
const command: TurnDaemonCommand = { async (type) => {
type: 'vacation', const requestId = `integration:engine:stale-owner-replay:${type}`;
const command: TurnDaemonCommand =
type === 'vacation'
? { type, requestId, userId: 'old-owner', generalId: 7 }
: {
type,
reason: 'tournamentBet',
requestId, requestId,
userId: 'old-owner', userId: 'old-owner',
generalId: 7, generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
}; };
await db.inputEvent.create({ await db.inputEvent.create({
data: { data: {
@@ -336,7 +345,8 @@ integration('database command queue', () => {
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]); await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce(); expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled(); expect(mutation).not.toHaveBeenCalled();
}); }
);
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])( it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'handles pre-opening user commands in %s without treating them as scheduled turns', 'handles pre-opening user commands in %s without treating them as scheduled turns',
@@ -630,6 +640,8 @@ integration('database command queue', () => {
const metaId = 'integration:engine:suspended-tournament-bet-meta'; const metaId = 'integration:engine:suspended-tournament-bet-meta';
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback'; const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
const unrelatedId = 'integration:engine:suspended-resource-adjustment'; const unrelatedId = 'integration:engine:suspended-resource-adjustment';
const atomicBetId = 'integration:engine:suspended-atomic-bet';
const atomicJoinId = 'integration:engine:suspended-atomic-join';
await db.inputEvent.createMany({ await db.inputEvent.createMany({
data: [ data: [
{ {
@@ -679,6 +691,41 @@ integration('database command queue', () => {
], ],
}); });
await db.inputEvent.createMany({
data: [
{
requestId: atomicBetId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentBet',
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
},
},
{
requestId: atomicJoinId,
target: 'ENGINE',
eventType: 'tournamentAdjustGeneral',
actorUserId: 'user-7',
payload: {
type: 'tournamentAdjustGeneral',
requestId: atomicJoinId,
userId: 'user-7',
generalId: 7,
reason: 'tournamentJoin',
goldDelta: -200,
minGoldAfter: 0,
},
},
],
});
const queue = new DatabaseTurnDaemonCommandQueue(db); const queue = new DatabaseTurnDaemonCommandQueue(db);
await expect(queue.drain()).resolves.toEqual([ await expect(queue.drain()).resolves.toEqual([
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }), expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
@@ -688,7 +735,15 @@ integration('database command queue', () => {
requestId: rollbackId, requestId: rollbackId,
reason: 'tournamentBetRollback', reason: 'tournamentBetRollback',
}), }),
expect.objectContaining({
type: 'tournamentAdjustGeneral',
requestId: atomicBetId,
reason: 'tournamentBet',
}),
]); ]);
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
status: 'PENDING',
});
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({ await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
status: 'PROCESSING', status: 'PROCESSING',
processingGameTick: 321n, processingGameTick: 321n,
@@ -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,
}); });
}); });
@@ -79,6 +79,83 @@ const buildWorld = (
return new InMemoryTurnWorld(state, snapshot, { schedule }); return new InMemoryTurnWorld(state, snapshot, { schedule });
}; };
describe('tournament mutation ownership', () => {
it('checks the current owner and commits gold plus bet statistics together', async () => {
const general = { ...buildGeneral(1), userId: 'owner' };
const world = buildWorld([general]);
const handler = createTurnDaemonCommandHandler({ world });
const command = {
type: 'tournamentAdjustGeneral' as const,
reason: 'tournamentBet' as const,
userId: 'owner',
generalId: 1,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await expect(handler.handle(command)).resolves.toEqual({ type: command.type, ok: true, generalId: 1 });
expect(world.getGeneralById(1)).toMatchObject({ gold: 900, meta: { betgold: 100 } });
const before = structuredClone(world.getGeneralById(1));
await expect(handler.handle({ ...command, goldDelta: -500 })).resolves.toMatchObject({
ok: false,
code: 'BAD_REQUEST',
});
expect(world.getGeneralById(1)).toEqual(before);
world.updateGeneral(1, { userId: 'new-owner' });
const transferred = structuredClone(world.getGeneralById(1));
for (const goldDelta of [-100, 100]) {
await expect(handler.handle({ ...command, goldDelta })).resolves.toMatchObject({
ok: false,
code: 'FORBIDDEN',
});
expect(world.getGeneralById(1)).toEqual(transferred);
}
});
it('skips possessed NPCs and commits only eligible NPC fees and statistics', async () => {
const world = buildWorld([
{ ...buildGeneral(1, {}, 1), userId: 'new-owner' },
buildGeneral(2, {}, 2),
{ ...buildGeneral(3, {}, 2), gold: 509 },
]);
const handler = createTurnDaemonCommandHandler({ world });
const possessed = structuredClone(world.getGeneralById(1));
await expect(
handler.handle({
type: 'tournamentSeedNpcBets',
bets: [1, 2, 3].map((generalId) => ({ generalId, amount: 10 })),
})
).resolves.toEqual({
type: 'tournamentSeedNpcBets',
ok: true,
processedGeneralIds: [2],
skippedGeneralIds: [1, 3],
});
expect(world.getGeneralById(1)).toEqual(possessed);
expect(world.getGeneralById(2)).toMatchObject({ gold: 990, meta: { betgold: 10 } });
expect(world.getGeneralById(3)).toMatchObject({ gold: 509, meta: { killturn: 24 } });
});
it.each(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'])(
'rejects actorless legacy %s commands before mutation',
async (reason) => {
const world = buildWorld([{ ...buildGeneral(1), userId: 'new-owner' }]);
const before = structuredClone(world.getGeneralById(1));
const handler = createTurnDaemonCommandHandler({ world });
for (const command of [
{ type: 'adjustGeneralResources' as const, reason, adjustments: [{ generalId: 1, goldDelta: -100 }] },
{
type: 'adjustGeneralMeta' as const,
reason,
adjustments: [{ generalId: 1, metaDelta: { betgold: 100 } }],
},
])
await expect(handler.handle(command)).resolves.toMatchObject({ ok: false });
expect(world.getGeneralById(1)).toEqual(before);
}
);
});
describe('tournament world commands', () => { describe('tournament world commands', () => {
it('uses the current world develcost for Ref-compatible tournament prizes', () => { it('uses the current world develcost for Ref-compatible tournament prizes', () => {
const currentWorld = buildWorld([], { const currentWorld = buildWorld([], {
@@ -187,7 +264,7 @@ describe('tournament world commands', () => {
}); });
it('records all four tournament types and NPC betting for at least ten generals', async () => { it('records all four tournament types and NPC betting for at least ten generals', async () => {
const generals = Array.from({ length: 12 }, (_, index) => buildGeneral(index + 1)); const generals = Array.from({ length: 12 }, (_, index) => ({ ...buildGeneral(index + 1, {}, 2), gold: 2_000 }));
const world = buildWorld(generals); const world = buildWorld(generals);
const handler = createTurnDaemonCommandHandler({ world }); const handler = createTurnDaemonCommandHandler({ world });
@@ -204,13 +281,11 @@ describe('tournament world commands', () => {
} }
} }
await handler.handle({ await handler.handle({
type: 'adjustGeneralMeta', type: 'tournamentSeedNpcBets',
reason: 'tournamentNpcBet', bets: generals.map((general) => ({ generalId: general.id, amount: 1_000 })),
adjustments: generals.map((general) => ({
generalId: general.id,
metaDelta: { betgold: 1_000 },
})),
}); });
// Ref의 betwin 통계 대상은 npcState <= 1이다. 개방 시 NPC 비용 검증과 분리한다.
for (const general of generals) world.updateGeneral(general.id, { npcState: 0 });
await handler.handle({ await handler.handle({
type: 'tournamentBettingPayout', type: 'tournamentBettingPayout',
bettingId: 1, bettingId: 1,
@@ -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('포상');

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