Author SHA1 Message Date
Hide_D b3f7a419a9 운영 보안을 보존하여 빌드 개선 배포본 통합 2026-10-23 01:33:42 +09:00
Hide_D af51968782 빌드 선언 생성 병목과 Vue 중복 타입 검사를 개선 2026-10-23 01:33:34 +09:00
Hide_D b377250fb9 fix: 첫 빌드 전 닫힌 서버도 패치 정책으로 오픈 요청을 받는다 2026-10-23 00:42:11 +09:00
Hide_D d4a87c981f fix: 오픈 요청 화면의 복사 전용 안내를 실제 등록 흐름에 맞춘다 2026-10-23 00:35:54 +09:00
Hide_D e42eaa4e58 fix: 오픈 요청 관계의 갱신 규칙을 migration 제약과 맞춘다 2026-10-23 00:23:33 +09:00
Hide_D 2662091a6a merge: 최신 main의 NPC 정책과 운영 보완을 오픈 요청에 통합한다 2026-10-23 00:18:45 +09:00
Hide_D 236f1bc4ed feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다 2026-10-23 00:18:45 +09:00
Hide_D 8e5bd94cb8 NPC 사령턴에서 비활성 발령 절차를 건너뛰는지 검증한다 2026-10-23 00:01:51 +09:00
Hide_D e5a39cb714 최신 main의 수비 설정 수정을 NPC 정책 작업에 통합한다 2026-10-22 23:50:35 +09:00
Hide_D 039c5d2a59 NPC 정책 적용을 검증하고 자국 변경 이력 조회를 제공한다 2026-10-22 23:50:35 +09:00
Hide_D 856187b315 수비끔 전환에서 전투 훈련·사기 상한을 보존한다 2026-10-22 23:11:52 +09:00
Hide_D b67924a71d chore: Node.js 24.22.1 보안 릴리스로 개발 버전을 고정한다 2026-10-22 17:18:48 +09:00
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
381 changed files with 24493 additions and 4912 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
WEB_PUSH_OUTBOX_POLL_MS=1000
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_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
@@ -103,3 +112,7 @@ VITE_CASUAL_CHAT_URL=
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
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=
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.1
+8 -1
View File
@@ -126,6 +126,10 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m
분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와
검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로
`build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요.
- 짧은 제목·상태·버튼·정보 셀은 가로와 세로 정렬을 각각 결정합니다. 가로 가운데만
지정하고 끝내지 말고, 왼쪽/오른쪽 정렬 값도 세로 가운데가 적절한지 확인합니다.
여러 줄 본문·로그는 읽기 시작점을 보존하고, 좁은 화면의 줄바꿈과 overflow를
검증합니다. 구현 방법과 예외는 [두 축 텍스트 정렬](docs/frontend-css-architecture.md#text-alignment-on-both-axes-2026-09-29)을 따릅니다.
- 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`,
`.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요.
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
@@ -200,10 +204,13 @@ HTTPS 검증과 구분합니다.
## 코드 스타일
- Vite, Vue/Volar와 compiler API 소비자는 workspace 전체에서 정확히
- Vite, ESLint와 일반 compiler API 소비자는 workspace 전체에서 정확히
TypeScript `6.0.3`을 사용해 주세요. CLI project build/typecheck는 root의
`@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local
다른 compiler version이나 직접 `tsc` 호출을 추가하지 말아 주세요.
Vue CLI만 `tools/vue-typecheck.mjs`로 고정한
`typescript-native-bridge@6.0.3-bridge.18.tsgo.7.0.2`를 사용합니다. 이 예외는
Vue 타입 검사에 한하며 workspace `typescript` override를 교체하지 않습니다.
- TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요.
- public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`,
`as unknown as` 우회를 피해 주세요.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+11 -2
View File
@@ -5,6 +5,13 @@
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
작업 규칙과 분야별 지침의 시작점은 [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)
## 저장소 구성
| 경로 | 책임 |
@@ -80,8 +87,9 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는
## 도구 체인
- Node.js `24.21.0`, pnpm `12.5.1`, Turbo
- Node.js `24.22.1`, pnpm `12.5.1`, Turbo
- TypeScript `6.0.3` compiler API와 TypeScript `7.0.2` native `tsc`
- Vue CLI 전용 `typescript-native-bridge` (`6.0.3-bridge.18.tsgo.7.0.2`)
- Fastify, tRPC, zod
- Vue 3, Pinia, Vue Router, Vite
- PostgreSQL, Prisma, Redis
@@ -92,7 +100,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
식별합니다. 선언 파일 생성·Vue 검사 범위와 캐시 계약은 [빌드 도구](docs/build-performance.md)를
확인해 주세요. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용
toast로 한 번 안내합니다.
+3 -3
View File
@@ -40,10 +40,10 @@
"@trpc/server": "^11.8.1",
"date-fns": "^4.1.0",
"es-toolkit": "^1.43.0",
"fastify": "^5.6.2",
"fastify": "^5.12.5",
"redis": "^5.10.0",
"sanitize-html": "2.17.6",
"sharp": "^0.35.0",
"sanitize-html": "2.17.7",
"sharp": "^0.35.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 {
userId: string;
flushedAt: string;
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
}
export interface FlushStore {
getFlushedAt(userId: string): Date | null;
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
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 {
private readonly client: {
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 parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
export interface GameApiConfig {
host: string;
corsAllowedOrigins: string[];
port: number;
trpcPath: string;
eventsPath: string;
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
return {
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'),
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
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> => {
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
export const loadMapLayoutByName = async (
mapName: string,
options: MapLayoutLoaderOptions = {}
): Promise<MapLayout> => {
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
const cached = useCache ? layoutCache.get(mapName) : undefined;
if (cached) {
@@ -98,3 +100,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp
}
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 { 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 { asRecord, JosaUtil } from '@sammo-ts/common';
@@ -104,6 +109,7 @@ const persistEffects = async (
}
): Promise<void> => {
const changes: AuditDiplomacyEventDraft[] = [];
let collectionFailed = false;
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
state: row.stateCode,
@@ -129,6 +135,7 @@ const persistEffects = async (
},
});
if (audit) {
try {
const key = `${effect.srcNationId}:${effect.destNationId}`;
const previous = states.get(key);
if (!previous) throw new Error('Missing locked diplomacy audit state');
@@ -145,6 +152,9 @@ const persistEffects = async (
});
}
states.set(key, updated);
} catch {
collectionFailed = true;
}
}
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
const patch = effect.patch;
@@ -159,7 +169,11 @@ const persistEffects = async (
}
}
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[]> => {
@@ -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.',
});
}
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) {
throw new TRPCError({
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 { z } from 'zod';
import { randomBytes } from 'crypto';
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
import { getMyGeneral } from '../shared/general.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_LONG_EDGE = 2048;
const WEBP_QUALITY = 80;
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
buffer: Buffer,
{ animated, resize }: { animated: boolean; 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) {
pipeline = pipeline.resize({
width: MAX_LONG_EDGE,
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
...(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> => {
let pipeline = sharp(buffer, { animated: true });
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) {
pipeline = pipeline.resize({
width: MAX_LONG_EDGE,
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
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({
@@ -213,16 +232,39 @@ export const boardRouter = router({
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);
assertBoardAccess(permission, true);
try {
return await boardImageBudget.run(async () => {
const buffer = parseDataUrl(input.dataUrl);
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) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
}
@@ -249,7 +291,11 @@ export const boardRouter = router({
animated: isAnimated || format === 'gif',
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;
outputFormat = 'webp';
} else {
@@ -259,7 +305,10 @@ export const boardRouter = router({
}
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 uploaded = await ctx.contentImageUpload.upload({
@@ -268,7 +317,9 @@ export const boardRouter = router({
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 {
url: uploaded.publicUrl,
@@ -278,5 +329,19 @@ export const boardRouter = router({
animated: isAnimated,
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 { 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 {
@@ -989,7 +989,7 @@ export const generalRouter = router({
const now = new Date();
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({
where: {
lastActionAt: {
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
},
}),
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
// logical clock advances through negative ticks before the opening anchor,
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
return {
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,
onlineNations,
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 { appoint } from './endpoints/appoint.js';
import { changePermission } from './endpoints/changePermission.js';
@@ -25,6 +26,8 @@ export const nationRouter = router({
getSecretGeneralList,
getCityOverview,
getPersonnelInfo,
getCityOfficeRequests,
cityOfficeRequest,
getStratFinan,
setNotice,
setScoutMsg,
+91
View File
@@ -0,0 +1,91 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { authedProcedure } from '../../trpc.js';
import { getAuthenticatedUserId } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
import { projectPolicyConfiguration, projectPolicySummary } from '../playAudit/policies.js';
import { readAudit, readAuditWorld } from '../playAudit/shared.js';
const area = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY']);
const summarySelect = {
id: true,
schemaVersion: true,
nationId: true,
area: true,
revision: true,
previousId: true,
source: true,
year: true,
month: true,
actor: true,
createdAt: true,
} satisfies GamePrisma.PlayAuditPolicySelect;
// 소속·기밀 권한과 이력을 같은 snapshot에서 읽어 이적/권한 변경 경합을 막는다.
const readNationId = async (ctx: GameApiContext, tx: GamePrisma.TransactionClient): Promise<number> => {
const general = await tx.general.findFirst({ where: { userId: getAuthenticatedUserId(ctx) } });
if (!general) throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
if (general.nationId <= 0)
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' });
const nation = await tx.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } });
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
if (resolveSecretPermission(general, nation.meta) < 1)
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
return general.nationId;
};
export const policyHistory = authedProcedure
.input(
z
.object({
area,
cursor: z.number().int().positive().optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.strict()
)
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const nationId = await readNationId(ctx, tx);
const world = await readAuditWorld(tx);
const rows = world.serverId
? await tx.playAuditPolicy.findMany({
where: {
serverId: world.serverId,
nationId,
area: input.area,
revision: input.cursor === undefined ? undefined : { lt: input.cursor },
},
orderBy: { revision: 'desc' },
take: input.limit + 1,
select: summarySelect,
})
: [];
return {
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
historyGap: world.historyGap,
items: rows.slice(0, input.limit).map(projectPolicySummary),
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
};
})
);
export const policyVersion = authedProcedure
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const nationId = await readNationId(ctx, tx);
const world = await readAuditWorld(tx);
const row = world.serverId
? await tx.playAuditPolicy.findFirst({
where: { id: input.id, serverId: world.serverId, nationId, area: { in: area.options } },
select: { ...summarySelect, before: true, after: true },
})
: null;
if (!row)
throw new TRPCError({ code: 'NOT_FOUND', message: '자국의 현재 기수 정책 기록을 찾을 수 없습니다.' });
return projectPolicyConfiguration(row);
})
);
+3
View File
@@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit
import type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
import { policyHistory, policyVersion } from './history.js';
type SetterInfo = {
setter: string | null;
@@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async (
};
export const npcRouter = router({
policyHistory,
policyVersion,
getPolicy: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
@@ -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 }) =>
readAudit(ctx, async (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);
if (
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
@@ -206,6 +234,8 @@ export const decisionHistory = auditProcedure
return {
...world,
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),
items: rows.slice(0, input.limit).map(project),
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 { decisionHistory, decisionDetail } from './decisions.js';
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
@@ -27,6 +30,7 @@ import {
} from './projection.js';
export const playAuditRouter = router({
cityMap,
requestState,
decisionHistory,
decisionDetail,
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
name: z.string().trim().max(64).optional(),
order: z.enum(['asc', 'desc']).default('asc'),
sort: zGeneralSort.default('id'),
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
})
)
.query(({ ctx, input }) =>
readAudit(ctx, async (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 =
input.population === 'human'
? { lt: 2 }
@@ -1,3 +1,5 @@
import { GamePrisma } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
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 zPopulation = z.object({
count: z.number().int().nonnegative(),
crew: z.number().nonnegative().nullable().default(null),
gold: z.number(),
rice: z.number(),
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 });
export const nationSeries = auditProcedure
.input(
@@ -151,6 +182,35 @@ export const nationSeries = auditProcedure
})
: [];
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 items: ReturnType<typeof summarizeNationPeriod>[] = [];
for (let period = start; period <= end; period += width) {
@@ -166,6 +226,17 @@ export const nationSeries = auditProcedure
}
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,
startMonth,
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,
asOf: new Date().toISOString(),
};
+50 -65
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
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 { TournamentState } from '../../tournament/types.js';
@@ -18,6 +18,16 @@ import {
} from '../../services/redisClockFence.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 => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true;
@@ -197,6 +207,7 @@ const zMatch = z.object({
const zBetEntry = z.object({
generalId: z.number().int().positive(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int().positive(),
amount: z.number().int().positive(),
});
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
});
}),
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, entries, matches] = await Promise.all([
store.getState(),
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
}
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + 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;
myAmount += entry.amount;
}
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
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') {
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,
});
}
assertTournamentAdjustment(feeResult);
const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
try {
await store.setParticipants(next);
} catch (error) {
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
});
userId: ctx.auth!.user.id,
generalId: general.id,
goldDelta: develCost,
minGoldAfter: 0,
})
);
throw error;
}
return { ok: true, count: next.length };
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
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
.filter((entry) => entry.generalId === general.id)
.filter((entry) => entry.userId === ctx.auth!.user.id)
.reduce((sum, entry) => sum + entry.amount, 0);
if (previousBetAmount + input.amount > 1_000) {
throw new TRPCError({
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
}
const adjustResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
reason: 'tournamentBet',
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
});
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: [
{
userId: ctx.auth!.user.id,
generalId: general.id,
metaDelta: { betgold: input.amount },
},
],
goldDelta: -input.amount,
betGoldDelta: input.amount,
minGoldAfter: 500,
});
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
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: '베팅 기록을 저장하지 못했습니다.' });
}
assertTournamentAdjustment(adjustResult);
try {
await store.appendBettingEntry({
generalId: general.id,
userId: ctx.auth!.user.id,
targetId: input.targetId,
amount: input.amount,
});
} catch (error) {
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
assertTournamentAdjustment(
await ctx.turnDaemon.requestCommand({
type: 'tournamentAdjustGeneral',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
reason: 'tournamentBetRollback',
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
}),
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralMeta',
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
reason: 'tournamentBetRollback',
adjustments: [
{
userId: ctx.auth!.user.id,
generalId: general.id,
metaDelta: { betgold: -input.amount },
},
],
}),
]);
goldDelta: input.amount,
betGoldDelta: -input.amount,
minGoldAfter: 0,
})
);
throw error;
}
return { ok: true };
+22
View File
@@ -11,6 +11,7 @@ import {
buildRecruitmentCommandInfo,
buildTurnCommandTable,
evaluateReservedTurnPermission,
resolveCommandJoinEnv,
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
@@ -45,7 +46,10 @@ import {
buildRefAmountPresets,
buildRefGeneralTargetOptions,
buildRefNationTargetOptions,
COUNTER_STRATEGY_COMMAND_KEYS,
} from '../../turns/commandTargets.js';
import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js';
import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js';
const zPushAmount = z
.number()
@@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
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({
actorNationId: general.nationId,
join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) },
strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined),
nations: nations.map((entry) => {
const relation = diplomacyByNation.get(entry.id);
const meta = asRecord(entry.meta);
const gennum = readGeneralMetaNumber(meta, 'gennum');
return {
id: entry.id,
name: entry.name,
@@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
diplomacyTerm: relation?.term ?? 0,
adjacent: adjacentNationIds.has(entry.id),
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,
},
});
const dexterityByArmType: Record<string, number> = {};
const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({
value: entry.id,
@@ -406,6 +424,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
})),
nations: nationTargetOptions.nations,
nationTargets: nationTargetOptions.nationTargets,
nationScoutMessages: nationTargetOptions.nationScoutMessages,
strategyCooldowns,
generals: generalTargetOptions.generals,
generalTargets: generalTargetOptions.generalTargets,
crewTypes: (environment.unitSet.crewTypes ?? [])
@@ -413,6 +433,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
.map((entry) => ({ value: entry.id, label: entry.name })),
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
if (dexterity !== null) dexterityByArmType[value] = dexterity;
return {
value: Number(value),
label,
@@ -449,6 +470,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
actorRice: general.rice,
...(city ? { citySecurity: city.security } : {}),
...(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 type { GameApiContext } from '../../context.js';
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import {
formatGeneralAccessLimitMessage,
@@ -339,11 +340,23 @@ export const yearbookRouter = router({
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
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 {
firstYearMonth,
lastYearMonth,
currentYearMonth: selectedYearMonth,
mapLayout,
};
}),
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 cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'node:fs/promises';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
buildGameEventChannel,
@@ -21,7 +23,7 @@ import {
import { resolveGameApiConfigFromEnv } from './config.js';
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.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 {
consumeRealtimeAccessGrantHeader,
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.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 {
shouldForwardRealtimeEvent,
@@ -51,7 +56,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.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 => {
if (!value) {
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
const resolveAuthFromToken = async (
token: string | null,
accessTokenStore: RedisAccessTokenStore,
flushStore: FlushStore
flushStore: FlushStore,
profileName: string
): Promise<GameSessionTokenPayload | null> => {
if (!token) {
return null;
}
const stored = await accessTokenStore.get(token);
if (!stored) {
if (!stored || stored.profile !== profileName) {
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) {
return null;
}
@@ -99,11 +110,18 @@ export const createGameApiServer = async () => {
imageUploadSecret
);
const app = fastify({
logger: true,
logger: safeHttpLoggerOptions,
routerOptions: {
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 redis = createRedisConnector(resolveRedisConfigFromEnv());
@@ -134,7 +152,18 @@ export const createGameApiServer = async () => {
requestTimeoutMs: config.battleSimRequestTimeoutMs,
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();
try {
await flushSubscriberClient.connect();
@@ -150,6 +179,12 @@ export const createGameApiServer = async () => {
config.flushChannel,
flushStore,
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)]);
},
(error, event) => {
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
app.addHook('onClose', closeResources);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, {
origin: true,
origin: config.corsAllowedOrigins,
credentials: true,
});
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
...trpcJsonBodyHttpServerOptions,
createContext: async ({ req }: { req: FastifyRequest }) => {
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'])
? req.headers['idempotency-key'][0]
: 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) => {
const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization);
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
if (!auth) {
if (query.token !== undefined || !tokenFromHeader) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
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 general = await postgres.prisma.general.findFirst({
@@ -334,6 +421,7 @@ export const createGameApiServer = async () => {
};
};
let viewerIdentity = await loadViewerIdentity();
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
reply.hijack();
const requestOrigin = request.headers.origin;
@@ -351,28 +439,20 @@ export const createGameApiServer = async () => {
reply.raw.setTimeout?.(0);
reply.raw.flushHeaders?.();
const sendFrame = (payload: string) => {
try {
reply.raw.write(payload);
} catch {
return;
}
};
sendFrame(
formatSseFrame({
event: 'ready',
data: '{}',
})
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
resolveRealtimeAuth(tokenFromHeader)
);
let closed = false;
let eventQueue = Promise.resolve();
realtimeConnections.add(connection);
connection.onClose(() => {
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) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
eventQueue = eventQueue
.then(async () => {
if (closed) return;
connection.enqueue(async (currentAuth) => {
const identities = [viewerIdentity];
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
const nextIdentity = await loadViewerIdentity();
@@ -381,69 +461,64 @@ export const createGameApiServer = async () => {
}
let refreshGrant: string | undefined;
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
refreshGrant ??= createRealtimeAccessGrant(
currentAuth,
config.profileName,
config.gameTokenSecret
);
return refreshGrant;
});
if (!publicEvent || closed) return;
if (!publicEvent || connection.closed) return null;
if (refreshGrant) {
try {
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
} catch {
// Preserve the invalidation. An unregistered grant safely falls back
// to the normal scored refresh path.
// An unregistered grant falls back to the scored refresh path.
}
}
sendFrame(
formatSseFrame({
event: publicEvent.type,
data: JSON.stringify(publicEvent),
})
);
})
.catch(() => {
// A best-effort notification must not affect committed game state.
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
});
});
connection.onClose(unsubscribe);
let heartbeatPending = false;
const heartbeat = setInterval(() => {
if (heartbeatPending) return;
if (heartbeatPending || connection.closed) return;
heartbeatPending = true;
void turnEngineStatus
.get()
.then((turnEngineRunning) => {
if (closed) return;
sendFrame(
formatSseFrame({
connection.enqueue(async () => {
try {
return formatSseFrame({
event: 'ping',
data: JSON.stringify({ turnEngineRunning }),
})
);
})
.finally(() => {
heartbeatPending = false;
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
});
}, 15000);
const close = () => {
closed = true;
} finally {
heartbeatPending = false;
}
});
}, 15_000);
connection.onClose(() => {
clearInterval(heartbeat);
unsubscribe();
};
request.raw.on('close', close);
request.raw.on('aborted', close);
});
request.raw.once('aborted', connection.close);
connection.onClose(() => {
request.raw.off('aborted', connection.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) => {
const clock = await loadClockReadiness(postgres.prisma);
if (!clock.reconciliationComplete) reply.code(503);
const readiness = await loadProfileReadiness(postgres.prisma, config.profileName);
if (!readiness.ok) reply.code(503);
return {
ok: clock.reconciliationComplete,
...readiness,
profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
clock,
};
});
@@ -1,4 +1,5 @@
import { parseGameClockPhase } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
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,
hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents,
withPlayAuditSavepoint,
projectAuditDocumentState,
readTurnRuntimeReady,
type AuditDiplomacyEventDraft,
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
};
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
let collectionFailed = false;
const changes: {
letter: Letter;
before: Record<string, unknown> | null;
@@ -92,15 +94,21 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
return {
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
if (!ctx.auditInput) return;
try {
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
} catch {
collectionFailed = true;
}
},
flush: async (): Promise<void> => {
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
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)
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;
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
schemaVersion: 1,
@@ -133,7 +141,8 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
before: change.before,
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 {
publicUrl: string;
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid content image filename.');
}
const pathname = `/v1/uploads/content/core2026/${input.filename}`;
const expires = String(Math.floor(this.now() / 1000) + 60);
const requestId = randomUUID();
const digest = createHash('sha256').update(input.body).digest('hex');
const signature = createHmac('sha256', this.secret)
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
.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,
await performSignedImageUpload(
{
...input,
kind: 'content',
baseUrl: this.baseUrl,
secret: this.secret,
},
body: new Uint8Array(input.body),
});
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.');
}
this.fetchImpl,
this.now
);
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
}
}
+5 -1
View File
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
const zTournamentBet = z
.object({
generalId: z.number().int(),
userId: z.string().min(1).nullable().optional(),
targetId: z.number().int(),
amount: z.number(),
})
@@ -286,7 +287,10 @@ export class TournamentStore {
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries();
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) {
existing.amount += entry.amount;
+2
View File
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
export interface TournamentBetEntry {
generalId: number;
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
userId?: string | null;
targetId: number;
amount: number;
}
+18 -13
View File
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds);
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;
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
}
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
await daemonTransport.sendCommand({
type: 'adjustGeneralResources',
requestId: `${requestPrefix}:resources`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
});
await daemonTransport.sendCommand({
type: 'adjustGeneralMeta',
requestId: `${requestPrefix}:meta`,
reason: 'tournamentNpcBet',
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
const result = await daemonTransport.requestCommand({
type: 'tournamentSeedNpcBets',
requestId: `${requestPrefix}:atomic`,
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: 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 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 { initTRPC, TRPCError } from '@trpc/server';
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
@@ -20,10 +21,10 @@ import {
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.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 }) => {
if (!ctx.auth) {
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
+31 -7
View File
@@ -36,6 +36,8 @@ export interface TurnCommandOption {
crew?: number;
troopId?: number;
npcState?: number;
/** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */
nationId?: number;
}
export interface TurnCommandAmountPreset {
@@ -97,10 +99,21 @@ export interface TurnCommandInputField {
tupleLabels?: string[];
}
/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */
export interface TurnCommandNationScoutMessage {
nationId: number;
name: string;
color: string;
message: string;
}
export interface TurnCommandInputOptions {
cities: TurnCommandOption[];
nations: TurnCommandOption[];
nationTargets?: Record<string, TurnCommandOption[]>;
nationScoutMessages?: TurnCommandNationScoutMessage[];
/** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */
strategyCooldowns?: Record<string, number>;
generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[];
@@ -117,6 +130,8 @@ export interface TurnCommandInputOptions {
nationGold?: number;
nationRice?: number;
nationLevel?: number;
/** 숙련전환 전/후 미리보기용 본인 병과별 숙련(`dex<armType>`). */
dexterity?: Record<string, number>;
};
}
@@ -151,6 +166,8 @@ export const buildEquipmentTradeItemOptions = (options: {
{
value: 'None',
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 붉은색 `(불가)`로 표시한다.
...(ownedItem ? {} : { availableNow: false }),
description: ownedItem
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
: ownedCode && ownedCode !== 'None'
@@ -177,6 +194,7 @@ export const buildEquipmentTradeItemOptions = (options: {
items[item.slot].push({
value: item.key,
label: item.name,
availableNow: options.currentSecurity >= item.reqSecu && options.generalGold >= cost,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
@@ -229,8 +247,9 @@ const FIELD_LABELS: Record<string, string> = {
nationName: '국가명',
nationType: '국가 성향',
colorType: '국기 색상',
srcArmType: '기존 병과',
destArmType: '변경 병과',
// 숙련전환 전용. Ref che_숙련전환.vue의 '감소 대상 숙련 :'·'전환 대상 숙련 :'.
srcArmType: '감소 대상 숙련',
destArmType: '전환 대상 숙련',
itemType: '장비 종류',
itemCode: '장비',
crewType: '병종',
@@ -240,7 +259,8 @@ const FIELD_LABELS: Record<string, string> = {
optionText: '행동',
year: '기간(년)',
month: '기간(월)',
commandType: '대응 명령',
// 피장파장 전용. Ref che_피장파장.vue의 '전략 :'.
commandType: '전략',
amountList: '지원 물자',
};
@@ -266,10 +286,14 @@ const STATIC_LABELS: Record<string, Record<string, string>> = {
item: '도구',
},
commandType: {
che_선전포고: '선전포고',
che_불가침제의: '불가침 제의',
che_불가침파기제의: '불가침 파기 제의',
che_종전제의: '종전 제의',
// 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()).
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 => ({
id: row.id,
name: row.name,
+150 -29
View File
@@ -1,6 +1,6 @@
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 {
id: number;
@@ -33,6 +33,20 @@ export interface NationTargetSource {
diplomacyTerm: number;
adjacent: 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 {
@@ -43,11 +57,21 @@ export interface RefGeneralTargetOptions {
export interface RefNationTargetOptions {
nations: TurnCommandOption[];
nationTargets: Record<string, TurnCommandOption[]>;
nationScoutMessages: TurnCommandNationScoutMessage[];
}
const SAME_NATION_GENERAL_COMMANDS = ['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으로 투영한다. */
export const buildRefGeneralTargetOptions = (options: {
actorId: number;
@@ -64,29 +88,30 @@ export const buildRefGeneralTargetOptions = (options: {
const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
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})`;
})();
const details = [
// Ref che_선양.vue는 후보에 (통/무/지)만 보인다.
const details = (
action === 'che_선양'
? []
: [
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
].filter((value): value is string => Boolean(value));
]
).filter((value): value is string => Boolean(value));
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
const assignmentDetails =
action === 'che_발령'
? [
[
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
]
.filter(Boolean)
.join(' · '),
statsText(entry),
[
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
@@ -104,6 +129,9 @@ export const buildRefGeneralTargetOptions = (options: {
.filter(Boolean)
.join('\n')
: undefined;
// Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다.
const stats = statsText(entry);
if (stats && action !== 'che_발령') details.push(stats);
if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
}
@@ -125,33 +153,48 @@ export const buildRefGeneralTargetOptions = (options: {
npcState: entry.npcState,
};
};
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
options.generals.filter(predicate).map((entry) => toOption(entry));
// Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의
// 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[]> = {};
for (const action of SAME_NATION_GENERAL_COMMANDS) {
generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId)
.map((entry) => toOption(entry, action));
generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action));
}
for (const action of SAME_NATION_NATION_COMMANDS) {
generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId)
.filter(isSameNation)
.map((entry) => toOption(entry, action))
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
}
generalTargets.che_선양 = project(
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
);
generalTargets.che_등용 = project(
generalTargets.che_선양 = options.generals
.filter((entry) => isSameNation(entry) && entry.id !== options.actorId)
.map((entry) => toOption(entry, 'che_선양'));
generalTargets.che_등용 = projectPublic(
(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 {
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
generals: project((entry) => entry.npcState < 2),
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다.
generals: projectPublic((entry) => entry.npcState < 2),
generalTargets,
};
};
@@ -169,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
'che_피장파장',
'che_이호경식',
'che_급습',
] as const;
const nationAvailability = (
action: (typeof NATION_TARGET_COMMANDS)[number],
/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
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,
target: NationTargetSource
): { available: boolean; reason: string } => {
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_물자원조') {
return target.diplomacyRestricted
? { available: false, reason: '상대국이 외교제한 중입니다.' }
@@ -205,7 +297,12 @@ const nationAvailability = (
export const buildRefNationTargetOptions = (options: {
actorNationId: number;
nations: readonly NationTargetSource[];
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
join?: NationJoinContext;
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
strategyAvailable?: boolean;
}): RefNationTargetOptions => {
const strategyAvailable = options.strategyAvailable ?? true;
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
value: entry.id,
label: entry.name,
@@ -217,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
for (const action of NATION_TARGET_COMMANDS) {
nationTargets[action] = options.nations
.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 term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
return {
@@ -238,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
)
.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 = [
@@ -5,6 +5,7 @@ import { SystemClock } from '@sammo-ts/common';
import {
createDatabaseTurnHooks,
DatabaseTurnDaemonCommandQueue,
DatabaseTurnDaemonLease,
EngineStateManager,
InMemoryTurnStateStore,
InMemoryTurnWorld,
@@ -695,6 +696,10 @@ liveDescribe('auction worker durable recovery', () => {
const redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
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 abortController = new AbortController();
const worker = runAuctionWorker({ signal: abortController.signal });
@@ -725,6 +730,8 @@ liveDescribe('auction worker durable recovery', () => {
} finally {
abortController.abort();
await worker;
await lease.close();
await connector.prisma.turnDaemonLease.delete({ where: { profile: profileName } });
await redisConnector.client.del([keys.timerKey, keys.historyKey]);
await redisConnector.disconnect();
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 });
});
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 () => {
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
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 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', () => {
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 () => {
const db = {} as DatabaseClient;
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: '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(
nationFields
@@ -321,6 +344,9 @@ describe('turn command argument input', () => {
'che_계략_향낭',
]);
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', () => {
@@ -341,5 +367,6 @@ describe('turn command argument input', () => {
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
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,
buildRefNationTargetOptions,
type GeneralTargetSource,
type NationTargetSource,
} from '../src/turns/commandTargets.js';
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', () => {
const options = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 0,
generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })],
// 이름·국가·도시·부대 원본이 없는 경우. 재야 actor는 같은 국가 후보가 없으므로 국가 ID만 둔다.
actorNationId: 5,
generals: [general({ name: '없음', nationId: 5, cityId: 0, troopId: 99 })],
nationNames: new Map(),
cityNames: 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_발령?.[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([
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
{ 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_몰수?.[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', () => {
@@ -219,6 +324,88 @@ describe('Ref nation target guidance', () => {
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
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', () => {
@@ -384,8 +384,8 @@ integration('generic general creation through the durable turn daemon', () => {
actorUserId: userId,
});
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const turnGridOffsetSeconds =
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
// 상속 턴 시간대는 daemon cursor가 아닌 5분 격자의 위상을 보존한다.
const turnGridOffsetSeconds = ((created.turnTime.getTime() / 1000) % 300 + 300) % 300;
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
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 { 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 $$
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
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: '감사 실패',
detail: '롤백',
})
).rejects.toThrow('injected audit insert failure');
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
).resolves.toBeDefined();
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(1);
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBeGreaterThan(0);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
expect(
await db.inputEvent.findUniqueOrThrow({
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 {
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
const deleteProfileRedisKeys = async (): Promise<void> => {
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 })) {
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');
accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000);
@@ -59,6 +59,7 @@ const classifications = {
'join.reselectPoolGeneral',
'join.selectPoolGeneral',
'nation.appoint',
'nation.cityOfficeRequest',
'nation.changePermission',
'nation.kick',
'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.
expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(88);
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 { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
describe('RedisGatewayFlushSubscriber', () => {
it('reports asynchronous flush handler failures with event context', async () => {
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
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,
generalAccessTracking: true,
requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' },
profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource,
} as unknown as GameApiContext;
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
auth: token,
db: { $executeRaw: executeRaw },
generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' },
profile: { id: 'che', name: 'che:default', scenario: 'default' },
}) as unknown as GameApiContext;
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 = (
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,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: {
general: {
findFirst: vi.fn(async () => ({
id: 7,
userId: 'owner',
officerLevel: options.officerLevel ?? 1,
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: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]),
]
),
},
worldState: {
findFirst: vi.fn(async () => ({
@@ -109,6 +121,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({
serverId: 'che_260819_front',
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
onlineUserCount: 4,
onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우',
@@ -173,3 +186,31 @@ describe('general.getFrontStatus', () => {
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 }>>) =>
({
auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: {
general: {
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> = {
'durable-journal': 19,
'engine-owned': 38,
'engine-owned': 39,
'explicit-no-realtime-consumer': 7,
'external-upload': 1,
'mixed-saga': 9,
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(88);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames());
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
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,
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
}
accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 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 { ChangeJournal } from '@sammo-ts/common';
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
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())));
}
});
@@ -0,0 +1,372 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, GamePrisma } from '@sammo-ts/infra';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { GameApiContext } from '../src/context.js';
const databaseUrl = process.env.NPC_POLICY_DATABASE_URL;
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null;
const redis = process.env.REDIS_URL ? createRedisConnector({ url: process.env.REDIS_URL }) : null;
let runtime: TurnDaemonRuntime | undefined;
let loop: Promise<void> | undefined;
const profile = `npc-policy:${databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : 'fixture'}`;
const baseTime = new Date('0185-01-01T00:00:00Z');
beforeAll(async () => {
if (!databaseUrl) return;
if (!new URL(databaseUrl).searchParams.get('schema')?.endsWith('_npc_policy_fixture'))
throw new Error('Dedicated NPC policy fixture schema required');
await connector!.connect();
await redis!.connect();
});
afterAll(async () => {
if (loop) {
await runtime?.lifecycle.stop();
await loop;
}
await runtime?.close();
await connector?.disconnect();
await redis?.disconnect();
});
integration(
'HTTP user edits survive daemon restart and real NPC turns, with readable actor and before/after history',
async () => {
const db = connector!.prisma;
await db.playAuditPolicy.deleteMany({
where: {
OR: [
{ serverId: 'npc-policy-lifecycle' },
{ id: { in: ['c'.repeat(64), 'd'.repeat(64), 'e'.repeat(64)] } },
],
},
});
await db.playAuditMonth.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: 'npc-policy-lifecycle' } } });
await db.playAuditDecision.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'npc-policy-http-' } } });
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl: databaseUrl!,
now: new Date(),
installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5,
npcMode: 2,
showImgLevel: 3,
serverId: 'npc-policy-lifecycle',
season: 1,
},
});
await db.generalTurn.deleteMany();
await db.nationTurn.deleteMany();
await db.general.deleteMany();
await db.nation.deleteMany();
await db.diplomacy.deleteMany();
// 185년 고정 fixture에 180년 시나리오의 지연 등장 이벤트를 이월하지 않는다.
// 일반턴·월간 재정/도시 갱신은 실제 runtime handler를 그대로 실행한다.
await db.event.deleteMany();
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
const world = await db.worldState.findFirstOrThrow();
await db.worldState.update({
where: { id: world.id },
data: {
currentYear: 185,
currentMonth: 1,
clockBaseTime: baseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockMode: 'realtime',
clockWallAnchor: new Date(),
config: { ...asRecord(world.config), tournamentTrig: false },
},
});
await db.nation.create({
data: {
id: 1,
name: '정책국',
color: '#ffffff',
level: 3,
chiefGeneralId: 1,
capitalCityId: city.id,
gold: 100_000,
rice: 100_000,
tech: 3000,
meta: {},
},
});
await db.city.updateMany({ data: { nationId: 0 } });
await db.city.update({
where: { id: city.id },
data: {
nationId: 1,
population: 100_000,
populationMax: 200_000,
supplyState: 1,
frontState: 1,
meta: { trust: 80, trade: 100 },
},
});
await db.general.createMany({
data: [
{
id: 1,
userId: 'npc-policy-owner',
name: '정책군주',
nationId: 1,
cityId: city.id,
officerLevel: 12,
npcState: 0,
turnTime: new Date('0185-02-01Z'),
turnTick: 100_000_000n,
meta: { belong: 5, killturn: 24 },
},
{
id: 2,
name: '징병검증장',
nationId: 1,
cityId: city.id,
officerLevel: 5,
npcState: 2,
leadership: 80,
strength: 80,
intel: 30,
gold: 100_000,
rice: 100_000,
crew: 0,
turnTime: baseTime,
turnTick: 0n,
meta: { killturn: 24, armType: 1, dex1: 1000 },
},
],
});
const transport = new DatabaseTurnDaemonTransport(db, 10_000);
const auth: NonNullable<GameApiContext['auth']> = {
version: 1,
profile,
sessionId: 'fixture-session',
issuedAt: new Date().toISOString(),
expiresAt: '2999-01-01T00:00:00Z',
user: { id: 'npc-policy-owner', username: 'fixture', displayName: 'fixture', roles: [] },
sanctions: {},
};
const app = fastify();
let request = 0;
await app.register(fastifyTRPCPlugin, {
prefix: '/che/api/trpc',
trpcOptions: {
router: appRouter,
createContext: ({
req,
}: {
req: { headers: Record<string, string | string[] | undefined> };
}): GameApiContext => ({
db,
redis: redis!.client,
profile: { id: 'hwe', scenario: '903', name: profile },
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
requestId: `npc-policy-http-${++request}`,
turnDaemon: transport,
accessTokenStore: new RedisAccessTokenStore(redis!.client, profile),
flushStore: new InMemoryFlushStore(),
battleSim: {} as GameApiContext['battleSim'],
uploadDir: '/tmp',
uploadPath: '/uploads',
uploadPublicUrl: null,
gameTokenSecret: 'fixture-only',
}),
},
});
const actions: Array<{ kind: string; actionKey: string; generalId: number; completed?: boolean }> = [];
const start = async () => {
const instance = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableLeaseHeartbeat: false,
onActionResolved: (action) => actions.push(action),
});
// 자연 진행은 정지하고, 사용자 command와 명시한 due 턴만 처리한다.
instance.lifecycle.pause('NPC policy fixture');
return instance;
};
const call = async (endpoint: string, input: unknown, mutation = false) => {
const response = await app.inject({
method: mutation ? 'POST' : 'GET',
url: `/che/api/trpc/npc.${endpoint}${mutation ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`,
headers: {
'x-session-token': 'fixture-session',
...(mutation ? { 'content-type': 'application/json' } : {}),
},
...(mutation ? { payload: JSON.stringify(input) } : {}),
});
expect(response.statusCode, response.body).toBe(200);
return response.json<{ result: { data: unknown } }>().result.data;
};
const configuration = (value: unknown) => {
const data = asRecord(value);
return {
values: data.currentNationPolicy,
nation: data.currentNationPriority,
general: data.currentGeneralActionPriority,
setters: data.lastSetters,
};
};
try {
runtime = await start();
loop = runtime.lifecycle.start();
// 발령과 징병을 목록에서 빼면 실제 턴에서도 수행하지 않아야 한다.
await call('setNationPriority', ['NPC포상'], true);
await call('setGeneralPriority', ['출병', '일반내정'], true);
await call(
'setNationPolicy',
{ minWarCrew: 2000, minNPCRecruitCityPopulation: 30000, safeRecruitCityPopulationRatio: 0 },
true
);
const withoutRecruitment = await call('getPolicy', {});
expect(withoutRecruitment).toMatchObject({
currentNationPriority: ['NPC포상'],
currentGeneralActionPriority: ['출병', '일반내정'],
});
await runtime.lifecycle.stop();
await loop;
loop = undefined;
await runtime.close();
runtime = await start();
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
let result = await runtime.processor.run(baseTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1 });
expect(result.processedGenerals).toBe(1);
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
expect(actions).toContainEqual(expect.objectContaining({ generalId: 2, kind: 'nation' }));
expect(
actions.filter((action) => action.generalId === 2).some((action) => action.actionKey === 'che_징병')
).toBe(false);
await runtime.hooks!.flushChanges!(result);
const nationDecision = await db.playAuditDecision.findFirstOrThrow({
where: { generalId: 2, phase: 'nation' },
include: { chunks: { orderBy: { ordinal: 'asc' } } },
});
const procedures = nationDecision.chunks.flatMap((chunk) =>
Array.isArray(chunk.steps)
? chunk.steps
.map(asRecord)
.filter((step) => step.kind === 'PROCEDURE_START')
.map((step) => step.procedure)
: []
);
expect(procedures).toEqual(['NPC포상']);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
// 같은 장수·자원으로 징병 우선순위를 맨 앞에 저장한 뒤 다시 시작한다.
loop = runtime.lifecycle.start();
await call('setGeneralPriority', ['징병', '출병', '일반내정'], true);
const saved = await call('getPolicy', {});
await runtime.lifecycle.stop();
await loop;
loop = undefined;
await runtime.close();
runtime = await start();
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
const beforeMeta = asRecord(runtime.world.getNationById(1)!.meta);
// 실제 due 턴과 command를 실행한다. 전방의 중립 도시가 전쟁 상태를 만든다.
actions.length = 0;
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
budgetMs: 10_000,
maxGenerals: 1,
catchUpCap: 1,
});
expect(result.processedGenerals).toBe(1);
expect(runtime.world.getGeneralById(2)!.crew).toBeGreaterThan(0);
expect(actions).toContainEqual(
expect.objectContaining({ generalId: 2, actionKey: 'che_징병', completed: true })
);
await runtime.hooks!.flushChanges!(result);
expect((await db.general.findUniqueOrThrow({ where: { id: 2 } })).crew).toBeGreaterThan(0);
expect(asRecord(runtime.world.getNationById(1)!.meta).npc_nation_policy).toEqual(
beforeMeta.npc_nation_policy
);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
// 병력이 소진되고 도시 인구가 정책 하한 미만이면 징병을 거른다.
runtime.world.updateGeneral(2, { crew: 0 });
runtime.world.updateCity(city.id, { population: 30_100 });
actions.length = 0;
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
budgetMs: 10_000,
maxGenerals: 1,
catchUpCap: 1,
});
expect(result.processedGenerals).toBe(1);
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
expect(actions.some((action) => action.actionKey === 'che_징병')).toBe(false);
await runtime.hooks!.flushChanges!(result);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
// 이후의 개인턴/월간 갱신도 정책을 덮어쓰면 안 된다.
const target = new Date(runtime.world.getGeneralById(2)!.turnTime.getTime() + 30 * 60_000);
result = await runtime.processor.run(target, { budgetMs: 10_000, maxGenerals: 100, catchUpCap: 12 });
expect(result.processedGenerals).toBeGreaterThan(1);
await runtime.hooks!.flushChanges!(result);
expect(await call('getPolicy', {})).toMatchObject({
currentNationPriority: ['NPC포상'],
currentGeneralActionPriority: ['징병', '출병', '일반내정'],
currentNationPolicy: { minWarCrew: 2000, minNPCRecruitCityPopulation: 30000 },
});
expect(runtime.world.getState().meta.playAuditGap).toBeUndefined();
const history = await call('policyHistory', { area: 'NPC_NATION_PRIORITY' });
expect(history).toMatchObject({
items: [
expect.objectContaining({ source: 'CHANGE', actor: expect.objectContaining({ name: '정책군주' }) }),
expect.anything(),
],
});
const row = await db.playAuditPolicy.findFirstOrThrow({
where: { nationId: 1, area: 'NPC_NATION_PRIORITY', source: 'CHANGE' },
});
expect(await call('policyVersion', { id: row.id })).toMatchObject({
actor: { name: '정책군주' },
fields: [{ key: 'priority', beforeJson: 'null', afterJson: '["NPC포상"]', changed: true }],
});
expect(row.inputSequence).not.toBeNull();
expect((await db.inputEvent.findUniqueOrThrow({ where: { sequence: row.inputSequence! } })).status).toBe(
'SUCCEEDED'
);
// 외국/이전 기수/국방 정책 ID를 알아도 자국 NPC 정책으로 조회할 수 없다.
for (const [id, patch] of [
['c'.repeat(64), { nationId: 2 }],
['d'.repeat(64), { serverId: 'previous-season' }],
['e'.repeat(64), { area: 'DEFENCE' }],
] as const) {
await db.playAuditPolicy.create({
data: {
...row,
id,
...patch,
actor: row.actor ?? GamePrisma.DbNull,
before: row.before ?? GamePrisma.DbNull,
after: row.after ?? GamePrisma.JsonNull,
},
});
const response = await app.inject({
url: `/che/api/trpc/npc.policyVersion?input=${encodeURIComponent(JSON.stringify({ id }))}`,
headers: { 'x-session-token': 'fixture-session' },
});
expect(response.statusCode).toBe(404);
}
await db.general.update({ where: { id: 1 }, data: { penalty: { noChief: true } } });
const denied = await app.inject({
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify({ area: 'NPC_VALUES' }))}`,
headers: { 'x-session-token': 'fixture-session' },
});
expect(denied.statusCode).toBe(403);
} finally {
await app.close();
}
},
90_000
);
+154 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
@@ -96,10 +98,16 @@ const createContext = (
options: {
me?: GeneralRow;
nation?: typeof baseNation;
world?: typeof baseWorld;
world?: {
config: typeof baseWorld.config;
meta: Record<string, unknown>;
currentYear?: number;
currentMonth?: number;
};
requestCommand?: ReturnType<typeof vi.fn>;
troopRows?: Array<{ troopLeaderId: number }>;
cityRows?: Array<{ id: number }>;
historyRows?: Array<Record<string, unknown>>;
} = {}
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
const requestCommand =
@@ -117,6 +125,11 @@ const createContext = (
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
playAuditPolicy: {
findMany: vi.fn(async () => options.historyRows ?? []),
findFirst: vi.fn(async () => options.historyRows?.[0] ?? null),
},
$transaction: async <T>(read: (tx: object) => Promise<T>): Promise<T> => read(db),
};
const redisClient = { get: async () => null, set: async () => null };
return {
@@ -140,6 +153,146 @@ const createContext = (
};
describe('NPC policy router', () => {
const historyRow = {
id: 'a'.repeat(64),
schemaVersion: 1,
nationId: 1,
area: 'NPC_NATION_PRIORITY',
revision: 2,
previousId: 'b'.repeat(64),
source: 'CHANGE',
year: 185,
month: 1,
createdAt: new Date('2026-01-01Z'),
actor: {
userId: 'private-user',
generalId: 22,
name: '정책담당',
nationId: 1,
officerLevel: 12,
npcState: 0,
permission: 4,
},
before: { priority: ['NPC전방발령', 'NPC포상'] },
after: { priority: ['NPC포상'] },
};
const historyContext = (options: Parameters<typeof createContext>[0] = {}) =>
createContext({
...options,
world: {
...baseWorld,
currentYear: 185,
currentMonth: 1,
meta: { ...baseWorld.meta, serverId: 'current-season' },
},
historyRows: options.historyRows ?? [historyRow],
});
it('lets an ordinary nation reader inspect the actor and changes without admin request/account fields', async () => {
const fixture = historyContext({ me: { ...baseGeneral, officerLevel: 2 } });
const caller = appRouter.createCaller(fixture.context);
const page = await caller.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
expect(page.items[0]).toMatchObject({ actor: { name: '정책담당' }, revision: 2 });
const detail = await caller.npc.policyVersion({ id: historyRow.id });
expect(detail.fields).toEqual([
{ key: 'priority', beforeJson: '["NPC전방발령","NPC포상"]', afterJson: '["NPC포상"]', changed: true },
]);
expect(JSON.stringify(detail)).not.toMatch(/private-user|permission|requestId|inputSequence/);
});
it.each([
{ nationId: 0 },
{ officerLevel: 0 },
{ officerLevel: 1, meta: { belong: 0 } },
{ penalty: { noChief: true } },
])('denies history when current nation read permission is missing: %j', async (patch) => {
const fixture = historyContext({ me: { ...baseGeneral, ...patch } });
await expect(
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
).rejects.toBeDefined();
});
it('scopes cursor and version lookups to the authenticated nation and current season', async () => {
const fixture = historyContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.policyHistory({ area: 'NPC_VALUES', cursor: 7, limit: 1 });
const tx = await fixture.context.db.$transaction!(async (db) => db);
expect(tx.playAuditPolicy.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { serverId: 'current-season', nationId: 1, area: 'NPC_VALUES', revision: { lt: 7 } },
take: 2,
})
);
await caller.npc.policyVersion({ id: historyRow.id });
expect(tx.playAuditPolicy.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: historyRow.id,
serverId: 'current-season',
nationId: 1,
area: { in: ['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY'] },
},
})
);
await expect(caller.npc.policyHistory({ area: 'NPC_VALUES', limit: 51 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.npc.policyVersion({ id: 'bad' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('requires authentication and rejects caller-supplied nation or defence history over HTTP', async () => {
const fixture = historyContext();
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/che/api/trpc',
trpcOptions: {
router: appRouter,
createContext: ({ req }: { req: { headers: Record<string, string | string[] | undefined> } }) => ({
...fixture.context,
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
}),
},
});
try {
const query = (input: object, authenticated = true) =>
app.inject({
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify(input))}`,
headers: authenticated ? { 'x-session-token': 'fixture-session' } : {},
});
expect((await query({ area: 'NPC_VALUES' }, false)).statusCode).toBe(401);
expect((await query({ area: 'NPC_VALUES' })).statusCode).toBe(200);
expect((await query({ area: 'DEFENCE' })).statusCode).toBe(400);
expect((await query({ area: 'NPC_VALUES', nationId: 2 })).statusCode).toBe(400);
} finally {
await app.close();
}
});
it('denies sanctioned sessions before reading history', async () => {
const fixture = historyContext();
fixture.context.auth = { ...auth, sanctions: { suspendedUntil: '2999-01-01T00:00:00Z' } };
await expect(
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(fixture.findFirst).not.toHaveBeenCalled();
});
it('paginates revisions without exposing a missing or unavailable version', async () => {
const fixture = historyContext({ historyRows: [historyRow, { ...historyRow, revision: 1 }] });
const page = await appRouter
.createCaller(fixture.context)
.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
expect(page.items).toHaveLength(1);
expect(page.nextCursor).toBe(2);
const missing = historyContext({ historyRows: [] });
await expect(
appRouter.createCaller(missing.context).npc.policyVersion({ id: historyRow.id })
).rejects.toMatchObject({ code: 'NOT_FOUND' });
const noIdentity = createContext();
await expect(
appRouter.createCaller(noIdentity.context).npc.policyHistory({ area: 'NPC_VALUES' })
).resolves.toMatchObject({ coverage: 'IDENTITY_MISSING', items: [], nextCursor: null });
});
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
const fixture = createContext();
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
summarizeNationPeriod,
projectCurrentSettlement,
zAuditNation,
type AuditNationData,
type NationMonthPoint,
} from '../src/router/playAudit/nationSeries.js';
const population = {
count: 0,
crew: null,
gold: 0,
rice: 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 = {
version: 1,
profile: 'che',
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session',
@@ -239,7 +239,7 @@ const buildContext = (options?: {
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile: selectedProfile,
auth: options?.authenticated === false ? null : auth,
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
uploadDir: 'uploads',
uploadPath: '/uploads',
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(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 { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common';
@@ -68,6 +70,7 @@ const envKeys = [
'GATEWAY_REDIS_PREFIX',
'GATEWAY_INTERNAL_API_URL',
'GAME_UPLOAD_DIR',
'API_ALLOWED_ORIGINS',
] as const;
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
@@ -172,7 +175,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
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({
MATCH: pattern,
COUNT: 100,
@@ -416,7 +419,7 @@ const readAccessTelemetryState = async () => ({
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
if (!redis) return [];
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 (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_INTERNAL_API_URL = await listenGatewayStatusStub();
process.env.GAME_UPLOAD_DIR = uploadDir;
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
await connector.connect();
@@ -742,6 +746,8 @@ integration('game API security over HTTP transport', () => {
await redis.connect();
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();
baseUrl = await server.app.listen({
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 () => {
const accessToken = await createAccessToken('json-query-body', {});
const general = await requestTrpc('general.me', {
@@ -2317,9 +2345,51 @@ integration('game API security over HTTP transport', () => {
{
decisionId: decisionIds[0]!,
ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
? { ...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 })),
steps: Array.from({ length: 128 }, (_, sequence) =>
sequence === 127
? {
...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]!,
@@ -2352,6 +2422,33 @@ integration('game API security over HTTP transport', () => {
],
});
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', await token(['admin']), decisionInput)).status).toBe(403);
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({
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 });
expect(first.status).toBe(200);
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, {
nationId: ownerNationId,
from: { year: 190, month: 1 },
@@ -3134,7 +3390,10 @@ integration('game API security over HTTP transport', () => {
year: 190,
month: 1,
complete: true,
stock: { gold: 600 },
stock: {
gold: 600,
populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 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 } }] } },
});
// 정산 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.
await db.worldState.update({
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
// last so this lifecycle assertion cannot invalidate the actor tokens used
// 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> {
this.commands.push(command);
if (command.type === 'adjustGeneralResources') {
const adjustment = command.adjustments[0];
if (!adjustment) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
if (command.type === 'tournamentAdjustGeneral') {
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
}
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
if (nextGold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
}
this.gold.set(adjustment.generalId, nextGold);
return {
type: 'adjustGeneralResources',
ok: true,
processed: 1,
missing: 0,
totalGoldDelta: adjustment.goldDelta ?? 0,
totalRiceDelta: 0,
};
this.gold.set(command.generalId, nextGold);
return { type: command.type, ok: true, generalId: command.generalId };
}
if (command.type === 'setMySetting') {
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;
}
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
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.objectContaining({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
userId: 'user-1',
})
);
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 });
expect(transport.gold.get(general.id)).toBe(1_936);
expect(transport.commands).toContainEqual({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
userId: 'user-1',
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(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet',
userId: 'user-1',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
reason: 'tournamentBet',
type: 'tournamentAdjustGeneral',
userId: 'user-1',
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 () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
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 transport = new TournamentTransport();
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({
code: 'INTERNAL_SERVER_ERROR',
code: 'BAD_REQUEST',
});
expect(transport.gold.get(general.id)).toBe(2_000);
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 => ({
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,
});
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
const commands: TurnDaemonCommand[] = [];
const daemonTransport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
requestCommand: async (command) => {
expect((await store.getState())?.stage).toBe(5);
commands.push(command);
return 'ok';
return createNoopDaemonTransport().requestCommand(command);
},
};
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(commands).toEqual([
{
type: 'adjustGeneralResources',
requestId: 'tournament:123:npc-bet:resources',
reason: 'tournamentNpcBet',
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 } })),
type: 'tournamentSeedNpcBets',
requestId: 'tournament:123:npc-bet:atomic',
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
},
]);
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
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.
await store.setBettingEntries([bets[0]!]);
await seedNpcBets({
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
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 () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
const state = createTournamentState({ stage: 5 });
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
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 state = createTournamentState({ stage: 5, bettingId: 456 });
await store.setState(state);
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
let fail = true;
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'adjustGeneralMeta' && fail) {
if (command.type === 'tournamentSeedNpcBets' && fail) {
fail = false;
// The accepted debit may already have committed before retry.
prisma.general.findMany = async () => [];
throw new Error('enqueue unavailable');
}
return 'ok';
return createNoopDaemonTransport().requestCommand(command);
},
};
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
);
expect(await store.getState()).toMatchObject({
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([]);
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([
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:atomic',
]);
expect(await store.getBettingEntries()).toHaveLength(1);
});
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
commands.push(command);
return 'ok';
},
requestCommand: async () => null,
requestCommand: async (command) => {
commands.push(command);
return createNoopDaemonTransport().requestCommand(command);
},
requestStatus: async () => null,
};
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
expect(commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
reason: 'tournamentNpcBet',
adjustments: expect.arrayContaining([
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
]),
type: 'tournamentSeedNpcBets',
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
})
);
}
@@ -142,6 +142,10 @@ const buildContext = (
meta: options.worldMeta ?? { serverId: currentServerId },
}),
},
gameHistory: {
findUnique: async ({ where }: { where: { serverId: string } }) =>
where.serverId === archiveServerId ? { map: 'miniche' } : null,
},
yearbookHistory: {
findFirst: async (args: {
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 result = await caller.yearbook.getRange({ serverID: archiveServerId });
expect(result).toEqual({
expect(result).toMatchObject({
firstYearMonth: 200 * 12,
lastYearMonth: 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 () => {
@@ -249,20 +255,22 @@ describe('historical yearbook access from dynasty', () => {
expect(canonical).toEqual(alias);
expect(alias).toEqual(omitted);
expect(canonical).toEqual({
expect(canonical).toMatchObject({
firstYearMonth: 219 * 12 + 11,
lastYearMonth: 219 * 12 + 11,
currentYearMonth: 220 * 12,
mapLayout: { mapName: 'che' },
});
});
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: {} }));
await expect(caller.yearbook.getRange()).resolves.toEqual({
await expect(caller.yearbook.getRange()).resolves.toMatchObject({
firstYearMonth: 219 * 12 + 10,
lastYearMonth: 219 * 12 + 10,
currentYearMonth: 220 * 12,
mapLayout: { mapName: 'che' },
});
});
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": false
"composite": false,
"paths": {
"@sammo-ts/game-api": ["./src/index.ts"],
"@sammo-ts/game-api/*": ["./src/*"]
}
},
"include": ["src"]
}
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
const claimCoordinate = await readInputEventClockCoordinate(transaction);
const world = await transaction.worldState.findFirst({
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이 별도로 지킨다.
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
OR (
${suspendedTournamentBetCommand}
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
)
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 type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.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 };
};
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;
const flows = readFlows(world);
const key = `${settlement.nationId}:${settlement.resource}`;
@@ -44,12 +49,18 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
};
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
world.updateWorldMeta({ playAuditFlows: flows });
};
},
undefined
);
export const queueAuditMonth = (
world: InMemoryTurnWorld,
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
): void => {
): void =>
collectPlayAudit(
world,
'queueAuditMonth',
() => {
const state = world.getState();
const serverId = state.meta.serverId;
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
@@ -71,10 +82,16 @@ export const queueAuditMonth = (
kind,
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 serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false;
@@ -111,7 +128,9 @@ export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt =
},
});
return true;
};
},
false
);
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => {
+39 -9
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { TurnDiplomacy } from '../turn/types.js';
@@ -7,7 +8,11 @@ export const recordMonthlyAuditDiplomacy = (
world: InMemoryTurnWorld,
before: readonly TurnDiplomacy[],
afterByKey: ReadonlyMap<string, TurnDiplomacy>
): void => {
): void =>
collectPlayAudit(
world,
'recordMonthlyAuditDiplomacy',
() => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return;
@@ -46,7 +51,9 @@ export const recordMonthlyAuditDiplomacy = (
after: nextState,
});
}
};
},
undefined
);
export interface AuditDiplomacyAction {
actionKey: string;
@@ -68,7 +75,11 @@ export const recordTurnAuditDiplomacy = (
after: TurnDiplomacy,
action: AuditDiplomacyAction,
turn: { generalId: number; tick: number; ordinal: number }
): void => {
): void =>
collectPlayAudit(
world,
'recordTurnAuditDiplomacy',
() => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return;
@@ -95,14 +106,25 @@ export const recordTurnAuditDiplomacy = (
ordinal: turn.ordinal,
requestId: 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,
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 serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return false;
@@ -177,7 +199,9 @@ export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt =
},
});
return true;
};
},
false
);
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
export const recordNationAuditDiplomacy = (
@@ -186,7 +210,11 @@ export const recordNationAuditDiplomacy = (
relations: readonly TurnDiplomacy[],
operation: 'CREATED' | 'REMOVED',
observedTick?: number
): void => {
): void =>
collectPlayAudit(
world,
'recordNationAuditDiplomacy',
() => {
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return;
@@ -229,4 +257,6 @@ export const recordNationAuditDiplomacy = (
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 { asRecord } from '@sammo-ts/common';
import type { Nation } from '@sammo-ts/logic';
@@ -70,7 +71,11 @@ export const recordAuditPolicyChange = (options: {
actor?: TurnGeneral;
permission?: number;
requestId?: string;
}): { _playAuditPolicy?: Record<string, PolicyHead> } => {
}): { _playAuditPolicy?: Record<string, PolicyHead> } =>
collectPlayAudit(
options.world,
'recordAuditPolicyChange',
() => {
const { world, nation, area } = options;
const state = world.getState();
const serverId = state.meta.serverId;
@@ -134,9 +139,15 @@ export const recordAuditPolicyChange = (options: {
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
if (!head) throw new Error('Play audit policy baseline missing');
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);
if (!nation) return;
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 } })!;
}
}
};
},
undefined
);
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
@@ -1,5 +1,7 @@
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 (
tx: GamePrisma.TransactionClient,
@@ -43,3 +45,48 @@ export const persistAuditPolicies = async (
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 {
count: number;
crew: number;
gold: number;
rice: number;
dex: AuditDex;
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
const emptyPopulation = (): AuditPopulationSummary => ({
count: 0,
crew: 0,
gold: 0,
rice: 0,
dex: emptyDex(),
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
const population = nations.get(general.nationId)?.populations[projected.population];
if (!population) continue;
population.count++;
population.crew += projected.crew;
population.gold += projected.gold;
population.rice += projected.rice;
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 {
GAME_TICKS_PER_TURN,
readOfficeRequest,
MAX_SAFE_GAME_TICK,
GameClock,
buildClockAlignmentPlan,
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
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(
'auction-open-occurrence',
'KEEP',
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
const shift = BigInt(shiftTicks);
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.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
_max: { pickMoreFromTick: true },
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]> = [
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
['general.turn_tick', general._max.turnTick],
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
['auction.close_tick', auction._max.closeTick],
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
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(
'auction-deadline',
await db.$executeRaw(GamePrisma.sql`
@@ -185,6 +185,16 @@ const zKick = z.object({
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({
type: z.literal('appoint'),
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({
type: z.literal('adjustGeneralResources'),
reason: z.string().optional(),
@@ -666,6 +691,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
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 command = parseWith(zAppoint, envelope.command);
if (!command) {
@@ -722,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
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 command = parseWith(zAdjustGeneralResources, envelope.command);
if (!command) {
@@ -883,12 +923,15 @@ const normalizers: CommandNormalizerMap = {
changePermission: normalizeChangePermission,
kick: normalizeKick,
appoint: normalizeAppoint,
cityOfficeRequest: normalizeCityOfficeRequest,
tournamentRefund: normalizeTournamentRefund,
tournamentBettingPayout: normalizeTournamentBettingPayout,
tournamentReward: normalizeTournamentReward,
voteReward: normalizeVoteReward,
setNationSetting: normalizeSetNationSetting,
setNpcPolicy: normalizeSetNpcPolicy,
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
adjustGeneralResources: normalizeAdjustGeneralResources,
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
+71 -13
View File
@@ -1,7 +1,8 @@
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 { persistAuditPolicies } from '../playAudit/policyPersistence.js';
import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
@@ -78,6 +79,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
flushChanges(): Promise<void>;
restoreMissingAuditPolicyHeads(): Promise<boolean>;
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
@@ -206,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
permission: asRecord(general.meta).permission,
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: '',
lobbyCount: signature({ npcState: general.npcState }),
lobbyPersonal: signature({
@@ -1235,6 +1244,7 @@ export const createDatabaseTurnHooks = async (
}
const persistedClock = await prisma.$queryRaw<
Array<{
audit_gap: unknown;
clock_phase: string;
clock_revision: bigint;
deadline_generation: bigint;
@@ -1242,7 +1252,7 @@ export const createDatabaseTurnHooks = async (
opening_reached: boolean;
}>
>(GamePrisma.sql`
SELECT clock_phase,
SELECT meta->'playAuditGap' AS audit_gap, clock_phase,
clock_revision,
deadline_generation,
clock_base_time IS NOT NULL
@@ -1258,6 +1268,21 @@ export const createDatabaseTurnHooks = async (
if (!durableClock) {
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 expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
@@ -1388,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
GamePrisma.sql`
UPDATE general
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(
@@ -1894,11 +1924,25 @@ export const createDatabaseTurnHooks = async (
data: pendingLogRows,
});
}
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
await persistAuditDecisions(prisma, pendingAuditDecisions);
for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot);
if (
pendingAuditPolicies.length ||
pendingAuditDiplomacy.length ||
pendingAuditDecisions.length ||
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) {
await persistYearbookSnapshot(prisma, snapshot);
@@ -2093,8 +2137,8 @@ export const createDatabaseTurnHooks = async (
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
};
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
if (hasAuditDocumentBaseline(world)) {
if (force || world.hasPendingAuditRecords()) await flushChanges();
if (collectPlayAudit(world, 'document-boundary', () => hasAuditDocumentBaseline(world), false)) {
if (force || world.hasPendingAuditRecords() || world.getState().meta.playAuditGap) await flushChanges();
return;
}
const checkpoint = world.captureState();
@@ -2107,7 +2151,10 @@ export const createDatabaseTurnHooks = async (
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
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);
}, transactionOptions);
} catch (error) {
@@ -2164,6 +2211,17 @@ export const createDatabaseTurnHooks = async (
hooks,
flushChanges,
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: () => {
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 {
recordTurnAuditDiplomacy,
@@ -21,6 +22,7 @@ import type {
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import {
GAME_TICKS_PER_TURN,
shiftOfficeRequestDeadline,
GameClock,
assertGameplayCommitAllowed,
inferClockPhase,
@@ -818,6 +820,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
this.generals.set(generalId, {
...general,
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
turnTick,
turnTime: clock.tickToDate(turnTick),
});
@@ -1043,6 +1046,7 @@ export class InMemoryTurnWorld {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
this.generals.set(generalId, {
...general,
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
turnTick,
turnTime: clock.tickToDate(turnTick),
});
@@ -1393,15 +1397,36 @@ export class InMemoryTurnWorld {
}
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
collectPlayAudit(
this,
'queueDiplomacy',
() => {
this.pendingAuditDiplomacy.push(structuredClone(event));
},
undefined
);
}
queueAuditDecision(decision: PendingAuditDecision): void {
collectPlayAudit(
this,
'queueDecision',
() => {
this.pendingAuditDecisions.push(structuredClone(decision));
},
undefined
);
}
queueAuditPolicy(policy: PendingAuditPolicy): void {
collectPlayAudit(
this,
'queuePolicy',
() => {
this.pendingAuditPolicies.push(structuredClone(policy));
},
undefined
);
}
hasPendingAuditRecords(): boolean {
@@ -1414,7 +1439,14 @@ export class InMemoryTurnWorld {
}
queueAuditMonth(snapshot: PendingAuditMonth): void {
collectPlayAudit(
this,
'queueMonth',
() => {
this.pendingAuditMonths.push(structuredClone(snapshot));
},
undefined
);
}
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
@@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: {
}
world.updateNation(nationId, {
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]) {
options.reservedTurns.ensureNationTurns(nationId, officerLevel);
@@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: {
}
world.updateNation(nationId, {
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, 11);
@@ -1,3 +1,4 @@
import { collectPlayAudit } from '../playAudit/bestEffort.js';
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
@@ -1085,7 +1086,11 @@ export const createReservedTurnHandler = async (options: {
const onDecisionTrace: AiDecisionTraceObserver | undefined =
collectDecisions || options.onDecisionTrace
? (event) => {
if (collectDecisions) {
if (collectDecisions && worldRef)
collectPlayAudit(
worldRef,
'decision-trace',
() => {
if (event.kind === 'DECISION_START') {
decisionSteps.set(event.phase, []);
const heads = asRecord(currentNation?.meta._playAuditPolicy);
@@ -1094,7 +1099,8 @@ export const createReservedTurnHandler = async (options: {
Object.fromEntries(
AUDIT_POLICY_AREAS.flatMap((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]]
: [];
})
@@ -1104,11 +1110,18 @@ export const createReservedTurnHandler = async (options: {
decisionSteps
.get(event.phase)
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
}
},
undefined
);
options.onDecisionTrace?.(event);
}
: undefined;
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
if (!worldRef) return;
collectPlayAudit(
worldRef,
'decision-summary',
() => {
const steps = decisionSteps.get(phase);
const first = steps?.[0];
if (!collectDecisions || !first || !steps) return;
@@ -1125,6 +1138,9 @@ export const createReservedTurnHandler = async (options: {
month,
tick,
});
},
undefined
);
};
const finishDecision = (
phase: 'general' | 'nation',
@@ -1135,6 +1151,11 @@ export const createReservedTurnHandler = async (options: {
blockedReason?: string;
}
): void => {
if (!worldRef) return;
collectPlayAudit(
worldRef,
'decision-summary',
() => {
const steps = decisionSteps.get(phase);
const first = steps?.[0];
const last = steps?.find((step) => step.kind === 'DECISION_END');
@@ -1193,6 +1214,9 @@ export const createReservedTurnHandler = async (options: {
},
steps,
});
},
undefined
);
};
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
+4 -1
View File
@@ -1,3 +1,4 @@
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
import { initializeAuditPolicies } from '../playAudit/policy.js';
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
const calendarHandler = composeCalendarHandlers(
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
options.monthlyEventHandler,
createCityOfficeRequestCalendarHandler(options.getWorld),
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
createPlayAuditHandler(options.getWorld),
@@ -937,10 +939,11 @@ const createTurnDaemonRuntimeWithLease = async (
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
const policyHeadsRestored = await dbHooks.restoreMissingAuditPolicyHeads();
initializeAuditPolicies(world);
const diplomacyInitialized = initializeAuditDiplomacy(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();
} catch (error) {
await Promise.allSettled([
@@ -1,3 +1,4 @@
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
import type {
TurnDaemonCommandHandler,
TurnDaemonCommand,
@@ -174,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
};
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'tournamentAdjustGeneral',
'troopCreate',
'troopJoin',
'troopExit',
@@ -187,6 +189,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
'changePermission',
'kick',
'appoint',
'cityOfficeRequest',
'voteReward',
'syncDiplomaticResponse',
] as const satisfies readonly TurnDaemonCommand['type'][];
@@ -625,11 +628,84 @@ async function handleSetNpcPolicy(
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(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
}
@@ -686,6 +762,9 @@ async function handleAdjustGeneralMeta(
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
): Promise<TurnDaemonCommandResult> {
const { world } = ctx;
if (isLegacyTournamentAdjustment(command.reason)) {
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
}
if (!command.adjustments || command.adjustments.length === 0) {
return {
type: 'adjustGeneralMeta',
@@ -2002,8 +2081,10 @@ async function handleSetMySetting(
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
const constValues = asRecord(world.getScenarioConfig().const);
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
// Ref GameConstBase의 전투 상한을 사용한다. 명령 상한(100)으로 제한하면
// 전투에서 오른 훈련·사기가 수비끔 전환 한 번에 소실된다.
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 110);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 150);
const trainDelta = ignoresPenalty ? 0 : -3;
const atmosDelta = ignoresPenalty ? 0 : -6;
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
@@ -2587,6 +2668,7 @@ async function handleAppoint(
} else {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
}
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
refreshActorKillturn(world, general);
return { type: 'appoint', ok: true, generalId: command.generalId };
}
@@ -3243,6 +3325,12 @@ export const createTurnDaemonCommandHandler = (options: {
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
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' }>),
tournamentRefund: (command) =>
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
@@ -3258,6 +3346,13 @@ export const createTurnDaemonCommandHandler = (options: {
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
setNpcPolicy: (command) =>
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) =>
handleAdjustGeneralResources(
ctx,
@@ -72,6 +72,14 @@ const state: TurnWorldState = {
currentMonth: 1,
tickSeconds: 600,
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: {
killturn: 24,
lastTurnTime: '2026-07-31T09:00:00.000Z',
@@ -209,6 +217,14 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
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,
meta: state.meta as GamePrisma.InputJsonValue,
},
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
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: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
@@ -45,6 +55,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
destCityId: 1,
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: '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 { 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 {
createGamePostgresConnector,
readTurnRuntimeReady,
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
meta:
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: 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>;
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(
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 () => {
const requestId = 'integration:engine:stale-owner-replay';
const command: TurnDaemonCommand = {
type: 'vacation',
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
'stores a stale-owner %s rejection once and never redispatches it',
async (type) => {
const requestId = `integration:engine:stale-owner-replay:${type}`;
const command: TurnDaemonCommand =
type === 'vacation'
? { type, requestId, userId: 'old-owner', generalId: 7 }
: {
type,
reason: 'tournamentBet',
requestId,
userId: 'old-owner',
generalId: 7,
goldDelta: -100,
betGoldDelta: 100,
minGoldAfter: 500,
};
await db.inputEvent.create({
data: {
@@ -336,7 +345,8 @@ integration('database command queue', () => {
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
expect(handle).toHaveBeenCalledOnce();
expect(mutation).not.toHaveBeenCalled();
});
}
);
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
'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 rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
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({
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);
await expect(queue.drain()).resolves.toEqual([
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
@@ -688,7 +735,15 @@ integration('database command queue', () => {
requestId: rollbackId,
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({
status: 'PROCESSING',
processingGameTick: 321n,
@@ -224,6 +224,14 @@ integration('monthly catalog boundary persistence', () => {
currentYear: 190,
currentMonth: 12,
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,
meta: {
hiddenSeed: 'monthly-boundary-core-catalog',
@@ -157,6 +157,23 @@ integration('monthly diplomacy persistence', () => {
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
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();
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
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
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
try {
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure');
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued);
await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
currentMonth: 1,
currentMonth: 2,
});
expect(
await db.diplomacy.findUniqueOrThrow({
@@ -185,7 +202,7 @@ integration('monthly diplomacy persistence', () => {
},
},
})
).toMatchObject({ stateCode: 1, term: 1 });
).toMatchObject({ stateCode: 0, term: 6 });
} finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
@@ -193,18 +210,25 @@ integration('monthly diplomacy persistence', () => {
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
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(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`);
await db.$executeRawUnsafe(
`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 {
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback');
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]);
await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
expect(await db.playAuditDecision.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 {
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
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?.({
lastTurnTime: '0193-02-01T00:00:00.000Z',
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
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
try {
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure');
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals);
await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
} finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
@@ -331,7 +355,7 @@ integration('monthly diplomacy persistence', () => {
await db.playAuditDiplomacyEvent.count({
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
})
).toBe(6);
).toBe(0);
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
await hooks.flushChanges();
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' },
})
).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 {
await hooks.close();
}
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic';
@@ -186,6 +187,31 @@ describe('invader monthly actions', () => {
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 () => {
const harness = buildHarness();
const handler = createRaiseInvaderHandler({
@@ -1,3 +1,4 @@
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
parseScenarioGeneralPoolCandidate,
@@ -228,6 +229,21 @@ describe('RaiseNPCNation monthly action', () => {
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 () => {
const info = {
generalName: '부장후보',
@@ -337,6 +353,32 @@ describe('RaiseNPCNation monthly action', () => {
"id": 2,
"level": 2,
"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,
"can_국기변경": 1,
"gennum": 1,
@@ -87,7 +87,11 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
const buildWorld = (
general = buildGeneral(),
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
options: {
autorunLimit?: boolean;
scenarioEffect?: ScenarioEffectKey | null;
scenarioConst?: Record<string, unknown>;
} = {}
) => {
const state: TurnWorldState = {
id: 1,
@@ -126,7 +130,7 @@ const buildWorld = (
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
const: options.scenarioConst ?? {},
environment: {
mapName: 'test',
unitSet: 'test',
@@ -277,6 +281,53 @@ describe('my information world commands', () => {
});
});
it.each([
{ train: 110, atmos: 150, expectedTrain: 107, expectedAtmos: 144 },
{ train: 104, atmos: 107, expectedTrain: 101, expectedAtmos: 101 },
{ train: 90, atmos: 90, expectedTrain: 87, expectedAtmos: 84 },
{ train: 21, atmos: 25, expectedTrain: 20, expectedAtmos: 20 },
{
train: 140,
atmos: 180,
scenarioConst: { maxTrainByWar: 120, maxAtmosByWar: 160 },
expectedTrain: 120,
expectedAtmos: 160,
},
{
train: 110,
atmos: 150,
scenarioConst: { maxTrainByWar: 100, maxAtmosByWar: 100 },
expectedTrain: 100,
expectedAtmos: 100,
},
])('applies war caps to no-defence penalties: $train/$atmos', async (testCase) => {
const fixture = buildWorld(buildGeneral({ train: testCase.train, atmos: testCase.atmos }), {
scenarioConst: testCase.scenarioConst,
});
const command = {
type: 'setMySetting' as const,
userId: 'user-7',
generalId: 7,
settings: { defence_train: 999 },
};
await expect(fixture.handler.handle(command)).resolves.toMatchObject({ ok: true });
const expected = {
train: testCase.expectedTrain,
atmos: testCase.expectedAtmos,
meta: { defence_train: 999, myset: 2 },
};
expect(fixture.world.getGeneralById(7)).toMatchObject(expected);
// 같은 설정의 재저장과 수비 재개는 훈련·사기를 다시 차감하지 않는다.
await fixture.handler.handle(command);
expect(fixture.world.getGeneralById(7)).toMatchObject(expected);
await fixture.handler.handle({ ...command, settings: { defence_train: 90 } });
expect(fixture.world.getGeneralById(7)).toMatchObject({
...expected,
meta: { defence_train: 90, myset: 1 },
});
});
it.each([
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
@@ -284,14 +335,18 @@ describe('my information world commands', () => {
] satisfies ScenarioEffectKey[])(
'preserves the %s scenario that waives the no-defence penalty',
async (scenarioEffect) => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
const fixture = buildWorld(buildGeneral({ train: 110, atmos: 150 }), { scenarioEffect });
await fixture.handler.handle({
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
expect(fixture.world.getGeneralById(7)).toMatchObject({
train: 110,
atmos: 150,
meta: { defence_train: 999, myset: 2 },
});
}
);
@@ -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 }),
};
};

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