Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c877b4a6f | ||
|
|
b3f7a419a9 | ||
|
|
af51968782 | ||
|
|
b377250fb9 | ||
|
|
d4a87c981f | ||
|
|
e42eaa4e58 | ||
|
|
2662091a6a | ||
|
|
236f1bc4ed | ||
|
|
8e5bd94cb8 | ||
|
|
e5a39cb714 | ||
|
|
039c5d2a59 | ||
|
|
856187b315 | ||
|
|
b67924a71d | ||
|
|
4e668b9fb6 | ||
|
|
5771701007 | ||
|
|
c5716cf144 | ||
|
|
005f88d7ca | ||
|
|
eeda225429 | ||
|
|
6755f09ee7 | ||
|
|
3d9f7392df | ||
|
|
9dceaf8e4e | ||
|
|
87f810a878 | ||
|
|
e378936051 | ||
|
|
df6c9269fa | ||
|
|
4b00c1052d | ||
|
|
b060bc83bf | ||
|
|
7c65cf60f3 | ||
|
|
5bafdfc1e7 | ||
|
|
cd6ded4970 | ||
|
|
08c3d0e936 | ||
|
|
71155186b7 | ||
|
|
295ecb39a3 | ||
|
|
aea5056834 | ||
|
|
6b65633324 | ||
|
|
daf7a87799 | ||
|
|
11d0a4afdd | ||
|
|
51d0317a69 | ||
|
|
e35679335c | ||
|
|
2b2edeaeff | ||
|
|
8823434610 | ||
|
|
4bf812b601 | ||
|
|
3c75de16e9 | ||
|
|
08be92b6fa | ||
|
|
7e1783d651 | ||
|
|
f74c3322b7 | ||
|
|
4a50c354fe | ||
|
|
0a9c47eddf | ||
|
|
b90dd4499d | ||
|
|
a373ed327d | ||
|
|
9d0a1a4add |
@@ -38,6 +38,15 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
|
|||||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
||||||
WEB_PUSH_OUTBOX_POLL_MS=1000
|
WEB_PUSH_OUTBOX_POLL_MS=1000
|
||||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||||
|
# Browser origins default to GATEWAY_PUBLIC_URL (Game API also accepts GAME_PUBLIC_URL).
|
||||||
|
# Add exact HTTP(S) origins for a separate frontend; wildcards/null/path entries are rejected.
|
||||||
|
# Requests with a different Origin fail with 403 before mutation. No-Origin clients still need auth.
|
||||||
|
# API_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
|
||||||
|
# GAME_PUBLIC_URL=http://localhost:14000
|
||||||
|
# Gateway authentication protection shares atomic 60s counters under this prefix.
|
||||||
|
# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally;
|
||||||
|
# expensive auth work is capped at 4/process. Forwarded headers are not trusted.
|
||||||
|
# These are security defaults in auth/attemptBudget.ts, not disable-able env toggles.
|
||||||
GATEWAY_REDIS_PREFIX=sammo:gateway
|
GATEWAY_REDIS_PREFIX=sammo:gateway
|
||||||
GATEWAY_DB_SCHEMA=public
|
GATEWAY_DB_SCHEMA=public
|
||||||
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
||||||
@@ -103,3 +112,7 @@ VITE_CASUAL_CHAT_URL=
|
|||||||
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
|
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
|
||||||
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
|
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
|
||||||
TURN_BUILD_COMMIT_SHA=
|
TURN_BUILD_COMMIT_SHA=
|
||||||
|
|
||||||
|
# Set both only when builds use the dedicated remote builder; never expose token to VITE_*.
|
||||||
|
RELEASE_BUILDER_URL=
|
||||||
|
RELEASE_BUILDER_TOKEN_FILE=
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m
|
|||||||
분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와
|
분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와
|
||||||
검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로
|
검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로
|
||||||
`build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요.
|
`build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요.
|
||||||
|
- 짧은 제목·상태·버튼·정보 셀은 가로와 세로 정렬을 각각 결정합니다. 가로 가운데만
|
||||||
|
지정하고 끝내지 말고, 왼쪽/오른쪽 정렬 값도 세로 가운데가 적절한지 확인합니다.
|
||||||
|
여러 줄 본문·로그는 읽기 시작점을 보존하고, 좁은 화면의 줄바꿈과 overflow를
|
||||||
|
검증합니다. 구현 방법과 예외는 [두 축 텍스트 정렬](docs/frontend-css-architecture.md#text-alignment-on-both-axes-2026-09-29)을 따릅니다.
|
||||||
- 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`,
|
- 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`,
|
||||||
`.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요.
|
`.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요.
|
||||||
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
||||||
@@ -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 `6.0.3`을 사용해 주세요. CLI project build/typecheck는 root의
|
||||||
`@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local
|
`@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local
|
||||||
다른 compiler version이나 직접 `tsc` 호출을 추가하지 말아 주세요.
|
다른 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 스타일을 유지해 주세요.
|
- TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요.
|
||||||
- public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`,
|
- public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`,
|
||||||
`as unknown as` 우회를 피해 주세요.
|
`as unknown as` 우회를 피해 주세요.
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
||||||
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
||||||
|
|
||||||
|
## 독자별 시작점
|
||||||
|
|
||||||
|
- 게임이 처음인 플레이어: [삼국지 모의전투 첫 안내](docs/user/index.md)
|
||||||
|
- 프로그래밍 기초만 아는 독자: [화면·서버·저장부터 배우기](docs/developer/first-steps.md)
|
||||||
|
- 개발 경험이 있는 독자: [요청·실행·운영의 구조 읽기](docs/developer/system-walkthrough.md)
|
||||||
|
- 전체 문서 탐색: [핸드북](docs/index.md), [게시판 용어 사전](docs/user/glossary.md)
|
||||||
|
|
||||||
## 저장소 구성
|
## 저장소 구성
|
||||||
|
|
||||||
| 경로 | 책임 |
|
| 경로 | 책임 |
|
||||||
@@ -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`
|
- 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
|
- Fastify, tRPC, zod
|
||||||
- Vue 3, Pinia, Vue Router, Vite
|
- Vue 3, Pinia, Vue Router, Vite
|
||||||
- PostgreSQL, Prisma, Redis
|
- PostgreSQL, Prisma, Redis
|
||||||
@@ -92,7 +100,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
|
|||||||
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
|
`package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
|
||||||
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
|
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
|
||||||
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
|
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
|
||||||
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
|
식별합니다. 선언 파일 생성·Vue 검사 범위와 캐시 계약은 [빌드 도구](docs/build-performance.md)를
|
||||||
|
확인해 주세요. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
|
||||||
주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용
|
주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용
|
||||||
toast로 한 번 안내합니다.
|
toast로 한 번 안내합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,10 @@
|
|||||||
"@trpc/server": "^11.8.1",
|
"@trpc/server": "^11.8.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.12.5",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
"sanitize-html": "2.17.6",
|
"sanitize-html": "2.17.7",
|
||||||
"sharp": "^0.35.0",
|
"sharp": "^0.35.5",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
buildSessionRevocationBaselineKey,
|
||||||
|
buildUserSessionRevocationKey,
|
||||||
|
parseSessionRevocationWatermark,
|
||||||
|
SessionRevocationCommandBudget,
|
||||||
|
} from '@sammo-ts/common/auth/sessionRevocation';
|
||||||
|
|
||||||
export interface GatewayUserFlushEvent {
|
export interface GatewayUserFlushEvent {
|
||||||
userId: string;
|
userId: string;
|
||||||
flushedAt: string;
|
flushedAt: string;
|
||||||
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlushStore {
|
export interface FlushStore {
|
||||||
getFlushedAt(userId: string): Date | null;
|
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
|
||||||
applyFlush(event: GatewayUserFlushEvent): void;
|
applyFlush(event: GatewayUserFlushEvent): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RevocationRedisClient {
|
||||||
|
readonly isReady?: boolean;
|
||||||
|
mGet(keys: string[]): Promise<(string | null)[]>;
|
||||||
|
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
|
||||||
|
export class RedisFlushStore implements FlushStore {
|
||||||
|
private readonly local = new Map<string, Date>();
|
||||||
|
private readonly commands = new SessionRevocationCommandBudget(64);
|
||||||
|
constructor(
|
||||||
|
private readonly client: RevocationRedisClient,
|
||||||
|
private readonly channel: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private commandClient(): RevocationRedisClient {
|
||||||
|
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
|
||||||
|
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertInitialized(): Promise<void> {
|
||||||
|
const values = await this.commands.run(() =>
|
||||||
|
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
|
||||||
|
);
|
||||||
|
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
|
||||||
|
throw new Error('Gateway session revocation must be initialized before game API startup.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlushedAt(userId: string): Promise<Date | null> {
|
||||||
|
const values = await this.commands.run(() =>
|
||||||
|
this.commandClient().mGet([
|
||||||
|
buildSessionRevocationBaselineKey(this.channel),
|
||||||
|
buildUserSessionRevocationKey(this.channel, userId),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
|
||||||
|
const baseline = parseSessionRevocationWatermark(values[0]!);
|
||||||
|
if (!baseline) throw new Error('Session revocation storage is unavailable.');
|
||||||
|
const persisted = parseSessionRevocationWatermark(values[1]!);
|
||||||
|
return new Date(
|
||||||
|
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFlush(event: GatewayUserFlushEvent): void {
|
||||||
|
const parsed = new Date(event.flushedAt);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return;
|
||||||
|
const prior = this.local.get(event.userId);
|
||||||
|
if (!prior || parsed > prior) {
|
||||||
|
this.local.delete(event.userId);
|
||||||
|
this.local.set(event.userId, parsed);
|
||||||
|
}
|
||||||
|
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
|
||||||
|
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RedisGatewayFlushSubscriber {
|
export class RedisGatewayFlushSubscriber {
|
||||||
private readonly client: {
|
private readonly client: {
|
||||||
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseNumberWithFallback } from '@sammo-ts/common';
|
import { parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
|
||||||
|
|
||||||
const parseReconcileInterval = (value: string | undefined): number => {
|
const parseReconcileInterval = (value: string | undefined): number => {
|
||||||
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
const parsed = parseNumberWithFallback(value, 30_000, 'ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
|
||||||
@@ -10,6 +10,7 @@ const parseReconcileInterval = (value: string | undefined): number => {
|
|||||||
|
|
||||||
export interface GameApiConfig {
|
export interface GameApiConfig {
|
||||||
host: string;
|
host: string;
|
||||||
|
corsAllowedOrigins: string[];
|
||||||
port: number;
|
port: number;
|
||||||
trpcPath: string;
|
trpcPath: string;
|
||||||
eventsPath: string;
|
eventsPath: string;
|
||||||
@@ -48,6 +49,10 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||||
|
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [
|
||||||
|
env.GATEWAY_PUBLIC_URL,
|
||||||
|
env.GAME_PUBLIC_URL,
|
||||||
|
]),
|
||||||
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
port: parseNumberWithFallback(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||||
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
trpcPath: env.GAME_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ const resolveMapName = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
export const loadMapLayoutByName = async (
|
||||||
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
mapName: string,
|
||||||
|
options: MapLayoutLoaderOptions = {}
|
||||||
|
): Promise<MapLayout> => {
|
||||||
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
|
const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap;
|
||||||
const cached = useCache ? layoutCache.get(mapName) : undefined;
|
const cached = useCache ? layoutCache.get(mapName) : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -98,3 +100,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp
|
|||||||
}
|
}
|
||||||
return layout;
|
return layout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||||
|
const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
|
||||||
|
return loadMapLayoutByName(mapName, options);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra';
|
import {
|
||||||
|
withPlayAuditSavepoint,
|
||||||
|
persistAuditDiplomacyEvents,
|
||||||
|
type AuditDiplomacyEventDraft,
|
||||||
|
type GamePrisma,
|
||||||
|
} from '@sammo-ts/infra';
|
||||||
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
||||||
|
|
||||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -104,6 +109,7 @@ const persistEffects = async (
|
|||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const changes: AuditDiplomacyEventDraft[] = [];
|
const changes: AuditDiplomacyEventDraft[] = [];
|
||||||
|
let collectionFailed = false;
|
||||||
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
||||||
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
||||||
state: row.stateCode,
|
state: row.stateCode,
|
||||||
@@ -129,6 +135,7 @@ const persistEffects = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (audit) {
|
if (audit) {
|
||||||
|
try {
|
||||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||||
const previous = states.get(key);
|
const previous = states.get(key);
|
||||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||||
@@ -145,6 +152,9 @@ const persistEffects = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
states.set(key, updated);
|
states.set(key, updated);
|
||||||
|
} catch {
|
||||||
|
collectionFailed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
||||||
const patch = effect.patch;
|
const patch = effect.patch;
|
||||||
@@ -159,7 +169,11 @@ const persistEffects = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
||||||
await persistAuditDiplomacyEvents(db, changes);
|
if (changes.length || collectionFailed)
|
||||||
|
await withPlayAuditSavepoint(db, async (auditDb) => {
|
||||||
|
if (collectionFailed) throw new Error('Audit relation collection failed');
|
||||||
|
await persistAuditDiplomacyEvents(auditDb, changes);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
|
const MAX_PENDING_FRAMES = 32;
|
||||||
|
const MAX_BUFFER_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
/** Revalidate before work and before writing its result; never retain unbounded frames. */
|
||||||
|
export class AuthenticatedRealtimeConnection {
|
||||||
|
private queue = Promise.resolve();
|
||||||
|
private pending = 0;
|
||||||
|
private ended = false;
|
||||||
|
private readonly expiryTimer: ReturnType<typeof setTimeout>;
|
||||||
|
private cleanup = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly auth: GameSessionTokenPayload,
|
||||||
|
private readonly response: ServerResponse,
|
||||||
|
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
|
||||||
|
) {
|
||||||
|
// AccessTokenStore requires a full remaining TTL second on every lookup.
|
||||||
|
this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999));
|
||||||
|
this.expiryTimer.unref();
|
||||||
|
response.once('close', this.close);
|
||||||
|
response.once('error', this.close);
|
||||||
|
}
|
||||||
|
|
||||||
|
get closed(): boolean {
|
||||||
|
return this.ended;
|
||||||
|
}
|
||||||
|
|
||||||
|
whenIdle(): Promise<void> {
|
||||||
|
return this.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(cleanup: () => void): void {
|
||||||
|
if (this.ended) cleanup();
|
||||||
|
else this.cleanup.add(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentAuth(): Promise<GameSessionTokenPayload | null> {
|
||||||
|
if (this.ended) return null;
|
||||||
|
const current = await this.resolveAuth();
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
this.ended ||
|
||||||
|
current.sessionId !== this.auth.sessionId ||
|
||||||
|
current.user.id !== this.auth.user.id ||
|
||||||
|
current.profile !== this.auth.profile
|
||||||
|
) {
|
||||||
|
this.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(work: (auth: GameSessionTokenPayload) => Promise<string | null>): void {
|
||||||
|
if (this.ended) return;
|
||||||
|
if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pending += 1;
|
||||||
|
this.queue = this.queue
|
||||||
|
.then(async () => {
|
||||||
|
const current = await this.currentAuth();
|
||||||
|
if (!current) return;
|
||||||
|
const frame = await work(current);
|
||||||
|
if (!frame || !(await this.currentAuth())) return;
|
||||||
|
if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Redis/auth failure is fail closed. Reconnect must authenticate again.
|
||||||
|
this.close();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.pending -= 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close = (): void => {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
clearTimeout(this.expiryTimer);
|
||||||
|
this.response.off('close', this.close);
|
||||||
|
this.response.off('error', this.close);
|
||||||
|
for (const cleanup of this.cleanup) cleanup();
|
||||||
|
this.cleanup.clear();
|
||||||
|
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const DEFAULT_LIMITS = { connections: 512, perUser: 8, opening: 32 } as const;
|
||||||
|
const OPENING_DEADLINE_MS = 5000;
|
||||||
|
|
||||||
|
export interface RealtimeConnectionReservation {
|
||||||
|
canContinue(): boolean;
|
||||||
|
reserveUser(userId: string): boolean;
|
||||||
|
startStreaming(): boolean;
|
||||||
|
cancelOpening(): void;
|
||||||
|
release(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One game API process owns both pending setup and live streams; no waiting queue. */
|
||||||
|
export class RealtimeConnectionAdmission {
|
||||||
|
private readonly limits: { connections: number; perUser: number; opening: number };
|
||||||
|
private total = 0;
|
||||||
|
private opening = 0;
|
||||||
|
private closed = false;
|
||||||
|
private readonly byUser = new Map<string, number>();
|
||||||
|
private readonly pending = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(limits: Partial<{ connections: number; perUser: number; opening: number }> = {}) {
|
||||||
|
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||||
|
if (Object.values(this.limits).some((value) => !Number.isSafeInteger(value) || value < 1))
|
||||||
|
throw new Error('Invalid realtime connection limits.');
|
||||||
|
}
|
||||||
|
|
||||||
|
begin(onTimeout: () => void): RealtimeConnectionReservation | null {
|
||||||
|
if (this.closed || this.total >= this.limits.connections || this.opening >= this.limits.opening) return null;
|
||||||
|
this.total += 1;
|
||||||
|
this.opening += 1;
|
||||||
|
let userId: string | null = null;
|
||||||
|
let streaming = false;
|
||||||
|
let cancelled = false;
|
||||||
|
let released = false;
|
||||||
|
const deadline = Date.now() + OPENING_DEADLINE_MS;
|
||||||
|
const expire = () => {
|
||||||
|
if (released || streaming || cancelled) return;
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
onTimeout();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(expire, OPENING_DEADLINE_MS);
|
||||||
|
timer.unref();
|
||||||
|
this.pending.add(expire);
|
||||||
|
const canContinue = () => {
|
||||||
|
if (!released && !streaming && !cancelled && Date.now() >= deadline) expire();
|
||||||
|
return !released && !streaming && !cancelled;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
canContinue,
|
||||||
|
reserveUser: (id) => {
|
||||||
|
if (!canContinue() || userId !== null) return false;
|
||||||
|
const count = this.byUser.get(id) ?? 0;
|
||||||
|
if (count >= this.limits.perUser) return false;
|
||||||
|
this.byUser.set(id, count + 1);
|
||||||
|
userId = id;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
startStreaming: () => {
|
||||||
|
if (!canContinue() || userId === null) return false;
|
||||||
|
streaming = true;
|
||||||
|
this.opening -= 1;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pending.delete(expire);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cancelOpening: () => {
|
||||||
|
if (released || streaming) return;
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
// Disconnect/timeout does not prove the awaited Redis/SQL operation has settled.
|
||||||
|
},
|
||||||
|
release: () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
this.total -= 1;
|
||||||
|
if (!streaming) this.opening -= 1;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pending.delete(expire);
|
||||||
|
if (userId !== null) {
|
||||||
|
const count = (this.byUser.get(userId) ?? 1) - 1;
|
||||||
|
if (count === 0) this.byUser.delete(userId);
|
||||||
|
else this.byUser.set(userId, count);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
for (const expire of this.pending) expire();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,15 @@ export const authRouter = router({
|
|||||||
message: 'Game access is restricted for this account.',
|
message: 'Game access is restricted for this account.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
|
let flushedAt: Date | null;
|
||||||
|
try {
|
||||||
|
flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id);
|
||||||
|
} catch {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'SERVICE_UNAVAILABLE',
|
||||||
|
message: 'Authentication protection is unavailable.',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
|
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
ImageWorkBudget,
|
||||||
|
ImageWorkBusyError,
|
||||||
|
ImageUploadError,
|
||||||
|
isRasterImage,
|
||||||
|
} from '@sammo-ts/common/images/imageUpload';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
|
|||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||||
|
|
||||||
|
const boardImageBudget = new ImageWorkBudget();
|
||||||
|
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||||
|
|
||||||
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
||||||
const MAX_LONG_EDGE = 2048;
|
const MAX_LONG_EDGE = 2048;
|
||||||
const WEBP_QUALITY = 80;
|
const WEBP_QUALITY = 80;
|
||||||
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
|
|||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
{ animated, resize }: { animated: boolean; resize: boolean }
|
{ animated, resize }: { animated: boolean; resize: boolean }
|
||||||
): Promise<Buffer> => {
|
): Promise<Buffer> => {
|
||||||
let pipeline = sharp(buffer, { animated: true });
|
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||||
if (resize) {
|
if (resize) {
|
||||||
pipeline = pipeline.resize({
|
pipeline = pipeline.resize({
|
||||||
width: MAX_LONG_EDGE,
|
width: MAX_LONG_EDGE,
|
||||||
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
|
|||||||
...(animated ? { animated: true } : {}),
|
...(animated ? { animated: true } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return pipeline.webp(webpOptions).toBuffer();
|
return pipeline
|
||||||
|
.webp(webpOptions)
|
||||||
|
.toBuffer()
|
||||||
|
.catch(() => {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
|
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
|
||||||
let pipeline = sharp(buffer, { animated: true });
|
let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
|
||||||
if (resize) {
|
if (resize) {
|
||||||
pipeline = pipeline.resize({
|
pipeline = pipeline.resize({
|
||||||
width: MAX_LONG_EDGE,
|
width: MAX_LONG_EDGE,
|
||||||
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
|
|||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer();
|
return pipeline
|
||||||
|
.avif({ quality: 60, effort: 4 })
|
||||||
|
.toBuffer()
|
||||||
|
.catch(() => {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const boardRouter = router({
|
export const boardRouter = router({
|
||||||
@@ -213,16 +232,39 @@ export const boardRouter = router({
|
|||||||
|
|
||||||
return { id: comment.id };
|
return { id: comment.id };
|
||||||
}),
|
}),
|
||||||
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
uploadImage: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
dataUrl: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { permission } = await getBoardActor(ctx);
|
const { permission } = await getBoardActor(ctx);
|
||||||
assertBoardAccess(permission, true);
|
assertBoardAccess(permission, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await boardImageBudget.run(async () => {
|
||||||
const buffer = parseDataUrl(input.dataUrl);
|
const buffer = parseDataUrl(input.dataUrl);
|
||||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||||
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
|
throw new TRPCError({
|
||||||
|
code: 'PAYLOAD_TOO_LARGE',
|
||||||
|
message: '이미지 용량 제한(1MB)을 초과했습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
if (!isRasterImage(buffer))
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||||
|
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||||
|
.timeout({ seconds: 10 })
|
||||||
|
.metadata()
|
||||||
|
.catch(() => {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||||
|
});
|
||||||
|
if ((metadata.pages ?? 1) > 256)
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||||
if (!metadata.format || !metadata.width || !metadata.height) {
|
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||||
}
|
}
|
||||||
@@ -249,7 +291,11 @@ export const boardRouter = router({
|
|||||||
animated: isAnimated || format === 'gif',
|
animated: isAnimated || format === 'gif',
|
||||||
resize: needsResize,
|
resize: needsResize,
|
||||||
});
|
});
|
||||||
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
|
if (
|
||||||
|
format === 'webp' &&
|
||||||
|
!needsResize &&
|
||||||
|
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
|
||||||
|
) {
|
||||||
outputBuffer = buffer;
|
outputBuffer = buffer;
|
||||||
outputFormat = 'webp';
|
outputFormat = 'webp';
|
||||||
} else {
|
} else {
|
||||||
@@ -259,7 +305,10 @@ export const boardRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.contentImageUpload) {
|
if (!ctx.contentImageUpload) {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||||
const uploaded = await ctx.contentImageUpload.upload({
|
const uploaded = await ctx.contentImageUpload.upload({
|
||||||
@@ -268,7 +317,9 @@ export const boardRouter = router({
|
|||||||
body: outputBuffer,
|
body: outputBuffer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
|
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||||
|
.timeout({ seconds: 10 })
|
||||||
|
.metadata();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: uploaded.publicUrl,
|
url: uploaded.publicUrl,
|
||||||
@@ -278,5 +329,19 @@ export const boardRouter = router({
|
|||||||
animated: isAnimated,
|
animated: isAnimated,
|
||||||
size: outputBuffer.length,
|
size: outputBuffer.length,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ImageWorkBusyError)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
});
|
||||||
|
if (error instanceof ImageUploadError)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'SERVICE_UNAVAILABLE',
|
||||||
|
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
|
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import {
|
import {
|
||||||
@@ -989,7 +989,7 @@ export const generalRouter = router({
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
||||||
const [onlineAccess, ownNation, openPolls, gameTime] = await Promise.all([
|
const [onlineAccess, ownNation, openPolls, gameTime, officeRequestRows] = await Promise.all([
|
||||||
ctx.db.generalAccessLog.findMany({
|
ctx.db.generalAccessLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
lastActionAt: {
|
lastActionAt: {
|
||||||
@@ -1020,6 +1020,15 @@ export const generalRouter = router({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
loadCurrentGameTime(ctx.db, now),
|
loadCurrentGameTime(ctx.db, now),
|
||||||
|
me.nationId > 0 && me.officerLevel >= 5 && !asRecord(me.penalty).noChief
|
||||||
|
? ctx.db.general.findMany({
|
||||||
|
where: {
|
||||||
|
nationId: me.nationId,
|
||||||
|
meta: { path: ['cityOfficeRequest', 'status'], equals: 'pending' },
|
||||||
|
},
|
||||||
|
select: { id: true, userId: true, meta: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||||
// logical clock advances through negative ticks before the opening anchor,
|
// logical clock advances through negative ticks before the opening anchor,
|
||||||
@@ -1088,6 +1097,19 @@ export const generalRouter = router({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
|
cityOfficeRequests: {
|
||||||
|
generalId: me.id,
|
||||||
|
nationId: me.nationId,
|
||||||
|
ids: officeRequestRows.flatMap((row) => {
|
||||||
|
const request = readOfficeRequest(row.meta);
|
||||||
|
return request?.status === 'pending' &&
|
||||||
|
request.generalId === row.id &&
|
||||||
|
request.nationId === me.nationId &&
|
||||||
|
request.userId === row.userId
|
||||||
|
? [request.id]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
},
|
||||||
onlineUserCount: onlineGenerals.length,
|
onlineUserCount: onlineGenerals.length,
|
||||||
onlineNations,
|
onlineNations,
|
||||||
onlineGenerals: myOnlineGenerals,
|
onlineGenerals: myOnlineGenerals,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
GAME_TICKS_PER_TURN,
|
||||||
|
officeRequestQuarter,
|
||||||
|
readOfficeRequest,
|
||||||
|
type CityOfficeLevel,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
|
import { resolveOfficeRequestEligibility, type OfficeCandidate } from '@sammo-ts/logic';
|
||||||
|
import { accessAuthedProcedure, engineAuthedProcedure } from '../../../trpc.js';
|
||||||
|
import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js';
|
||||||
|
import { throwIfCommandRejected } from '../../shared/turnDaemon.js';
|
||||||
|
import { assertNationAccess, resolveChiefStatMin } from '../shared.js';
|
||||||
|
import { loadCurrentGameTime } from '../../../services/gameClock.js';
|
||||||
|
|
||||||
|
export const cityOfficeRequest = engineAuthedProcedure
|
||||||
|
.input(
|
||||||
|
z.discriminatedUnion('action', [
|
||||||
|
z.object({
|
||||||
|
action: z.literal('request'),
|
||||||
|
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.enum(['approve', 'reject', 'withdraw']),
|
||||||
|
targetGeneralId: z.number().int().positive(),
|
||||||
|
officeRequestId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const general = await getMyGeneral(ctx);
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
...input,
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
userId: getAuthenticatedUserId(ctx),
|
||||||
|
generalId: general.id,
|
||||||
|
...(ctx.requestId
|
||||||
|
? { requestId: `${ctx.requestId}:nation.cityOfficeRequest:engine:0:cityOfficeRequest` }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
throwIfCommandRejected(result);
|
||||||
|
if (!result || result.type !== 'cityOfficeRequest')
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
if (!result.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getCityOfficeRequests = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
assertNationAccess(me);
|
||||||
|
const [nation, cities, rows, state, clock] = await Promise.all([
|
||||||
|
ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { meta: true } }),
|
||||||
|
ctx.db.city.findMany({
|
||||||
|
where: { nationId: me.nationId },
|
||||||
|
select: { id: true, nationId: true, name: true, meta: true },
|
||||||
|
}),
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
where: { nationId: me.nationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
name: true,
|
||||||
|
nationId: true,
|
||||||
|
cityId: true,
|
||||||
|
officerLevel: true,
|
||||||
|
npcState: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
|
meta: true,
|
||||||
|
penalty: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.worldState.findFirst(),
|
||||||
|
loadCurrentGameTime(ctx.db),
|
||||||
|
]);
|
||||||
|
if (!nation || !state)
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가 또는 게임 상태를 찾을 수 없습니다.' });
|
||||||
|
const candidates = rows.map((row) => ({ ...row, intelligence: row.intel }));
|
||||||
|
const applicant: OfficeCandidate = { ...me, intelligence: me.intel };
|
||||||
|
const city = cities.find((entry) => entry.id === me.cityId) ?? null;
|
||||||
|
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
|
||||||
|
const own = readOfficeRequest(me.meta);
|
||||||
|
const requests = candidates.flatMap((general) => {
|
||||||
|
const request = readOfficeRequest(general.meta);
|
||||||
|
if (
|
||||||
|
!request ||
|
||||||
|
request.generalId !== general.id ||
|
||||||
|
request.nationId !== me.nationId ||
|
||||||
|
request.userId !== general.userId
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
return [{ general, request }];
|
||||||
|
});
|
||||||
|
const evaluate = (general: OfficeCandidate, cityId: number, level: CityOfficeLevel) =>
|
||||||
|
resolveOfficeRequestEligibility({
|
||||||
|
applicant: general,
|
||||||
|
city: cities.find((entry) => entry.id === cityId) ?? null,
|
||||||
|
level,
|
||||||
|
incumbent:
|
||||||
|
candidates.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.officerLevel === level &&
|
||||||
|
Number(asRecord(entry.meta).officerCity ?? asRecord(entry.meta).officer_city ?? 0) === cityId
|
||||||
|
) ?? null,
|
||||||
|
nationMeta: nation.meta,
|
||||||
|
chiefStatMin: resolveChiefStatMin(state),
|
||||||
|
});
|
||||||
|
const canManage = me.officerLevel >= 5 && !asRecord(me.penalty).noChief;
|
||||||
|
const nowTick = clock.tick;
|
||||||
|
const options = ([4, 3, 2] as const).map((level) => {
|
||||||
|
const check = evaluate(applicant, me.cityId, level);
|
||||||
|
const occupied = requests.some(
|
||||||
|
({ request }) =>
|
||||||
|
request.status === 'pending' && request.cityId === me.cityId && request.officerLevel === level
|
||||||
|
);
|
||||||
|
const reason =
|
||||||
|
check.reason ??
|
||||||
|
(own?.status === 'pending'
|
||||||
|
? '요청 대기 중'
|
||||||
|
: own?.quarter === quarter
|
||||||
|
? '이번 분기 자원 완료'
|
||||||
|
: occupied
|
||||||
|
? '다른 장수 자원 중'
|
||||||
|
: nowTick === null
|
||||||
|
? '게임 시계 준비 중'
|
||||||
|
: null);
|
||||||
|
return { level, ...check, allowed: !reason, reason };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
canManage,
|
||||||
|
generalId: me.id,
|
||||||
|
nationId: me.nationId,
|
||||||
|
serverId: String(asRecord(state.meta).serverId ?? ctx.profile?.name ?? 'game'),
|
||||||
|
city: city ? { id: city.id, name: city.name } : null,
|
||||||
|
options,
|
||||||
|
running: clock.running,
|
||||||
|
tickSeconds: state.tickSeconds,
|
||||||
|
requests: requests
|
||||||
|
.filter(({ general, request }) => general.id === me.id || (canManage && request.status === 'pending'))
|
||||||
|
.map(({ general, request }) => {
|
||||||
|
const check = evaluate(general, request.cityId, request.officerLevel);
|
||||||
|
const defaultDecision =
|
||||||
|
request.defaultDecision === 'reject' || check.defaultDecision === 'reject'
|
||||||
|
? ('reject' as const)
|
||||||
|
: ('approve' as const);
|
||||||
|
const months = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((request.dueTick - Number(state.lastTurnTick ?? 0)) / GAME_TICKS_PER_TURN)
|
||||||
|
);
|
||||||
|
const monthIndex = state.currentYear * 12 + state.currentMonth - 1 + months;
|
||||||
|
return {
|
||||||
|
id: request.id,
|
||||||
|
generalId: general.id,
|
||||||
|
generalName: general.name,
|
||||||
|
cityId: request.cityId,
|
||||||
|
cityName: cities.find((entry) => entry.id === request.cityId)?.name ?? '-',
|
||||||
|
officerLevel: request.officerLevel,
|
||||||
|
status: request.status,
|
||||||
|
defaultDecision,
|
||||||
|
defaultReason: check.defaultReason === 'secret' ? ('secret' as const) : request.defaultReason,
|
||||||
|
resultReason: request.resultReason ?? null,
|
||||||
|
dueYear: Math.floor(monthIndex / 12),
|
||||||
|
dueMonth: (monthIndex % 12) + 1,
|
||||||
|
remainingSeconds:
|
||||||
|
nowTick === null
|
||||||
|
? null
|
||||||
|
: Math.max(0, ((request.dueTick - nowTick) / GAME_TICKS_PER_TURN) * state.tickSeconds),
|
||||||
|
invalidReason: request.status === 'pending' ? check.reason : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cityOfficeRequest, getCityOfficeRequests } from './endpoints/cityOfficeRequests.js';
|
||||||
import { router } from '../../trpc.js';
|
import { router } from '../../trpc.js';
|
||||||
import { appoint } from './endpoints/appoint.js';
|
import { appoint } from './endpoints/appoint.js';
|
||||||
import { changePermission } from './endpoints/changePermission.js';
|
import { changePermission } from './endpoints/changePermission.js';
|
||||||
@@ -25,6 +26,8 @@ export const nationRouter = router({
|
|||||||
getSecretGeneralList,
|
getSecretGeneralList,
|
||||||
getCityOverview,
|
getCityOverview,
|
||||||
getPersonnelInfo,
|
getPersonnelInfo,
|
||||||
|
getCityOfficeRequests,
|
||||||
|
cityOfficeRequest,
|
||||||
getStratFinan,
|
getStratFinan,
|
||||||
setNotice,
|
setNotice,
|
||||||
setScoutMsg,
|
setScoutMsg,
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit
|
|||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||||
|
import { policyHistory, policyVersion } from './history.js';
|
||||||
|
|
||||||
type SetterInfo = {
|
type SetterInfo = {
|
||||||
setter: string | null;
|
setter: string | null;
|
||||||
@@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const npcRouter = router({
|
export const npcRouter = router({
|
||||||
|
policyHistory,
|
||||||
|
policyVersion,
|
||||||
getPolicy: authedProcedure.query(async ({ ctx }) => {
|
getPolicy: authedProcedure.query(async ({ ctx }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
if (general.nationId <= 0) {
|
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,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -173,7 +173,35 @@ export const decisionHistory = auditProcedure
|
|||||||
.query(({ ctx, input }) =>
|
.query(({ ctx, input }) =>
|
||||||
readAudit(ctx, async (tx) => {
|
readAudit(ctx, async (tx) => {
|
||||||
const world = await readAuditWorld(tx);
|
const world = await readAuditWorld(tx);
|
||||||
const month = input.month ?? { year: world.year, month: world.month };
|
// 장수 턴은 월 경계와 동시에 실행되지 않는다. 현재 상태에서는 마지막으로
|
||||||
|
// 실제 결정이 저장된 월을 열고, 명시한 과거 월은 빈 월이어도 그대로 보존한다.
|
||||||
|
const latest =
|
||||||
|
!input.month && world.serverId
|
||||||
|
? await tx.playAuditDecision.findFirst({
|
||||||
|
where: {
|
||||||
|
serverId: world.serverId,
|
||||||
|
generalId: input.generalId,
|
||||||
|
phase: input.phase,
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ year: { gt: world.startYear } },
|
||||||
|
{ year: world.startYear, month: { gte: world.startMonth } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ year: { lt: world.year } },
|
||||||
|
{ year: world.year, month: { lte: world.month } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ year: 'desc' }, { month: 'desc' }, { tick: 'desc' }, { id: 'desc' }],
|
||||||
|
select: { year: true, month: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const month = input.month ?? latest ?? { year: world.year, month: world.month };
|
||||||
const ordinal = monthOrdinal(month.year, month.month);
|
const ordinal = monthOrdinal(month.year, month.month);
|
||||||
if (
|
if (
|
||||||
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
|
ordinal < monthOrdinal(world.startYear, world.startMonth) ||
|
||||||
@@ -206,6 +234,8 @@ export const decisionHistory = auditProcedure
|
|||||||
return {
|
return {
|
||||||
...world,
|
...world,
|
||||||
month,
|
month,
|
||||||
|
currentMonth: { year: world.year, month: world.month },
|
||||||
|
selection: input.month ? ('MONTH' as const) : ('LATEST' as const),
|
||||||
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||||
items: rows.slice(0, input.limit).map(project),
|
items: rows.slice(0, input.limit).map(project),
|
||||||
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
|
nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { generalSelect, projectCurrentGeneral, zAuditGeneralData } from './projection.js';
|
||||||
|
|
||||||
|
export const zGeneralSort = z.enum([
|
||||||
|
'id',
|
||||||
|
'gold',
|
||||||
|
'rice',
|
||||||
|
'crew',
|
||||||
|
'train',
|
||||||
|
'atmos',
|
||||||
|
'leadership',
|
||||||
|
'strength',
|
||||||
|
'intelligence',
|
||||||
|
'experience',
|
||||||
|
'dedication',
|
||||||
|
'dex1',
|
||||||
|
'dex2',
|
||||||
|
'dex3',
|
||||||
|
'dex4',
|
||||||
|
'dex5',
|
||||||
|
]);
|
||||||
|
export const zGeneralCursor = z.object({ id: z.number().int().nonnegative(), value: z.number().nullable() }).strict();
|
||||||
|
const columns: Record<z.infer<typeof zGeneralSort>, string> = {
|
||||||
|
id: 'id',
|
||||||
|
gold: 'gold',
|
||||||
|
rice: 'rice',
|
||||||
|
crew: 'crew',
|
||||||
|
train: 'train',
|
||||||
|
atmos: 'atmos',
|
||||||
|
leadership: 'leadership',
|
||||||
|
strength: 'strength',
|
||||||
|
intelligence: 'intel',
|
||||||
|
experience: 'experience',
|
||||||
|
dedication: 'dedication',
|
||||||
|
dex1: 'dex1',
|
||||||
|
dex2: 'dex2',
|
||||||
|
dex3: 'dex3',
|
||||||
|
dex4: 'dex4',
|
||||||
|
dex5: 'dex5',
|
||||||
|
};
|
||||||
|
export const readSortedGenerals = async (
|
||||||
|
tx: GamePrisma.TransactionClient,
|
||||||
|
input: {
|
||||||
|
sort: z.infer<typeof zGeneralSort>;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
limit: number;
|
||||||
|
cursor?: number | z.infer<typeof zGeneralCursor>;
|
||||||
|
nationId?: number;
|
||||||
|
cityId?: number;
|
||||||
|
population?: 'human' | 'npc' | 'troopNpc';
|
||||||
|
name?: string;
|
||||||
|
},
|
||||||
|
sampleId?: string
|
||||||
|
) => {
|
||||||
|
if (typeof input.cursor === 'number')
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '정렬 조건에 맞는 다음 페이지를 선택해 주세요.' });
|
||||||
|
// SQL 식별자/JSON 경로는 서버 allowlist에서만 조립한다. 사용자 문자열은 parameter다.
|
||||||
|
const id = GamePrisma.raw(sampleId ? 'general_id' : 'id');
|
||||||
|
const path = ['leadership', 'strength', 'intelligence'].includes(input.sort)
|
||||||
|
? `stats,${input.sort}`
|
||||||
|
: input.sort.startsWith('dex')
|
||||||
|
? `dex,${input.sort}`
|
||||||
|
: input.sort;
|
||||||
|
const metric = sampleId
|
||||||
|
? GamePrisma.raw(`(data #>> '{${path}}')::double precision`)
|
||||||
|
: input.sort.startsWith('dex')
|
||||||
|
? GamePrisma.raw(
|
||||||
|
`CASE WHEN jsonb_typeof(meta->'${input.sort}') = 'number' THEN (meta->>'${input.sort}')::double precision ELSE 0 END`
|
||||||
|
)
|
||||||
|
: GamePrisma.raw(columns[input.sort]);
|
||||||
|
const filters: GamePrisma.Sql[] = [];
|
||||||
|
if (sampleId) filters.push(GamePrisma.sql`sample_id = ${sampleId}`);
|
||||||
|
if (input.nationId !== undefined) filters.push(GamePrisma.sql`nation_id = ${input.nationId}`);
|
||||||
|
if (input.cityId !== undefined) filters.push(GamePrisma.sql`city_id = ${input.cityId}`);
|
||||||
|
if (input.population === 'human') filters.push(GamePrisma.sql`npc_state < 2`);
|
||||||
|
if (input.population === 'npc') filters.push(GamePrisma.sql`npc_state >= 2 AND npc_state <> 5`);
|
||||||
|
if (input.population === 'troopNpc') filters.push(GamePrisma.sql`npc_state = 5`);
|
||||||
|
if (input.name)
|
||||||
|
filters.push(
|
||||||
|
GamePrisma.sql`${GamePrisma.raw(sampleId ? "data->>'name'" : 'name')} LIKE ${`%${input.name.replace(/[\\%_]/g, '\\$&')}%`}`
|
||||||
|
);
|
||||||
|
if (input.cursor) {
|
||||||
|
const { value, id: cursorId } = input.cursor;
|
||||||
|
filters.push(
|
||||||
|
value === null
|
||||||
|
? GamePrisma.sql`(${metric} IS NULL AND ${id} > ${cursorId})`
|
||||||
|
: GamePrisma.sql`(${metric} ${GamePrisma.raw(input.order === 'asc' ? '>' : '<')} ${value} OR (${metric} = ${value} AND ${id} > ${cursorId}) OR ${metric} IS NULL)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows = await tx.$queryRaw<{ id: number; value: number | null; data: unknown }[]>(GamePrisma.sql`
|
||||||
|
SELECT ${id} AS id, ${metric} AS value, ${sampleId ? GamePrisma.raw('data') : GamePrisma.sql`NULL`} AS data
|
||||||
|
FROM ${GamePrisma.raw(sampleId ? 'play_audit_general' : 'general')}
|
||||||
|
WHERE ${filters.length ? GamePrisma.join(filters, ' AND ') : GamePrisma.sql`TRUE`}
|
||||||
|
ORDER BY ${metric} ${GamePrisma.raw(input.order.toUpperCase())} NULLS LAST, ${id} ASC LIMIT ${input.limit + 1}
|
||||||
|
`);
|
||||||
|
const page = rows.slice(0, input.limit);
|
||||||
|
const current = sampleId
|
||||||
|
? []
|
||||||
|
: await tx.general.findMany({ where: { id: { in: page.map((row) => row.id) } }, select: generalSelect });
|
||||||
|
const byId = new Map(current.map((row) => [row.id, projectCurrentGeneral(row)]));
|
||||||
|
return {
|
||||||
|
items: page.map((row) => (sampleId ? zAuditGeneralData.parse(row.data) : byId.get(row.id)!)),
|
||||||
|
nextCursor:
|
||||||
|
rows.length > input.limit ? { id: page[page.length - 1]!.id, value: page[page.length - 1]!.value } : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { cityMap } from './cityMap.js';
|
||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { readSortedGenerals, zGeneralSort, zGeneralCursor } from './generalSort.js';
|
||||||
import { requestState } from './requests.js';
|
import { requestState } from './requests.js';
|
||||||
import { decisionHistory, decisionDetail } from './decisions.js';
|
import { decisionHistory, decisionDetail } from './decisions.js';
|
||||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||||
@@ -27,6 +30,7 @@ import {
|
|||||||
} from './projection.js';
|
} from './projection.js';
|
||||||
|
|
||||||
export const playAuditRouter = router({
|
export const playAuditRouter = router({
|
||||||
|
cityMap,
|
||||||
requestState,
|
requestState,
|
||||||
decisionHistory,
|
decisionHistory,
|
||||||
decisionDetail,
|
decisionDetail,
|
||||||
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
|
|||||||
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
|
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
|
||||||
name: z.string().trim().max(64).optional(),
|
name: z.string().trim().max(64).optional(),
|
||||||
order: z.enum(['asc', 'desc']).default('asc'),
|
order: z.enum(['asc', 'desc']).default('asc'),
|
||||||
|
sort: zGeneralSort.default('id'),
|
||||||
|
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(({ ctx, input }) =>
|
.query(({ ctx, input }) =>
|
||||||
readAudit(ctx, async (tx) => {
|
readAudit(ctx, async (tx) => {
|
||||||
const world = await readAuditWorld(tx);
|
const world = await readAuditWorld(tx);
|
||||||
|
if (input.sort !== 'id') {
|
||||||
|
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
|
||||||
|
if (input.at && !sample) return { ...world, sample, collected: false, items: [], nextCursor: null };
|
||||||
|
return { ...world, sample, collected: true, ...(await readSortedGenerals(tx, input, sample?.id)) };
|
||||||
|
}
|
||||||
|
if (typeof input.cursor === 'object')
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 번호 정렬의 다음 페이지가 아닙니다.' });
|
||||||
const npcState =
|
const npcState =
|
||||||
input.population === 'human'
|
input.population === 'human'
|
||||||
? { lt: 2 }
|
? { lt: 2 }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||||
@@ -5,6 +7,7 @@ import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth }
|
|||||||
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
|
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
|
||||||
const zPopulation = z.object({
|
const zPopulation = z.object({
|
||||||
count: z.number().int().nonnegative(),
|
count: z.number().int().nonnegative(),
|
||||||
|
crew: z.number().nonnegative().nullable().default(null),
|
||||||
gold: z.number(),
|
gold: z.number(),
|
||||||
rice: z.number(),
|
rice: z.number(),
|
||||||
dex: zDex,
|
dex: zDex,
|
||||||
@@ -92,6 +95,34 @@ export const summarizeNationPeriod = (start: number, width: number, months: Nati
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 같은 transaction에서 commit된 당월 정산만 반환한다. 미관측을 0으로 만들지 않는다. */
|
||||||
|
export const projectCurrentSettlement = (meta: unknown, year: number, month: number, nationId: number) => {
|
||||||
|
const flows = asRecord(asRecord(meta).playAuditFlows);
|
||||||
|
const matches = flows.year === year && flows.month === month;
|
||||||
|
const entries = asRecord(flows.entries);
|
||||||
|
const resource = (key: 'gold' | 'rice') => {
|
||||||
|
const row = asRecord(entries[`${nationId}:${key}`]);
|
||||||
|
if (
|
||||||
|
!matches ||
|
||||||
|
row.nationId !== nationId ||
|
||||||
|
row.resource !== key ||
|
||||||
|
typeof row.income !== 'number' ||
|
||||||
|
!Number.isFinite(row.income) ||
|
||||||
|
typeof row.paid !== 'number' ||
|
||||||
|
!Number.isFinite(row.paid)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return { income: row.income, paid: row.paid };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
gold: resource('gold'),
|
||||||
|
rice: resource('rice'),
|
||||||
|
complete: matches && flows.complete === true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const zCalendarMonth = zAuditMonth.omit({ kind: true });
|
const zCalendarMonth = zAuditMonth.omit({ kind: true });
|
||||||
export const nationSeries = auditProcedure
|
export const nationSeries = auditProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -151,6 +182,35 @@ export const nationSeries = auditProcedure
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
|
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
|
||||||
|
// 기존 집계는 같은 월의 장수 표본을 DB에서 합산한다. 원문은 전송하지 않는다.
|
||||||
|
const legacySamples = [...dataBySample]
|
||||||
|
.filter(([, nation]) => Object.values(nation.populations).some((group) => group.crew === null))
|
||||||
|
.map(([id]) => id);
|
||||||
|
if (legacySamples.length) {
|
||||||
|
const troops = await tx.$queryRaw<
|
||||||
|
{ sampleId: string; population: 'human' | 'npc' | 'troopNpc'; count: number; crew: number | null }[]
|
||||||
|
>(GamePrisma.sql`
|
||||||
|
SELECT sample_id AS "sampleId",
|
||||||
|
CASE WHEN npc_state = 5 THEN 'troopNpc' WHEN npc_state < 2 THEN 'human' ELSE 'npc' END AS population,
|
||||||
|
count(*)::int AS count,
|
||||||
|
CASE WHEN bool_and(jsonb_typeof(data->'crew') = 'number' AND data->'crew' IS NOT NULL)
|
||||||
|
THEN sum(CASE WHEN jsonb_typeof(data->'crew') = 'number' THEN (data->>'crew')::double precision END) ELSE NULL END AS crew
|
||||||
|
FROM play_audit_general
|
||||||
|
WHERE nation_id = ${input.nationId} AND sample_id IN (${GamePrisma.join(legacySamples)})
|
||||||
|
GROUP BY sample_id, population
|
||||||
|
`);
|
||||||
|
for (const sampleId of legacySamples) {
|
||||||
|
const nation = dataBySample.get(sampleId)!;
|
||||||
|
for (const key of ['human', 'npc', 'troopNpc'] as const) {
|
||||||
|
const group = nation.populations[key];
|
||||||
|
if (group.crew !== null) continue;
|
||||||
|
const row = troops.find((item) => item.sampleId === sampleId && item.population === key);
|
||||||
|
// 저장 인원수와 원본 표본 수가 같을 때만 확정한다.
|
||||||
|
if (group.count === 0 && !row) group.crew = 0;
|
||||||
|
else if (row?.count === group.count) group.crew = row.crew;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
|
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
|
||||||
const items: ReturnType<typeof summarizeNationPeriod>[] = [];
|
const items: ReturnType<typeof summarizeNationPeriod>[] = [];
|
||||||
for (let period = start; period <= end; period += width) {
|
for (let period = start; period <= end; period += width) {
|
||||||
@@ -166,6 +226,17 @@ export const nationSeries = auditProcedure
|
|||||||
}
|
}
|
||||||
items.push(summarizeNationPeriod(period, width, months));
|
items.push(summarizeNationPeriod(period, width, months));
|
||||||
}
|
}
|
||||||
return { ...world, items, nextCursor: end < to ? dateOf(start + input.limit * width) : null };
|
const currentState =
|
||||||
|
world.serverId && end === current
|
||||||
|
? await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } })
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...world,
|
||||||
|
items,
|
||||||
|
currentSettlement: currentState
|
||||||
|
? projectCurrentSettlement(currentState.meta, world.year, world.month, input.nationId)
|
||||||
|
: null,
|
||||||
|
nextCursor: end < to ? dateOf(start + input.limit * width) : null,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
|
|||||||
startYear,
|
startYear,
|
||||||
startMonth,
|
startMonth,
|
||||||
collectionStart,
|
collectionStart,
|
||||||
|
historyGap: (() => {
|
||||||
|
const gap = asRecord(meta.playAuditGap);
|
||||||
|
return gap.serverId === serverId && Number.isInteger(gap.firstYear) && Number.isInteger(gap.firstMonth)
|
||||||
|
? { firstYear: Number(gap.firstYear), firstMonth: Number(gap.firstMonth) }
|
||||||
|
: null;
|
||||||
|
})(),
|
||||||
tick: world.lastTurnTick?.toString() ?? null,
|
tick: world.lastTurnTick?.toString() ?? null,
|
||||||
asOf: new Date().toISOString(),
|
asOf: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import type { TournamentType } from '@sammo-ts/logic';
|
import type { TournamentType } from '@sammo-ts/logic';
|
||||||
import type { TournamentState } from '../../tournament/types.js';
|
import type { TournamentState } from '../../tournament/types.js';
|
||||||
|
|
||||||
@@ -18,6 +18,16 @@ import {
|
|||||||
} from '../../services/redisClockFence.js';
|
} from '../../services/redisClockFence.js';
|
||||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
||||||
|
|
||||||
|
const assertTournamentAdjustment = (result: TurnDaemonCommandResult | null): void => {
|
||||||
|
if (result?.type === 'commandRejected') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
||||||
|
}
|
||||||
|
if (!result || result.type !== 'tournamentAdjustGeneral') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected tournament response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) throw new TRPCError({ code: result.code, message: result.reason });
|
||||||
|
};
|
||||||
|
|
||||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||||
return true;
|
return true;
|
||||||
@@ -197,6 +207,7 @@ const zMatch = z.object({
|
|||||||
|
|
||||||
const zBetEntry = z.object({
|
const zBetEntry = z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
|
userId: z.string().min(1).nullable().optional(),
|
||||||
targetId: z.number().int().positive(),
|
targetId: z.number().int().positive(),
|
||||||
amount: z.number().int().positive(),
|
amount: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
@@ -458,7 +469,7 @@ export const tournamentRouter = router({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const [state, entries, matches] = await Promise.all([
|
const [state, entries, matches] = await Promise.all([
|
||||||
store.getState(),
|
store.getState(),
|
||||||
@@ -488,7 +499,7 @@ export const tournamentRouter = router({
|
|||||||
}
|
}
|
||||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||||
totalAmount += entry.amount;
|
totalAmount += entry.amount;
|
||||||
if (entry.generalId === general.id) {
|
if (entry.userId === ctx.auth!.user.id) {
|
||||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||||
myAmount += entry.amount;
|
myAmount += entry.amount;
|
||||||
}
|
}
|
||||||
@@ -523,20 +534,15 @@ export const tournamentRouter = router({
|
|||||||
|
|
||||||
const develCost = resolveCurrentDevelCost(worldState);
|
const develCost = resolveCurrentDevelCost(worldState);
|
||||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
||||||
reason: 'tournamentJoin',
|
reason: 'tournamentJoin',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
userId: ctx.auth!.user.id,
|
||||||
|
generalId: general.id,
|
||||||
|
goldDelta: -develCost,
|
||||||
|
minGoldAfter: 0,
|
||||||
});
|
});
|
||||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
assertTournamentAdjustment(feeResult);
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
|
||||||
}
|
|
||||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = asRecord(general.meta);
|
const meta = asRecord(general.meta);
|
||||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||||
@@ -558,12 +564,17 @@ export const tournamentRouter = router({
|
|||||||
try {
|
try {
|
||||||
await store.setParticipants(next);
|
await store.setParticipants(next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
assertTournamentAdjustment(
|
||||||
await ctx.turnDaemon.requestCommand({
|
await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||||
reason: 'tournamentJoinRollback',
|
reason: 'tournamentJoinRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
userId: ctx.auth!.user.id,
|
||||||
});
|
generalId: general.id,
|
||||||
|
goldDelta: develCost,
|
||||||
|
minGoldAfter: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { ok: true, count: next.length };
|
return { ok: true, count: next.length };
|
||||||
@@ -663,8 +674,13 @@ export const tournamentRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 구형 row의 당시 actor는 현재 장수 소유자로 추정할 수 없다.
|
||||||
|
// 운영 전환은 베팅 종료 뒤 하거나 검증한 actor 정보를 관리자가 복원해야 한다.
|
||||||
|
if (entries.some((entry) => entry.userId === undefined)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이전 베팅의 사용자 확인이 필요합니다.' });
|
||||||
|
}
|
||||||
const previousBetAmount = entries
|
const previousBetAmount = entries
|
||||||
.filter((entry) => entry.generalId === general.id)
|
.filter((entry) => entry.userId === ctx.auth!.user.id)
|
||||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
if (previousBetAmount + input.amount > 1_000) {
|
if (previousBetAmount + input.amount > 1_000) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -674,68 +690,37 @@ export const tournamentRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
userId: ctx.auth!.user.id,
|
||||||
});
|
|
||||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
|
||||||
}
|
|
||||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
|
||||||
type: 'adjustGeneralMeta',
|
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
|
||||||
reason: 'tournamentBet',
|
|
||||||
adjustments: [
|
|
||||||
{
|
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
metaDelta: { betgold: input.amount },
|
goldDelta: -input.amount,
|
||||||
},
|
betGoldDelta: input.amount,
|
||||||
],
|
minGoldAfter: 500,
|
||||||
});
|
});
|
||||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
assertTournamentAdjustment(adjustResult);
|
||||||
await ctx.turnDaemon.requestCommand({
|
|
||||||
type: 'adjustGeneralResources',
|
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
|
||||||
reason: 'tournamentBetRollback',
|
|
||||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
|
||||||
});
|
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.appendBettingEntry({
|
await store.appendBettingEntry({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
|
userId: ctx.auth!.user.id,
|
||||||
targetId: input.targetId,
|
targetId: input.targetId,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.all([
|
assertTournamentAdjustment(
|
||||||
ctx.turnDaemon.requestCommand({
|
await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
userId: ctx.auth!.user.id,
|
||||||
}),
|
|
||||||
ctx.turnDaemon.requestCommand({
|
|
||||||
type: 'adjustGeneralMeta',
|
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
|
||||||
reason: 'tournamentBetRollback',
|
|
||||||
adjustments: [
|
|
||||||
{
|
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
metaDelta: { betgold: -input.amount },
|
goldDelta: input.amount,
|
||||||
},
|
betGoldDelta: -input.amount,
|
||||||
],
|
minGoldAfter: 0,
|
||||||
}),
|
})
|
||||||
]);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
buildRecruitmentCommandInfo,
|
buildRecruitmentCommandInfo,
|
||||||
buildTurnCommandTable,
|
buildTurnCommandTable,
|
||||||
evaluateReservedTurnPermission,
|
evaluateReservedTurnPermission,
|
||||||
|
resolveCommandJoinEnv,
|
||||||
} from '../../turns/commandTable.js';
|
} from '../../turns/commandTable.js';
|
||||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +46,10 @@ import {
|
|||||||
buildRefAmountPresets,
|
buildRefAmountPresets,
|
||||||
buildRefGeneralTargetOptions,
|
buildRefGeneralTargetOptions,
|
||||||
buildRefNationTargetOptions,
|
buildRefNationTargetOptions,
|
||||||
|
COUNTER_STRATEGY_COMMAND_KEYS,
|
||||||
} from '../../turns/commandTargets.js';
|
} from '../../turns/commandTargets.js';
|
||||||
|
import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js';
|
||||||
|
import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js';
|
||||||
|
|
||||||
const zPushAmount = z
|
const zPushAmount = z
|
||||||
.number()
|
.number()
|
||||||
@@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
|
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ref che_피장파장 availableCommandTypeList: 아국 nation_env next_execute_<전략명> 기준 남은 턴.
|
||||||
|
const strategyCooldowns = Object.fromEntries(
|
||||||
|
resolveImpossibleStrategicCommands(nation?.meta, worldState.currentYear, worldState.currentMonth).map(
|
||||||
|
(entry) => [`che_${entry.name}`, entry.remainingTurns]
|
||||||
|
)
|
||||||
|
);
|
||||||
const nationTargetOptions = buildRefNationTargetOptions({
|
const nationTargetOptions = buildRefNationTargetOptions({
|
||||||
actorNationId: general.nationId,
|
actorNationId: general.nationId,
|
||||||
|
join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) },
|
||||||
|
strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined),
|
||||||
nations: nations.map((entry) => {
|
nations: nations.map((entry) => {
|
||||||
const relation = diplomacyByNation.get(entry.id);
|
const relation = diplomacyByNation.get(entry.id);
|
||||||
|
const meta = asRecord(entry.meta);
|
||||||
|
const gennum = readGeneralMetaNumber(meta, 'gennum');
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
diplomacyTerm: relation?.term ?? 0,
|
diplomacyTerm: relation?.term ?? 0,
|
||||||
adjacent: adjacentNationIds.has(entry.id),
|
adjacent: adjacentNationIds.has(entry.id),
|
||||||
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
|
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
|
||||||
|
...(gennum === null ? {} : { gennum }),
|
||||||
|
blockScout: resolveNationBlockScout(meta),
|
||||||
|
scoutMessage: resolveNationScoutMessage(meta),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -398,6 +415,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
item: general.itemCode,
|
item: general.itemCode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const dexterityByArmType: Record<string, number> = {};
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
cities: cities.map((entry) => ({
|
cities: cities.map((entry) => ({
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
@@ -406,6 +424,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
})),
|
})),
|
||||||
nations: nationTargetOptions.nations,
|
nations: nationTargetOptions.nations,
|
||||||
nationTargets: nationTargetOptions.nationTargets,
|
nationTargets: nationTargetOptions.nationTargets,
|
||||||
|
nationScoutMessages: nationTargetOptions.nationScoutMessages,
|
||||||
|
strategyCooldowns,
|
||||||
generals: generalTargetOptions.generals,
|
generals: generalTargetOptions.generals,
|
||||||
generalTargets: generalTargetOptions.generalTargets,
|
generalTargets: generalTargetOptions.generalTargets,
|
||||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||||
@@ -413,6 +433,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
|
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
|
||||||
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
||||||
|
if (dexterity !== null) dexterityByArmType[value] = dexterity;
|
||||||
return {
|
return {
|
||||||
value: Number(value),
|
value: Number(value),
|
||||||
label,
|
label,
|
||||||
@@ -449,6 +470,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
actorRice: general.rice,
|
actorRice: general.rice,
|
||||||
...(city ? { citySecurity: city.security } : {}),
|
...(city ? { citySecurity: city.security } : {}),
|
||||||
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
||||||
|
dexterity: dexterityByArmType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
|||||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
|
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
|
||||||
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
||||||
import {
|
import {
|
||||||
formatGeneralAccessLimitMessage,
|
formatGeneralAccessLimitMessage,
|
||||||
@@ -339,11 +340,23 @@ export const yearbookRouter = router({
|
|||||||
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
||||||
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
||||||
const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth;
|
const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth;
|
||||||
|
// Ref v_history.php resolves an archived generation's map from ng_games.map.
|
||||||
|
const mapLayout = target.isCurrentProfile
|
||||||
|
? await loadMapLayout(ctx.profile.scenario)
|
||||||
|
: await loadMapLayoutByName(
|
||||||
|
(
|
||||||
|
await ctx.db.gameHistory.findUnique({
|
||||||
|
where: { serverId: target.archiveKey },
|
||||||
|
select: { map: true },
|
||||||
|
})
|
||||||
|
)?.map || 'che'
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
firstYearMonth,
|
firstYearMonth,
|
||||||
lastYearMonth,
|
lastYearMonth,
|
||||||
currentYearMonth: selectedYearMonth,
|
currentYearMonth: selectedYearMonth,
|
||||||
|
mapLayout,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getHistory: authedProcedure
|
getHistory: authedProcedure
|
||||||
|
|||||||
+147
-72
@@ -1,8 +1,10 @@
|
|||||||
|
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
|
||||||
import fastify, { type FastifyRequest } from 'fastify';
|
import fastify, { type FastifyRequest } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import {
|
import {
|
||||||
buildGameEventChannel,
|
buildGameEventChannel,
|
||||||
@@ -21,7 +23,7 @@ import {
|
|||||||
import { resolveGameApiConfigFromEnv } from './config.js';
|
import { resolveGameApiConfigFromEnv } from './config.js';
|
||||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||||
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
|
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
|
||||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||||
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
import {
|
import {
|
||||||
consumeRealtimeAccessGrantHeader,
|
consumeRealtimeAccessGrantHeader,
|
||||||
@@ -32,6 +34,9 @@ import { appRouter } from './router.js';
|
|||||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||||
|
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||||
|
import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js';
|
||||||
|
import { RealtimeConnectionAdmission } from './realtime/connectionAdmission.js';
|
||||||
import { formatSseFrame } from './realtime/sse.js';
|
import { formatSseFrame } from './realtime/sse.js';
|
||||||
import {
|
import {
|
||||||
shouldForwardRealtimeEvent,
|
shouldForwardRealtimeEvent,
|
||||||
@@ -51,7 +56,7 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
|||||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||||
import { scopeHttpIdempotencyKey } from './requestId.js';
|
import { scopeHttpIdempotencyKey } from './requestId.js';
|
||||||
import { loadClockReadiness } from './services/clockReadiness.js';
|
import { loadProfileReadiness } from './services/clockReadiness.js';
|
||||||
|
|
||||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -71,16 +76,22 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
|||||||
const resolveAuthFromToken = async (
|
const resolveAuthFromToken = async (
|
||||||
token: string | null,
|
token: string | null,
|
||||||
accessTokenStore: RedisAccessTokenStore,
|
accessTokenStore: RedisAccessTokenStore,
|
||||||
flushStore: FlushStore
|
flushStore: FlushStore,
|
||||||
|
profileName: string
|
||||||
): Promise<GameSessionTokenPayload | null> => {
|
): Promise<GameSessionTokenPayload | null> => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const stored = await accessTokenStore.get(token);
|
const stored = await accessTokenStore.get(token);
|
||||||
if (!stored) {
|
if (!stored || stored.profile !== profileName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
let flushedAt: Date | null;
|
||||||
|
try {
|
||||||
|
flushedAt = await flushStore.getFlushedAt(stored.user.id);
|
||||||
|
} catch {
|
||||||
|
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
|
||||||
|
}
|
||||||
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -99,11 +110,18 @@ export const createGameApiServer = async () => {
|
|||||||
imageUploadSecret
|
imageUploadSecret
|
||||||
);
|
);
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: safeHttpLoggerOptions,
|
||||||
routerOptions: {
|
routerOptions: {
|
||||||
maxParamLength: 2048,
|
maxParamLength: 2048,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
app.setErrorHandler(safeHttpErrorHandler);
|
||||||
|
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
|
||||||
|
const realtimeAdmission = new RealtimeConnectionAdmission();
|
||||||
|
app.addHook('preClose', async () => {
|
||||||
|
realtimeAdmission.close();
|
||||||
|
for (const connection of realtimeConnections) connection.close();
|
||||||
|
});
|
||||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
|
||||||
@@ -134,7 +152,18 @@ export const createGameApiServer = async () => {
|
|||||||
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
requestTimeoutMs: config.battleSimRequestTimeoutMs,
|
||||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||||
});
|
});
|
||||||
const flushStore = new InMemoryFlushStore();
|
const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
|
||||||
|
try {
|
||||||
|
await flushStore.assertInitialized();
|
||||||
|
} catch {
|
||||||
|
await Promise.allSettled([
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (redis.client.isOpen) redis.client.destroy();
|
||||||
|
}),
|
||||||
|
postgres.disconnect(),
|
||||||
|
]);
|
||||||
|
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
|
||||||
|
}
|
||||||
const flushSubscriberClient = redis.client.duplicate();
|
const flushSubscriberClient = redis.client.duplicate();
|
||||||
try {
|
try {
|
||||||
await flushSubscriberClient.connect();
|
await flushSubscriberClient.connect();
|
||||||
@@ -150,6 +179,12 @@ export const createGameApiServer = async () => {
|
|||||||
config.flushChannel,
|
config.flushChannel,
|
||||||
flushStore,
|
flushStore,
|
||||||
async (event) => {
|
async (event) => {
|
||||||
|
const flushedAt = Date.parse(event.flushedAt);
|
||||||
|
for (const connection of realtimeConnections) {
|
||||||
|
if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
|
await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
|
||||||
},
|
},
|
||||||
(error, event) => {
|
(error, event) => {
|
||||||
@@ -239,8 +274,9 @@ export const createGameApiServer = async () => {
|
|||||||
|
|
||||||
app.addHook('onClose', closeResources);
|
app.addHook('onClose', closeResources);
|
||||||
|
|
||||||
|
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: true,
|
origin: config.corsAllowedOrigins,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,7 +292,7 @@ export const createGameApiServer = async () => {
|
|||||||
...trpcJsonBodyHttpServerOptions,
|
...trpcJsonBodyHttpServerOptions,
|
||||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||||
const token = extractBearerToken(req.headers.authorization);
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||||
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key'])
|
||||||
? req.headers['idempotency-key'][0]
|
? req.headers['idempotency-key'][0]
|
||||||
: req.headers['idempotency-key'];
|
: req.headers['idempotency-key'];
|
||||||
@@ -299,17 +335,68 @@ export const createGameApiServer = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
|
||||||
|
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore, config.profileName);
|
||||||
|
if (
|
||||||
|
!auth ||
|
||||||
|
auth.profile !== config.profileName ||
|
||||||
|
isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
|
|
||||||
app.get(config.eventsPath, async (request, reply) => {
|
app.get(config.eventsPath, async (request, reply) => {
|
||||||
const query = request.query as { token?: string; scope?: string };
|
const query = request.query as { token?: string; scope?: string };
|
||||||
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
|
||||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||||
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
if (query.token !== undefined || !tokenFromHeader) {
|
||||||
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
|
||||||
|
|
||||||
if (!auth) {
|
|
||||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let expired = false;
|
||||||
|
let disconnected = false;
|
||||||
|
const opening = realtimeAdmission.begin(() => {
|
||||||
|
expired = true;
|
||||||
|
if (!reply.sent && !reply.raw.destroyed) {
|
||||||
|
reply.header('Retry-After', '15');
|
||||||
|
void reply.status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!opening) {
|
||||||
|
await reply.header('Retry-After', '15').status(503).send({ ok: false, error: 'realtime_unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cancelOpening = () => {
|
||||||
|
disconnected = true;
|
||||||
|
opening.cancelOpening();
|
||||||
|
};
|
||||||
|
request.raw.once('aborted', cancelOpening);
|
||||||
|
reply.raw.once('close', cancelOpening);
|
||||||
|
let transferred = false;
|
||||||
|
try {
|
||||||
|
let auth: GameSessionTokenPayload | null = null;
|
||||||
|
try {
|
||||||
|
auth = await resolveRealtimeAuth(tokenFromHeader);
|
||||||
|
} catch {
|
||||||
|
if (opening.canContinue() && !reply.raw.destroyed)
|
||||||
|
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!opening.canContinue() || reply.raw.destroyed) return;
|
||||||
|
|
||||||
|
if (!auth || auth.profile !== config.profileName) {
|
||||||
|
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!opening.reserveUser(auth.user.id)) {
|
||||||
|
if (opening.canContinue())
|
||||||
|
await reply
|
||||||
|
.header('Retry-After', '15')
|
||||||
|
.status(429)
|
||||||
|
.send({ ok: false, error: 'realtime_connection_limit' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||||
const general = await postgres.prisma.general.findFirst({
|
const general = await postgres.prisma.general.findFirst({
|
||||||
@@ -334,6 +421,7 @@ export const createGameApiServer = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
let viewerIdentity = await loadViewerIdentity();
|
let viewerIdentity = await loadViewerIdentity();
|
||||||
|
if (!opening.canContinue() || reply.raw.destroyed || !opening.startStreaming()) return;
|
||||||
|
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
const requestOrigin = request.headers.origin;
|
const requestOrigin = request.headers.origin;
|
||||||
@@ -351,28 +439,20 @@ export const createGameApiServer = async () => {
|
|||||||
reply.raw.setTimeout?.(0);
|
reply.raw.setTimeout?.(0);
|
||||||
reply.raw.flushHeaders?.();
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
const sendFrame = (payload: string) => {
|
const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
|
||||||
try {
|
resolveRealtimeAuth(tokenFromHeader)
|
||||||
reply.raw.write(payload);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sendFrame(
|
|
||||||
formatSseFrame({
|
|
||||||
event: 'ready',
|
|
||||||
data: '{}',
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
realtimeConnections.add(connection);
|
||||||
let closed = false;
|
connection.onClose(() => {
|
||||||
let eventQueue = Promise.resolve();
|
realtimeConnections.delete(connection);
|
||||||
|
// A disconnected socket can still own an in-flight Redis/SQL operation.
|
||||||
|
void connection.whenIdle().then(opening.release, opening.release);
|
||||||
|
});
|
||||||
|
transferred = true;
|
||||||
|
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
|
||||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||||
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
|
||||||
eventQueue = eventQueue
|
connection.enqueue(async (currentAuth) => {
|
||||||
.then(async () => {
|
|
||||||
if (closed) return;
|
|
||||||
const identities = [viewerIdentity];
|
const identities = [viewerIdentity];
|
||||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||||
const nextIdentity = await loadViewerIdentity();
|
const nextIdentity = await loadViewerIdentity();
|
||||||
@@ -381,69 +461,64 @@ export const createGameApiServer = async () => {
|
|||||||
}
|
}
|
||||||
let refreshGrant: string | undefined;
|
let refreshGrant: string | undefined;
|
||||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||||
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret);
|
refreshGrant ??= createRealtimeAccessGrant(
|
||||||
|
currentAuth,
|
||||||
|
config.profileName,
|
||||||
|
config.gameTokenSecret
|
||||||
|
);
|
||||||
return refreshGrant;
|
return refreshGrant;
|
||||||
});
|
});
|
||||||
if (!publicEvent || closed) return;
|
if (!publicEvent || connection.closed) return null;
|
||||||
if (refreshGrant) {
|
if (refreshGrant) {
|
||||||
try {
|
try {
|
||||||
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
|
||||||
} catch {
|
} catch {
|
||||||
// Preserve the invalidation. An unregistered grant safely falls back
|
// An unregistered grant falls back to the scored refresh path.
|
||||||
// to the normal scored refresh path.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendFrame(
|
return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
|
||||||
formatSseFrame({
|
|
||||||
event: publicEvent.type,
|
|
||||||
data: JSON.stringify(publicEvent),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// A best-effort notification must not affect committed game state.
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
connection.onClose(unsubscribe);
|
||||||
let heartbeatPending = false;
|
let heartbeatPending = false;
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
if (heartbeatPending) return;
|
if (heartbeatPending || connection.closed) return;
|
||||||
heartbeatPending = true;
|
heartbeatPending = true;
|
||||||
void turnEngineStatus
|
connection.enqueue(async () => {
|
||||||
.get()
|
try {
|
||||||
.then((turnEngineRunning) => {
|
return formatSseFrame({
|
||||||
if (closed) return;
|
|
||||||
sendFrame(
|
|
||||||
formatSseFrame({
|
|
||||||
event: 'ping',
|
event: 'ping',
|
||||||
data: JSON.stringify({ turnEngineRunning }),
|
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
heartbeatPending = false;
|
|
||||||
});
|
});
|
||||||
}, 15000);
|
} finally {
|
||||||
|
heartbeatPending = false;
|
||||||
const close = () => {
|
}
|
||||||
closed = true;
|
});
|
||||||
|
}, 15_000);
|
||||||
|
connection.onClose(() => {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
});
|
||||||
};
|
request.raw.once('aborted', connection.close);
|
||||||
|
connection.onClose(() => {
|
||||||
request.raw.on('close', close);
|
request.raw.off('aborted', connection.close);
|
||||||
request.raw.on('aborted', close);
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!expired && !disconnected) throw error;
|
||||||
|
} finally {
|
||||||
|
request.raw.off('aborted', cancelOpening);
|
||||||
|
reply.raw.off('close', cancelOpening);
|
||||||
|
if (!transferred) opening.release();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/healthz', async (_request, reply) => {
|
app.get('/healthz', async (_request, reply) => {
|
||||||
const clock = await loadClockReadiness(postgres.prisma);
|
const readiness = await loadProfileReadiness(postgres.prisma, config.profileName);
|
||||||
if (!clock.reconciliationComplete) reply.code(503);
|
if (!readiness.ok) reply.code(503);
|
||||||
return {
|
return {
|
||||||
ok: clock.reconciliationComplete,
|
...readiness,
|
||||||
profile: config.profileName,
|
profile: config.profileName,
|
||||||
postgresPool: postgres.getPoolStats(),
|
postgresPool: postgres.getPoolStats(),
|
||||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||||
clock,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
import type { DatabaseClient } from '../context.js';
|
||||||
|
|
||||||
@@ -90,3 +91,20 @@ export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** API 기동과 별개로 데몬의 초기 감사 저장 및 clock 복구가 끝나야 배포 준비 완료다. */
|
||||||
|
export const loadProfileReadiness = async (db: DatabaseClient, profileName: string) => {
|
||||||
|
const [clock, leases] = await Promise.all([
|
||||||
|
loadClockReadiness(db),
|
||||||
|
db.$queryRaw<Array<{ ready: boolean }>>(GamePrisma.sql`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM turn_daemon_lease
|
||||||
|
WHERE profile = ${profileName}
|
||||||
|
AND clock_ready = TRUE
|
||||||
|
AND lease_until > (clock_timestamp() AT TIME ZONE 'UTC')
|
||||||
|
) AS ready
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
const daemonReady = leases[0]?.ready === true;
|
||||||
|
return { ok: clock.reconciliationComplete && daemonReady, clock, daemonReady };
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
GamePrisma,
|
GamePrisma,
|
||||||
hashAuditDiplomacyDocument,
|
hashAuditDiplomacyDocument,
|
||||||
persistAuditDiplomacyEvents,
|
persistAuditDiplomacyEvents,
|
||||||
|
withPlayAuditSavepoint,
|
||||||
projectAuditDocumentState,
|
projectAuditDocumentState,
|
||||||
readTurnRuntimeReady,
|
readTurnRuntimeReady,
|
||||||
type AuditDiplomacyEventDraft,
|
type AuditDiplomacyEventDraft,
|
||||||
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
||||||
|
let collectionFailed = false;
|
||||||
const changes: {
|
const changes: {
|
||||||
letter: Letter;
|
letter: Letter;
|
||||||
before: Record<string, unknown> | null;
|
before: Record<string, unknown> | null;
|
||||||
@@ -92,15 +94,21 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
|
|||||||
return {
|
return {
|
||||||
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
||||||
if (!ctx.auditInput) return;
|
if (!ctx.auditInput) return;
|
||||||
|
try {
|
||||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||||
|
} catch {
|
||||||
|
collectionFailed = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
flush: async (): Promise<void> => {
|
flush: async (): Promise<void> => {
|
||||||
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
||||||
const input = ctx.auditInput;
|
const input = ctx.auditInput;
|
||||||
if (!input || !changes.length) return;
|
if (!input || (!changes.length && !collectionFailed)) return;
|
||||||
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
||||||
throw new Error('Play audit diplomacy actor mismatch');
|
throw new Error('Play audit diplomacy actor mismatch');
|
||||||
const coordinate = await readCoordinate(ctx);
|
await withPlayAuditSavepoint(ctx.db, async (auditDb) => {
|
||||||
|
if (collectionFailed) throw new Error('Audit document collection failed');
|
||||||
|
const coordinate = await readCoordinate({ ...ctx, db: auditDb });
|
||||||
if (!coordinate) return;
|
if (!coordinate) return;
|
||||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -133,7 +141,8 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
|
|||||||
before: change.before,
|
before: change.before,
|
||||||
after: change.after,
|
after: change.after,
|
||||||
}));
|
}));
|
||||||
await persistAuditDiplomacyEvents(ctx.db, events);
|
await persistAuditDiplomacyEvents(auditDb, events);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||||
|
|
||||||
export interface ContentImageUploadResult {
|
export interface ContentImageUploadResult {
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
|
|||||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||||
throw new Error('Invalid content image filename.');
|
throw new Error('Invalid content image filename.');
|
||||||
}
|
}
|
||||||
const pathname = `/v1/uploads/content/core2026/${input.filename}`;
|
await performSignedImageUpload(
|
||||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
{
|
||||||
const requestId = randomUUID();
|
...input,
|
||||||
const digest = createHash('sha256').update(input.body).digest('hex');
|
kind: 'content',
|
||||||
const signature = createHmac('sha256', this.secret)
|
baseUrl: this.baseUrl,
|
||||||
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
|
secret: this.secret,
|
||||||
.digest('hex');
|
|
||||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'content-type': input.contentType,
|
|
||||||
'x-image-client': 'core2026',
|
|
||||||
'x-image-expires': expires,
|
|
||||||
'x-image-request-id': requestId,
|
|
||||||
'x-image-signature': signature,
|
|
||||||
},
|
},
|
||||||
body: new Uint8Array(input.body),
|
this.fetchImpl,
|
||||||
});
|
this.now
|
||||||
if (!response.ok) {
|
);
|
||||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
|
||||||
}
|
|
||||||
const expectedPath = `uploads/core2026/${input.filename}`;
|
|
||||||
const payload: unknown = await response.json();
|
|
||||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
|
|
||||||
throw new Error('Image repository returned an unexpected content path.');
|
|
||||||
}
|
|
||||||
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
|
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class CorruptTournamentProjectionError extends Error {
|
|||||||
const zTournamentBet = z
|
const zTournamentBet = z
|
||||||
.object({
|
.object({
|
||||||
generalId: z.number().int(),
|
generalId: z.number().int(),
|
||||||
|
userId: z.string().min(1).nullable().optional(),
|
||||||
targetId: z.number().int(),
|
targetId: z.number().int(),
|
||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
})
|
})
|
||||||
@@ -286,7 +287,10 @@ export class TournamentStore {
|
|||||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||||
const entries = await this.getBettingEntries();
|
const entries = await this.getBettingEntries();
|
||||||
const existing = entries.find(
|
const existing = entries.find(
|
||||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
(candidate) =>
|
||||||
|
candidate.generalId === entry.generalId &&
|
||||||
|
candidate.targetId === entry.targetId &&
|
||||||
|
candidate.userId === entry.userId
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.amount += entry.amount;
|
existing.amount += entry.amount;
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface TournamentMatchEntry {
|
|||||||
|
|
||||||
export interface TournamentBetEntry {
|
export interface TournamentBetEntry {
|
||||||
generalId: number;
|
generalId: number;
|
||||||
|
// 로그인 actor는 장수 빙의와 독립적이다. null은 서버가 만든 NPC, 생략은 구형 row다.
|
||||||
|
userId?: string | null;
|
||||||
targetId: number;
|
targetId: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ const buildNpcBettingPlan = async (options: {
|
|||||||
for (const npc of npcBetList) {
|
for (const npc of npcBetList) {
|
||||||
const targetId = rng.choice(candidateIds);
|
const targetId = rng.choice(candidateIds);
|
||||||
if (!existingBettors.has(npc.id as number)) {
|
if (!existingBettors.has(npc.id as number)) {
|
||||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
entries.push({ generalId: npc.id as number, userId: null, targetId, amount: betGold });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
@@ -789,19 +789,24 @@ export const seedNpcBets = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
|
||||||
await daemonTransport.sendCommand({
|
// Wait for one durable ENGINE result before publishing entries or opening stage 6.
|
||||||
type: 'adjustGeneralResources',
|
// The engine skips NPCs whose owner changed after this deterministic plan was stored.
|
||||||
requestId: `${requestPrefix}:resources`,
|
const result = await daemonTransport.requestCommand({
|
||||||
reason: 'tournamentNpcBet',
|
type: 'tournamentSeedNpcBets',
|
||||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
requestId: `${requestPrefix}:atomic`,
|
||||||
});
|
bets: plan.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })),
|
||||||
await daemonTransport.sendCommand({
|
|
||||||
type: 'adjustGeneralMeta',
|
|
||||||
requestId: `${requestPrefix}:meta`,
|
|
||||||
reason: 'tournamentNpcBet',
|
|
||||||
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
|
||||||
});
|
});
|
||||||
|
if (!result || result.type !== 'tournamentSeedNpcBets' || !result.ok) {
|
||||||
|
throw new Error('NPC tournament betting did not commit.');
|
||||||
|
}
|
||||||
|
const processed = new Set(result.processedGeneralIds);
|
||||||
const existing = await store.getBettingEntries();
|
const existing = await store.getBettingEntries();
|
||||||
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||||
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
|
await store.setBettingEntries(
|
||||||
|
existing.concat(
|
||||||
|
plan
|
||||||
|
.filter((entry) => processed.has(entry.generalId) && !existingBettors.has(entry.generalId))
|
||||||
|
.map((entry) => ({ ...entry, userId: null }))
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { initTRPC, TRPCError } from '@trpc/server';
|
import { initTRPC, TRPCError } from '@trpc/server';
|
||||||
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
|
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
|
||||||
@@ -20,10 +21,10 @@ import {
|
|||||||
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
||||||
import { recordGeneralActivity } from './services/generalActivity.js';
|
import { recordGeneralActivity } from './services/generalActivity.js';
|
||||||
|
|
||||||
const t = initTRPC.context<GameApiContext>().create();
|
const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||||
|
|
||||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||||
if (!ctx.auth) {
|
if (!ctx.auth || ctx.auth.profile !== ctx.profile.name) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface TurnCommandOption {
|
|||||||
crew?: number;
|
crew?: number;
|
||||||
troopId?: number;
|
troopId?: number;
|
||||||
npcState?: number;
|
npcState?: number;
|
||||||
|
/** 등용·장수대상임관처럼 여러 국가 장수를 고르는 목록의 국가별 묶음 기준. */
|
||||||
|
nationId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnCommandAmountPreset {
|
export interface TurnCommandAmountPreset {
|
||||||
@@ -97,10 +99,21 @@ export interface TurnCommandInputField {
|
|||||||
tupleLabels?: string[];
|
tupleLabels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */
|
||||||
|
export interface TurnCommandNationScoutMessage {
|
||||||
|
nationId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnCommandInputOptions {
|
export interface TurnCommandInputOptions {
|
||||||
cities: TurnCommandOption[];
|
cities: TurnCommandOption[];
|
||||||
nations: TurnCommandOption[];
|
nations: TurnCommandOption[];
|
||||||
nationTargets?: Record<string, TurnCommandOption[]>;
|
nationTargets?: Record<string, TurnCommandOption[]>;
|
||||||
|
nationScoutMessages?: TurnCommandNationScoutMessage[];
|
||||||
|
/** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */
|
||||||
|
strategyCooldowns?: Record<string, number>;
|
||||||
generals: TurnCommandOption[];
|
generals: TurnCommandOption[];
|
||||||
generalTargets?: Record<string, TurnCommandOption[]>;
|
generalTargets?: Record<string, TurnCommandOption[]>;
|
||||||
crewTypes: TurnCommandOption[];
|
crewTypes: TurnCommandOption[];
|
||||||
@@ -117,6 +130,8 @@ export interface TurnCommandInputOptions {
|
|||||||
nationGold?: number;
|
nationGold?: number;
|
||||||
nationRice?: number;
|
nationRice?: number;
|
||||||
nationLevel?: number;
|
nationLevel?: number;
|
||||||
|
/** 숙련전환 전/후 미리보기용 본인 병과별 숙련(`dex<armType>`). */
|
||||||
|
dexterity?: Record<string, number>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +166,8 @@ export const buildEquipmentTradeItemOptions = (options: {
|
|||||||
{
|
{
|
||||||
value: 'None',
|
value: 'None',
|
||||||
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
||||||
|
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 붉은색 `(불가)`로 표시한다.
|
||||||
|
...(ownedItem ? {} : { availableNow: false }),
|
||||||
description: ownedItem
|
description: ownedItem
|
||||||
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
||||||
: ownedCode && ownedCode !== 'None'
|
: ownedCode && ownedCode !== 'None'
|
||||||
@@ -177,6 +194,7 @@ export const buildEquipmentTradeItemOptions = (options: {
|
|||||||
items[item.slot].push({
|
items[item.slot].push({
|
||||||
value: item.key,
|
value: item.key,
|
||||||
label: item.name,
|
label: item.name,
|
||||||
|
availableNow: options.currentSecurity >= item.reqSecu && options.generalGold >= cost,
|
||||||
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -229,8 +247,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
nationName: '국가명',
|
nationName: '국가명',
|
||||||
nationType: '국가 성향',
|
nationType: '국가 성향',
|
||||||
colorType: '국기 색상',
|
colorType: '국기 색상',
|
||||||
srcArmType: '기존 병과',
|
// 숙련전환 전용. Ref che_숙련전환.vue의 '감소 대상 숙련 :'·'전환 대상 숙련 :'.
|
||||||
destArmType: '변경 병과',
|
srcArmType: '감소 대상 숙련',
|
||||||
|
destArmType: '전환 대상 숙련',
|
||||||
itemType: '장비 종류',
|
itemType: '장비 종류',
|
||||||
itemCode: '장비',
|
itemCode: '장비',
|
||||||
crewType: '병종',
|
crewType: '병종',
|
||||||
@@ -240,7 +259,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
optionText: '행동',
|
optionText: '행동',
|
||||||
year: '기간(년)',
|
year: '기간(년)',
|
||||||
month: '기간(월)',
|
month: '기간(월)',
|
||||||
commandType: '대응 명령',
|
// 피장파장 전용. Ref che_피장파장.vue의 '전략 :'.
|
||||||
|
commandType: '전략',
|
||||||
amountList: '지원 물자',
|
amountList: '지원 물자',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,10 +286,14 @@ const STATIC_LABELS: Record<string, Record<string, string>> = {
|
|||||||
item: '도구',
|
item: '도구',
|
||||||
},
|
},
|
||||||
commandType: {
|
commandType: {
|
||||||
che_선전포고: '선전포고',
|
// 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()).
|
||||||
che_불가침제의: '불가침 제의',
|
che_필사즉생: '필사즉생',
|
||||||
che_불가침파기제의: '불가침 파기 제의',
|
che_백성동원: '백성동원',
|
||||||
che_종전제의: '종전 제의',
|
che_수몰: '수몰',
|
||||||
|
che_허보: '허보',
|
||||||
|
che_의병모집: '의병모집',
|
||||||
|
che_이호경식: '이호경식',
|
||||||
|
che_급습: '급습',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -479,6 +479,19 @@ const mapCityRow = (row: CityRow): City => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 명령표의 임관 대상 표시에 쓰는 Ref AllowJoinDestNation 시점 값. */
|
||||||
|
export const resolveCommandJoinEnv = (
|
||||||
|
worldState: WorldStateRow
|
||||||
|
): { relYear: number; openingPartYear: number; initialNationGenLimit: number } => {
|
||||||
|
const constValues = asRecord(asRecord(worldState.config).const);
|
||||||
|
const env = buildConstraintEnv(worldState);
|
||||||
|
return {
|
||||||
|
relYear: Number(env.relYear),
|
||||||
|
openingPartYear: Number(env.openingPartYear),
|
||||||
|
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const mapNationRow = (row: NationRow): Nation => ({
|
const mapNationRow = (row: NationRow): Nation => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
|
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js';
|
import type { TurnCommandAmountPreset, TurnCommandNationScoutMessage, TurnCommandOption } from './commandInput.js';
|
||||||
|
|
||||||
export interface GeneralTargetSource {
|
export interface GeneralTargetSource {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,6 +33,20 @@ export interface NationTargetSource {
|
|||||||
diplomacyTerm: number;
|
diplomacyTerm: number;
|
||||||
adjacent: boolean;
|
adjacent: boolean;
|
||||||
diplomacyRestricted?: boolean;
|
diplomacyRestricted?: boolean;
|
||||||
|
/** Ref nation.gennum. 없으면 generalCount를 쓴다. */
|
||||||
|
gennum?: number;
|
||||||
|
/** 임관 금지(nation.scout). */
|
||||||
|
blockScout?: boolean;
|
||||||
|
/** 서버에서 purifyNationHtml로 정제한 임관 권유문. */
|
||||||
|
scoutMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ref AllowJoinDestNation이 쓰는 actor·시점 정보. */
|
||||||
|
export interface NationJoinContext {
|
||||||
|
actorNpcState: number;
|
||||||
|
relYear: number;
|
||||||
|
openingPartYear: number;
|
||||||
|
initialNationGenLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefGeneralTargetOptions {
|
export interface RefGeneralTargetOptions {
|
||||||
@@ -43,11 +57,21 @@ export interface RefGeneralTargetOptions {
|
|||||||
export interface RefNationTargetOptions {
|
export interface RefNationTargetOptions {
|
||||||
nations: TurnCommandOption[];
|
nations: TurnCommandOption[];
|
||||||
nationTargets: Record<string, TurnCommandOption[]>;
|
nationTargets: Record<string, TurnCommandOption[]>;
|
||||||
|
nationScoutMessages: TurnCommandNationScoutMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
||||||
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
|
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
|
||||||
|
|
||||||
|
const statsText = (entry: GeneralTargetSource): string =>
|
||||||
|
[
|
||||||
|
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
||||||
|
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
||||||
|
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
|
||||||
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
|
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
|
||||||
export const buildRefGeneralTargetOptions = (options: {
|
export const buildRefGeneralTargetOptions = (options: {
|
||||||
actorId: number;
|
actorId: number;
|
||||||
@@ -64,29 +88,30 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||||
const label = (() => {
|
const label = (() => {
|
||||||
if (action === 'che_발령') return `${entry.name} (${cityName})`;
|
// 같은 국가 후보만 있는 명령은 국명을 반복하지 않는다.
|
||||||
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
if (['che_발령', 'che_포상', 'che_몰수', 'che_선양'].includes(action ?? '')) {
|
||||||
|
return `${entry.name} (${cityName})`;
|
||||||
|
}
|
||||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||||
})();
|
})();
|
||||||
const details = [
|
// Ref che_선양.vue는 후보에 (통/무/지)만 보인다.
|
||||||
|
const details = (
|
||||||
|
action === 'che_선양'
|
||||||
|
? []
|
||||||
|
: [
|
||||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
||||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
||||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
||||||
].filter((value): value is string => Boolean(value));
|
]
|
||||||
|
).filter((value): value is string => Boolean(value));
|
||||||
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
||||||
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
||||||
const assignmentDetails =
|
const assignmentDetails =
|
||||||
action === 'che_발령'
|
action === 'che_발령'
|
||||||
? [
|
? [
|
||||||
[
|
statsText(entry),
|
||||||
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
|
||||||
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
|
||||||
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
[
|
[
|
||||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
||||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
||||||
@@ -104,6 +129,9 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Ref ProcessGeneralAmount·ProcessGeneral은 같은 국가 후보에 (통/무/지)를 함께 보인다.
|
||||||
|
const stats = statsText(entry);
|
||||||
|
if (stats && action !== 'che_발령') details.push(stats);
|
||||||
if (isTroopExit) {
|
if (isTroopExit) {
|
||||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||||
}
|
}
|
||||||
@@ -125,33 +153,48 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
npcState: entry.npcState,
|
npcState: entry.npcState,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
// Ref che_등용·che_장수대상임관 exportJSVars()는 타국·재야 장수의
|
||||||
options.generals.filter(predicate).map((entry) => toOption(entry));
|
// no,name,nation,officer_level,npc,leadership,strength,intel만 내보낸다.
|
||||||
|
// 명령표는 모든 장수에게 전달되므로 금·쌀·병력·훈련·사기·부대·위치 도시를 싣지 않는다.
|
||||||
|
const toPublicOption = (entry: GeneralTargetSource): TurnCommandOption => {
|
||||||
|
const nationName = options.nationNames.get(entry.nationId) ?? null;
|
||||||
|
return {
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.name} (${nationName ?? '재야'})`,
|
||||||
|
targetNames: { name: entry.name, nationName },
|
||||||
|
description: statsText(entry),
|
||||||
|
npcState: entry.npcState,
|
||||||
|
nationId: entry.nationId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const projectPublic = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||||
|
options.generals.filter(predicate).map(toPublicOption);
|
||||||
|
// 재야는 국가가 아니므로 같은 국가 명령 후보가 없다. 해당 명령도 NotBeNeutral로 막힌다.
|
||||||
|
const isSameNation = (entry: GeneralTargetSource): boolean =>
|
||||||
|
options.actorNationId !== 0 && entry.nationId === options.actorNationId;
|
||||||
|
|
||||||
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
||||||
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
||||||
generalTargets[action] = options.generals
|
generalTargets[action] = options.generals.filter(isSameNation).map((entry) => toOption(entry, action));
|
||||||
.filter((entry) => entry.nationId === options.actorNationId)
|
|
||||||
.map((entry) => toOption(entry, action));
|
|
||||||
}
|
}
|
||||||
for (const action of SAME_NATION_NATION_COMMANDS) {
|
for (const action of SAME_NATION_NATION_COMMANDS) {
|
||||||
generalTargets[action] = options.generals
|
generalTargets[action] = options.generals
|
||||||
.filter((entry) => entry.nationId === options.actorNationId)
|
.filter(isSameNation)
|
||||||
.map((entry) => toOption(entry, action))
|
.map((entry) => toOption(entry, action))
|
||||||
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
generalTargets.che_선양 = project(
|
generalTargets.che_선양 = options.generals
|
||||||
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
.filter((entry) => isSameNation(entry) && entry.id !== options.actorId)
|
||||||
);
|
.map((entry) => toOption(entry, 'che_선양'));
|
||||||
generalTargets.che_등용 = project(
|
generalTargets.che_등용 = projectPublic(
|
||||||
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
||||||
);
|
);
|
||||||
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId);
|
generalTargets.che_장수대상임관 = projectPublic((entry) => entry.id !== options.actorId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
|
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다. 타국 장수가 섞이므로 공개 필드만 둔다.
|
||||||
generals: project((entry) => entry.npcState < 2),
|
generals: projectPublic((entry) => entry.npcState < 2),
|
||||||
generalTargets,
|
generalTargets,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -169,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
|
|||||||
'che_선전포고',
|
'che_선전포고',
|
||||||
'che_종전제의',
|
'che_종전제의',
|
||||||
'che_불가침파기제의',
|
'che_불가침파기제의',
|
||||||
|
'che_피장파장',
|
||||||
|
'che_이호경식',
|
||||||
|
'che_급습',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const nationAvailability = (
|
/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
|
||||||
action: (typeof NATION_TARGET_COMMANDS)[number],
|
export const COUNTER_STRATEGY_COMMAND_KEYS = [
|
||||||
|
'che_필사즉생',
|
||||||
|
'che_백성동원',
|
||||||
|
'che_수몰',
|
||||||
|
'che_허보',
|
||||||
|
'che_의병모집',
|
||||||
|
'che_이호경식',
|
||||||
|
'che_급습',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Ref che_임관 exportJSVars()의 notAvailable: AllowJoinDestNation의 대상 국가 조건. */
|
||||||
|
const joinAvailability = (
|
||||||
|
join: NationJoinContext,
|
||||||
actorNationId: number,
|
actorNationId: number,
|
||||||
target: NationTargetSource
|
target: NationTargetSource
|
||||||
): { available: boolean; reason: string } => {
|
): { available: boolean; reason: string } => {
|
||||||
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
||||||
|
const gennum = target.gennum ?? target.generalCount;
|
||||||
|
if (join.relYear < join.openingPartYear && join.initialNationGenLimit > 0 && gennum >= join.initialNationGenLimit) {
|
||||||
|
return { available: false, reason: '임관이 제한되고 있습니다.' };
|
||||||
|
}
|
||||||
|
if (target.blockScout) return { available: false, reason: '임관이 금지되어 있습니다.' };
|
||||||
|
if (join.actorNpcState < 2 && target.name.startsWith('ⓤ')) {
|
||||||
|
return { available: false, reason: '유저장은 태수국에 임관할 수 없습니다.' };
|
||||||
|
}
|
||||||
|
if (join.actorNpcState !== 9 && target.name.startsWith('ⓞ')) {
|
||||||
|
return { available: false, reason: '이민족 국가에 임관할 수 없습니다.' };
|
||||||
|
}
|
||||||
|
return { available: true, reason: '현재 임관 가능' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const nationAvailability = (
|
||||||
|
action: (typeof NATION_TARGET_COMMANDS)[number],
|
||||||
|
actorNationId: number,
|
||||||
|
target: NationTargetSource,
|
||||||
|
strategyAvailable: boolean
|
||||||
|
): { available: boolean; reason: string } => {
|
||||||
|
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
||||||
|
// Ref 피장파장은 쓸 수 있는 전략이 하나도 없으면 모든 국가를 불가로 표시한다.
|
||||||
|
if (action === 'che_피장파장' && !strategyAvailable) {
|
||||||
|
return { available: false, reason: '사용할 수 있는 전략이 없습니다.' };
|
||||||
|
}
|
||||||
|
if (action === 'che_피장파장' || action === 'che_이호경식') {
|
||||||
|
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
|
||||||
|
return { available, reason: available ? '현재 발동 가능' : '선포, 전쟁중인 상대국에게만 가능합니다.' };
|
||||||
|
}
|
||||||
|
if (action === 'che_급습') {
|
||||||
|
// Ref AllowDiplomacyWithTerm(1, 12): 선포 상태이고 남은 기간이 12개월 이상.
|
||||||
|
const available = target.diplomacyState === DIPLOMACY_STATE.DECLARATION && target.diplomacyTerm >= 12;
|
||||||
|
return { available, reason: available ? '현재 발동 가능' : '선포 12개월 이상인 상대국에만 가능합니다.' };
|
||||||
|
}
|
||||||
if (action === 'che_물자원조') {
|
if (action === 'che_물자원조') {
|
||||||
return target.diplomacyRestricted
|
return target.diplomacyRestricted
|
||||||
? { available: false, reason: '상대국이 외교제한 중입니다.' }
|
? { available: false, reason: '상대국이 외교제한 중입니다.' }
|
||||||
@@ -205,7 +297,12 @@ const nationAvailability = (
|
|||||||
export const buildRefNationTargetOptions = (options: {
|
export const buildRefNationTargetOptions = (options: {
|
||||||
actorNationId: number;
|
actorNationId: number;
|
||||||
nations: readonly NationTargetSource[];
|
nations: readonly NationTargetSource[];
|
||||||
|
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
|
||||||
|
join?: NationJoinContext;
|
||||||
|
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
|
||||||
|
strategyAvailable?: boolean;
|
||||||
}): RefNationTargetOptions => {
|
}): RefNationTargetOptions => {
|
||||||
|
const strategyAvailable = options.strategyAvailable ?? true;
|
||||||
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
label: entry.name,
|
label: entry.name,
|
||||||
@@ -217,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
|
|||||||
for (const action of NATION_TARGET_COMMANDS) {
|
for (const action of NATION_TARGET_COMMANDS) {
|
||||||
nationTargets[action] = options.nations
|
nationTargets[action] = options.nations
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const availability = nationAvailability(action, options.actorNationId, entry);
|
const availability = nationAvailability(action, options.actorNationId, entry, strategyAvailable);
|
||||||
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
|
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
|
||||||
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
|
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
|
||||||
return {
|
return {
|
||||||
@@ -238,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
|
|||||||
)
|
)
|
||||||
.map(({ power: _power, ...entry }) => entry);
|
.map(({ power: _power, ...entry }) => entry);
|
||||||
}
|
}
|
||||||
return { nations: baseOptions, nationTargets };
|
const join = options.join;
|
||||||
|
if (join) {
|
||||||
|
// Ref 임관 목록은 nation 표 순서 그대로이며 불가 국가도 남긴다. 재야(0)는 국가가 아니다.
|
||||||
|
nationTargets.che_임관 = options.nations.flatMap((entry, index) => {
|
||||||
|
if (entry.id <= 0) return [];
|
||||||
|
const availability = joinAvailability(join, options.actorNationId, entry);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...baseOptions[index]!,
|
||||||
|
availableNow: availability.available,
|
||||||
|
description: `${availability.reason} · ${baseOptions[index]!.description}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Ref che_임관·che_장수대상임관은 모든 국가의 권유문(scout_msg)을 함께 내보낸다.
|
||||||
|
const nationScoutMessages = options.nations
|
||||||
|
.filter((entry) => entry.id > 0)
|
||||||
|
.map((entry) => ({
|
||||||
|
nationId: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
color: entry.color,
|
||||||
|
message: entry.scoutMessage ?? '',
|
||||||
|
}));
|
||||||
|
return { nations: baseOptions, nationTargets, nationScoutMessages };
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESOURCE_ACTION_GUIDE = [
|
const RESOURCE_ACTION_GUIDE = [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SystemClock } from '@sammo-ts/common';
|
|||||||
import {
|
import {
|
||||||
createDatabaseTurnHooks,
|
createDatabaseTurnHooks,
|
||||||
DatabaseTurnDaemonCommandQueue,
|
DatabaseTurnDaemonCommandQueue,
|
||||||
|
DatabaseTurnDaemonLease,
|
||||||
EngineStateManager,
|
EngineStateManager,
|
||||||
InMemoryTurnStateStore,
|
InMemoryTurnStateStore,
|
||||||
InMemoryTurnWorld,
|
InMemoryTurnWorld,
|
||||||
@@ -695,6 +696,10 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
|
|
||||||
const redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
const redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
await redisConnector.connect();
|
await redisConnector.connect();
|
||||||
|
// 독립 worker는 활성 daemon의 clock_ready fence가 있을 때만 GAME_TIME을 진행한다.
|
||||||
|
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile: profileName });
|
||||||
|
await lease.acquire();
|
||||||
|
await lease.markClockReady();
|
||||||
const keys = buildAuctionTimerKeys(profileName);
|
const keys = buildAuctionTimerKeys(profileName);
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const worker = runAuctionWorker({ signal: abortController.signal });
|
const worker = runAuctionWorker({ signal: abortController.signal });
|
||||||
@@ -725,6 +730,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
} finally {
|
} finally {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
await worker;
|
await worker;
|
||||||
|
await lease.close();
|
||||||
|
await connector.prisma.turnDaemonLease.delete({ where: { profile: profileName } });
|
||||||
await redisConnector.client.del([keys.timerKey, keys.historyKey]);
|
await redisConnector.client.del([keys.timerKey, keys.historyKey]);
|
||||||
await redisConnector.disconnect();
|
await redisConnector.disconnect();
|
||||||
await connector.prisma.errorLog.deleteMany({
|
await connector.prisma.errorLog.deleteMany({
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||||
|
|
||||||
|
const payload = (): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
sessionId: 'test-session',
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
user: {
|
||||||
|
id: 'test-user',
|
||||||
|
username: 'test',
|
||||||
|
displayName: 'test',
|
||||||
|
roles: ['user'],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
|
||||||
|
const response = new PassThrough();
|
||||||
|
const frames: string[] = [];
|
||||||
|
response.on('data', (chunk) => frames.push(String(chunk)));
|
||||||
|
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
|
||||||
|
return { response, frames, connection };
|
||||||
|
};
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe('authenticated realtime connection', () => {
|
||||||
|
it('does not forward an in-flight result after the token is revoked during its work', async () => {
|
||||||
|
const auth = payload();
|
||||||
|
let current: GameSessionTokenPayload | null = auth;
|
||||||
|
const { connection, frames } = fixture(async () => current, auth);
|
||||||
|
connection.enqueue(async () => {
|
||||||
|
current = null;
|
||||||
|
return 'private invalidation';
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||||
|
expect(frames).toEqual([]);
|
||||||
|
});
|
||||||
|
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
|
||||||
|
const auth = payload();
|
||||||
|
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
|
||||||
|
connection.enqueue(async () => 'private invalidation');
|
||||||
|
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||||
|
expect(frames).toEqual([]);
|
||||||
|
});
|
||||||
|
it('fails closed when authentication storage fails', async () => {
|
||||||
|
const { connection, frames } = fixture(async () => {
|
||||||
|
throw new Error('redis unavailable');
|
||||||
|
});
|
||||||
|
connection.enqueue(async () => 'private invalidation');
|
||||||
|
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||||
|
expect(frames).toEqual([]);
|
||||||
|
});
|
||||||
|
it('ends an idle expired stream and releases subscriptions only once', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const auth = payload();
|
||||||
|
const { connection } = fixture(async () => auth, auth);
|
||||||
|
const cleanup = vi.fn();
|
||||||
|
connection.onClose(cleanup);
|
||||||
|
await vi.advanceTimersByTimeAsync(60_001);
|
||||||
|
expect(connection.closed).toBe(true);
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
connection.close();
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('bounds queued work while an earlier event is blocked', async () => {
|
||||||
|
const auth = payload();
|
||||||
|
const { connection, frames } = fixture(async () => auth, auth);
|
||||||
|
let release!: () => void;
|
||||||
|
const waiting = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
connection.enqueue(async () => {
|
||||||
|
await waiting;
|
||||||
|
return 'private invalidation';
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
|
||||||
|
expect(connection.closed).toBe(true);
|
||||||
|
release();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(frames).toEqual([]);
|
||||||
|
});
|
||||||
|
it('ends a slow socket instead of retaining further frames', async () => {
|
||||||
|
const auth = payload();
|
||||||
|
const { connection, response } = fixture(async () => auth, auth);
|
||||||
|
response.write = vi.fn(() => false);
|
||||||
|
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
|
||||||
|
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||||
|
expect(response.writableEnded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
|
|||||||
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
|
||||||
|
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||||
|
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||||
|
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
expect(png.length).toBeLessThan(1024 * 1024);
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||||
|
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
|
||||||
|
const pending: Array<() => void> = [];
|
||||||
|
const upload = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ publicUrl: string }>((resolve) => {
|
||||||
|
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||||
|
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
|
||||||
|
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
|
||||||
|
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||||
|
} finally {
|
||||||
|
pending.forEach((done) => done());
|
||||||
|
await Promise.all(admitted);
|
||||||
|
}
|
||||||
|
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||||
|
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SVG before a native image parser or upload is used', async () => {
|
||||||
|
const upload = vi.fn();
|
||||||
|
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||||
|
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
|
||||||
|
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||||
|
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
const makeGif = (pages: number) =>
|
||||||
|
sharp(Buffer.alloc(pages * 4, 255), {
|
||||||
|
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
|
||||||
|
})
|
||||||
|
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
|
||||||
|
.toBuffer();
|
||||||
|
const excessive = await makeGif(257);
|
||||||
|
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
|
||||||
|
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
});
|
||||||
|
expect(upload).not.toHaveBeenCalled();
|
||||||
|
const ordinary = await makeGif(3);
|
||||||
|
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
|
||||||
|
animated: true,
|
||||||
|
format: 'webp',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
|
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
|
||||||
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
||||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { DatabaseClient } from '../src/context.js';
|
import type { DatabaseClient } from '../src/context.js';
|
||||||
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
|
import { loadClockAdminStatus, loadClockReadiness, loadProfileReadiness } from '../src/services/clockReadiness.js';
|
||||||
|
|
||||||
describe('clock reconciliation readiness', () => {
|
describe('clock reconciliation readiness', () => {
|
||||||
|
it.each([false, true])('requires the profile daemon lease to be ready: %s', async (ready) => {
|
||||||
|
const db = {
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({ clockPhase: 'PREOPEN', clockRevision: 1n, deadlineGeneration: 0n })),
|
||||||
|
},
|
||||||
|
clockProjectionOutbox: { count: vi.fn(async () => 0) },
|
||||||
|
$queryRaw: vi.fn(async () => [{ ready }]),
|
||||||
|
} as unknown as DatabaseClient;
|
||||||
|
await expect(loadProfileReadiness(db, 'che:default')).resolves.toMatchObject({
|
||||||
|
ok: ready,
|
||||||
|
daemonReady: ready,
|
||||||
|
clock: { reconciliationComplete: true, gameplayEnabled: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('fails closed when the reconciliation schema is not available', async () => {
|
it('fails closed when the reconciliation schema is not available', async () => {
|
||||||
const db = {} as DatabaseClient;
|
const db = {} as DatabaseClient;
|
||||||
await expect(loadClockReadiness(db)).resolves.toEqual({
|
await expect(loadClockReadiness(db)).resolves.toEqual({
|
||||||
|
|||||||
@@ -53,6 +53,29 @@ describe('turn command argument input', () => {
|
|||||||
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||||
{ key: 'amountList', kind: 'numberTuple' },
|
{ key: 'amountList', kind: 'numberTuple' },
|
||||||
]);
|
]);
|
||||||
|
// Ref che_피장파장.vue: '전략 :' select에 자신을 뺀 전략 이름을 보인다.
|
||||||
|
expect(fields.find((entry) => entry.key === 'che_피장파장')?.fields).toMatchObject([
|
||||||
|
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||||
|
{
|
||||||
|
key: 'commandType',
|
||||||
|
label: '전략',
|
||||||
|
kind: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'che_필사즉생', label: '필사즉생' },
|
||||||
|
{ value: 'che_백성동원', label: '백성동원' },
|
||||||
|
{ value: 'che_수몰', label: '수몰' },
|
||||||
|
{ value: 'che_허보', label: '허보' },
|
||||||
|
{ value: 'che_의병모집', label: '의병모집' },
|
||||||
|
{ value: 'che_이호경식', label: '이호경식' },
|
||||||
|
{ value: 'che_급습', label: '급습' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Ref che_숙련전환.vue: '감소 대상 숙련 :', '전환 대상 숙련 :'.
|
||||||
|
expect(fields.find((entry) => entry.key === 'che_숙련전환')?.fields).toMatchObject([
|
||||||
|
{ key: 'srcArmType', label: '감소 대상 숙련', kind: 'select', optionSource: 'armTypes' },
|
||||||
|
{ key: 'destArmType', label: '전환 대상 숙련', kind: 'select', optionSource: 'armTypes' },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
nationFields
|
nationFields
|
||||||
@@ -321,6 +344,9 @@ describe('turn command argument input', () => {
|
|||||||
'che_계략_향낭',
|
'che_계략_향낭',
|
||||||
]);
|
]);
|
||||||
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
||||||
|
expect(items.item[1]?.availableNow).toBe(true);
|
||||||
|
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 불가로 표시한다.
|
||||||
|
expect(items.item[0]?.availableNow).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
||||||
@@ -341,5 +367,6 @@ describe('turn command argument input', () => {
|
|||||||
|
|
||||||
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
|
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
|
||||||
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
|
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
|
||||||
|
expect(items.item[1]?.availableNow).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildRefGeneralTargetOptions,
|
buildRefGeneralTargetOptions,
|
||||||
buildRefNationTargetOptions,
|
buildRefNationTargetOptions,
|
||||||
type GeneralTargetSource,
|
type GeneralTargetSource,
|
||||||
|
type NationTargetSource,
|
||||||
} from '../src/turns/commandTargets.js';
|
} from '../src/turns/commandTargets.js';
|
||||||
|
|
||||||
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
|
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
|
||||||
@@ -49,8 +50,9 @@ describe('Ref command general targets', () => {
|
|||||||
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
|
it('indexes genuine names even when named 없음, and excludes missing-name placeholders', () => {
|
||||||
const options = buildRefGeneralTargetOptions({
|
const options = buildRefGeneralTargetOptions({
|
||||||
actorId: 1,
|
actorId: 1,
|
||||||
actorNationId: 0,
|
// 이름·국가·도시·부대 원본이 없는 경우. 재야 actor는 같은 국가 후보가 없으므로 국가 ID만 둔다.
|
||||||
generals: [general({ name: '없음', nationId: 0, cityId: 0, troopId: 99 })],
|
actorNationId: 5,
|
||||||
|
generals: [general({ name: '없음', nationId: 5, cityId: 0, troopId: 99 })],
|
||||||
nationNames: new Map(),
|
nationNames: new Map(),
|
||||||
cityNames: new Map(),
|
cityNames: new Map(),
|
||||||
troopNames: new Map(),
|
troopNames: new Map(),
|
||||||
@@ -118,7 +120,10 @@ describe('Ref command general targets', () => {
|
|||||||
);
|
);
|
||||||
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000');
|
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe('병력 1,000\n금 5,000 · 쌀 4,000');
|
||||||
expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대');
|
expect(detailed.generalTargets.che_발령?.[2]?.targetNames?.troopName).toBe('청룡대');
|
||||||
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
|
// Ref ProcessGeneralAmount.vue처럼 증여 후보도 (통/무/지)를 함께 보인다. 발령의 줄바꿈 형식은 쓰지 않는다.
|
||||||
|
expect(detailed.generalTargets.che_증여?.[1]?.description).toBe(
|
||||||
|
'금 100 · 쌀 200 · 병력 900 · 훈련 80 · 사기 70 · 통솔 100 · 무력 95 · 지력 0'
|
||||||
|
);
|
||||||
expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([
|
expect(detailed.generalTargets.che_증여?.map((entry) => entry.targetNames)).toEqual([
|
||||||
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
|
{ name: '본인', nationName: '아국', cityName: '업', troopName: null },
|
||||||
{ name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
{ name: '부대원', nationName: '아국', cityName: '업', troopName: '청룡대' },
|
||||||
@@ -132,6 +137,106 @@ describe('Ref command general targets', () => {
|
|||||||
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
|
expect(detailed.generalTargets.che_포상?.[0]?.description).toBe('금 5,000 · 쌀 4,000 · 병력 1,000');
|
||||||
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
|
expect(detailed.generalTargets.che_몰수?.[1]?.description).not.toContain('탑승 부대');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('limits cross-nation recruitment and joining targets to the fields Ref exports', () => {
|
||||||
|
const foreign = general({
|
||||||
|
id: 7,
|
||||||
|
name: '타국장수',
|
||||||
|
nationId: 2,
|
||||||
|
cityId: 20,
|
||||||
|
officerLevel: 1,
|
||||||
|
gold: 9000,
|
||||||
|
rice: 8000,
|
||||||
|
crew: 7000,
|
||||||
|
train: 90,
|
||||||
|
atmos: 80,
|
||||||
|
troopId: 7,
|
||||||
|
leadership: 75,
|
||||||
|
strength: 60,
|
||||||
|
intel: 45,
|
||||||
|
});
|
||||||
|
const wanderer = general({ id: 8, name: '재야장수', nationId: 0, cityId: 30, gold: 500, rice: 400 });
|
||||||
|
const options = buildRefGeneralTargetOptions({
|
||||||
|
actorId: 1,
|
||||||
|
actorNationId: 1,
|
||||||
|
generals: [general({}), foreign, wanderer],
|
||||||
|
nationNames: new Map([
|
||||||
|
[1, '아국'],
|
||||||
|
[2, '타국'],
|
||||||
|
]),
|
||||||
|
cityNames: new Map([
|
||||||
|
[10, '업'],
|
||||||
|
[20, '허창'],
|
||||||
|
[30, '단양'],
|
||||||
|
]),
|
||||||
|
troopNames: new Map([[7, '비밀부대']]),
|
||||||
|
});
|
||||||
|
for (const list of [
|
||||||
|
options.generalTargets.che_등용,
|
||||||
|
options.generalTargets.che_장수대상임관,
|
||||||
|
options.generals,
|
||||||
|
]) {
|
||||||
|
const target = list?.find((entry) => entry.value === 7);
|
||||||
|
// Ref: no,name,nation,officer_level,npc,leadership,strength,intel
|
||||||
|
expect(target).toEqual({
|
||||||
|
value: 7,
|
||||||
|
label: '타국장수 (타국)',
|
||||||
|
targetNames: { name: '타국장수', nationName: '타국' },
|
||||||
|
description: '통솔 75 · 무력 60 · 지력 45',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 2,
|
||||||
|
});
|
||||||
|
const serialized = JSON.stringify(list);
|
||||||
|
for (const hidden of ['9,000', '9000', '8000', '7000', '허창', '단양', '비밀부대', '훈련', '사기']) {
|
||||||
|
expect(serialized).not.toContain(hidden);
|
||||||
|
}
|
||||||
|
expect(list?.find((entry) => entry.value === 8)).toMatchObject({
|
||||||
|
label: '재야장수 (재야)',
|
||||||
|
targetNames: { name: '재야장수', nationName: null },
|
||||||
|
nationId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a wandering actor no same-nation general candidates', () => {
|
||||||
|
const options = buildRefGeneralTargetOptions({
|
||||||
|
actorId: 1,
|
||||||
|
actorNationId: 0,
|
||||||
|
generals: [general({ nationId: 0 }), general({ id: 2, name: '다른재야', nationId: 0, gold: 900 })],
|
||||||
|
nationNames: new Map(),
|
||||||
|
cityNames: new Map([[10, '업']]),
|
||||||
|
});
|
||||||
|
for (const action of ['che_증여', 'che_선양', 'che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) {
|
||||||
|
expect(options.generalTargets[action]).toEqual([]);
|
||||||
|
}
|
||||||
|
expect(options.generalTargets.che_장수대상임관?.map((entry) => entry.value)).toEqual([2]);
|
||||||
|
expect(JSON.stringify(options.generalTargets.che_장수대상임관)).not.toContain('900');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only city and Ref stats for abdication candidates', () => {
|
||||||
|
const options = buildRefGeneralTargetOptions({
|
||||||
|
actorId: 1,
|
||||||
|
actorNationId: 1,
|
||||||
|
generals: [
|
||||||
|
general({}),
|
||||||
|
general({
|
||||||
|
id: 2,
|
||||||
|
name: '후계',
|
||||||
|
gold: 100,
|
||||||
|
rice: 200,
|
||||||
|
crew: 300,
|
||||||
|
leadership: 80,
|
||||||
|
strength: 70,
|
||||||
|
intel: 60,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
nationNames: new Map([[1, '아국']]),
|
||||||
|
cityNames: new Map([[10, '업']]),
|
||||||
|
});
|
||||||
|
expect(options.generalTargets.che_선양).toMatchObject([
|
||||||
|
{ value: 2, label: '후계 (업)', description: '통솔 80 · 무력 70 · 지력 60' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Ref nation target guidance', () => {
|
describe('Ref nation target guidance', () => {
|
||||||
@@ -219,6 +324,88 @@ describe('Ref nation target guidance', () => {
|
|||||||
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
|
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
|
||||||
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
|
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks counter-strategy targets like Ref exportJSVars notAvailable', () => {
|
||||||
|
const withDeclaration = [
|
||||||
|
...nations,
|
||||||
|
{ ...nations[3]!, id: 5, name: '선포국', diplomacyState: 1, diplomacyTerm: 12 },
|
||||||
|
{ ...nations[3]!, id: 6, name: '신규선포국', diplomacyState: 1, diplomacyTerm: 11 },
|
||||||
|
];
|
||||||
|
const result = buildRefNationTargetOptions({ actorNationId: 1, nations: withDeclaration });
|
||||||
|
const available = (action: string) =>
|
||||||
|
result.nationTargets[action]?.filter((entry) => entry.availableNow).map((entry) => entry.value);
|
||||||
|
// 피장파장·이호경식: AllowDiplomacyBetweenStatus([0, 1])
|
||||||
|
expect(available('che_피장파장')).toEqual([4, 5, 6]);
|
||||||
|
expect(available('che_이호경식')).toEqual([4, 5, 6]);
|
||||||
|
// 급습: AllowDiplomacyWithTerm(1, 12)
|
||||||
|
expect(available('che_급습')).toEqual([5]);
|
||||||
|
expect(result.nationTargets.che_급습?.find((entry) => entry.value === 6)?.description).toContain(
|
||||||
|
'선포 12개월 이상인 상대국에만 가능합니다.'
|
||||||
|
);
|
||||||
|
expect(result.nationTargets.che_피장파장?.find((entry) => entry.value === 1)).toMatchObject({
|
||||||
|
availableNow: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exhausted = buildRefNationTargetOptions({
|
||||||
|
actorNationId: 1,
|
||||||
|
nations: withDeclaration,
|
||||||
|
strategyAvailable: false,
|
||||||
|
});
|
||||||
|
expect(exhausted.nationTargets.che_피장파장?.every((entry) => entry.availableNow === false)).toBe(true);
|
||||||
|
expect(exhausted.nationTargets.che_피장파장?.find((entry) => entry.value === 4)?.description).toContain(
|
||||||
|
'사용할 수 있는 전략이 없습니다.'
|
||||||
|
);
|
||||||
|
expect(exhausted.nationTargets.che_이호경식?.some((entry) => entry.availableNow)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Ref nation order for appointment targets and exports purified scout messages', () => {
|
||||||
|
const joinNations: NationTargetSource[] = [
|
||||||
|
{ ...nations[0]!, scoutMessage: '<p>어서 오시오</p>' },
|
||||||
|
{ ...nations[1]!, blockScout: true },
|
||||||
|
{ ...nations[2]!, name: 'ⓤ태수국' },
|
||||||
|
{ ...nations[3]!, name: 'ⓞ이민족' },
|
||||||
|
{ ...nations[3]!, id: 5, name: '초반만원국', generalCount: 3, gennum: 10 },
|
||||||
|
];
|
||||||
|
const join = { actorNpcState: 0, relYear: 1, openingPartYear: 3, initialNationGenLimit: 10 };
|
||||||
|
const result = buildRefNationTargetOptions({ actorNationId: 0, nations: joinNations, join });
|
||||||
|
expect(result.nationTargets.che_임관?.map((entry) => [entry.value, entry.availableNow])).toEqual([
|
||||||
|
[1, true],
|
||||||
|
[2, false],
|
||||||
|
[3, false],
|
||||||
|
[4, false],
|
||||||
|
[5, false],
|
||||||
|
]);
|
||||||
|
expect(result.nationTargets.che_임관?.map((entry) => entry.description?.split(' · ')[0])).toEqual([
|
||||||
|
'현재 임관 가능',
|
||||||
|
'임관이 금지되어 있습니다.',
|
||||||
|
'유저장은 태수국에 임관할 수 없습니다.',
|
||||||
|
'이민족 국가에 임관할 수 없습니다.',
|
||||||
|
'임관이 제한되고 있습니다.',
|
||||||
|
]);
|
||||||
|
// 초반 제한이 끝나면 인원 제한은 없고, NPC(9)는 이민족 국가에 임관할 수 있다.
|
||||||
|
const later = buildRefNationTargetOptions({
|
||||||
|
actorNationId: 0,
|
||||||
|
nations: joinNations,
|
||||||
|
join: { ...join, actorNpcState: 9, relYear: 3 },
|
||||||
|
});
|
||||||
|
expect(later.nationTargets.che_임관?.map((entry) => entry.availableNow)).toEqual([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
]);
|
||||||
|
expect(result.nationScoutMessages).toEqual(
|
||||||
|
joinNations.map((entry) => ({
|
||||||
|
nationId: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
color: entry.color,
|
||||||
|
message: entry.scoutMessage ?? '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
// join 정보가 없으면 임관은 기존 공통 국가 목록을 그대로 쓴다.
|
||||||
|
expect(buildRefNationTargetOptions({ actorNationId: 0, nations }).nationTargets.che_임관).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Ref amount presets', () => {
|
describe('Ref amount presets', () => {
|
||||||
|
|||||||
@@ -384,8 +384,8 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||||
const turnGridOffsetSeconds =
|
// 상속 턴 시간대는 daemon cursor가 아닌 5분 격자의 위상을 보존한다.
|
||||||
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
const turnGridOffsetSeconds = ((created.turnTime.getTime() / 1000) % 300 + 300) % 300;
|
||||||
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
||||||
expect(turnGridOffsetSeconds).toBeLessThan(40);
|
expect(turnGridOffsetSeconds).toBeLessThan(40);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -742,7 +743,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rolls back the document and notices if the audit insert fails', async () => {
|
it('commits the document and notices with a gap when audit insert fails', async () => {
|
||||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
||||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||||
@@ -754,15 +755,19 @@ integration('diplomacy document message persistence', () => {
|
|||||||
brief: '감사 실패',
|
brief: '감사 실패',
|
||||||
detail: '롤백',
|
detail: '롤백',
|
||||||
})
|
})
|
||||||
).rejects.toThrow('injected audit insert failure');
|
).resolves.toBeDefined();
|
||||||
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
|
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(1);
|
||||||
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
|
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBeGreaterThan(0);
|
||||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
|
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
await db.inputEvent.findUniqueOrThrow({
|
await db.inputEvent.findUniqueOrThrow({
|
||||||
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
|
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
|
||||||
})
|
})
|
||||||
).toMatchObject({ status: 'FAILED', attempts: 1 });
|
).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||||
|
expect(
|
||||||
|
asRecord((await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldStateId } })).meta)
|
||||||
|
.playAuditGap
|
||||||
|
).toMatchObject({ serverId: requestPrefix });
|
||||||
} finally {
|
} finally {
|
||||||
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
|
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
|
||||||
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
|
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
|
|||||||
|
|
||||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||||
if (!redis) return;
|
if (!redis) return;
|
||||||
|
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||||
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
|
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
|
||||||
if (keys.length > 0) await redis.client.del(keys);
|
if (keys.length > 0) await redis.client.del(keys);
|
||||||
}
|
}
|
||||||
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
|
|||||||
if (!created) throw new Error('failed to seed diplomacy HTML access token');
|
if (!created) throw new Error('failed to seed diplomacy HTML access token');
|
||||||
accessToken = created.accessToken;
|
accessToken = created.accessToken;
|
||||||
|
|
||||||
|
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||||
server = await createGameApiServer();
|
server = await createGameApiServer();
|
||||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const classifications = {
|
|||||||
'join.reselectPoolGeneral',
|
'join.reselectPoolGeneral',
|
||||||
'join.selectPoolGeneral',
|
'join.selectPoolGeneral',
|
||||||
'nation.appoint',
|
'nation.appoint',
|
||||||
|
'nation.cityOfficeRequest',
|
||||||
'nation.changePermission',
|
'nation.changePermission',
|
||||||
'nation.kick',
|
'nation.kick',
|
||||||
'nation.setBill',
|
'nation.setBill',
|
||||||
@@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => {
|
|||||||
// count independently catches mutations that were added to a router but never mounted.
|
// count independently catches mutations that were added to a router but never mounted.
|
||||||
expect(declaredCount).toBe(actual.length);
|
expect(declaredCount).toBe(actual.length);
|
||||||
expect(new Set(classified).size).toBe(classified.length);
|
expect(new Set(classified).size).toBe(classified.length);
|
||||||
expect(classified).toHaveLength(88);
|
|
||||||
expect(actual).toEqual(classified);
|
expect(actual).toEqual(classified);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createServer, type Socket } from 'node:net';
|
||||||
|
import { createClient } from 'redis';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||||
|
|
||||||
describe('RedisGatewayFlushSubscriber', () => {
|
describe('RedisGatewayFlushSubscriber', () => {
|
||||||
it('reports asynchronous flush handler failures with event context', async () => {
|
it('reports asynchronous flush handler failures with event context', async () => {
|
||||||
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
|
|||||||
expect(stopped).toBe(true);
|
expect(stopped).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RedisFlushStore authorization state', () => {
|
||||||
|
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
|
||||||
|
let persisted: string | null = null;
|
||||||
|
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
|
||||||
|
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
|
||||||
|
await store.assertInitialized();
|
||||||
|
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
|
||||||
|
persisted = '200';
|
||||||
|
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
|
||||||
|
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
|
||||||
|
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
|
||||||
|
expect(mGet).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
it('retains revocation after local cache eviction and a fresh store instance', async () => {
|
||||||
|
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
|
||||||
|
const store = new RedisFlushStore(client, 'fixture:flush');
|
||||||
|
for (let i = 0; i < 4100; i++)
|
||||||
|
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
|
||||||
|
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||||
|
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||||
|
});
|
||||||
|
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
|
||||||
|
'rejects missing or corrupt initialization %s',
|
||||||
|
async (baseline) => {
|
||||||
|
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
|
||||||
|
await expect(store.assertInitialized()).rejects.toThrow();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
|
||||||
|
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
|
||||||
|
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
|
||||||
|
const offlineGet = vi.fn(async () => ['0', null]);
|
||||||
|
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
|
||||||
|
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
|
||||||
|
expect(offlineGet).not.toHaveBeenCalled();
|
||||||
|
const mGet = vi.fn(async () => ['0', '100']);
|
||||||
|
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
|
||||||
|
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
|
||||||
|
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
|
||||||
|
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
let receivedRead = false;
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on('close', () => sockets.delete(socket));
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
const request = data.toString();
|
||||||
|
// Real node-redis handshake succeeds; its MGET command receives no reply.
|
||||||
|
if (request.includes('MGET')) {
|
||||||
|
receivedRead = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
|
||||||
|
socket.write('+OK\r\n'.repeat(count));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
|
||||||
|
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
expect(client.isReady).toBe(true);
|
||||||
|
const start = Date.now();
|
||||||
|
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
|
||||||
|
expect(receivedRead).toBe(true);
|
||||||
|
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
|
||||||
|
expect(Date.now() - start).toBeLessThan(4500);
|
||||||
|
} finally {
|
||||||
|
if (client.isOpen) client.destroy();
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
|
|||||||
db,
|
db,
|
||||||
generalAccessTracking: true,
|
generalAccessTracking: true,
|
||||||
requestId: 'access-boundary-test',
|
requestId: 'access-boundary-test',
|
||||||
profile: { id: 'che:default', name: 'che' },
|
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||||
profileStatusSource,
|
profileStatusSource,
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
|
|||||||
auth: token,
|
auth: token,
|
||||||
db: { $executeRaw: executeRaw },
|
db: { $executeRaw: executeRaw },
|
||||||
generalAccessTracking: true,
|
generalAccessTracking: true,
|
||||||
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||||
}) as unknown as GameApiContext;
|
}) as unknown as GameApiContext;
|
||||||
|
|
||||||
const activityRouter = router({
|
const activityRouter = router({
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import fastify from 'fastify';
|
||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
|
||||||
|
|
||||||
|
const internalFailure = () => {
|
||||||
|
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
|
||||||
|
};
|
||||||
|
const badInput = () => {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '잘못된 입력입니다.',
|
||||||
|
cause: new Error('synthetic-private-cause'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('public HTTP error privacy', () => {
|
||||||
|
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
|
||||||
|
const app = fastify();
|
||||||
|
await app.register(fastifyTRPCPlugin, {
|
||||||
|
prefix: '/game',
|
||||||
|
trpcOptions: {
|
||||||
|
router: gameRouter({
|
||||||
|
internal: sessionActivityProcedure.query(internalFailure),
|
||||||
|
invalid: sessionActivityProcedure.query(badInput),
|
||||||
|
}),
|
||||||
|
createContext: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||||
|
for (const prefix of ['game']) {
|
||||||
|
for (const procedure of ['internal', 'invalid']) {
|
||||||
|
const response = await fetch(`${address}/${prefix}/${procedure}`);
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
error: { message: string; data: { code: string; stack?: string } };
|
||||||
|
};
|
||||||
|
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
|
||||||
|
expect(body.error.message).toBe(
|
||||||
|
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
|
||||||
|
);
|
||||||
|
expect(body.error.data.stack).toBeUndefined();
|
||||||
|
expect(JSON.stringify(body)).not.toContain('synthetic-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,23 +21,35 @@ const auth: GameSessionTokenPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = (
|
const buildContext = (
|
||||||
options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {}
|
options: {
|
||||||
|
auth?: GameSessionTokenPayload | null;
|
||||||
|
hasVoted?: boolean;
|
||||||
|
preopenClock?: boolean;
|
||||||
|
officerLevel?: number;
|
||||||
|
officeRows?: Array<{ id: number; userId: string; meta: Record<string, unknown> }>;
|
||||||
|
} = {}
|
||||||
) =>
|
) =>
|
||||||
({
|
({
|
||||||
auth: options.auth === undefined ? auth : options.auth,
|
auth: options.auth === undefined ? auth : options.auth,
|
||||||
|
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||||
db: {
|
db: {
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
id: 7,
|
id: 7,
|
||||||
userId: 'owner',
|
userId: 'owner',
|
||||||
|
officerLevel: options.officerLevel ?? 1,
|
||||||
nationId: 2,
|
nationId: 2,
|
||||||
})),
|
})),
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
|
||||||
|
input.where?.meta
|
||||||
|
? (options.officeRows ?? [])
|
||||||
|
: [
|
||||||
{ id: 7, name: '유비', nationId: 2 },
|
{ id: 7, name: '유비', nationId: 2 },
|
||||||
{ id: 8, name: '관우', nationId: 2 },
|
{ id: 8, name: '관우', nationId: 2 },
|
||||||
{ id: 9, name: '조조', nationId: 3 },
|
{ id: 9, name: '조조', nationId: 3 },
|
||||||
{ id: 10, name: '재야장수', nationId: 0 },
|
{ id: 10, name: '재야장수', nationId: 0 },
|
||||||
]),
|
]
|
||||||
|
),
|
||||||
},
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
@@ -109,6 +121,7 @@ describe('general.getFrontStatus', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
serverId: 'che_260819_front',
|
serverId: 'che_260819_front',
|
||||||
|
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
|
||||||
onlineUserCount: 4,
|
onlineUserCount: 4,
|
||||||
onlineNations: '【촉】, 【위】',
|
onlineNations: '【촉】, 【위】',
|
||||||
onlineGenerals: '유비, 관우',
|
onlineGenerals: '유비, 관우',
|
||||||
@@ -173,3 +186,31 @@ describe('general.getFrontStatus', () => {
|
|||||||
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only notifies chiefs about pending requests belonging to current members and owners', async () => {
|
||||||
|
const pending = {
|
||||||
|
id: '8:760',
|
||||||
|
generalId: 8,
|
||||||
|
userId: 'user-8',
|
||||||
|
nationId: 2,
|
||||||
|
cityId: 1,
|
||||||
|
officerLevel: 4,
|
||||||
|
incumbentId: 0,
|
||||||
|
quarter: 760,
|
||||||
|
createdTick: 0,
|
||||||
|
dueTick: 3600000,
|
||||||
|
defaultDecision: 'approve',
|
||||||
|
defaultReason: 'vacant',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
const officeRows = [
|
||||||
|
{ id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } },
|
||||||
|
{ id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } },
|
||||||
|
{ id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } },
|
||||||
|
{ id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } },
|
||||||
|
];
|
||||||
|
const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows }));
|
||||||
|
expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']);
|
||||||
|
const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows }));
|
||||||
|
expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type LogQuery = {
|
|||||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||||
({
|
({
|
||||||
auth,
|
auth,
|
||||||
|
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||||
db: {
|
db: {
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
|
|||||||
|
|
||||||
const expectedOwnerCounts: Record<string, number> = {
|
const expectedOwnerCounts: Record<string, number> = {
|
||||||
'durable-journal': 19,
|
'durable-journal': 19,
|
||||||
'engine-owned': 38,
|
'engine-owned': 39,
|
||||||
'explicit-no-realtime-consumer': 7,
|
'explicit-no-realtime-consumer': 7,
|
||||||
'external-upload': 1,
|
'external-upload': 1,
|
||||||
'mixed-saga': 9,
|
'mixed-saga': 9,
|
||||||
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
|
|||||||
const rows = parseManifest();
|
const rows = parseManifest();
|
||||||
const manifestRoutes = rows.map(({ route }) => route);
|
const manifestRoutes = rows.map(({ route }) => route);
|
||||||
|
|
||||||
expect(rows).toHaveLength(88);
|
|
||||||
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
|
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
|
||||||
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
|
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
|
||||||
expect(manifestRoutes).toEqual(mountedMutationNames());
|
expect(manifestRoutes).toEqual(mountedMutationNames());
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
|||||||
if (!redis) {
|
if (!redis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||||
for await (const keys of redis.client.scanIterator({
|
for await (const keys of redis.client.scanIterator({
|
||||||
MATCH: `sammo:game:*:${profileName}:*`,
|
MATCH: `sammo:game:*:${profileName}:*`,
|
||||||
COUNT: 100,
|
COUNT: 100,
|
||||||
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
|
|||||||
}
|
}
|
||||||
accessToken = created.accessToken;
|
accessToken = created.accessToken;
|
||||||
|
|
||||||
|
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||||
server = await createGameApiServer();
|
server = await createGameApiServer();
|
||||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ChangeJournal } from '@sammo-ts/common';
|
import { ChangeJournal } from '@sammo-ts/common';
|
||||||
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
|
|||||||
expect(memberCommand).not.toHaveBeenCalled();
|
expect(memberCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('city office requests', () => {
|
||||||
|
it.each(['approve', 'reject', 'withdraw'] as const)(
|
||||||
|
'binds %s to the session actor and durable request ID',
|
||||||
|
async (action) => {
|
||||||
|
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
|
||||||
|
const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand }));
|
||||||
|
await expect(
|
||||||
|
caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' })
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
expect(requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
userId: 'user-22',
|
||||||
|
generalId: 22,
|
||||||
|
requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest',
|
||||||
|
action,
|
||||||
|
targetGeneralId: 30,
|
||||||
|
officeRequestId: '30:760',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('validates request fields and propagates engine rejection', async () => {
|
||||||
|
const requestCommand = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' });
|
||||||
|
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
||||||
|
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({
|
||||||
|
message: '이번 분기 자원 완료',
|
||||||
|
});
|
||||||
|
// @ts-expect-error invalid officer must also be rejected at the HTTP boundary
|
||||||
|
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
});
|
||||||
|
expect(requestCommand).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => {
|
||||||
|
const office = {
|
||||||
|
id: '30:760',
|
||||||
|
generalId: 30,
|
||||||
|
userId: 'user-30',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
officerLevel: 4,
|
||||||
|
incumbentId: 0,
|
||||||
|
quarter: 760,
|
||||||
|
createdTick: 0,
|
||||||
|
dueTick: 3600000,
|
||||||
|
defaultDecision: 'approve',
|
||||||
|
defaultReason: 'vacant',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
const me = { ...baseGeneral, officerLevel };
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
createContext({
|
||||||
|
me,
|
||||||
|
db: {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async () => me),
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
me,
|
||||||
|
{
|
||||||
|
...baseGeneral,
|
||||||
|
id: 30,
|
||||||
|
userId: 'user-30',
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { belong: 5, cityOfficeRequest: office },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) },
|
||||||
|
city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) },
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
currentYear: 190,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
meta: {},
|
||||||
|
config: { stat: { chiefMin: 65 } },
|
||||||
|
clockBaseTime: new Date('2026-01-01'),
|
||||||
|
clockTick: 0n,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockWallAnchor: new Date('2026-01-01'),
|
||||||
|
clockPhase: 'RUNNING',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const result = await caller.nation.getCityOfficeRequests();
|
||||||
|
expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('user-30');
|
||||||
|
expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => {
|
||||||
|
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
|
||||||
|
const server = createHTTPServer({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: ({ req }): GameApiContext => ({
|
||||||
|
...createContext({ requestId: 'office-http-wire', requestCommand }),
|
||||||
|
auth: req.headers['x-test-session'] ? auth : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const send = (body: unknown, authenticated = true) =>
|
||||||
|
fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401);
|
||||||
|
expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400);
|
||||||
|
expect(requestCommand).not.toHaveBeenCalled();
|
||||||
|
expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200);
|
||||||
|
expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' }));
|
||||||
|
requestCommand.mockResolvedValueOnce({
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
ok: false,
|
||||||
|
generalId: 22,
|
||||||
|
reason: '이미 처리된 요청',
|
||||||
|
});
|
||||||
|
expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
@@ -96,10 +98,16 @@ const createContext = (
|
|||||||
options: {
|
options: {
|
||||||
me?: GeneralRow;
|
me?: GeneralRow;
|
||||||
nation?: typeof baseNation;
|
nation?: typeof baseNation;
|
||||||
world?: typeof baseWorld;
|
world?: {
|
||||||
|
config: typeof baseWorld.config;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
currentYear?: number;
|
||||||
|
currentMonth?: number;
|
||||||
|
};
|
||||||
requestCommand?: ReturnType<typeof vi.fn>;
|
requestCommand?: ReturnType<typeof vi.fn>;
|
||||||
troopRows?: Array<{ troopLeaderId: number }>;
|
troopRows?: Array<{ troopLeaderId: number }>;
|
||||||
cityRows?: Array<{ id: number }>;
|
cityRows?: Array<{ id: number }>;
|
||||||
|
historyRows?: Array<Record<string, unknown>>;
|
||||||
} = {}
|
} = {}
|
||||||
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
|
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
|
||||||
const requestCommand =
|
const requestCommand =
|
||||||
@@ -117,6 +125,11 @@ const createContext = (
|
|||||||
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
|
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
|
||||||
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
|
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
|
||||||
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
|
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 };
|
const redisClient = { get: async () => null, set: async () => null };
|
||||||
return {
|
return {
|
||||||
@@ -140,6 +153,146 @@ const createContext = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('NPC policy router', () => {
|
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 () => {
|
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
|
||||||
const fixture = createContext();
|
const fixture = createContext();
|
||||||
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
summarizeNationPeriod,
|
summarizeNationPeriod,
|
||||||
|
projectCurrentSettlement,
|
||||||
|
zAuditNation,
|
||||||
type AuditNationData,
|
type AuditNationData,
|
||||||
type NationMonthPoint,
|
type NationMonthPoint,
|
||||||
} from '../src/router/playAudit/nationSeries.js';
|
} from '../src/router/playAudit/nationSeries.js';
|
||||||
|
|
||||||
const population = {
|
const population = {
|
||||||
count: 0,
|
count: 0,
|
||||||
|
crew: null,
|
||||||
gold: 0,
|
gold: 0,
|
||||||
rice: 0,
|
rice: 0,
|
||||||
dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
|
dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
|
||||||
@@ -89,3 +92,44 @@ describe('play audit half-year summary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('current committed settlement', () => {
|
||||||
|
it.each([
|
||||||
|
[1, 'gold'],
|
||||||
|
[7, 'rice'],
|
||||||
|
] as const)('exposes month %s settlement before month-end', (month, resource) => {
|
||||||
|
const meta = {
|
||||||
|
playAuditFlows: {
|
||||||
|
year: 200,
|
||||||
|
month,
|
||||||
|
complete: true,
|
||||||
|
entries: { [`2:${resource}`]: { nationId: 2, resource, income: 1234.5, paid: 234 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(projectCurrentSettlement(meta, 200, month, 2)[resource]).toEqual({ income: 1234.5, paid: 234 });
|
||||||
|
expect(projectCurrentSettlement(meta, 200, month + 1, 2)[resource]).toBeNull();
|
||||||
|
expect(projectCurrentSettlement(meta, 200, month, 3)[resource]).toBeNull();
|
||||||
|
});
|
||||||
|
it('keeps absent and malformed observations unknown, preserves actual zero', () => {
|
||||||
|
expect(projectCurrentSettlement({}, 200, 1, 2).gold).toBeNull();
|
||||||
|
const meta = {
|
||||||
|
playAuditFlows: {
|
||||||
|
year: 200,
|
||||||
|
month: 1,
|
||||||
|
complete: false,
|
||||||
|
entries: { '2:gold': { nationId: 2, resource: 'gold', income: 0, paid: 0 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(projectCurrentSettlement(meta, 200, 1, 2)).toMatchObject({
|
||||||
|
gold: { income: 0, paid: 0 },
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
meta.playAuditFlows.entries['2:gold'].income = NaN;
|
||||||
|
expect(projectCurrentSettlement(meta, 200, 1, 2).gold).toBeNull();
|
||||||
|
});
|
||||||
|
it('reads old snapshots without inventing troop counts', () => {
|
||||||
|
const old = JSON.parse(JSON.stringify(nation(1)));
|
||||||
|
delete old.populations.human.crew;
|
||||||
|
expect(zAuditNation.parse(old).populations.human.crew).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
import { loadProfileReadiness } from '../src/services/clockReadiness.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
integration('profile deployment readiness', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
close = () => connector.disconnect();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await close?.();
|
||||||
|
});
|
||||||
|
it('rejects absent, initializing, expired and other-profile leases; accepts ready paused/preopen runtime', async () => {
|
||||||
|
const rollback = new Error('readiness fixture rollback');
|
||||||
|
await expect(
|
||||||
|
db.$transaction(async (tx) => {
|
||||||
|
// Shared integration fixtures may leave projection work; rollback restores it.
|
||||||
|
await tx.clockProjectionOutbox.deleteMany();
|
||||||
|
await tx.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: -998901,
|
||||||
|
scenarioCode: 'readiness-fixture',
|
||||||
|
currentYear: 190,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 300,
|
||||||
|
clockPhase: 'PREOPEN',
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const profile = 'readiness:fixture';
|
||||||
|
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
|
||||||
|
await tx.turnDaemonLease.create({
|
||||||
|
data: {
|
||||||
|
profile,
|
||||||
|
ownerId: 'fixture',
|
||||||
|
fencingEpoch: 1n,
|
||||||
|
heartbeatAt: new Date(),
|
||||||
|
leaseUntil: new Date(Date.now() + 60_000),
|
||||||
|
clockReady: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
|
||||||
|
await tx.turnDaemonLease.update({ where: { profile }, data: { clockReady: true } });
|
||||||
|
expect((await loadProfileReadiness(tx, profile)).ok).toBe(true);
|
||||||
|
expect((await loadProfileReadiness(tx, 'readiness:other')).ok).toBe(false);
|
||||||
|
await tx.turnDaemonLease.update({
|
||||||
|
where: { profile },
|
||||||
|
data: { leaseUntil: new Date(Date.now() - 60_000) },
|
||||||
|
});
|
||||||
|
expect((await loadProfileReadiness(tx, profile)).ok).toBe(false);
|
||||||
|
throw rollback;
|
||||||
|
})
|
||||||
|
).rejects.toBe(rollback);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ const profile: GameProfile = {
|
|||||||
|
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: 'che',
|
profile: 'che:default',
|
||||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
sessionId: 'ranking-session',
|
sessionId: 'ranking-session',
|
||||||
@@ -239,7 +239,7 @@ const buildContext = (options?: {
|
|||||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
battleSim: new InMemoryBattleSimTransport(),
|
battleSim: new InMemoryBattleSimTransport(),
|
||||||
profile: selectedProfile,
|
profile: selectedProfile,
|
||||||
auth: options?.authenticated === false ? null : auth,
|
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import { RealtimeConnectionAdmission } from '../src/realtime/connectionAdmission.js';
|
||||||
|
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe('realtime connection admission', () => {
|
||||||
|
it('reserves the process total before authentication and counts live streams too', () => {
|
||||||
|
const budget = new RealtimeConnectionAdmission({ connections: 2, perUser: 2, opening: 2 });
|
||||||
|
const first = budget.begin(vi.fn())!;
|
||||||
|
const second = budget.begin(vi.fn())!;
|
||||||
|
expect(first.reserveUser('actor')).toBe(true);
|
||||||
|
expect(first.startStreaming()).toBe(true);
|
||||||
|
expect(budget.begin(vi.fn())).toBeNull();
|
||||||
|
second.release();
|
||||||
|
const replacement = budget.begin(vi.fn())!;
|
||||||
|
expect(replacement).not.toBeNull();
|
||||||
|
replacement.release();
|
||||||
|
first.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares the authenticated actor limit while allowing another actor', () => {
|
||||||
|
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 1, opening: 5 });
|
||||||
|
const first = budget.begin(vi.fn())!;
|
||||||
|
expect(first.reserveUser('one')).toBe(true);
|
||||||
|
const rejected = budget.begin(vi.fn())!;
|
||||||
|
expect(rejected.reserveUser('one')).toBe(false);
|
||||||
|
expect(rejected.startStreaming()).toBe(false);
|
||||||
|
rejected.release();
|
||||||
|
const other = budget.begin(vi.fn())!;
|
||||||
|
expect(other.reserveUser('two')).toBe(true);
|
||||||
|
first.release();
|
||||||
|
first.release();
|
||||||
|
const replacement = budget.begin(vi.fn())!;
|
||||||
|
expect(replacement.reserveUser('one')).toBe(true);
|
||||||
|
other.release();
|
||||||
|
replacement.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps timed-out and disconnected backend work admitted until it actually settles', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 5, opening: 2 });
|
||||||
|
const timeout = vi.fn();
|
||||||
|
const stalled = budget.begin(timeout)!;
|
||||||
|
const disconnected = budget.begin(vi.fn())!;
|
||||||
|
expect(stalled.reserveUser('stalled')).toBe(true);
|
||||||
|
disconnected.cancelOpening();
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(timeout).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stalled.canContinue()).toBe(false);
|
||||||
|
expect(stalled.startStreaming()).toBe(false);
|
||||||
|
expect(disconnected.canContinue()).toBe(false);
|
||||||
|
expect(budget.begin(vi.fn())).toBeNull();
|
||||||
|
stalled.release();
|
||||||
|
const recovered = budget.begin(vi.fn())!;
|
||||||
|
expect(recovered.reserveUser('stalled')).toBe(true);
|
||||||
|
expect(recovered.startStreaming()).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(timeout).toHaveBeenCalledTimes(1);
|
||||||
|
disconnected.release();
|
||||||
|
recovered.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops pending setup on shutdown and permanently refuses new admissions', () => {
|
||||||
|
const budget = new RealtimeConnectionAdmission();
|
||||||
|
const timeout = vi.fn();
|
||||||
|
const pending = budget.begin(timeout)!;
|
||||||
|
budget.close();
|
||||||
|
budget.close();
|
||||||
|
expect(timeout).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pending.canContinue()).toBe(false);
|
||||||
|
expect(budget.begin(vi.fn())).toBeNull();
|
||||||
|
pending.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a closed stream counted while its in-flight authentication is still pending', async () => {
|
||||||
|
const budget = new RealtimeConnectionAdmission({ connections: 1, perUser: 1, opening: 1 });
|
||||||
|
const slot = budget.begin(vi.fn())!;
|
||||||
|
expect(slot.reserveUser('actor')).toBe(true);
|
||||||
|
expect(slot.startStreaming()).toBe(true);
|
||||||
|
let finish!: () => void;
|
||||||
|
let started!: () => void;
|
||||||
|
const inFlight = new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
const running = new Promise<void>((resolve) => {
|
||||||
|
started = resolve;
|
||||||
|
});
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
sessionId: 'session',
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
user: {
|
||||||
|
id: 'actor',
|
||||||
|
username: 'actor',
|
||||||
|
displayName: 'actor',
|
||||||
|
roles: ['user'],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
const response = new PassThrough();
|
||||||
|
const connection = new AuthenticatedRealtimeConnection(
|
||||||
|
auth,
|
||||||
|
response as unknown as ServerResponse,
|
||||||
|
async () => {
|
||||||
|
started();
|
||||||
|
await inFlight;
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
connection.onClose(() => {
|
||||||
|
void connection.whenIdle().then(slot.release, slot.release);
|
||||||
|
});
|
||||||
|
connection.enqueue(async () => 'event: ready\ndata: {}\n\n');
|
||||||
|
await running;
|
||||||
|
connection.close();
|
||||||
|
expect(budget.begin(vi.fn())).toBeNull();
|
||||||
|
finish();
|
||||||
|
await connection.whenIdle();
|
||||||
|
const replacement = budget.begin(vi.fn())!;
|
||||||
|
expect(replacement.reserveUser('actor')).toBe(true);
|
||||||
|
replacement.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the production 512 total and 32 pending openings without accumulating a queue', () => {
|
||||||
|
const budget = new RealtimeConnectionAdmission();
|
||||||
|
const pending = Array.from({ length: 32 }, () => budget.begin(vi.fn())!);
|
||||||
|
expect(pending.every(Boolean)).toBe(true);
|
||||||
|
expect(budget.begin(vi.fn())).toBeNull();
|
||||||
|
for (const slot of pending) slot.release();
|
||||||
|
const live = Array.from({ length: 512 }, (_, index) => {
|
||||||
|
const slot = budget.begin(vi.fn())!;
|
||||||
|
expect(slot.reserveUser(`actor-${index}`)).toBe(true);
|
||||||
|
expect(slot.startStreaming()).toBe(true);
|
||||||
|
return slot;
|
||||||
|
});
|
||||||
|
expect(budget.begin(vi.fn())).toBeNull();
|
||||||
|
live[0]!.release();
|
||||||
|
const replacement = budget.begin(vi.fn())!;
|
||||||
|
expect(replacement.reserveUser('replacement')).toBe(true);
|
||||||
|
expect(replacement.startStreaming()).toBe(true);
|
||||||
|
for (const slot of live) slot.release();
|
||||||
|
replacement.release();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
|
|||||||
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
|
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
|
||||||
expect(Object.values(headers)).not.toContain(secret);
|
expect(Object.values(headers)).not.toContain(secret);
|
||||||
});
|
});
|
||||||
|
it('rejects oversized upload response JSON before trusting its path', async () => {
|
||||||
|
const filename = `${'d'.repeat(32)}.png`;
|
||||||
|
const store = new RemoteContentImageStore(
|
||||||
|
'https://image.example',
|
||||||
|
'https://image.example/public',
|
||||||
|
'synthetic-secret',
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
|
||||||
|
);
|
||||||
|
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Writable } from 'node:stream';
|
||||||
|
import fastify from 'fastify';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
describe('HTTP log credential boundaries', () => {
|
||||||
|
it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => {
|
||||||
|
let output = '';
|
||||||
|
const stream = new Writable({
|
||||||
|
write(chunk, _encoding, done) {
|
||||||
|
output += String(chunk);
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } });
|
||||||
|
app.setErrorHandler(safeHttpErrorHandler);
|
||||||
|
app.post('/events', async () => {
|
||||||
|
throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), {
|
||||||
|
code: 'P2002',
|
||||||
|
detail: 'synthetic-detail-secret',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||||
|
const response = await fetch(`${address}/events?token=synthetic-query-secret`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: 'synthetic-body-secret' }),
|
||||||
|
});
|
||||||
|
expect(await response.json()).toMatchObject({ message: 'Internal server error' });
|
||||||
|
await fetch(`${address}/synthetic-path-secret`);
|
||||||
|
const logs = output
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true);
|
||||||
|
expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true);
|
||||||
|
expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true);
|
||||||
|
for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) {
|
||||||
|
expect(output).not.toContain(`synthetic-${field}-secret`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { buildGameEventChannel } from '@sammo-ts/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
|
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
@@ -68,6 +70,7 @@ const envKeys = [
|
|||||||
'GATEWAY_REDIS_PREFIX',
|
'GATEWAY_REDIS_PREFIX',
|
||||||
'GATEWAY_INTERNAL_API_URL',
|
'GATEWAY_INTERNAL_API_URL',
|
||||||
'GAME_UPLOAD_DIR',
|
'GAME_UPLOAD_DIR',
|
||||||
|
'API_ALLOWED_ORIGINS',
|
||||||
] as const;
|
] as const;
|
||||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||||
|
|
||||||
@@ -172,7 +175,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
|||||||
if (!redis) {
|
if (!redis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||||
for await (const keys of redis.client.scanIterator({
|
for await (const keys of redis.client.scanIterator({
|
||||||
MATCH: pattern,
|
MATCH: pattern,
|
||||||
COUNT: 100,
|
COUNT: 100,
|
||||||
@@ -416,7 +419,7 @@ const readAccessTelemetryState = async () => ({
|
|||||||
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||||
if (!redis) return [];
|
if (!redis) return [];
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||||
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||||
for (const key of batch) keys.add(key);
|
for (const key of batch) keys.add(key);
|
||||||
}
|
}
|
||||||
@@ -640,6 +643,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||||
|
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
|
||||||
|
|
||||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
@@ -742,6 +746,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
await redis.connect();
|
await redis.connect();
|
||||||
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
|
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
|
||||||
|
|
||||||
|
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||||
server = await createGameApiServer();
|
server = await createGameApiServer();
|
||||||
baseUrl = await server.app.listen({
|
baseUrl = await server.app.listen({
|
||||||
host: server.config.host,
|
host: server.config.host,
|
||||||
@@ -818,6 +824,28 @@ integration('game API security over HTTP transport', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
|
||||||
|
const input = { dataUrl: 'data:image/png;base64,AA==' };
|
||||||
|
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
|
||||||
|
expect(unauthenticated.response.status).toBe(401);
|
||||||
|
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
|
||||||
|
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
|
||||||
|
expect(denied.response.status).toBe(403);
|
||||||
|
const officer = await createAccessToken('image-officer', {});
|
||||||
|
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
|
||||||
|
expect(malformed.response.status).toBe(400);
|
||||||
|
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const oversized = await requestTrpc('board.uploadImage', {
|
||||||
|
method: 'POST',
|
||||||
|
accessToken: officer,
|
||||||
|
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
|
||||||
|
});
|
||||||
|
expect(oversized.response.status).toBe(400);
|
||||||
|
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||||
const accessToken = await createAccessToken('json-query-body', {});
|
const accessToken = await createAccessToken('json-query-body', {});
|
||||||
const general = await requestTrpc('general.me', {
|
const general = await requestTrpc('general.me', {
|
||||||
@@ -2317,9 +2345,51 @@ integration('game API security over HTTP transport', () => {
|
|||||||
{
|
{
|
||||||
decisionId: decisionIds[0]!,
|
decisionId: decisionIds[0]!,
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
|
steps: Array.from({ length: 128 }, (_, sequence) =>
|
||||||
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} }
|
sequence === 127
|
||||||
: ({ ...step, sequence })),
|
? {
|
||||||
|
...step,
|
||||||
|
sequence,
|
||||||
|
kind: 'DECISION_START',
|
||||||
|
reservedAction: '휴식',
|
||||||
|
effectivePolicy: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
|
||||||
|
nation: {
|
||||||
|
priority: ['천도'],
|
||||||
|
flags: { 천도: true },
|
||||||
|
values: {
|
||||||
|
reqNationGold: 4321,
|
||||||
|
reqNationRice: 100,
|
||||||
|
reqHumanWarUrgentGold: 100,
|
||||||
|
reqHumanWarUrgentRice: 100,
|
||||||
|
reqHumanWarRecommandGold: 100,
|
||||||
|
reqHumanWarRecommandRice: 100,
|
||||||
|
reqHumanDevelGold: 100,
|
||||||
|
reqHumanDevelRice: 100,
|
||||||
|
reqNpcWarGold: 100,
|
||||||
|
reqNpcWarRice: 100,
|
||||||
|
reqNpcDevelGold: 100,
|
||||||
|
reqNpcDevelRice: 100,
|
||||||
|
minimumResourceActionAmount: 100,
|
||||||
|
maximumResourceActionAmount: 100,
|
||||||
|
minNpcWarLeadership: 100,
|
||||||
|
minWarCrew: 100,
|
||||||
|
minNpcRecruitCityPopulation: 100,
|
||||||
|
safeRecruitCityPopulationRatio: 100,
|
||||||
|
properWarTrainAtmos: 100,
|
||||||
|
cureThreshold: 100,
|
||||||
|
},
|
||||||
|
combatForce: { '1': [2, 3] },
|
||||||
|
supportForce: [4],
|
||||||
|
developForce: [5],
|
||||||
|
secret: 'decision-secret',
|
||||||
|
},
|
||||||
|
secret: 'decision-secret',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { ...step, sequence }
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decisionId: decisionIds[0]!,
|
decisionId: decisionIds[0]!,
|
||||||
@@ -2352,6 +2422,33 @@ integration('game API security over HTTP transport', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
|
const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 };
|
||||||
|
// 월 경계 뒤 장수의 첫 턴 전에도 직전 결정에 도달할 수 있어야 한다.
|
||||||
|
expect((await get('decisionHistory', admin, { generalId: decisionGeneral })).body).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
selection: 'LATEST',
|
||||||
|
month: { year: 190, month: 1 },
|
||||||
|
currentMonth: { year: 190, month: 2 },
|
||||||
|
items: expect.arrayContaining([expect.objectContaining({ id: decisionIds[0] })]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await get('decisionHistory', admin, { generalId: decisionGeneral, month: { year: 190, month: 2 } }))
|
||||||
|
.body
|
||||||
|
).toMatchObject({
|
||||||
|
result: { data: { selection: 'MONTH', month: { year: 190, month: 2 }, items: [] } },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await get('decisionHistory', admin, { generalId: decisionGeneral, phase: 'nation' })).body
|
||||||
|
).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: { selection: 'LATEST', month: { year: 190, month: 1 }, items: [{ id: decisionIds[1] }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await get('decisionHistory', admin, { generalId: 2147483647 })).body).toMatchObject({
|
||||||
|
result: { data: { selection: 'LATEST', month: { year: 190, month: 2 }, items: [] } },
|
||||||
|
});
|
||||||
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
|
expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401);
|
||||||
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
|
expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403);
|
||||||
const decisionList = await get('decisionHistory', admin, decisionInput);
|
const decisionList = await get('decisionHistory', admin, decisionInput);
|
||||||
@@ -2852,6 +2949,132 @@ integration('game API security over HTTP transport', () => {
|
|||||||
expect((await get('capabilities', admin)).body).toMatchObject({
|
expect((await get('capabilities', admin)).body).toMatchObject({
|
||||||
result: { data: { read: true, accounts: false } },
|
result: { data: { read: true, accounts: false } },
|
||||||
});
|
});
|
||||||
|
// 현재/과거 정렬은 전체 조건 안에서 수행하고 동점은 ID로 안정적으로 넘긴다.
|
||||||
|
const originalSortRows = await db.general.findMany({
|
||||||
|
where: { id: { in: [generalId, sameNationGeneralId] } },
|
||||||
|
select: { id: true, gold: true },
|
||||||
|
});
|
||||||
|
await db.general.updateMany({
|
||||||
|
where: { id: { in: [generalId, sameNationGeneralId] } },
|
||||||
|
data: { gold: 54321 },
|
||||||
|
});
|
||||||
|
const sortedInput = { nationId: ownerNationId, sort: 'gold', order: 'desc', limit: 1 };
|
||||||
|
expect((await get('generals', admin, sortedInput)).body).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
items: [{ id: generalId, gold: 54321 }],
|
||||||
|
nextCursor: { id: generalId, value: 54321 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await get('generals', admin, { ...sortedInput, cursor: { id: generalId, value: 54321 } })).body
|
||||||
|
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }] } } });
|
||||||
|
for (const row of originalSortRows)
|
||||||
|
await db.general.update({ where: { id: row.id }, data: { gold: row.gold } });
|
||||||
|
for (const sort of [
|
||||||
|
'gold',
|
||||||
|
'rice',
|
||||||
|
'crew',
|
||||||
|
'train',
|
||||||
|
'atmos',
|
||||||
|
'leadership',
|
||||||
|
'strength',
|
||||||
|
'intelligence',
|
||||||
|
'experience',
|
||||||
|
'dedication',
|
||||||
|
'dex1',
|
||||||
|
'dex2',
|
||||||
|
'dex3',
|
||||||
|
'dex4',
|
||||||
|
'dex5',
|
||||||
|
]) {
|
||||||
|
for (const at of [undefined, { year: 190, month: 1 }]) {
|
||||||
|
expect((await get('generals', admin, { sort, at })).status).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const oldGeneral = await db.playAuditGeneral.findUniqueOrThrow({
|
||||||
|
where: { sampleId_generalId: { sampleId, generalId } },
|
||||||
|
});
|
||||||
|
await db.playAuditGeneral.create({
|
||||||
|
data: {
|
||||||
|
sampleId,
|
||||||
|
generalId: sameNationGeneralId,
|
||||||
|
nationId: oldGeneral.nationId,
|
||||||
|
cityId: oldGeneral.cityId,
|
||||||
|
npcState: oldGeneral.npcState,
|
||||||
|
data: { ...asRecord(oldGeneral.data), id: sameNationGeneralId, name: '동점과거장수' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const historicalValue = past.gold;
|
||||||
|
expect(
|
||||||
|
(await get('generals', admin, { sort: 'gold', order: 'desc', at: { year: 190, month: 1 }, limit: 1 }))
|
||||||
|
.body
|
||||||
|
).toMatchObject({
|
||||||
|
result: { data: { items: [{ id: generalId }], nextCursor: { id: generalId, value: historicalValue } } },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await get('generals', admin, {
|
||||||
|
sort: 'gold',
|
||||||
|
order: 'desc',
|
||||||
|
at: { year: 190, month: 1 },
|
||||||
|
limit: 1,
|
||||||
|
cursor: { id: generalId, value: historicalValue },
|
||||||
|
})
|
||||||
|
).body
|
||||||
|
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }], nextCursor: null } } });
|
||||||
|
await db.playAuditGeneral.delete({
|
||||||
|
where: { sampleId_generalId: { sampleId, generalId: sameNationGeneralId } },
|
||||||
|
});
|
||||||
|
expect((await get('generals', admin, { sort: 'gold', cursor: 1 })).status).toBe(400);
|
||||||
|
expect((await get('generals', admin, { sort: 'gold; DROP TABLE general' })).status).toBe(400);
|
||||||
|
expect(
|
||||||
|
(await get('generals', admin, { sort: 'gold', at: { year: 190, month: 1 }, name: '%' })).body
|
||||||
|
).toMatchObject({ result: { data: { items: [] } } });
|
||||||
|
for (const accessToken of [
|
||||||
|
undefined,
|
||||||
|
await token(['admin']),
|
||||||
|
await token(['admin.playAudit.read:other:default']),
|
||||||
|
]) {
|
||||||
|
expect((await get('cityMap', accessToken, {})).status).toBe(accessToken ? 403 : 401);
|
||||||
|
expect((await get('generals', accessToken, { sort: 'crew' })).status).toBe(accessToken ? 403 : 401);
|
||||||
|
}
|
||||||
|
const citySource = await db.playAuditCity.findFirstOrThrow({ where: { sampleId }, select: { data: true } });
|
||||||
|
await db.playAuditCity.create({
|
||||||
|
data: {
|
||||||
|
sampleId,
|
||||||
|
cityId: 1,
|
||||||
|
nationId: ownerNationId,
|
||||||
|
data: { ...asRecord(citySource.data), id: 1, name: '지도과거도시' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const map = await get('cityMap', admin, { at: { year: 190, month: 1 } });
|
||||||
|
expect(map.status).toBe(200);
|
||||||
|
expect(map.body).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
collected: true,
|
||||||
|
map: {
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
cityList: expect.arrayContaining([[1, 4, 0, ownerNationId, expect.any(Number), 1]]),
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
cityList: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: 1, name: '지도과거도시' }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(map.body)).not.toContain('must-not-expose');
|
||||||
|
await db.playAuditCity.delete({ where: { sampleId_cityId: { sampleId, cityId: 1 } } });
|
||||||
|
expect((await get('cityMap', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
|
||||||
|
result: { data: { collected: false, map: null } },
|
||||||
|
});
|
||||||
|
expect((await get('cityMap', admin, {})).status).toBe(200);
|
||||||
|
expect((await get('cityMap', admin, { at: { year: 9999, month: 1 } })).status).toBe(400);
|
||||||
const first = await get('generals', admin, { limit: 1 });
|
const first = await get('generals', admin, { limit: 1 });
|
||||||
expect(first.status).toBe(200);
|
expect(first.status).toBe(200);
|
||||||
expect(first.body).toMatchObject({
|
expect(first.body).toMatchObject({
|
||||||
@@ -3119,6 +3342,39 @@ integration('game API security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await db.playAuditNation.update({
|
||||||
|
where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } },
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
...asRecord(finalNation.data),
|
||||||
|
populations: {
|
||||||
|
human: { ...population, count: 2 },
|
||||||
|
npc: { ...population, count: 1 },
|
||||||
|
troopNpc: population,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.playAuditGeneral.createMany({
|
||||||
|
data: [1, 2].map((id) => ({
|
||||||
|
sampleId: `${seasonId}:190:6`,
|
||||||
|
generalId: id,
|
||||||
|
nationId: ownerNationId,
|
||||||
|
cityId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
data: { crew: id * 1200 },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await db.playAuditGeneral.create({
|
||||||
|
data: {
|
||||||
|
sampleId: `${seasonId}:190:6`,
|
||||||
|
generalId: 3,
|
||||||
|
nationId: ownerNationId,
|
||||||
|
cityId: 0,
|
||||||
|
npcState: 2,
|
||||||
|
data: { crew: 'invalid' },
|
||||||
|
},
|
||||||
|
});
|
||||||
const series = await get('nationSeries', admin, {
|
const series = await get('nationSeries', admin, {
|
||||||
nationId: ownerNationId,
|
nationId: ownerNationId,
|
||||||
from: { year: 190, month: 1 },
|
from: { year: 190, month: 1 },
|
||||||
@@ -3134,7 +3390,10 @@ integration('game API security over HTTP transport', () => {
|
|||||||
year: 190,
|
year: 190,
|
||||||
month: 1,
|
month: 1,
|
||||||
complete: true,
|
complete: true,
|
||||||
stock: { gold: 600 },
|
stock: {
|
||||||
|
gold: 600,
|
||||||
|
populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 0 } },
|
||||||
|
},
|
||||||
flows: { incomeGold: 21, incomeRice: 0 },
|
flows: { incomeGold: 21, incomeRice: 0 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3166,6 +3425,59 @@ integration('game API security over HTTP transport', () => {
|
|||||||
result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } },
|
result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 정산 commit 직후, 아직 월말 표본이 없는 상태를 HTTP로 확인한다.
|
||||||
|
for (const [settlementMonth, resource] of [
|
||||||
|
[1, 'gold'],
|
||||||
|
[7, 'rice'],
|
||||||
|
] as const) {
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: fixtureWorldId },
|
||||||
|
data: {
|
||||||
|
currentYear: 191,
|
||||||
|
currentMonth: settlementMonth,
|
||||||
|
meta: {
|
||||||
|
serverId: seasonId,
|
||||||
|
scenarioMeta: { startYear: 190 },
|
||||||
|
playAuditFlows: {
|
||||||
|
year: 191,
|
||||||
|
month: settlementMonth,
|
||||||
|
complete: true,
|
||||||
|
entries: {
|
||||||
|
[`${ownerNationId}:${resource}`]: {
|
||||||
|
nationId: ownerNationId,
|
||||||
|
resource,
|
||||||
|
income: 8765.5,
|
||||||
|
paid: 4321,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
nationId: ownerNationId,
|
||||||
|
from: { year: 191, month: settlementMonth },
|
||||||
|
to: { year: 191, month: settlementMonth },
|
||||||
|
};
|
||||||
|
expect((await get('nationSeries', admin, input)).body).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
currentSettlement: {
|
||||||
|
year: 191,
|
||||||
|
month: settlementMonth,
|
||||||
|
[resource]: { income: 8765.5, paid: 4321 },
|
||||||
|
},
|
||||||
|
items: [{ stock: null, complete: false }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await get('nationSeries', undefined, input)).status).toBe(401);
|
||||||
|
expect((await get('nationSeries', await token(['admin']), input)).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await get('nationSeries', await token(['admin.playAudit.read:other:default']), input)).status
|
||||||
|
).toBe(403);
|
||||||
|
}
|
||||||
|
|
||||||
// A synchronized opening may start before the scenario's gameplay year.
|
// A synchronized opening may start before the scenario's gameplay year.
|
||||||
await db.worldState.update({
|
await db.worldState.update({
|
||||||
where: { id: fixtureWorldId },
|
where: { id: fixtureWorldId },
|
||||||
@@ -3288,6 +3600,414 @@ integration('game API security over HTTP transport', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openRealtime = async (accessToken: string) => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
signal: abort.signal,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const ready = await reader.read();
|
||||||
|
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||||
|
return { abort, reader };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('SSE limits simultaneous openings for one authenticated user', async () => {
|
||||||
|
const actor = `security-http-sse-concurrent-${process.pid}`;
|
||||||
|
const token = await createAccessToken('sse-concurrent', {}, actor);
|
||||||
|
const aborts = Array.from({ length: 10 }, () => new AbortController());
|
||||||
|
try {
|
||||||
|
const responses = await Promise.all(
|
||||||
|
aborts.map((abort) =>
|
||||||
|
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
signal: abort.signal,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
|
||||||
|
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
|
||||||
|
for (const response of responses.filter((item) => item.status === 429)) {
|
||||||
|
expect(response.headers.get('retry-after')).toBe('15');
|
||||||
|
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||||
|
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const abort of aborts) abort.abort();
|
||||||
|
await accessTokenStore.revoke(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
|
||||||
|
const actor = `security-http-sse-shared-${process.pid}`;
|
||||||
|
const token = await createAccessToken('sse-shared-a', {}, actor);
|
||||||
|
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
|
||||||
|
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
|
||||||
|
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
|
||||||
|
const replacementAbort = new AbortController();
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
|
||||||
|
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||||
|
headers: { authorization: `Bearer ${otherToken}` },
|
||||||
|
});
|
||||||
|
expect(rejected.status).toBe(429);
|
||||||
|
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||||
|
streams.push(await openRealtime(otherActorToken));
|
||||||
|
await streams[0]!.reader.cancel();
|
||||||
|
streams[0]!.abort.abort();
|
||||||
|
let replacement: Response | undefined;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||||
|
headers: { authorization: `Bearer ${otherToken}` },
|
||||||
|
signal: replacementAbort.signal,
|
||||||
|
});
|
||||||
|
if (response.status === 200) replacement = response;
|
||||||
|
else await response.arrayBuffer();
|
||||||
|
return response.status;
|
||||||
|
})
|
||||||
|
.toBe(200);
|
||||||
|
const ready = await replacement!.body!.getReader().read();
|
||||||
|
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||||
|
} finally {
|
||||||
|
replacementAbort.abort();
|
||||||
|
for (const stream of streams) stream.abort.abort();
|
||||||
|
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
|
||||||
|
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
|
||||||
|
const actor = `security-http-sse-pending-${process.pid}`;
|
||||||
|
const tokens = await Promise.all(
|
||||||
|
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
|
||||||
|
);
|
||||||
|
const aborts = Array.from({ length: 33 }, () => new AbortController());
|
||||||
|
let unlock!: () => void;
|
||||||
|
const release = new Promise<void>((resolve) => {
|
||||||
|
unlock = resolve;
|
||||||
|
});
|
||||||
|
let ready!: (tx: GamePrisma.TransactionClient) => void;
|
||||||
|
let lockError!: (error: unknown) => void;
|
||||||
|
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
|
||||||
|
ready = resolve;
|
||||||
|
lockError = reject;
|
||||||
|
});
|
||||||
|
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
|
||||||
|
const locking = db.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
|
||||||
|
ready(tx);
|
||||||
|
await release;
|
||||||
|
},
|
||||||
|
{ timeout: 20_000 }
|
||||||
|
);
|
||||||
|
void locking.catch(lockError);
|
||||||
|
try {
|
||||||
|
const tx = await locked;
|
||||||
|
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${tokens[0]!}` },
|
||||||
|
signal: aborts[0]!.signal,
|
||||||
|
}).then(
|
||||||
|
() => false,
|
||||||
|
() => true
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const rows = await tx.$queryRaw<Array<{ count: number }>>`
|
||||||
|
SELECT COUNT(*)::int AS count FROM pg_locks
|
||||||
|
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
|
||||||
|
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||||
|
`;
|
||||||
|
return rows[0]!.count;
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const waiting = tokens.slice(1, 32).map((token, index) =>
|
||||||
|
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
signal: aborts[index + 1]!.signal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
aborts[0]!.abort();
|
||||||
|
expect(await first).toBe(true);
|
||||||
|
const responses = await Promise.all(waiting);
|
||||||
|
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
|
||||||
|
expect(Date.now() - startedAt).toBeLessThan(8500);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
|
||||||
|
}
|
||||||
|
const refusedAt = Date.now();
|
||||||
|
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||||
|
signal: aborts[32]!.signal,
|
||||||
|
});
|
||||||
|
expect(refused.status).toBe(503);
|
||||||
|
expect(refused.headers.get('retry-after')).toBe('15');
|
||||||
|
expect(Date.now() - refusedAt).toBeLessThan(2000);
|
||||||
|
await refused.arrayBuffer();
|
||||||
|
unlock();
|
||||||
|
await locking;
|
||||||
|
let recovered: Response | undefined;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||||
|
signal: aborts[32]!.signal,
|
||||||
|
});
|
||||||
|
if (response.status === 200) recovered = response;
|
||||||
|
else await response.arrayBuffer();
|
||||||
|
return response.status;
|
||||||
|
})
|
||||||
|
.toBe(200);
|
||||||
|
const frame = await recovered!.body!.getReader().read();
|
||||||
|
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
|
||||||
|
} finally {
|
||||||
|
unlock();
|
||||||
|
for (const abort of aborts) abort.abort();
|
||||||
|
await locking;
|
||||||
|
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
|
||||||
|
const token = await createAccessToken('origin-mutation', {});
|
||||||
|
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
origin: 'https://untrusted.example',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||||
|
expect(await accessTokenStore.get(token)).not.toBeNull();
|
||||||
|
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
|
||||||
|
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
origin,
|
||||||
|
'access-control-request-method': 'POST',
|
||||||
|
'access-control-request-headers': 'authorization,content-type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
|
||||||
|
expect(preflight.headers.get('access-control-allow-origin')).toBe(
|
||||||
|
origin === 'https://sam.hided.net' ? origin : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await accessTokenStore.revoke(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
|
||||||
|
const foreign = await accessTokenStore.create({
|
||||||
|
...buildPayload('trpc-foreign', {}),
|
||||||
|
profile: 'other:default',
|
||||||
|
});
|
||||||
|
if (!foreign) throw new Error('Foreign token fixture failed');
|
||||||
|
const before = await readReservedMutationState();
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
|
||||||
|
{
|
||||||
|
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(await readReservedMutationState()).toEqual(before);
|
||||||
|
} finally {
|
||||||
|
await accessTokenStore.revoke(foreign.accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||||
|
const token = await createAccessToken('sse-query', {});
|
||||||
|
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||||
|
expect(queryOnly.status).toBe(401);
|
||||||
|
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
|
||||||
|
if (!foreign) throw new Error('SSE foreign token fixture failed');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const stream = await openRealtime(token);
|
||||||
|
stream.abort.abort();
|
||||||
|
} finally {
|
||||||
|
await accessTokenStore.revoke(token);
|
||||||
|
await accessTokenStore.revoke(foreign.accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||||
|
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||||
|
{
|
||||||
|
label: 'profile game restriction',
|
||||||
|
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
|
||||||
|
},
|
||||||
|
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
|
||||||
|
const token = await createAccessToken(`sse-${label}`, sanctions);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||||
|
} finally {
|
||||||
|
await accessTokenStore.revoke(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE closes on idle expiry without forwarding another frame', async () => {
|
||||||
|
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
|
||||||
|
const created = await accessTokenStore.create(auth);
|
||||||
|
if (!created) throw new Error('SSE expiry token fixture failed');
|
||||||
|
const stream = await openRealtime(created.accessToken);
|
||||||
|
try {
|
||||||
|
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||||
|
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
|
||||||
|
} finally {
|
||||||
|
stream.abort.abort();
|
||||||
|
await accessTokenStore.revoke(created.accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
|
||||||
|
const accessToken = await createAccessToken('sse-revoke', {});
|
||||||
|
const stream = await openRealtime(accessToken);
|
||||||
|
const channel = server!.config.profileName;
|
||||||
|
try {
|
||||||
|
const event = JSON.stringify({ type: 'tournamentChanged' });
|
||||||
|
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||||
|
const valid = await stream.reader.read();
|
||||||
|
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
|
||||||
|
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
|
||||||
|
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||||
|
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||||
|
} finally {
|
||||||
|
stream.abort.abort();
|
||||||
|
await accessTokenStore.revoke(accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
|
||||||
|
const sseUser = `security-http-sse-flush-${process.pid}`;
|
||||||
|
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
|
||||||
|
if (!created) throw new Error('SSE flush fixture failed');
|
||||||
|
const stream = await openRealtime(created.accessToken);
|
||||||
|
try {
|
||||||
|
await redis!.client.publish(
|
||||||
|
`${redisPrefix}:flush`,
|
||||||
|
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
|
||||||
|
);
|
||||||
|
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||||
|
} finally {
|
||||||
|
stream.abort.abort();
|
||||||
|
await accessTokenStore.revoke(created.accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
|
||||||
|
const actor = `security-http-durable-missed-${process.pid}`;
|
||||||
|
const accessToken = await createAccessToken('durable-missed', {}, actor);
|
||||||
|
try {
|
||||||
|
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||||
|
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||||
|
} finally {
|
||||||
|
await accessTokenStore.revoke(accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains persisted revocation state across an actual API restart', async () => {
|
||||||
|
const actor = `security-http-durable-restart-${process.pid}`;
|
||||||
|
const accessToken = await createAccessToken('durable-restart', {}, actor);
|
||||||
|
try {
|
||||||
|
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||||
|
await server!.app.close();
|
||||||
|
server = await createGameApiServer();
|
||||||
|
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
|
||||||
|
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||||
|
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
|
||||||
|
method: 'POST',
|
||||||
|
input: { gatewayToken },
|
||||||
|
});
|
||||||
|
expect(exchanged.response.status).toBe(401);
|
||||||
|
} finally {
|
||||||
|
await accessTokenStore.revoke(accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
|
||||||
|
const actor = `security-http-durable-sse-${process.pid}`;
|
||||||
|
const accessToken = await createAccessToken('durable-sse', {}, actor);
|
||||||
|
const stream = await openRealtime(accessToken);
|
||||||
|
try {
|
||||||
|
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||||
|
await redis!.client.publish(
|
||||||
|
buildGameEventChannel(profileName),
|
||||||
|
JSON.stringify({ type: 'tournamentChanged' })
|
||||||
|
);
|
||||||
|
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||||
|
} finally {
|
||||||
|
stream.abort.abort();
|
||||||
|
await accessTokenStore.revoke(accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
|
||||||
|
const actor = `security-http-durable-cutoff-${process.pid}`;
|
||||||
|
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
|
||||||
|
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||||
|
try {
|
||||||
|
await redis!.client.set(key, String(Date.now()));
|
||||||
|
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||||
|
const fresh = await accessTokenStore.create({
|
||||||
|
...buildPayload('fresh-cutoff', {}, actor),
|
||||||
|
issuedAt: new Date(Date.now() + 1).toISOString(),
|
||||||
|
});
|
||||||
|
expect(fresh).not.toBeNull();
|
||||||
|
try {
|
||||||
|
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
|
||||||
|
200
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await accessTokenStore.revoke(fresh!.accessToken);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await redis!.client.set(key, '0');
|
||||||
|
await accessTokenStore.revoke(accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
|
||||||
|
const actor = `security-http-durable-missing-${process.pid}`;
|
||||||
|
const accessToken = await createAccessToken('durable-missing', {}, actor);
|
||||||
|
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||||
|
try {
|
||||||
|
await redis!.client.del(key);
|
||||||
|
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
|
||||||
|
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(sse.status).toBe(503);
|
||||||
|
await expect(createGameApiServer()).rejects.toThrow('startup refused');
|
||||||
|
} finally {
|
||||||
|
await redis!.client.set(key, '0');
|
||||||
|
await accessTokenStore.revoke(accessToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Flush invalidates every token issued before the user watermark. Keep it
|
// Flush invalidates every token issued before the user watermark. Keep it
|
||||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||||
// by the transport authorization matrix above.
|
// by the transport authorization matrix above.
|
||||||
|
|||||||
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
|
|||||||
|
|
||||||
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
||||||
this.commands.push(command);
|
this.commands.push(command);
|
||||||
if (command.type === 'adjustGeneralResources') {
|
if (command.type === 'tournamentAdjustGeneral') {
|
||||||
const adjustment = command.adjustments[0];
|
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
|
||||||
if (!adjustment) {
|
this.failNextRankUpdate = false;
|
||||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
|
||||||
}
|
}
|
||||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
|
||||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
if (nextGold < command.minGoldAfter) {
|
||||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
|
||||||
}
|
}
|
||||||
this.gold.set(adjustment.generalId, nextGold);
|
this.gold.set(command.generalId, nextGold);
|
||||||
return {
|
return { type: command.type, ok: true, generalId: command.generalId };
|
||||||
type: 'adjustGeneralResources',
|
|
||||||
ok: true,
|
|
||||||
processed: 1,
|
|
||||||
missing: 0,
|
|
||||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
|
||||||
totalRiceDelta: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (command.type === 'setMySetting') {
|
if (command.type === 'setMySetting') {
|
||||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
if (command.type === 'adjustGeneralMeta') {
|
|
||||||
if (this.failNextRankUpdate) {
|
|
||||||
this.failNextRankUpdate = false;
|
|
||||||
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
|
||||||
}
|
|
||||||
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||||
|
|
||||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
|
||||||
expect(transport.commands).toContainEqual(
|
expect(transport.commands).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||||
reason: 'tournamentJoin',
|
userId: 'user-1',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||||
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||||
expect(transport.commands).toContainEqual({
|
expect(transport.commands).toContainEqual({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
|
userId: 'user-1',
|
||||||
reason: 'tournamentJoin',
|
reason: 'tournamentJoin',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
generalId: general.id,
|
||||||
|
goldDelta: -64,
|
||||||
|
minGoldAfter: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||||
expect(transport.commands).toContainEqual(
|
expect(transport.commands).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||||
reason: 'tournamentBet',
|
userId: 'user-1',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||||
expect(transport.commands).toContainEqual(
|
expect(transport.commands).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'tournamentAdjustGeneral',
|
||||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
userId: 'user-1',
|
||||||
reason: 'tournamentBet',
|
betGoldDelta: 600,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
|
||||||
|
const redis = new MemoryRedis();
|
||||||
|
const transport = new TournamentTransport();
|
||||||
|
const general = buildGeneral(1, 'user-2', 3_000);
|
||||||
|
transport.gold.set(general.id, general.gold);
|
||||||
|
await setTournamentFixture(redis, {
|
||||||
|
stage: 6,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: true,
|
||||||
|
openYear: 193,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt: '2026-07-26T01:00:00.000Z',
|
||||||
|
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await redis.set(
|
||||||
|
'sammo:che:default:tournament:betting',
|
||||||
|
JSON.stringify([
|
||||||
|
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
|
||||||
|
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
|
||||||
|
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||||
|
);
|
||||||
|
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||||
|
myAmount: 800,
|
||||||
|
myTotals: { 12: 800 },
|
||||||
|
totalAmount: 1_800,
|
||||||
|
});
|
||||||
|
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
});
|
||||||
|
expect(transport.commands).toHaveLength(0);
|
||||||
|
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
|
||||||
|
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||||
|
myAmount: 1_000,
|
||||||
|
myTotals: { 11: 200, 12: 800 },
|
||||||
|
});
|
||||||
|
expect(transport.gold.get(1)).toBe(2_800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not infer a legacy bet actor from the current general owner', async () => {
|
||||||
|
const redis = new MemoryRedis();
|
||||||
|
const transport = new TournamentTransport();
|
||||||
|
const general = buildGeneral(1, 'user-2', 3_000);
|
||||||
|
transport.gold.set(1, 3_000);
|
||||||
|
await setTournamentFixture(redis, {
|
||||||
|
stage: 6,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: true,
|
||||||
|
openYear: 193,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt: '2026-07-26T01:00:00.000Z',
|
||||||
|
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await redis.set(
|
||||||
|
'sammo:che:default:tournament:betting',
|
||||||
|
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
|
||||||
|
);
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||||
|
);
|
||||||
|
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
|
||||||
|
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
});
|
||||||
|
expect(transport.commands).toHaveLength(0);
|
||||||
|
expect(transport.gold.get(1)).toBe(3_000);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
expect(snapshot.sourceRevision).toBe('41');
|
expect(snapshot.sourceRevision).toBe('41');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
const general = buildGeneral(1, 'user-1', 2_000);
|
const general = buildGeneral(1, 'user-1', 2_000);
|
||||||
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'BAD_REQUEST',
|
||||||
});
|
});
|
||||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||||
|
|||||||
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
|||||||
|
|
||||||
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
||||||
sendCommand: async () => 'ok',
|
sendCommand: async () => 'ok',
|
||||||
requestCommand: async () => null,
|
requestCommand: async (command) =>
|
||||||
|
command.type === 'tournamentSeedNpcBets'
|
||||||
|
? {
|
||||||
|
type: command.type,
|
||||||
|
ok: true,
|
||||||
|
processedGeneralIds: command.bets.map((bet) => bet.generalId),
|
||||||
|
skippedGeneralIds: [],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
requestStatus: async () => null,
|
requestStatus: async () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
const commands: TurnDaemonCommand[] = [];
|
const commands: TurnDaemonCommand[] = [];
|
||||||
const daemonTransport: TurnDaemonTransport = {
|
const daemonTransport: TurnDaemonTransport = {
|
||||||
...createNoopDaemonTransport(),
|
...createNoopDaemonTransport(),
|
||||||
sendCommand: async (command) => {
|
requestCommand: async (command) => {
|
||||||
expect((await store.getState())?.stage).toBe(5);
|
expect((await store.getState())?.stage).toBe(5);
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
return 'ok';
|
return createNoopDaemonTransport().requestCommand(command);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
||||||
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
||||||
expect(commands).toEqual([
|
expect(commands).toEqual([
|
||||||
{
|
{
|
||||||
type: 'adjustGeneralResources',
|
type: 'tournamentSeedNpcBets',
|
||||||
requestId: 'tournament:123:npc-bet:resources',
|
requestId: 'tournament:123:npc-bet:atomic',
|
||||||
reason: 'tournamentNpcBet',
|
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
|
||||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'adjustGeneralMeta',
|
|
||||||
requestId: 'tournament:123:npc-bet:meta',
|
|
||||||
reason: 'tournamentNpcBet',
|
|
||||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
||||||
expect(await store.getBettingEntries()).toEqual(bets);
|
expect(await store.getBettingEntries()).toEqual(bets);
|
||||||
expect(commands).toHaveLength(2);
|
expect(commands).toHaveLength(1);
|
||||||
// A fresh projection with the same seed and DB inputs has the same choices.
|
// A fresh projection with the same seed and DB inputs has the same choices.
|
||||||
await store.setBettingEntries([bets[0]!]);
|
await store.setBettingEntries([bets[0]!]);
|
||||||
await seedNpcBets({
|
await seedNpcBets({
|
||||||
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
expect(await store.getBettingEntries()).toEqual(bets);
|
expect(await store.getBettingEntries()).toEqual(bets);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
|
||||||
|
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
|
||||||
|
const state = createTournamentState({
|
||||||
|
stage: 5,
|
||||||
|
bettingId: 789,
|
||||||
|
npcBettingPlan: [
|
||||||
|
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
|
||||||
|
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await store.setState(state);
|
||||||
|
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
|
||||||
|
const transport: TurnDaemonTransport = {
|
||||||
|
...createNoopDaemonTransport(),
|
||||||
|
requestCommand: async (command) => {
|
||||||
|
expect(command.type).toBe('tournamentSeedNpcBets');
|
||||||
|
expect((await store.getState())?.stage).toBe(5);
|
||||||
|
expect(await store.getBettingEntries()).toEqual([]);
|
||||||
|
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
|
||||||
|
expect(opened.stage).toBe(6);
|
||||||
|
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
||||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
||||||
const state = createTournamentState({ stage: 5 });
|
const state = createTournamentState({ stage: 5 });
|
||||||
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses durable command identities after a partial enqueue failure', async () => {
|
it('reuses durable command identities after an uncertain atomic command response', async () => {
|
||||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
||||||
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
||||||
await store.setState(state);
|
await store.setState(state);
|
||||||
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
let fail = true;
|
let fail = true;
|
||||||
const transport: TurnDaemonTransport = {
|
const transport: TurnDaemonTransport = {
|
||||||
...createNoopDaemonTransport(),
|
...createNoopDaemonTransport(),
|
||||||
sendCommand: async (command) => {
|
requestCommand: async (command) => {
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
if (command.type === 'adjustGeneralMeta' && fail) {
|
if (command.type === 'tournamentSeedNpcBets' && fail) {
|
||||||
fail = false;
|
fail = false;
|
||||||
// The accepted debit may already have committed before retry.
|
// The accepted debit may already have committed before retry.
|
||||||
prisma.general.findMany = async () => [];
|
prisma.general.findMany = async () => [];
|
||||||
throw new Error('enqueue unavailable');
|
throw new Error('enqueue unavailable');
|
||||||
}
|
}
|
||||||
return 'ok';
|
return createNoopDaemonTransport().requestCommand(command);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
||||||
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
);
|
);
|
||||||
expect(await store.getState()).toMatchObject({
|
expect(await store.getState()).toMatchObject({
|
||||||
stage: 5,
|
stage: 5,
|
||||||
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
|
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
|
||||||
});
|
});
|
||||||
expect(await store.getBettingEntries()).toEqual([]);
|
expect(await store.getBettingEntries()).toEqual([]);
|
||||||
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
||||||
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
|
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
|
||||||
expect(commands.map((command) => command.requestId)).toEqual([
|
expect(commands.map((command) => command.requestId)).toEqual([
|
||||||
'tournament:456:npc-bet:resources',
|
'tournament:456:npc-bet:atomic',
|
||||||
'tournament:456:npc-bet:meta',
|
'tournament:456:npc-bet:atomic',
|
||||||
'tournament:456:npc-bet:resources',
|
|
||||||
'tournament:456:npc-bet:meta',
|
|
||||||
]);
|
]);
|
||||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
commands.push(command);
|
commands.push(command);
|
||||||
return 'ok';
|
return 'ok';
|
||||||
},
|
},
|
||||||
requestCommand: async () => null,
|
requestCommand: async (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
return createNoopDaemonTransport().requestCommand(command);
|
||||||
|
},
|
||||||
requestStatus: async () => null,
|
requestStatus: async () => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
||||||
expect(commands).toContainEqual(
|
expect(commands).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'tournamentSeedNpcBets',
|
||||||
reason: 'tournamentNpcBet',
|
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
|
||||||
adjustments: expect.arrayContaining([
|
|
||||||
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
|
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ const buildContext = (
|
|||||||
meta: options.worldMeta ?? { serverId: currentServerId },
|
meta: options.worldMeta ?? { serverId: currentServerId },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
gameHistory: {
|
||||||
|
findUnique: async ({ where }: { where: { serverId: string } }) =>
|
||||||
|
where.serverId === archiveServerId ? { map: 'miniche' } : null,
|
||||||
|
},
|
||||||
yearbookHistory: {
|
yearbookHistory: {
|
||||||
findFirst: async (args: {
|
findFirst: async (args: {
|
||||||
where: { profileName: string; year?: number; month?: number };
|
where: { profileName: string; year?: number; month?: number };
|
||||||
@@ -187,11 +191,13 @@ describe('historical yearbook access from dynasty', () => {
|
|||||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||||
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toMatchObject({
|
||||||
firstYearMonth: 200 * 12,
|
firstYearMonth: 200 * 12,
|
||||||
lastYearMonth: 200 * 12 + 1,
|
lastYearMonth: 200 * 12 + 1,
|
||||||
currentYearMonth: 200 * 12 + 1,
|
currentYearMonth: 200 * 12 + 1,
|
||||||
|
mapLayout: { mapName: 'miniche' },
|
||||||
});
|
});
|
||||||
|
expect(result.mapLayout.cityList.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the same archived public history for distinct general owners', async () => {
|
it('returns the same archived public history for distinct general owners', async () => {
|
||||||
@@ -249,20 +255,22 @@ describe('historical yearbook access from dynasty', () => {
|
|||||||
|
|
||||||
expect(canonical).toEqual(alias);
|
expect(canonical).toEqual(alias);
|
||||||
expect(alias).toEqual(omitted);
|
expect(alias).toEqual(omitted);
|
||||||
expect(canonical).toEqual({
|
expect(canonical).toMatchObject({
|
||||||
firstYearMonth: 219 * 12 + 11,
|
firstYearMonth: 219 * 12 + 11,
|
||||||
lastYearMonth: 219 * 12 + 11,
|
lastYearMonth: 219 * 12 + 11,
|
||||||
currentYearMonth: 220 * 12,
|
currentYearMonth: 220 * 12,
|
||||||
|
mapLayout: { mapName: 'che' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads imported history under the short profile ID when world metadata has no server ID', async () => {
|
it('reads imported history under the short profile ID when world metadata has no server ID', async () => {
|
||||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} }));
|
const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} }));
|
||||||
|
|
||||||
await expect(caller.yearbook.getRange()).resolves.toEqual({
|
await expect(caller.yearbook.getRange()).resolves.toMatchObject({
|
||||||
firstYearMonth: 219 * 12 + 10,
|
firstYearMonth: 219 * 12 + 10,
|
||||||
lastYearMonth: 219 * 12 + 10,
|
lastYearMonth: 219 * 12 + 10,
|
||||||
currentYearMonth: 220 * 12,
|
currentYearMonth: 220 * 12,
|
||||||
|
mapLayout: { mapName: 'che' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"composite": false
|
"composite": false,
|
||||||
|
"paths": {
|
||||||
|
"@sammo-ts/game-api": ["./src/index.ts"],
|
||||||
|
"@sammo-ts/game-api/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
||||||
const world = await transaction.worldState.findFirst({
|
const world = await transaction.worldState.findFirst({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true, meta: true },
|
select: {
|
||||||
|
clockPhase: true,
|
||||||
|
clockRevision: true,
|
||||||
|
deadlineGeneration: true,
|
||||||
|
clockTick: true,
|
||||||
|
meta: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
||||||
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
||||||
@@ -184,7 +190,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
|
OR (${postUnificationSurvey} AND "event_type" = 'voteReward')
|
||||||
OR (
|
OR (
|
||||||
${suspendedTournamentBetCommand}
|
${suspendedTournamentBetCommand}
|
||||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta', 'tournamentAdjustGeneral')
|
||||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||||
|
|
||||||
|
/** A bounded, sticky notice: later successful writes cannot make history complete again. */
|
||||||
|
export const markAuditGap = (world: InMemoryTurnWorld, stage: string): void => {
|
||||||
|
const state = world.getState();
|
||||||
|
const previous = asRecord(state.meta.playAuditGap);
|
||||||
|
world.updateWorldMeta({
|
||||||
|
playAuditGap: {
|
||||||
|
serverId: state.meta.serverId ?? null,
|
||||||
|
firstYear:
|
||||||
|
previous.serverId === state.meta.serverId
|
||||||
|
? (previous.firstYear ?? state.currentYear)
|
||||||
|
: state.currentYear,
|
||||||
|
firstMonth:
|
||||||
|
previous.serverId === state.meta.serverId
|
||||||
|
? (previous.firstMonth ?? state.currentMonth)
|
||||||
|
: state.currentMonth,
|
||||||
|
lastYear: state.currentYear,
|
||||||
|
lastMonth: state.currentMonth,
|
||||||
|
stage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.warn(`[play-audit] ${stage}: history gap recorded; gameplay continues.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectPlayAudit = <T>(world: InMemoryTurnWorld, stage: string, collect: () => T, fallback: T): T => {
|
||||||
|
try {
|
||||||
|
return collect();
|
||||||
|
} catch {
|
||||||
|
markAuditGap(world, stage);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { collectPlayAudit } from './bestEffort.js';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js';
|
||||||
import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js';
|
import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js';
|
||||||
@@ -32,7 +33,11 @@ const readFlows = (world: InMemoryTurnWorld): MonthlyFlows => {
|
|||||||
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
|
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void => {
|
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'recordAuditSettlement',
|
||||||
|
() => {
|
||||||
if (typeof world.getState().meta.serverId !== 'string') return;
|
if (typeof world.getState().meta.serverId !== 'string') return;
|
||||||
const flows = readFlows(world);
|
const flows = readFlows(world);
|
||||||
const key = `${settlement.nationId}:${settlement.resource}`;
|
const key = `${settlement.nationId}:${settlement.resource}`;
|
||||||
@@ -44,12 +49,18 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
|
|||||||
};
|
};
|
||||||
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
|
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
|
||||||
world.updateWorldMeta({ playAuditFlows: flows });
|
world.updateWorldMeta({ playAuditFlows: flows });
|
||||||
};
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
export const queueAuditMonth = (
|
export const queueAuditMonth = (
|
||||||
world: InMemoryTurnWorld,
|
world: InMemoryTurnWorld,
|
||||||
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
|
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
|
||||||
): void => {
|
): void =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'queueAuditMonth',
|
||||||
|
() => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
|
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
|
||||||
@@ -71,10 +82,16 @@ export const queueAuditMonth = (
|
|||||||
kind,
|
kind,
|
||||||
settlementsComplete: flows.complete,
|
settlementsComplete: flows.complete,
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
|
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
|
||||||
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'initializeAuditCollection',
|
||||||
|
() => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||||
@@ -111,7 +128,9 @@ export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
|
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
|
||||||
beforeMonthChanged: (context) => {
|
beforeMonthChanged: (context) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { collectPlayAudit } from './bestEffort.js';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||||
import type { TurnDiplomacy } from '../turn/types.js';
|
import type { TurnDiplomacy } from '../turn/types.js';
|
||||||
@@ -7,7 +8,11 @@ export const recordMonthlyAuditDiplomacy = (
|
|||||||
world: InMemoryTurnWorld,
|
world: InMemoryTurnWorld,
|
||||||
before: readonly TurnDiplomacy[],
|
before: readonly TurnDiplomacy[],
|
||||||
afterByKey: ReadonlyMap<string, TurnDiplomacy>
|
afterByKey: ReadonlyMap<string, TurnDiplomacy>
|
||||||
): void => {
|
): void =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'recordMonthlyAuditDiplomacy',
|
||||||
|
() => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||||
@@ -46,7 +51,9 @@ export const recordMonthlyAuditDiplomacy = (
|
|||||||
after: nextState,
|
after: nextState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
export interface AuditDiplomacyAction {
|
export interface AuditDiplomacyAction {
|
||||||
actionKey: string;
|
actionKey: string;
|
||||||
@@ -68,7 +75,11 @@ export const recordTurnAuditDiplomacy = (
|
|||||||
after: TurnDiplomacy,
|
after: TurnDiplomacy,
|
||||||
action: AuditDiplomacyAction,
|
action: AuditDiplomacyAction,
|
||||||
turn: { generalId: number; tick: number; ordinal: number }
|
turn: { generalId: number; tick: number; ordinal: number }
|
||||||
): void => {
|
): void =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'recordTurnAuditDiplomacy',
|
||||||
|
() => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||||
@@ -95,14 +106,25 @@ export const recordTurnAuditDiplomacy = (
|
|||||||
ordinal: turn.ordinal,
|
ordinal: turn.ordinal,
|
||||||
requestId: null,
|
requestId: null,
|
||||||
inputSequence: null,
|
inputSequence: null,
|
||||||
actor: { ...action.actor, actionKey: action.actionKey, kind: action.kind, actionOrdinal: action.actionOrdinal },
|
actor: {
|
||||||
|
...action.actor,
|
||||||
|
actionKey: action.actionKey,
|
||||||
|
kind: action.kind,
|
||||||
|
actionOrdinal: action.actionOrdinal,
|
||||||
|
},
|
||||||
before: previousState,
|
before: previousState,
|
||||||
after: nextState,
|
after: nextState,
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
|
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
|
||||||
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'initializeAuditDiplomacy',
|
||||||
|
() => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||||
@@ -177,7 +199,9 @@ export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
|
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
|
||||||
export const recordNationAuditDiplomacy = (
|
export const recordNationAuditDiplomacy = (
|
||||||
@@ -186,7 +210,11 @@ export const recordNationAuditDiplomacy = (
|
|||||||
relations: readonly TurnDiplomacy[],
|
relations: readonly TurnDiplomacy[],
|
||||||
operation: 'CREATED' | 'REMOVED',
|
operation: 'CREATED' | 'REMOVED',
|
||||||
observedTick?: number
|
observedTick?: number
|
||||||
): void => {
|
): void =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'recordNationAuditDiplomacy',
|
||||||
|
() => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
if (typeof serverId !== 'string' || !serverId.trim()) return;
|
||||||
@@ -229,4 +257,6 @@ export const recordNationAuditDiplomacy = (
|
|||||||
after: operation === 'CREATED' ? value : null,
|
after: operation === 'CREATED' ? value : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { collectPlayAudit } from './bestEffort.js';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import type { Nation } from '@sammo-ts/logic';
|
import type { Nation } from '@sammo-ts/logic';
|
||||||
@@ -70,7 +71,11 @@ export const recordAuditPolicyChange = (options: {
|
|||||||
actor?: TurnGeneral;
|
actor?: TurnGeneral;
|
||||||
permission?: number;
|
permission?: number;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}): { _playAuditPolicy?: Record<string, PolicyHead> } => {
|
}): { _playAuditPolicy?: Record<string, PolicyHead> } =>
|
||||||
|
collectPlayAudit(
|
||||||
|
options.world,
|
||||||
|
'recordAuditPolicyChange',
|
||||||
|
() => {
|
||||||
const { world, nation, area } = options;
|
const { world, nation, area } = options;
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const serverId = state.meta.serverId;
|
const serverId = state.meta.serverId;
|
||||||
@@ -134,9 +139,15 @@ export const recordAuditPolicyChange = (options: {
|
|||||||
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
|
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
|
||||||
if (!head) throw new Error('Play audit policy baseline missing');
|
if (!head) throw new Error('Play audit policy baseline missing');
|
||||||
return { _playAuditPolicy: { ...heads, [area]: head } };
|
return { _playAuditPolicy: { ...heads, [area]: head } };
|
||||||
};
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => {
|
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void =>
|
||||||
|
collectPlayAudit(
|
||||||
|
world,
|
||||||
|
'initializeNationAuditPolicies',
|
||||||
|
() => {
|
||||||
let nation = world.getNationById(nationId);
|
let nation = world.getNationById(nationId);
|
||||||
if (!nation) return;
|
if (!nation) return;
|
||||||
for (const area of AUDIT_POLICY_AREAS) {
|
for (const area of AUDIT_POLICY_AREAS) {
|
||||||
@@ -148,7 +159,9 @@ export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId
|
|||||||
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
|
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
|
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
|
||||||
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
|
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
import { GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
||||||
import { auditPolicyHash, type PendingAuditPolicy } from './policy.js';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { AUDIT_POLICY_AREAS, auditPolicyHash, type PendingAuditPolicy } from './policy.js';
|
||||||
|
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||||
|
|
||||||
export const persistAuditPolicies = async (
|
export const persistAuditPolicies = async (
|
||||||
tx: GamePrisma.TransactionClient,
|
tx: GamePrisma.TransactionClient,
|
||||||
@@ -43,3 +45,48 @@ export const persistAuditPolicies = async (
|
|||||||
throw new Error('Play audit policy replay payload conflict');
|
throw new Error('Play audit policy replay payload conflict');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 원장은 그대로 두고, 구버전 NPC 생성이 유실한 head projection만 복구한다. */
|
||||||
|
export const restoreMissingAuditPolicyHeads = async (
|
||||||
|
tx: GamePrisma.TransactionClient,
|
||||||
|
world: InMemoryTurnWorld
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const serverId = world.getState().meta.serverId;
|
||||||
|
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||||
|
const missing = world.listNations().flatMap((nation) => {
|
||||||
|
const heads = asRecord(nation.meta._playAuditPolicy);
|
||||||
|
return AUDIT_POLICY_AREAS.filter((area) => heads[area] === undefined).map(
|
||||||
|
(area) => GamePrisma.sql`(nation_id = ${nation.id} AND area = ${area})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!missing.length) return false;
|
||||||
|
// DISTINCT ON으로 각 국가/영역의 마지막 revision만 읽는다. 과거 기수는 복구하지 않는다.
|
||||||
|
const rows = await tx.$queryRaw<
|
||||||
|
{ id: string; nationId: number; area: string; revision: number; after: InputJsonValue }[]
|
||||||
|
>(GamePrisma.sql`
|
||||||
|
SELECT DISTINCT ON (nation_id, area)
|
||||||
|
id, nation_id AS "nationId", area, revision, "after"
|
||||||
|
FROM play_audit_policy
|
||||||
|
WHERE server_id = ${serverId} AND (${GamePrisma.join(missing, ' OR ')})
|
||||||
|
ORDER BY nation_id, area, revision DESC
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.revision < 1 || row.id !== auditPolicyHash([serverId, row.nationId, row.area, row.revision])) {
|
||||||
|
throw new Error('Invalid persisted play audit policy head');
|
||||||
|
}
|
||||||
|
const nation = world.getNationById(row.nationId)!;
|
||||||
|
const heads = nation.meta._playAuditPolicy;
|
||||||
|
world.updateNation(nation.id, {
|
||||||
|
meta: {
|
||||||
|
...nation.meta,
|
||||||
|
_playAuditPolicy: {
|
||||||
|
...(heads && typeof heads === 'object' && !Array.isArray(heads) ? heads : {}),
|
||||||
|
[row.area]: { id: row.id, revision: row.revision, hash: auditPolicyHash(row.after), serverId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 실제 정책이 원장 after와 다르면 후속 initializeAuditPolicies가 OBSERVED_GAP을 남긴다.
|
||||||
|
// 이 메타데이터와 새 이력은 기존 lease/fencing을 거친 startup flush에서 함께 commit된다.
|
||||||
|
return rows.length > 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
|
|||||||
|
|
||||||
export interface AuditPopulationSummary {
|
export interface AuditPopulationSummary {
|
||||||
count: number;
|
count: number;
|
||||||
|
crew: number;
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
dex: AuditDex;
|
dex: AuditDex;
|
||||||
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
|
|||||||
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
|
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
|
||||||
const emptyPopulation = (): AuditPopulationSummary => ({
|
const emptyPopulation = (): AuditPopulationSummary => ({
|
||||||
count: 0,
|
count: 0,
|
||||||
|
crew: 0,
|
||||||
gold: 0,
|
gold: 0,
|
||||||
rice: 0,
|
rice: 0,
|
||||||
dex: emptyDex(),
|
dex: emptyDex(),
|
||||||
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
|
|||||||
const population = nations.get(general.nationId)?.populations[projected.population];
|
const population = nations.get(general.nationId)?.populations[projected.population];
|
||||||
if (!population) continue;
|
if (!population) continue;
|
||||||
population.count++;
|
population.count++;
|
||||||
|
population.crew += projected.crew;
|
||||||
population.gold += projected.gold;
|
population.gold += projected.gold;
|
||||||
population.rice += projected.rice;
|
population.rice += projected.rice;
|
||||||
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
|
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
GAME_TICKS_PER_TURN,
|
||||||
|
OFFICE_REQUEST_KEY,
|
||||||
|
officeRequestQuarter,
|
||||||
|
readOfficeRequest,
|
||||||
|
type OfficeRequest,
|
||||||
|
type TurnDaemonCommand,
|
||||||
|
type TurnDaemonCommandResult,
|
||||||
|
} from '@sammo-ts/common';
|
||||||
|
import {
|
||||||
|
LogCategory,
|
||||||
|
LogFormat,
|
||||||
|
LogScope,
|
||||||
|
resolveOfficeRequestEligibility,
|
||||||
|
type OfficeCandidate,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
|
const candidate = (general: TurnGeneral): OfficeCandidate => ({
|
||||||
|
...general,
|
||||||
|
strength: general.stats.strength,
|
||||||
|
intelligence: general.stats.intelligence,
|
||||||
|
});
|
||||||
|
const incumbentFor = (world: InMemoryTurnWorld, cityId: number, level: number): TurnGeneral | undefined =>
|
||||||
|
world
|
||||||
|
.listGenerals()
|
||||||
|
.find((g) => g.officerLevel === level && Number(g.meta.officerCity ?? g.meta.officer_city ?? 0) === cityId);
|
||||||
|
const eligibility = (world: InMemoryTurnWorld, general: TurnGeneral, cityId: number, level: 2 | 3 | 4) => {
|
||||||
|
const incumbent = incumbentFor(world, cityId, level);
|
||||||
|
return resolveOfficeRequestEligibility({
|
||||||
|
applicant: candidate(general),
|
||||||
|
city: world.getCityById(cityId) ?? null,
|
||||||
|
level,
|
||||||
|
incumbent: incumbent ? candidate(incumbent) : null,
|
||||||
|
nationMeta: world.getNationById(general.nationId)?.meta,
|
||||||
|
chiefStatMin: world.getScenarioConfig().stat.chiefMin,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const save = (world: InMemoryTurnWorld, generalId: number, request: OfficeRequest) => {
|
||||||
|
const general = world.getGeneralById(generalId);
|
||||||
|
if (general) world.updateGeneral(generalId, { meta: { ...general.meta, [OFFICE_REQUEST_KEY]: { ...request } } });
|
||||||
|
};
|
||||||
|
const finish = (world: InMemoryTurnWorld, request: OfficeRequest, status: OfficeRequest['status'], reason: string) => {
|
||||||
|
save(world, request.generalId, { ...request, status, resultReason: reason });
|
||||||
|
const general = world.getGeneralById(request.generalId);
|
||||||
|
if (!general) return;
|
||||||
|
const city = world.getCityById(request.cityId);
|
||||||
|
const title = { 2: '종사', 3: '군사', 4: '태수' }[request.officerLevel];
|
||||||
|
const text = `${general.name} · ${city?.name ?? '도시'} ${title} 자원: ${reason}`;
|
||||||
|
world.pushLog({
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
generalId: general.id,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
world.pushLog({
|
||||||
|
scope: LogScope.NATION,
|
||||||
|
nationId: request.nationId,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const invalidReason = (world: InMemoryTurnWorld, request: OfficeRequest): string | null => {
|
||||||
|
const general = world.getGeneralById(request.generalId);
|
||||||
|
if (!general || general.nationId !== request.nationId || general.userId !== request.userId)
|
||||||
|
return '소속 또는 장수 소유자 변경';
|
||||||
|
if ((incumbentFor(world, request.cityId, request.officerLevel)?.id ?? 0) !== request.incumbentId)
|
||||||
|
return '재직자 변경';
|
||||||
|
return eligibility(world, general, request.cityId, request.officerLevel).reason;
|
||||||
|
};
|
||||||
|
const approve = (world: InMemoryTurnWorld, request: OfficeRequest) => {
|
||||||
|
const incumbent = incumbentFor(world, request.cityId, request.officerLevel);
|
||||||
|
if (incumbent)
|
||||||
|
world.updateGeneral(incumbent.id, {
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { ...incumbent.meta, officerCity: 0, officer_city: 0 },
|
||||||
|
});
|
||||||
|
const general = world.getGeneralById(request.generalId)!;
|
||||||
|
world.updateGeneral(general.id, {
|
||||||
|
officerLevel: request.officerLevel,
|
||||||
|
meta: { ...general.meta, officerCity: request.cityId, officer_city: request.cityId },
|
||||||
|
});
|
||||||
|
const city = world.getCityById(request.cityId)!;
|
||||||
|
world.updateCity(city.id, {
|
||||||
|
meta: { ...city.meta, officer_set: Number(city.meta.officer_set ?? 0) | (1 << request.officerLevel) },
|
||||||
|
});
|
||||||
|
finish(world, request, 'approved', '임명');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 분기 lock 초기화 뒤 실행한다. 저장된 기본 거부는 조건이 완화되어도 자동 승인으로 바꾸지 않는다.
|
||||||
|
export const settleCityOfficeRequests = (world: InMemoryTurnWorld, tick: number): void => {
|
||||||
|
for (const general of world.listGenerals()) {
|
||||||
|
const request = readOfficeRequest(general.meta);
|
||||||
|
if (!request || request.status !== 'pending') continue;
|
||||||
|
const invalid = invalidReason(world, request);
|
||||||
|
if (invalid) {
|
||||||
|
finish(world, request, 'cancelled', invalid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tick < request.dueTick) continue;
|
||||||
|
const current = eligibility(world, general, request.cityId, request.officerLevel);
|
||||||
|
if (request.defaultDecision === 'approve' && current.defaultDecision === 'approve') approve(world, request);
|
||||||
|
else finish(world, request, 'rejected', '기한 만료 · 자동 거부');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const createCityOfficeRequestCalendarHandler = (
|
||||||
|
getWorld: () => InMemoryTurnWorld | null
|
||||||
|
): TurnCalendarHandler => ({
|
||||||
|
beforeMonthChanged: () => {
|
||||||
|
const world = getWorld();
|
||||||
|
if (world) settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
|
||||||
|
},
|
||||||
|
onMonthChanged: (context) => {
|
||||||
|
const world = getWorld();
|
||||||
|
if (world) settleCityOfficeRequests(world, world.dateToGameTick(context.turnTime));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const handleCityOfficeRequest = (
|
||||||
|
world: InMemoryTurnWorld,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
|
||||||
|
acceptedAt: Date
|
||||||
|
): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => {
|
||||||
|
const fail = (reason: string): Extract<TurnDaemonCommandResult, { type: 'cityOfficeRequest' }> => ({
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
generalId: command.generalId,
|
||||||
|
ok: false,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
const actor = world.getGeneralById(command.generalId);
|
||||||
|
if (!actor || actor.userId !== command.userId || !actor.nationId)
|
||||||
|
return fail('장수 소유권 또는 소속이 변경되었습니다.');
|
||||||
|
const tick = Math.max(world.dateToGameTick(world.getGameNow(acceptedAt)), world.getState().lastTurnTick ?? 0);
|
||||||
|
settleCityOfficeRequests(world, tick);
|
||||||
|
if (command.action === 'request') {
|
||||||
|
if (!command.officerLevel) return fail('관직을 선택해 주세요.');
|
||||||
|
const currentActor = world.getGeneralById(actor.id)!;
|
||||||
|
const state = world.getState();
|
||||||
|
const quarter = officeRequestQuarter(state.currentYear, state.currentMonth);
|
||||||
|
const previous = readOfficeRequest(currentActor.meta);
|
||||||
|
if (previous?.status === 'pending') return fail('이미 대기 중인 요청이 있습니다.');
|
||||||
|
if (previous?.quarter === quarter) return fail('이번 분기에 이미 자원했습니다.');
|
||||||
|
const check = eligibility(world, currentActor, currentActor.cityId, command.officerLevel);
|
||||||
|
if (!check.allowed) return fail(check.reason!);
|
||||||
|
const occupied = world.listGenerals().some((g) => {
|
||||||
|
const pending = readOfficeRequest(g.meta);
|
||||||
|
return (
|
||||||
|
pending?.status === 'pending' &&
|
||||||
|
pending.cityId === actor.cityId &&
|
||||||
|
pending.officerLevel === command.officerLevel
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (occupied) return fail('이미 다른 장수가 자원한 관직입니다.');
|
||||||
|
const boundary = state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime);
|
||||||
|
const dueTick =
|
||||||
|
boundary +
|
||||||
|
Math.max(1, Math.ceil((tick - boundary + GAME_TICKS_PER_TURN) / GAME_TICKS_PER_TURN)) * GAME_TICKS_PER_TURN;
|
||||||
|
save(world, actor.id, {
|
||||||
|
id: `${actor.id}:${quarter}`,
|
||||||
|
generalId: actor.id,
|
||||||
|
userId: command.userId,
|
||||||
|
nationId: actor.nationId,
|
||||||
|
cityId: actor.cityId,
|
||||||
|
officerLevel: command.officerLevel,
|
||||||
|
incumbentId: incumbentFor(world, actor.cityId, command.officerLevel)?.id ?? 0,
|
||||||
|
quarter,
|
||||||
|
createdTick: tick,
|
||||||
|
dueTick,
|
||||||
|
defaultDecision: check.defaultDecision,
|
||||||
|
defaultReason: check.defaultReason,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const target = world.getGeneralById(command.targetGeneralId ?? actor.id);
|
||||||
|
const request = readOfficeRequest(target?.meta);
|
||||||
|
if (!request || request.id !== command.officeRequestId || request.status !== 'pending')
|
||||||
|
return fail('이미 처리되었거나 없는 요청입니다.');
|
||||||
|
if (request.nationId !== actor.nationId) return fail('다른 국가의 요청입니다.');
|
||||||
|
if (command.action === 'withdraw') {
|
||||||
|
if (request.generalId !== actor.id) return fail('본인의 요청만 철회할 수 있습니다.');
|
||||||
|
finish(world, request, 'withdrawn', '본인 철회');
|
||||||
|
} else {
|
||||||
|
if (actor.officerLevel < 5 || asRecord(actor.penalty).noChief) return fail('수뇌만 처리할 수 있습니다.');
|
||||||
|
if (command.action === 'reject') finish(world, request, 'rejected', '수뇌 거부');
|
||||||
|
else {
|
||||||
|
if (asRecord(actor.penalty).noChiefChange && target && target.officerLevel >= 4)
|
||||||
|
return fail('수뇌인 장수를 변경할 수 없는 상태입니다.');
|
||||||
|
const invalid = invalidReason(world, request);
|
||||||
|
if (invalid) return fail(invalid);
|
||||||
|
approve(world, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: 'cityOfficeRequest', generalId: actor.id, ok: true };
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
GAME_TICKS_PER_TURN,
|
GAME_TICKS_PER_TURN,
|
||||||
|
readOfficeRequest,
|
||||||
MAX_SAFE_GAME_TICK,
|
MAX_SAFE_GAME_TICK,
|
||||||
GameClock,
|
GameClock,
|
||||||
buildClockAlignmentPlan,
|
buildClockAlignmentPlan,
|
||||||
@@ -335,6 +336,24 @@ const readParticipantSnapshots = async (
|
|||||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
snapshot(
|
||||||
|
'city-office-request-occurrence',
|
||||||
|
'KEEP',
|
||||||
|
generals.flatMap(({ id, meta }) => {
|
||||||
|
const request = readOfficeRequest(meta);
|
||||||
|
return request ? [{ id, createdTick: request.createdTick }] : [];
|
||||||
|
})
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
'city-office-request-deadline',
|
||||||
|
'SHIFT',
|
||||||
|
generals.flatMap(({ id, meta }) => {
|
||||||
|
const request = readOfficeRequest(meta);
|
||||||
|
return request?.status === 'pending' && BigInt(request.dueTick) >= cutTick
|
||||||
|
? [{ id, dueTick: request.dueTick }]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
),
|
||||||
snapshot(
|
snapshot(
|
||||||
'auction-open-occurrence',
|
'auction-open-occurrence',
|
||||||
'KEEP',
|
'KEEP',
|
||||||
@@ -536,7 +555,7 @@ const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTick
|
|||||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
||||||
const shift = BigInt(shiftTicks);
|
const shift = BigInt(shiftTicks);
|
||||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
||||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
const [general, reselection, auction, message, vote, pool, npcValid, npcMore, office] = await Promise.all([
|
||||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
||||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
||||||
@@ -561,8 +580,14 @@ const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks
|
|||||||
_max: { pickMoreFromTick: true },
|
_max: { pickMoreFromTick: true },
|
||||||
where: { pickMoreFromTick: { not: null } },
|
where: { pickMoreFromTick: { not: null } },
|
||||||
}),
|
}),
|
||||||
|
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
||||||
|
SELECT MAX((meta #>> '{cityOfficeRequest,dueTick}')::bigint) AS "maxTick"
|
||||||
|
FROM general WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||||
|
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
const values: Array<[string, bigint | null]> = [
|
const values: Array<[string, bigint | null]> = [
|
||||||
|
['general.meta.cityOfficeRequest.dueTick', office[0]?.maxTick ?? null],
|
||||||
['general.turn_tick', general._max.turnTick],
|
['general.turn_tick', general._max.turnTick],
|
||||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
||||||
['auction.close_tick', auction._max.closeTick],
|
['auction.close_tick', auction._max.closeTick],
|
||||||
@@ -632,6 +657,16 @@ const applyParticipantShift = async (
|
|||||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
|
affected.set(
|
||||||
|
'city-office-request-deadline',
|
||||||
|
await db.$executeRaw(GamePrisma.sql`
|
||||||
|
UPDATE general SET meta = jsonb_set(meta, '{cityOfficeRequest,dueTick}',
|
||||||
|
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${shiftTicks}))
|
||||||
|
WHERE meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||||
|
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||||
|
AND (meta #>> '{cityOfficeRequest,dueTick}')::bigint >= ${cutTick}
|
||||||
|
`)
|
||||||
|
);
|
||||||
affected.set(
|
affected.set(
|
||||||
'auction-deadline',
|
'auction-deadline',
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
await db.$executeRaw(GamePrisma.sql`
|
||||||
|
|||||||
@@ -185,6 +185,16 @@ const zKick = z.object({
|
|||||||
destGeneralId: zFiniteNumber,
|
destGeneralId: zFiniteNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zCityOfficeRequest = z.object({
|
||||||
|
type: z.literal('cityOfficeRequest'),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
action: z.enum(['request', 'approve', 'reject', 'withdraw']),
|
||||||
|
officerLevel: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
||||||
|
targetGeneralId: z.number().int().positive().optional(),
|
||||||
|
officeRequestId: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const zAppoint = z.object({
|
const zAppoint = z.object({
|
||||||
type: z.literal('appoint'),
|
type: z.literal('appoint'),
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -259,6 +269,21 @@ const zSetNpcPolicy = z.object({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zTournamentAdjustGeneral = z.object({
|
||||||
|
type: z.literal('tournamentAdjustGeneral'),
|
||||||
|
reason: z.enum(['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback']),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
generalId: zSafeInteger.positive(),
|
||||||
|
goldDelta: zFiniteNumber,
|
||||||
|
betGoldDelta: zFiniteNumber.optional(),
|
||||||
|
minGoldAfter: zFiniteNumber.nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zTournamentSeedNpcBets = z.object({
|
||||||
|
type: z.literal('tournamentSeedNpcBets'),
|
||||||
|
bets: z.array(z.object({ generalId: zSafeInteger.positive(), amount: zSafeInteger.positive() })).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
const zAdjustGeneralResources = z.object({
|
const zAdjustGeneralResources = z.object({
|
||||||
type: z.literal('adjustGeneralResources'),
|
type: z.literal('adjustGeneralResources'),
|
||||||
reason: z.string().optional(),
|
reason: z.string().optional(),
|
||||||
@@ -666,6 +691,11 @@ const normalizeKick: CommandNormalizer<'kick'> = (envelope) => {
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeCityOfficeRequest: CommandNormalizer<'cityOfficeRequest'> = (envelope) => {
|
||||||
|
const command = parseWith(zCityOfficeRequest, envelope.command);
|
||||||
|
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
|
const normalizeAppoint: CommandNormalizer<'appoint'> = (envelope) => {
|
||||||
const command = parseWith(zAppoint, envelope.command);
|
const command = parseWith(zAppoint, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -722,6 +752,16 @@ const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => {
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTournamentAdjustGeneral: CommandNormalizer<'tournamentAdjustGeneral'> = (envelope) => {
|
||||||
|
const command = parseWith(zTournamentAdjustGeneral, envelope.command);
|
||||||
|
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTournamentSeedNpcBets: CommandNormalizer<'tournamentSeedNpcBets'> = (envelope) => {
|
||||||
|
const command = parseWith(zTournamentSeedNpcBets, envelope.command);
|
||||||
|
return command ? { ...command, requestId: envelope.requestId } : null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
|
const normalizeAdjustGeneralResources: CommandNormalizer<'adjustGeneralResources'> = (envelope) => {
|
||||||
const command = parseWith(zAdjustGeneralResources, envelope.command);
|
const command = parseWith(zAdjustGeneralResources, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -883,12 +923,15 @@ const normalizers: CommandNormalizerMap = {
|
|||||||
changePermission: normalizeChangePermission,
|
changePermission: normalizeChangePermission,
|
||||||
kick: normalizeKick,
|
kick: normalizeKick,
|
||||||
appoint: normalizeAppoint,
|
appoint: normalizeAppoint,
|
||||||
|
cityOfficeRequest: normalizeCityOfficeRequest,
|
||||||
tournamentRefund: normalizeTournamentRefund,
|
tournamentRefund: normalizeTournamentRefund,
|
||||||
tournamentBettingPayout: normalizeTournamentBettingPayout,
|
tournamentBettingPayout: normalizeTournamentBettingPayout,
|
||||||
tournamentReward: normalizeTournamentReward,
|
tournamentReward: normalizeTournamentReward,
|
||||||
voteReward: normalizeVoteReward,
|
voteReward: normalizeVoteReward,
|
||||||
setNationSetting: normalizeSetNationSetting,
|
setNationSetting: normalizeSetNationSetting,
|
||||||
setNpcPolicy: normalizeSetNpcPolicy,
|
setNpcPolicy: normalizeSetNpcPolicy,
|
||||||
|
tournamentAdjustGeneral: normalizeTournamentAdjustGeneral,
|
||||||
|
tournamentSeedNpcBets: normalizeTournamentSeedNpcBets,
|
||||||
adjustGeneralResources: normalizeAdjustGeneralResources,
|
adjustGeneralResources: normalizeAdjustGeneralResources,
|
||||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
|
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
|
||||||
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
|
import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
|
||||||
|
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
|
||||||
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
|
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
|
||||||
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
|
import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js';
|
||||||
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
|
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
|
||||||
import { persistAuditMonth } from '../playAudit/persistence.js';
|
import { persistAuditMonth } from '../playAudit/persistence.js';
|
||||||
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
|
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
|
||||||
@@ -78,6 +79,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
|||||||
export interface DatabaseTurnHooks {
|
export interface DatabaseTurnHooks {
|
||||||
hooks: TurnDaemonHooks;
|
hooks: TurnDaemonHooks;
|
||||||
flushChanges(): Promise<void>;
|
flushChanges(): Promise<void>;
|
||||||
|
restoreMissingAuditPolicyHeads(): Promise<boolean>;
|
||||||
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
|
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
|
||||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||||
@@ -206,7 +208,14 @@ const generalSignatures = (general: TurnGeneral): ReadModelSignatures => ({
|
|||||||
permission: asRecord(general.meta).permission,
|
permission: asRecord(general.meta).permission,
|
||||||
penalty: general.penalty,
|
penalty: general.penalty,
|
||||||
}),
|
}),
|
||||||
frontStatus: signature({ name: general.name, nationId: general.nationId }),
|
frontStatus: signature({
|
||||||
|
name: general.name,
|
||||||
|
nationId: general.nationId,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
userId: general.userId,
|
||||||
|
penalty: general.penalty,
|
||||||
|
cityOfficeRequest: asRecord(general.meta).cityOfficeRequest,
|
||||||
|
}),
|
||||||
frontStatusGlobal: '',
|
frontStatusGlobal: '',
|
||||||
lobbyCount: signature({ npcState: general.npcState }),
|
lobbyCount: signature({ npcState: general.npcState }),
|
||||||
lobbyPersonal: signature({
|
lobbyPersonal: signature({
|
||||||
@@ -1235,6 +1244,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
}
|
}
|
||||||
const persistedClock = await prisma.$queryRaw<
|
const persistedClock = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
|
audit_gap: unknown;
|
||||||
clock_phase: string;
|
clock_phase: string;
|
||||||
clock_revision: bigint;
|
clock_revision: bigint;
|
||||||
deadline_generation: bigint;
|
deadline_generation: bigint;
|
||||||
@@ -1242,7 +1252,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
opening_reached: boolean;
|
opening_reached: boolean;
|
||||||
}>
|
}>
|
||||||
>(GamePrisma.sql`
|
>(GamePrisma.sql`
|
||||||
SELECT clock_phase,
|
SELECT meta->'playAuditGap' AS audit_gap, clock_phase,
|
||||||
clock_revision,
|
clock_revision,
|
||||||
deadline_generation,
|
deadline_generation,
|
||||||
clock_base_time IS NOT NULL
|
clock_base_time IS NOT NULL
|
||||||
@@ -1258,6 +1268,21 @@ export const createDatabaseTurnHooks = async (
|
|||||||
if (!durableClock) {
|
if (!durableClock) {
|
||||||
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
|
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
|
||||||
}
|
}
|
||||||
|
const durableGap = asRecord(durableClock.audit_gap);
|
||||||
|
if (durableGap.serverId && durableGap.serverId === state.meta.serverId) {
|
||||||
|
const localGap = asRecord(world.getState().meta.playAuditGap);
|
||||||
|
const gap =
|
||||||
|
localGap.serverId === durableGap.serverId
|
||||||
|
? {
|
||||||
|
...durableGap,
|
||||||
|
...localGap,
|
||||||
|
firstYear: durableGap.firstYear,
|
||||||
|
firstMonth: durableGap.firstMonth,
|
||||||
|
}
|
||||||
|
: durableGap;
|
||||||
|
world.updateWorldMeta({ playAuditGap: gap });
|
||||||
|
worldStateUpdate.meta = asJson(world.getState().meta);
|
||||||
|
}
|
||||||
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
|
||||||
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
const expectedRevision = BigInt(state.clockRevision ?? 1);
|
||||||
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
|
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
|
||||||
@@ -1388,7 +1413,12 @@ export const createDatabaseTurnHooks = async (
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE general
|
UPDATE general
|
||||||
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
|
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
|
||||||
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second')
|
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second'),
|
||||||
|
meta = CASE WHEN meta #>> '{cityOfficeRequest,status}' = 'pending'
|
||||||
|
AND meta #>> '{cityOfficeRequest,dueTick}' ~ '^-?[0-9]+$'
|
||||||
|
THEN jsonb_set(meta, '{cityOfficeRequest,dueTick}',
|
||||||
|
to_jsonb((meta #>> '{cityOfficeRequest,dueTick}')::bigint + ${deltaTicks}))
|
||||||
|
ELSE meta END
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
@@ -1894,11 +1924,25 @@ export const createDatabaseTurnHooks = async (
|
|||||||
data: pendingLogRows,
|
data: pendingLogRows,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
|
if (
|
||||||
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
|
pendingAuditPolicies.length ||
|
||||||
await persistAuditDecisions(prisma, pendingAuditDecisions);
|
pendingAuditDiplomacy.length ||
|
||||||
for (const snapshot of pendingAuditMonths) {
|
pendingAuditDecisions.length ||
|
||||||
await persistAuditMonth(prisma, snapshot);
|
pendingAuditMonths.length
|
||||||
|
) {
|
||||||
|
const audit = await withPlayAuditSavepoint(prisma, async (auditDb) => {
|
||||||
|
await persistAuditPolicies(auditDb, pendingAuditPolicies, auditCommand);
|
||||||
|
await persistAuditDiplomacyEvents(auditDb, pendingAuditDiplomacy);
|
||||||
|
await persistAuditDecisions(auditDb, pendingAuditDecisions);
|
||||||
|
for (const snapshot of pendingAuditMonths) await persistAuditMonth(auditDb, snapshot);
|
||||||
|
});
|
||||||
|
if (!audit.ok) {
|
||||||
|
markAuditGap(world, 'persistence');
|
||||||
|
await prisma.worldState.update({
|
||||||
|
where: { id: state.id },
|
||||||
|
data: { meta: world.getState().meta as GamePrisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const snapshot of pendingYearbookSnapshots) {
|
for (const snapshot of pendingYearbookSnapshots) {
|
||||||
await persistYearbookSnapshot(prisma, snapshot);
|
await persistYearbookSnapshot(prisma, snapshot);
|
||||||
@@ -2093,8 +2137,8 @@ export const createDatabaseTurnHooks = async (
|
|||||||
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
||||||
};
|
};
|
||||||
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
|
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
|
||||||
if (hasAuditDocumentBaseline(world)) {
|
if (collectPlayAudit(world, 'document-boundary', () => hasAuditDocumentBaseline(world), false)) {
|
||||||
if (force || world.hasPendingAuditRecords()) await flushChanges();
|
if (force || world.hasPendingAuditRecords() || world.getState().meta.playAuditGap) await flushChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const checkpoint = world.captureState();
|
const checkpoint = world.captureState();
|
||||||
@@ -2107,7 +2151,10 @@ export const createDatabaseTurnHooks = async (
|
|||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||||
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||||
await persistAuditDocumentBaseline(transaction, world, observedAt);
|
const audit = await withPlayAuditSavepoint(transaction, (auditDb) =>
|
||||||
|
persistAuditDocumentBaseline(auditDb, world, observedAt)
|
||||||
|
);
|
||||||
|
if (!audit.ok) markAuditGap(world, 'document-baseline');
|
||||||
return persistChanges(transaction);
|
return persistChanges(transaction);
|
||||||
}, transactionOptions);
|
}, transactionOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2164,6 +2211,17 @@ export const createDatabaseTurnHooks = async (
|
|||||||
hooks,
|
hooks,
|
||||||
flushChanges,
|
flushChanges,
|
||||||
flushInitialAudit,
|
flushInitialAudit,
|
||||||
|
restoreMissingAuditPolicyHeads: async () => {
|
||||||
|
const result = await prisma.$transaction(
|
||||||
|
(tx) => withPlayAuditSavepoint(tx, (auditDb) => restoreMissingAuditPolicyHeads(auditDb, world)),
|
||||||
|
transactionOptions
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
markAuditGap(world, 'policy-head-recovery');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return result.value;
|
||||||
|
},
|
||||||
takeCommittedReadModelChanges: () => {
|
takeCommittedReadModelChanges: () => {
|
||||||
return takeCommittedReceipt()?.changes ?? null;
|
return takeCommittedReceipt()?.changes ?? null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { collectPlayAudit } from '../playAudit/bestEffort.js';
|
||||||
import type { PendingAuditDecision } from '../playAudit/decision.js';
|
import type { PendingAuditDecision } from '../playAudit/decision.js';
|
||||||
import {
|
import {
|
||||||
recordTurnAuditDiplomacy,
|
recordTurnAuditDiplomacy,
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { getNextTurnAt, readScenarioGeneralPoolClaim, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
GAME_TICKS_PER_TURN,
|
GAME_TICKS_PER_TURN,
|
||||||
|
shiftOfficeRequestDeadline,
|
||||||
GameClock,
|
GameClock,
|
||||||
assertGameplayCommitAllowed,
|
assertGameplayCommitAllowed,
|
||||||
inferClockPhase,
|
inferClockPhase,
|
||||||
@@ -818,6 +820,7 @@ export class InMemoryTurnWorld {
|
|||||||
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
|
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), input.shiftTicks);
|
||||||
this.generals.set(generalId, {
|
this.generals.set(generalId, {
|
||||||
...general,
|
...general,
|
||||||
|
meta: shiftOfficeRequestDeadline(general.meta, input.shiftTicks, clock.tick),
|
||||||
turnTick,
|
turnTick,
|
||||||
turnTime: clock.tickToDate(turnTick),
|
turnTime: clock.tickToDate(turnTick),
|
||||||
});
|
});
|
||||||
@@ -1043,6 +1046,7 @@ export class InMemoryTurnWorld {
|
|||||||
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
|
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
|
||||||
this.generals.set(generalId, {
|
this.generals.set(generalId, {
|
||||||
...general,
|
...general,
|
||||||
|
meta: shiftOfficeRequestDeadline(general.meta, shiftedTicks),
|
||||||
turnTick,
|
turnTick,
|
||||||
turnTime: clock.tickToDate(turnTick),
|
turnTime: clock.tickToDate(turnTick),
|
||||||
});
|
});
|
||||||
@@ -1393,15 +1397,36 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
|
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
|
||||||
|
collectPlayAudit(
|
||||||
|
this,
|
||||||
|
'queueDiplomacy',
|
||||||
|
() => {
|
||||||
this.pendingAuditDiplomacy.push(structuredClone(event));
|
this.pendingAuditDiplomacy.push(structuredClone(event));
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queueAuditDecision(decision: PendingAuditDecision): void {
|
queueAuditDecision(decision: PendingAuditDecision): void {
|
||||||
|
collectPlayAudit(
|
||||||
|
this,
|
||||||
|
'queueDecision',
|
||||||
|
() => {
|
||||||
this.pendingAuditDecisions.push(structuredClone(decision));
|
this.pendingAuditDecisions.push(structuredClone(decision));
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queueAuditPolicy(policy: PendingAuditPolicy): void {
|
queueAuditPolicy(policy: PendingAuditPolicy): void {
|
||||||
|
collectPlayAudit(
|
||||||
|
this,
|
||||||
|
'queuePolicy',
|
||||||
|
() => {
|
||||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPendingAuditRecords(): boolean {
|
hasPendingAuditRecords(): boolean {
|
||||||
@@ -1414,7 +1439,14 @@ export class InMemoryTurnWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueAuditMonth(snapshot: PendingAuditMonth): void {
|
queueAuditMonth(snapshot: PendingAuditMonth): void {
|
||||||
|
collectPlayAudit(
|
||||||
|
this,
|
||||||
|
'queueMonth',
|
||||||
|
() => {
|
||||||
this.pendingAuditMonths.push(structuredClone(snapshot));
|
this.pendingAuditMonths.push(structuredClone(snapshot));
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
|
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
|
||||||
|
|||||||
@@ -396,7 +396,8 @@ export const createRaiseInvaderHandler = (options: {
|
|||||||
}
|
}
|
||||||
world.updateNation(nationId, {
|
world.updateNation(nationId, {
|
||||||
chiefGeneralId: ruler.id,
|
chiefGeneralId: ruler.id,
|
||||||
meta: { ...nation.meta, gennum: npcEachCount },
|
// addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
|
||||||
|
meta: { ...world.getNationById(nationId)!.meta, gennum: npcEachCount },
|
||||||
});
|
});
|
||||||
for (const officerLevel of [12, 11, 10, 9]) {
|
for (const officerLevel of [12, 11, 10, 9]) {
|
||||||
options.reservedTurns.ensureNationTurns(nationId, officerLevel);
|
options.reservedTurns.ensureNationTurns(nationId, officerLevel);
|
||||||
|
|||||||
@@ -395,7 +395,8 @@ export const createRaiseNpcNationHandler = (options: {
|
|||||||
}
|
}
|
||||||
world.updateNation(nationId, {
|
world.updateNation(nationId, {
|
||||||
chiefGeneralId: ruler.id,
|
chiefGeneralId: ruler.id,
|
||||||
meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length },
|
// addNation이 붙인 감사 이력 head를 오래된 생성 객체로 덮어쓰지 않는다.
|
||||||
|
meta: { ...world.getNationById(nationId)!.meta, gennum: 1 + subordinateCandidates.length },
|
||||||
});
|
});
|
||||||
options.reservedTurns.ensureNationTurns(nationId, 12);
|
options.reservedTurns.ensureNationTurns(nationId, 12);
|
||||||
options.reservedTurns.ensureNationTurns(nationId, 11);
|
options.reservedTurns.ensureNationTurns(nationId, 11);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { collectPlayAudit } from '../playAudit/bestEffort.js';
|
||||||
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
|
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
|
||||||
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
|
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
|
||||||
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
|
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
|
||||||
@@ -1085,7 +1086,11 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const onDecisionTrace: AiDecisionTraceObserver | undefined =
|
const onDecisionTrace: AiDecisionTraceObserver | undefined =
|
||||||
collectDecisions || options.onDecisionTrace
|
collectDecisions || options.onDecisionTrace
|
||||||
? (event) => {
|
? (event) => {
|
||||||
if (collectDecisions) {
|
if (collectDecisions && worldRef)
|
||||||
|
collectPlayAudit(
|
||||||
|
worldRef,
|
||||||
|
'decision-trace',
|
||||||
|
() => {
|
||||||
if (event.kind === 'DECISION_START') {
|
if (event.kind === 'DECISION_START') {
|
||||||
decisionSteps.set(event.phase, []);
|
decisionSteps.set(event.phase, []);
|
||||||
const heads = asRecord(currentNation?.meta._playAuditPolicy);
|
const heads = asRecord(currentNation?.meta._playAuditPolicy);
|
||||||
@@ -1094,7 +1099,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
AUDIT_POLICY_AREAS.flatMap((area) => {
|
AUDIT_POLICY_AREAS.flatMap((area) => {
|
||||||
const head = asRecord(heads[area]);
|
const head = asRecord(heads[area]);
|
||||||
return head.serverId === auditServerId && typeof head.id === 'string'
|
return head.serverId === auditServerId &&
|
||||||
|
typeof head.id === 'string'
|
||||||
? [[area, head.id]]
|
? [[area, head.id]]
|
||||||
: [];
|
: [];
|
||||||
})
|
})
|
||||||
@@ -1104,11 +1110,18 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
decisionSteps
|
decisionSteps
|
||||||
.get(event.phase)
|
.get(event.phase)
|
||||||
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
|
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
|
||||||
}
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
options.onDecisionTrace?.(event);
|
options.onDecisionTrace?.(event);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
|
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
|
||||||
|
if (!worldRef) return;
|
||||||
|
collectPlayAudit(
|
||||||
|
worldRef,
|
||||||
|
'decision-summary',
|
||||||
|
() => {
|
||||||
const steps = decisionSteps.get(phase);
|
const steps = decisionSteps.get(phase);
|
||||||
const first = steps?.[0];
|
const first = steps?.[0];
|
||||||
if (!collectDecisions || !first || !steps) return;
|
if (!collectDecisions || !first || !steps) return;
|
||||||
@@ -1125,6 +1138,9 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
month,
|
month,
|
||||||
tick,
|
tick,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
const finishDecision = (
|
const finishDecision = (
|
||||||
phase: 'general' | 'nation',
|
phase: 'general' | 'nation',
|
||||||
@@ -1135,6 +1151,11 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
blockedReason?: string;
|
blockedReason?: string;
|
||||||
}
|
}
|
||||||
): void => {
|
): void => {
|
||||||
|
if (!worldRef) return;
|
||||||
|
collectPlayAudit(
|
||||||
|
worldRef,
|
||||||
|
'decision-summary',
|
||||||
|
() => {
|
||||||
const steps = decisionSteps.get(phase);
|
const steps = decisionSteps.get(phase);
|
||||||
const first = steps?.[0];
|
const first = steps?.[0];
|
||||||
const last = steps?.find((step) => step.kind === 'DECISION_END');
|
const last = steps?.find((step) => step.kind === 'DECISION_END');
|
||||||
@@ -1193,6 +1214,9 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
},
|
},
|
||||||
steps,
|
steps,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createCityOfficeRequestCalendarHandler } from './cityOfficeRequests.js';
|
||||||
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
|
import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js';
|
||||||
import { initializeAuditPolicies } from '../playAudit/policy.js';
|
import { initializeAuditPolicies } from '../playAudit/policy.js';
|
||||||
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
|
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
|
||||||
@@ -488,6 +489,7 @@ const createMonthlyCalendarRuntime = async (options: {
|
|||||||
const calendarHandler = composeCalendarHandlers(
|
const calendarHandler = composeCalendarHandlers(
|
||||||
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
|
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
|
||||||
options.monthlyEventHandler,
|
options.monthlyEventHandler,
|
||||||
|
createCityOfficeRequestCalendarHandler(options.getWorld),
|
||||||
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
|
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
|
||||||
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
|
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
|
||||||
createPlayAuditHandler(options.getWorld),
|
createPlayAuditHandler(options.getWorld),
|
||||||
@@ -937,10 +939,11 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
|
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
|
||||||
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
|
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
|
||||||
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
|
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
|
||||||
|
const policyHeadsRestored = await dbHooks.restoreMissingAuditPolicyHeads();
|
||||||
initializeAuditPolicies(world);
|
initializeAuditPolicies(world);
|
||||||
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
|
||||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||||
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized);
|
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized || policyHeadsRestored);
|
||||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
|
||||||
import type {
|
import type {
|
||||||
TurnDaemonCommandHandler,
|
TurnDaemonCommandHandler,
|
||||||
TurnDaemonCommand,
|
TurnDaemonCommand,
|
||||||
@@ -174,6 +175,7 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
||||||
|
'tournamentAdjustGeneral',
|
||||||
'troopCreate',
|
'troopCreate',
|
||||||
'troopJoin',
|
'troopJoin',
|
||||||
'troopExit',
|
'troopExit',
|
||||||
@@ -187,6 +189,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [
|
|||||||
'changePermission',
|
'changePermission',
|
||||||
'kick',
|
'kick',
|
||||||
'appoint',
|
'appoint',
|
||||||
|
'cityOfficeRequest',
|
||||||
'voteReward',
|
'voteReward',
|
||||||
'syncDiplomaticResponse',
|
'syncDiplomaticResponse',
|
||||||
] as const satisfies readonly TurnDaemonCommand['type'][];
|
] as const satisfies readonly TurnDaemonCommand['type'][];
|
||||||
@@ -625,11 +628,84 @@ async function handleSetNpcPolicy(
|
|||||||
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTournamentAdjustGeneral(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const general = ctx.world.getGeneralById(command.generalId);
|
||||||
|
// Ref의 참가/베팅 actor는 로그인 장수다. 큐 대기 중 빙의 소유자가 바뀐 경우도 막는다.
|
||||||
|
if (!general || general.userId !== command.userId) {
|
||||||
|
return { type: command.type, ok: false, code: 'FORBIDDEN', reason: '장수의 현재 소유자가 일치하지 않습니다.' };
|
||||||
|
}
|
||||||
|
const gold = general.gold + command.goldDelta;
|
||||||
|
if (!Number.isFinite(gold) || gold < command.minGoldAfter) {
|
||||||
|
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '금이 부족합니다.' };
|
||||||
|
}
|
||||||
|
const meta = { ...general.meta };
|
||||||
|
if (command.betGoldDelta !== undefined) {
|
||||||
|
const current = typeof meta.betgold === 'number' ? meta.betgold : 0;
|
||||||
|
const betgold = current + command.betGoldDelta;
|
||||||
|
if (!Number.isFinite(betgold) || betgold < 0) {
|
||||||
|
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '베팅 기록이 일치하지 않습니다.' };
|
||||||
|
}
|
||||||
|
meta.betgold = betgold;
|
||||||
|
}
|
||||||
|
// 금과 통계는 하나의 ENGINE event에서 함께 commit하거나 함께 거부한다.
|
||||||
|
ctx.world.updateGeneral(command.generalId, { gold, meta });
|
||||||
|
return { type: command.type, ok: true, generalId: command.generalId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTournamentSeedNpcBets(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
if (ctx.commandDb) {
|
||||||
|
const event = command.requestId
|
||||||
|
? await ctx.commandDb.inputEvent.findUnique({
|
||||||
|
where: { requestId: command.requestId },
|
||||||
|
select: { actorUserId: true, target: true, eventType: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!event || event.actorUserId !== null || event.target !== 'ENGINE' || event.eventType !== command.type) {
|
||||||
|
return { type: command.type, ok: false, reason: 'NPC 베팅 입력 이벤트가 일치하지 않습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (new Set(command.bets.map((bet) => bet.generalId)).size !== command.bets.length) {
|
||||||
|
return { type: command.type, ok: false, reason: 'NPC 베팅 대상이 중복됩니다.' };
|
||||||
|
}
|
||||||
|
const processedGeneralIds: number[] = [];
|
||||||
|
const skippedGeneralIds: number[] = [];
|
||||||
|
for (const bet of command.bets) {
|
||||||
|
const general = ctx.world.getGeneralById(bet.generalId);
|
||||||
|
// 계획 이후 유저가 빙의한 장수는 NPC 비용·통계를 변경하지 않는다.
|
||||||
|
if (!general || general.userId != null || general.npcState < 2 || general.gold - bet.amount < 500) {
|
||||||
|
skippedGeneralIds.push(bet.generalId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const current = typeof general.meta.betgold === 'number' ? general.meta.betgold : 0;
|
||||||
|
ctx.world.updateGeneral(bet.generalId, {
|
||||||
|
gold: general.gold - bet.amount,
|
||||||
|
meta: { ...general.meta, betgold: current + bet.amount },
|
||||||
|
});
|
||||||
|
processedGeneralIds.push(bet.generalId);
|
||||||
|
}
|
||||||
|
return { type: command.type, ok: true, processedGeneralIds, skippedGeneralIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLegacyTournamentAdjustment = (reason: string | undefined): boolean =>
|
||||||
|
reason !== undefined &&
|
||||||
|
['tournamentJoin', 'tournamentJoinRollback', 'tournamentBet', 'tournamentBetRollback', 'tournamentNpcBet'].includes(
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
|
||||||
async function handleAdjustGeneralResources(
|
async function handleAdjustGeneralResources(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||||
): Promise<TurnDaemonCommandResult> {
|
): Promise<TurnDaemonCommandResult> {
|
||||||
const { world } = ctx;
|
const { world } = ctx;
|
||||||
|
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||||
|
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||||
|
}
|
||||||
if (!command.adjustments || command.adjustments.length === 0) {
|
if (!command.adjustments || command.adjustments.length === 0) {
|
||||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||||
}
|
}
|
||||||
@@ -686,6 +762,9 @@ async function handleAdjustGeneralMeta(
|
|||||||
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>
|
||||||
): Promise<TurnDaemonCommandResult> {
|
): Promise<TurnDaemonCommandResult> {
|
||||||
const { world } = ctx;
|
const { world } = ctx;
|
||||||
|
if (isLegacyTournamentAdjustment(command.reason)) {
|
||||||
|
return { type: command.type, ok: false, reason: '소유권 없는 이전 토너먼트 조정 명령은 허용하지 않습니다.' };
|
||||||
|
}
|
||||||
if (!command.adjustments || command.adjustments.length === 0) {
|
if (!command.adjustments || command.adjustments.length === 0) {
|
||||||
return {
|
return {
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
@@ -2002,8 +2081,10 @@ async function handleSetMySetting(
|
|||||||
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
||||||
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
|
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
|
||||||
const constValues = asRecord(world.getScenarioConfig().const);
|
const constValues = asRecord(world.getScenarioConfig().const);
|
||||||
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
// Ref GameConstBase의 전투 상한을 사용한다. 명령 상한(100)으로 제한하면
|
||||||
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
// 전투에서 오른 훈련·사기가 수비끔 전환 한 번에 소실된다.
|
||||||
|
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 110);
|
||||||
|
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 150);
|
||||||
const trainDelta = ignoresPenalty ? 0 : -3;
|
const trainDelta = ignoresPenalty ? 0 : -3;
|
||||||
const atmosDelta = ignoresPenalty ? 0 : -6;
|
const atmosDelta = ignoresPenalty ? 0 : -6;
|
||||||
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
|
nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta));
|
||||||
@@ -2587,6 +2668,7 @@ async function handleAppoint(
|
|||||||
} else {
|
} else {
|
||||||
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
|
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' };
|
||||||
}
|
}
|
||||||
|
settleCityOfficeRequests(world, Number.NEGATIVE_INFINITY);
|
||||||
refreshActorKillturn(world, general);
|
refreshActorKillturn(world, general);
|
||||||
return { type: 'appoint', ok: true, generalId: command.generalId };
|
return { type: 'appoint', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
@@ -3243,6 +3325,12 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
changePermission: (command) =>
|
changePermission: (command) =>
|
||||||
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
||||||
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
|
kick: (command) => handleKick(ctx, command as Extract<TurnDaemonCommand, { type: 'kick' }>),
|
||||||
|
cityOfficeRequest: async (command) =>
|
||||||
|
handleCityOfficeRequest(
|
||||||
|
ctx.world,
|
||||||
|
command as Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }>,
|
||||||
|
ctx.commandDb ? await resolveOperationalAcceptedAt(ctx.commandDb, command) : new Date()
|
||||||
|
),
|
||||||
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
|
appoint: (command) => handleAppoint(ctx, command as Extract<TurnDaemonCommand, { type: 'appoint' }>),
|
||||||
tournamentRefund: (command) =>
|
tournamentRefund: (command) =>
|
||||||
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
|
handleTournamentRefund(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>),
|
||||||
@@ -3258,6 +3346,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
handleSetNationSetting(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationSetting' }>),
|
||||||
setNpcPolicy: (command) =>
|
setNpcPolicy: (command) =>
|
||||||
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
|
handleSetNpcPolicy(ctx, command as Extract<TurnDaemonCommand, { type: 'setNpcPolicy' }>),
|
||||||
|
tournamentAdjustGeneral: (command) =>
|
||||||
|
handleTournamentAdjustGeneral(
|
||||||
|
ctx,
|
||||||
|
command as Extract<TurnDaemonCommand, { type: 'tournamentAdjustGeneral' }>
|
||||||
|
),
|
||||||
|
tournamentSeedNpcBets: (command) =>
|
||||||
|
handleTournamentSeedNpcBets(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentSeedNpcBets' }>),
|
||||||
adjustGeneralResources: (command) =>
|
adjustGeneralResources: (command) =>
|
||||||
handleAdjustGeneralResources(
|
handleAdjustGeneralResources(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ const state: TurnWorldState = {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
|
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||||
|
clockBaseTime: new Date('2026-07-31T09:00:00.000Z'),
|
||||||
|
clockTick: 0,
|
||||||
|
lastTurnTick: 0,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockPhase: 'MANUAL',
|
||||||
|
clockWallAnchor: new Date('2026-07-31T09:00:00.000Z'),
|
||||||
|
clockRevision: 1,
|
||||||
|
deadlineGeneration: 1,
|
||||||
meta: {
|
meta: {
|
||||||
killturn: 24,
|
killturn: 24,
|
||||||
lastTurnTime: '2026-07-31T09:00:00.000Z',
|
lastTurnTime: '2026-07-31T09:00:00.000Z',
|
||||||
@@ -209,6 +217,14 @@ integration('adjustGeneralIcon PostgreSQL persistence', () => {
|
|||||||
currentYear: state.currentYear,
|
currentYear: state.currentYear,
|
||||||
currentMonth: state.currentMonth,
|
currentMonth: state.currentMonth,
|
||||||
tickSeconds: state.tickSeconds,
|
tickSeconds: state.tickSeconds,
|
||||||
|
clockBaseTime: state.clockBaseTime,
|
||||||
|
clockTick: BigInt(state.clockTick ?? 0),
|
||||||
|
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
||||||
|
clockMode: state.clockMode,
|
||||||
|
clockPhase: state.clockPhase,
|
||||||
|
clockWallAnchor: state.clockWallAnchor,
|
||||||
|
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||||
|
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
||||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
|||||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||||
|
{
|
||||||
|
type: 'tournamentAdjustGeneral',
|
||||||
|
reason: 'tournamentBet',
|
||||||
|
requestId: 'tournamentAdjustGeneral',
|
||||||
|
userId,
|
||||||
|
generalId: 7,
|
||||||
|
goldDelta: -100,
|
||||||
|
betGoldDelta: 100,
|
||||||
|
minGoldAfter: 500,
|
||||||
|
},
|
||||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||||
@@ -45,6 +55,14 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
|||||||
destCityId: 1,
|
destCityId: 1,
|
||||||
officerLevel: 4,
|
officerLevel: 4,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
requestId: 'cityOfficeRequest',
|
||||||
|
userId,
|
||||||
|
generalId: 7,
|
||||||
|
action: 'request',
|
||||||
|
officerLevel: 4,
|
||||||
|
},
|
||||||
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
||||||
{
|
{
|
||||||
type: 'syncDiplomaticResponse',
|
type: 'syncDiplomaticResponse',
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { readOfficeRequest, type TurnDaemonCommand } from '@sammo-ts/common';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient, type GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { buildWorld } from './officeRequestFixture.js';
|
||||||
|
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { EngineStateManager } from '../src/turn/engineStateManager.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
import { createCityOfficeRequestCalendarHandler } from '../src/turn/cityOfficeRequests.js';
|
||||||
|
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const worldId = 992951;
|
||||||
|
const generalId = 992953;
|
||||||
|
const requestId = 'integration:city-office-request';
|
||||||
|
const constraint = 'city_office_request_rollback_test';
|
||||||
|
integration('city office request durable persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let disconnect: (() => Promise<void>) | undefined;
|
||||||
|
let hooks: DatabaseTurnHooks | undefined;
|
||||||
|
beforeAll(async () => {
|
||||||
|
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||||
|
if (!schema || !/^(ci_|office_requests)/.test(schema)) throw new Error('Dedicated integration schema required');
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
disconnect = () => connector.disconnect();
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${constraint}`);
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||||
|
await db.logEntry.deleteMany({
|
||||||
|
where: { OR: [{ nationId: worldId }, { generalId: { in: [worldId, worldId + 1, generalId] } }] },
|
||||||
|
});
|
||||||
|
await db.rankData.deleteMany({ where: { generalId: { in: [worldId, worldId + 1, generalId] } } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [worldId, worldId + 1, generalId] } } });
|
||||||
|
await db.city.deleteMany({ where: { id: worldId } });
|
||||||
|
await db.nation.deleteMany({ where: { id: worldId } });
|
||||||
|
await db.worldState.deleteMany();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await hooks?.close();
|
||||||
|
await disconnect?.();
|
||||||
|
});
|
||||||
|
it('rolls back request and journal together, reloads pending state, then persists automatic appointment once', async () => {
|
||||||
|
const base = buildWorld({});
|
||||||
|
const state = {
|
||||||
|
...base.state,
|
||||||
|
id: worldId,
|
||||||
|
clockBaseTime: base.state.lastTurnTime,
|
||||||
|
clockTick: 0,
|
||||||
|
lastTurnTick: 0,
|
||||||
|
clockMode: 'manual' as const,
|
||||||
|
clockPhase: 'MANUAL' as const,
|
||||||
|
clockRevision: 1,
|
||||||
|
deadlineGeneration: 1,
|
||||||
|
clockWallAnchor: base.state.lastTurnTime,
|
||||||
|
};
|
||||||
|
const snapshot = {
|
||||||
|
...base.snapshot,
|
||||||
|
generals: base.snapshot.generals.map((g) => ({
|
||||||
|
...g,
|
||||||
|
id: 992950 + g.id,
|
||||||
|
nationId: worldId,
|
||||||
|
cityId: worldId,
|
||||||
|
userId: `user-${992950 + g.id}`,
|
||||||
|
})),
|
||||||
|
cities: base.snapshot.cities.map((c) => ({ ...c, id: worldId, nationId: worldId })),
|
||||||
|
nations: base.snapshot.nations.map((n) => ({
|
||||||
|
...n,
|
||||||
|
id: worldId,
|
||||||
|
capitalCityId: worldId,
|
||||||
|
chiefGeneralId: worldId,
|
||||||
|
})),
|
||||||
|
scenarioConfig: { ...base.snapshot.scenarioConfig!, environment: { mapName: 'che', unitSet: 'che' } },
|
||||||
|
};
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: worldId,
|
||||||
|
scenarioCode: 'office-requests',
|
||||||
|
currentYear: state.currentYear,
|
||||||
|
currentMonth: state.currentMonth,
|
||||||
|
tickSeconds: 600,
|
||||||
|
clockBaseTime: state.clockBaseTime,
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockPhase: 'MANUAL',
|
||||||
|
clockWallAnchor: state.clockWallAnchor,
|
||||||
|
clockRevision: 1n,
|
||||||
|
deadlineGeneration: 1n,
|
||||||
|
config: JSON.parse(JSON.stringify(snapshot.scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.nation.create({
|
||||||
|
data: {
|
||||||
|
id: worldId,
|
||||||
|
name: '위',
|
||||||
|
color: '#777777',
|
||||||
|
level: 3,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 10000,
|
||||||
|
tech: 0,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
capitalCityId: worldId,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.city.create({
|
||||||
|
data: {
|
||||||
|
id: worldId,
|
||||||
|
name: '허창',
|
||||||
|
nationId: worldId,
|
||||||
|
level: 7,
|
||||||
|
population: 1000,
|
||||||
|
populationMax: 2000,
|
||||||
|
agriculture: 1000,
|
||||||
|
agricultureMax: 2000,
|
||||||
|
commerce: 1000,
|
||||||
|
commerceMax: 2000,
|
||||||
|
security: 1000,
|
||||||
|
securityMax: 2000,
|
||||||
|
defence: 1000,
|
||||||
|
defenceMax: 2000,
|
||||||
|
wall: 1000,
|
||||||
|
wallMax: 2000,
|
||||||
|
trust: 80,
|
||||||
|
trade: 100,
|
||||||
|
region: 1,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.general.createMany({
|
||||||
|
data: snapshot.generals.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
userId: g.userId,
|
||||||
|
name: g.name,
|
||||||
|
nationId: g.nationId,
|
||||||
|
cityId: g.cityId,
|
||||||
|
npcState: g.npcState,
|
||||||
|
officerLevel: g.officerLevel,
|
||||||
|
leadership: g.stats.leadership,
|
||||||
|
strength: g.stats.strength,
|
||||||
|
intel: g.stats.intelligence,
|
||||||
|
gold: g.gold,
|
||||||
|
rice: g.rice,
|
||||||
|
crew: g.crew,
|
||||||
|
crewTypeId: g.crewTypeId,
|
||||||
|
train: g.train,
|
||||||
|
atmos: g.atmos,
|
||||||
|
age: g.age,
|
||||||
|
turnTime: g.turnTime,
|
||||||
|
meta: g.meta,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
});
|
||||||
|
const handler = createTurnDaemonCommandHandler({ world });
|
||||||
|
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
const manager = new EngineStateManager();
|
||||||
|
manager.register('world', {
|
||||||
|
capture: () => world.captureState(),
|
||||||
|
restore: (saved) => world.restoreState(saved),
|
||||||
|
});
|
||||||
|
const command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }> = {
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
requestId,
|
||||||
|
userId: `user-${generalId}`,
|
||||||
|
generalId,
|
||||||
|
action: 'request',
|
||||||
|
officerLevel: 4,
|
||||||
|
};
|
||||||
|
await db.inputEvent.create({
|
||||||
|
data: {
|
||||||
|
requestId,
|
||||||
|
target: 'ENGINE',
|
||||||
|
eventType: command.type,
|
||||||
|
actorUserId: command.userId,
|
||||||
|
status: 'PROCESSING',
|
||||||
|
lockedBy: 'office-test',
|
||||||
|
leaseUntil: new Date('2099-01-01'),
|
||||||
|
attempts: 1,
|
||||||
|
payload: command,
|
||||||
|
acceptedGameTick: 0n,
|
||||||
|
acceptedClockRevision: 1n,
|
||||||
|
acceptedDeadlineGeneration: 1n,
|
||||||
|
processingGameTick: 0n,
|
||||||
|
processingClockRevision: 1n,
|
||||||
|
processingDeadlineGeneration: 1n,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const execute = () =>
|
||||||
|
manager.transaction(() =>
|
||||||
|
hooks!.hooks.executeCommand!(requestId, async (context) => {
|
||||||
|
const result = await handler.handle(command, context);
|
||||||
|
if (!result) throw new Error('Missing result');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE input_event ADD CONSTRAINT ${constraint} CHECK (request_id <> '${requestId}' OR status <> 'SUCCEEDED')`
|
||||||
|
);
|
||||||
|
await expect(execute()).rejects.toThrow();
|
||||||
|
expect(readOfficeRequest(world.getGeneralById(generalId)?.meta)).toBeNull();
|
||||||
|
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)).toBeNull();
|
||||||
|
expect(hooks.takeCommittedReadModelChanges()).toBeNull();
|
||||||
|
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${constraint}`);
|
||||||
|
await expect(execute()).resolves.toMatchObject({ type: 'cityOfficeRequest', ok: true });
|
||||||
|
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('SUCCEEDED');
|
||||||
|
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)?.status).toBe(
|
||||||
|
'pending'
|
||||||
|
);
|
||||||
|
expect(hooks.takeCommittedReadModelChanges()).toMatchObject({ frontStatusChanged: true });
|
||||||
|
await hooks.close();
|
||||||
|
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
const reloaded: InMemoryTurnWorld = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: createCityOfficeRequestCalendarHandler(() => reloaded),
|
||||||
|
});
|
||||||
|
const pending = readOfficeRequest(reloaded.getGeneralById(generalId)?.meta)!;
|
||||||
|
expect(pending.status).toBe('pending');
|
||||||
|
hooks = await createDatabaseTurnHooks(databaseUrl!, reloaded);
|
||||||
|
const turnTime = reloaded.gameTickToDate(pending.dueTick);
|
||||||
|
await reloaded.advanceMonth(turnTime);
|
||||||
|
await hooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: turnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
const saved = await db.general.findUniqueOrThrow({ where: { id: generalId } });
|
||||||
|
expect(saved.officerLevel).toBe(4);
|
||||||
|
expect(readOfficeRequest(saved.meta)?.status).toBe('approved');
|
||||||
|
expect((await db.city.findUniqueOrThrow({ where: { id: worldId } })).meta).toMatchObject({ officer_set: 16 });
|
||||||
|
const count = await db.logEntry.count({ where: { generalId } });
|
||||||
|
await hooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: turnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
expect(await db.logEntry.count({ where: { generalId } })).toBe(count);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GAME_TICKS_PER_TURN, readOfficeRequest, shiftOfficeRequestDeadline } from '@sammo-ts/common';
|
||||||
|
import { buildGeneral, buildWorld } from './officeRequestFixture.js';
|
||||||
|
import {
|
||||||
|
handleCityOfficeRequest,
|
||||||
|
settleCityOfficeRequests,
|
||||||
|
createCityOfficeRequestCalendarHandler,
|
||||||
|
} from '../src/turn/cityOfficeRequests.js';
|
||||||
|
const acceptedAt = new Date('0185-01-01T00:00:00Z');
|
||||||
|
const request = (world: ReturnType<typeof buildWorld>['world'], generalId = 3) =>
|
||||||
|
handleCityOfficeRequest(
|
||||||
|
world,
|
||||||
|
{ type: 'cityOfficeRequest', userId: `user-${generalId}`, generalId, action: 'request', officerLevel: 4 },
|
||||||
|
acceptedAt
|
||||||
|
);
|
||||||
|
const pending = (world: ReturnType<typeof buildWorld>['world']) => readOfficeRequest(world.getGeneralById(3)?.meta)!;
|
||||||
|
const decide = (
|
||||||
|
world: ReturnType<typeof buildWorld>['world'],
|
||||||
|
action: 'approve' | 'reject' | 'withdraw',
|
||||||
|
generalId = 2
|
||||||
|
) =>
|
||||||
|
handleCityOfficeRequest(
|
||||||
|
world,
|
||||||
|
{
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
userId: `user-${generalId}`,
|
||||||
|
generalId,
|
||||||
|
action,
|
||||||
|
targetGeneralId: 3,
|
||||||
|
officeRequestId: pending(world).id,
|
||||||
|
},
|
||||||
|
acceptedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('city office request policy and lifecycle', () => {
|
||||||
|
it.each([null, 2, 3])('defaults vacancy / N / M (%s) to approval only at the deadline', (npc) => {
|
||||||
|
const { world } = buildWorld({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(2, { officerLevel: 5 }),
|
||||||
|
buildGeneral(3),
|
||||||
|
...(npc === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
buildGeneral(4, {
|
||||||
|
userId: null,
|
||||||
|
npcState: npc,
|
||||||
|
officerLevel: 4,
|
||||||
|
meta: { killturn: 12, officerCity: 1 },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(request(world)).toMatchObject({ ok: true });
|
||||||
|
const entry = pending(world);
|
||||||
|
expect(entry).toMatchObject({ defaultDecision: 'approve', status: 'pending' });
|
||||||
|
expect(entry.dueTick - entry.createdTick).toBeGreaterThanOrEqual(GAME_TICKS_PER_TURN);
|
||||||
|
settleCityOfficeRequests(world, entry.dueTick - 1);
|
||||||
|
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
|
||||||
|
settleCityOfficeRequests(world, entry.dueTick);
|
||||||
|
expect(pending(world).status).toBe('approved');
|
||||||
|
expect(world.getGeneralById(3)).toMatchObject({ officerLevel: 4, meta: { officerCity: 1, officer_city: 1 } });
|
||||||
|
expect(Number(world.getCityById(1)?.meta.officer_set) & 16).toBe(16);
|
||||||
|
if (npc !== null) expect(world.getGeneralById(4)?.officerLevel).toBe(1);
|
||||||
|
settleCityOfficeRequests(world, entry.dueTick + GAME_TICKS_PER_TURN);
|
||||||
|
expect(pending(world).status).toBe('approved');
|
||||||
|
});
|
||||||
|
it.each(['absent', 'secret'] as const)('defaults %s to rejection but lets a chief explicitly approve', (reason) => {
|
||||||
|
for (const manual of [false, true]) {
|
||||||
|
const { world } = buildWorld({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(2, { officerLevel: 5 }),
|
||||||
|
buildGeneral(3, reason === 'secret' ? { meta: { killturn: 12, belong: 0 } } : {}),
|
||||||
|
...(reason === 'absent'
|
||||||
|
? [buildGeneral(4, { cityId: 2, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } })]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(request(world)).toMatchObject({ ok: true });
|
||||||
|
expect(pending(world)).toMatchObject({ defaultDecision: 'reject', defaultReason: reason });
|
||||||
|
if (manual) expect(decide(world, 'approve')).toMatchObject({ ok: true });
|
||||||
|
else settleCityOfficeRequests(world, pending(world).dueTick);
|
||||||
|
expect(pending(world).status).toBe(manual ? 'approved' : 'rejected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it.each([0, 1])('does not replace a stationed user / possessed general (%s)', (npcState) => {
|
||||||
|
const { world } = buildWorld({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(3),
|
||||||
|
buildGeneral(4, { npcState, officerLevel: 4, meta: { killturn: 12, officerCity: 1 } }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(request(world)).toMatchObject({ ok: false, reason: '재직자가 도시에 체류 중' });
|
||||||
|
});
|
||||||
|
it('lets chiefs approve or reject default approval, and permits owner withdrawal', () => {
|
||||||
|
for (const action of ['approve', 'reject', 'withdraw'] as const) {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
expect(decide(world, action, action === 'withdraw' ? 3 : 2)).toMatchObject({ ok: true });
|
||||||
|
expect(pending(world).status).toBe(
|
||||||
|
{ approve: 'approved', reject: 'rejected', withdraw: 'withdrawn' }[action]
|
||||||
|
);
|
||||||
|
expect(request(world).ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('rejects forged actors, ordinary approvers, foreign chiefs and stale request IDs', () => {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
expect(decide(world, 'approve', 3).ok).toBe(false);
|
||||||
|
world.updateGeneral(2, { nationId: 9 });
|
||||||
|
expect(decide(world, 'approve').ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
handleCityOfficeRequest(
|
||||||
|
world,
|
||||||
|
{
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
userId: 'forged',
|
||||||
|
generalId: 3,
|
||||||
|
action: 'withdraw',
|
||||||
|
officeRequestId: pending(world).id,
|
||||||
|
},
|
||||||
|
acceptedAt
|
||||||
|
).ok
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
handleCityOfficeRequest(
|
||||||
|
world,
|
||||||
|
{
|
||||||
|
type: 'cityOfficeRequest',
|
||||||
|
userId: 'user-3',
|
||||||
|
generalId: 3,
|
||||||
|
action: 'withdraw',
|
||||||
|
officeRequestId: 'stale',
|
||||||
|
},
|
||||||
|
acceptedAt
|
||||||
|
).ok
|
||||||
|
).toBe(false);
|
||||||
|
expect(pending(world).status).toBe('pending');
|
||||||
|
});
|
||||||
|
it.each(['move', 'nation', 'owner', 'occupation', 'lock', 'stat', 'penalty'] as const)(
|
||||||
|
'cancels after %s changes before settlement',
|
||||||
|
(change) => {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
const general = world.getGeneralById(3)!;
|
||||||
|
if (change === 'move') world.updateGeneral(3, { cityId: 2 });
|
||||||
|
if (change === 'nation') world.updateGeneral(3, { nationId: 2 });
|
||||||
|
if (change === 'owner') world.updateGeneral(3, { userId: 'other' });
|
||||||
|
if (change === 'occupation') world.updateCity(1, { nationId: 2 });
|
||||||
|
if (change === 'lock') world.updateCity(1, { meta: { officer_set: 16 } });
|
||||||
|
if (change === 'stat') world.updateGeneral(3, { stats: { ...general.stats, strength: 0 } });
|
||||||
|
if (change === 'penalty') world.updateGeneral(3, { penalty: { noChief: true } });
|
||||||
|
settleCityOfficeRequests(world, pending(world).dueTick);
|
||||||
|
expect(pending(world).status).toBe('cancelled');
|
||||||
|
expect(world.getGeneralById(3)?.officerLevel).toBe(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
it('does not auto-grant newly restricted secrets or upgrade a stored rejection', () => {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
world.updateNation(1, { meta: { secretlimit: 10 } });
|
||||||
|
settleCityOfficeRequests(world, pending(world).dueTick);
|
||||||
|
expect(pending(world).status).toBe('rejected');
|
||||||
|
});
|
||||||
|
it('reserves a slot against other applicants but not a manual chief appointment', async () => {
|
||||||
|
const { world, handler } = buildWorld({
|
||||||
|
generals: [buildGeneral(2, { officerLevel: 5 }), buildGeneral(3), buildGeneral(4)],
|
||||||
|
});
|
||||||
|
request(world);
|
||||||
|
expect(request(world, 4).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
await handler.handle({
|
||||||
|
type: 'appoint',
|
||||||
|
userId: 'user-2',
|
||||||
|
generalId: 2,
|
||||||
|
destGeneralId: 4,
|
||||||
|
destCityId: 1,
|
||||||
|
officerLevel: 4,
|
||||||
|
})
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
expect(pending(world).status).toBe('cancelled');
|
||||||
|
expect(world.getGeneralById(4)?.officerLevel).toBe(4);
|
||||||
|
});
|
||||||
|
it('uses the monthly calendar boundary and survives a world snapshot reload', async () => {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
const saved = JSON.parse(JSON.stringify(world.getGeneralById(3)?.meta));
|
||||||
|
const { world: reloaded } = buildWorld({ generals: [buildGeneral(3, { meta: saved })] });
|
||||||
|
await createCityOfficeRequestCalendarHandler(() => reloaded).onMonthChanged?.({
|
||||||
|
previousYear: 185,
|
||||||
|
previousMonth: 1,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 2,
|
||||||
|
turnTime: reloaded.gameTickToDate(pending(reloaded).dueTick),
|
||||||
|
});
|
||||||
|
expect(pending(reloaded).status).toBe('approved');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a full review month for requests accepted just before a month boundary', () => {
|
||||||
|
const { world } = buildWorld({
|
||||||
|
clock: {
|
||||||
|
clockBaseTime: acceptedAt,
|
||||||
|
clockWallAnchor: acceptedAt,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockTick: GAME_TICKS_PER_TURN - 1,
|
||||||
|
lastTurnTick: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(request(world)).toMatchObject({ ok: true });
|
||||||
|
expect(pending(world).dueTick).toBe(2 * GAME_TICKS_PER_TURN);
|
||||||
|
settleCityOfficeRequests(world, GAME_TICKS_PER_TURN);
|
||||||
|
expect(pending(world).status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the quarterly applicant limit next quarter while preserving same-quarter refusal', () => {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
decide(world, 'reject');
|
||||||
|
expect(request(world).ok).toBe(false);
|
||||||
|
const previous = pending(world);
|
||||||
|
world.updateGeneral(3, {
|
||||||
|
meta: { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...previous, quarter: previous.quarter - 1 } },
|
||||||
|
});
|
||||||
|
expect(request(world).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels pending requests before quarterly locks reset', async () => {
|
||||||
|
const { world } = buildWorld({});
|
||||||
|
request(world);
|
||||||
|
world.updateCity(1, { meta: { officer_set: 16 } });
|
||||||
|
await createCityOfficeRequestCalendarHandler(() => world).beforeMonthChanged?.({
|
||||||
|
previousYear: 185,
|
||||||
|
previousMonth: 3,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 4,
|
||||||
|
turnTime: acceptedAt,
|
||||||
|
});
|
||||||
|
expect(pending(world).status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not relax a stored secret rejection when tenure increases', () => {
|
||||||
|
const { world } = buildWorld({ generals: [buildGeneral(3, { meta: { killturn: 12, belong: 0 } })] });
|
||||||
|
request(world);
|
||||||
|
world.updateGeneral(3, { meta: { ...world.getGeneralById(3)!.meta, belong: 10 } });
|
||||||
|
settleCityOfficeRequests(world, pending(world).dueTick);
|
||||||
|
expect(pending(world).status).toBe('rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves pending deadlines on backlog rebase and preserves occurrence ticks', () => {
|
||||||
|
const anchor = new Date('2026-01-01T00:00:00Z');
|
||||||
|
const { world } = buildWorld({
|
||||||
|
clock: { clockBaseTime: anchor, clockWallAnchor: anchor, clockMode: 'realtime', clockTick: 0, lastTurnTick: 0 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
handleCityOfficeRequest(
|
||||||
|
world,
|
||||||
|
{ type: 'cityOfficeRequest', userId: 'user-3', generalId: 3, action: 'request', officerLevel: 4 },
|
||||||
|
anchor
|
||||||
|
)
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
const before = pending(world);
|
||||||
|
const shifted = world.rebaseRealtimeBacklog(new Date(anchor.getTime() + 13 * 600_000));
|
||||||
|
expect(shifted?.shiftedTicks).toBe(12 * GAME_TICKS_PER_TURN);
|
||||||
|
expect(pending(world)).toMatchObject({
|
||||||
|
createdTick: before.createdTick,
|
||||||
|
dueTick: before.dueTick + shifted!.shiftedTicks,
|
||||||
|
});
|
||||||
|
const completed = { ...world.getGeneralById(3)!.meta, cityOfficeRequest: { ...before, status: 'approved' } };
|
||||||
|
expect(shiftOfficeRequestDeadline(completed, 10)).toEqual(completed);
|
||||||
|
expect(shiftOfficeRequestDeadline(world.getGeneralById(3)!.meta, 10, pending(world).dueTick + 1)).toEqual(
|
||||||
|
world.getGeneralById(3)!.meta
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the existing secret-limit fallback for malformed metadata', () => {
|
||||||
|
const { world } = buildWorld({
|
||||||
|
nationMeta: { secretlimit: 'invalid' },
|
||||||
|
generals: [buildGeneral(3, { meta: { killturn: 12, belong: 'invalid' } })],
|
||||||
|
});
|
||||||
|
expect(request(world)).toMatchObject({ ok: true });
|
||||||
|
expect(pending(world).defaultDecision).toBe('reject');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
|
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery, readOfficeRequest } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
readTurnRuntimeReady,
|
readTurnRuntimeReady,
|
||||||
@@ -793,6 +793,21 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
meta:
|
meta:
|
||||||
index === 0
|
index === 0
|
||||||
? {
|
? {
|
||||||
|
cityOfficeRequest: {
|
||||||
|
id: '1:760',
|
||||||
|
generalId: 1,
|
||||||
|
userId: 'clock-user',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
officerLevel: 4,
|
||||||
|
incumbentId: 0,
|
||||||
|
quarter: 760,
|
||||||
|
createdTick: initialTick - 100,
|
||||||
|
dueTick: reselectionTick,
|
||||||
|
defaultDecision: 'approve',
|
||||||
|
defaultReason: 'vacant',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
next_change_tick: reselectionTick,
|
next_change_tick: reselectionTick,
|
||||||
next_change: clock.tickToDate(reselectionTick).toISOString(),
|
next_change: clock.tickToDate(reselectionTick).toISOString(),
|
||||||
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
|
nextChangeAt: clock.tickToDate(reselectionTick).toISOString(),
|
||||||
@@ -922,6 +937,11 @@ describeIntegration('durable clock reconciliation', () => {
|
|||||||
);
|
);
|
||||||
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
|
const shiftedReselectionMeta = generals[0]!.meta as Record<string, unknown>;
|
||||||
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
|
expect(shiftedReselectionMeta.next_change_tick).toBe(reselectionTick + reconciled.shiftTicks);
|
||||||
|
expect(readOfficeRequest(shiftedReselectionMeta)).toMatchObject({
|
||||||
|
createdTick: initialTick - 100,
|
||||||
|
dueTick: reselectionTick + reconciled.shiftTicks,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
|
expect(new Date(String(shiftedReselectionMeta.next_change)).getTime()).toBe(
|
||||||
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
|
clock.tickToDate(reselectionTick).getTime() + 65 * 60_000 + 17_250
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -274,13 +274,22 @@ integration('database command queue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => {
|
it.each(['vacation', 'tournamentAdjustGeneral'] as const)(
|
||||||
const requestId = 'integration:engine:stale-owner-replay';
|
'stores a stale-owner %s rejection once and never redispatches it',
|
||||||
const command: TurnDaemonCommand = {
|
async (type) => {
|
||||||
type: 'vacation',
|
const requestId = `integration:engine:stale-owner-replay:${type}`;
|
||||||
|
const command: TurnDaemonCommand =
|
||||||
|
type === 'vacation'
|
||||||
|
? { type, requestId, userId: 'old-owner', generalId: 7 }
|
||||||
|
: {
|
||||||
|
type,
|
||||||
|
reason: 'tournamentBet',
|
||||||
requestId,
|
requestId,
|
||||||
userId: 'old-owner',
|
userId: 'old-owner',
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
|
goldDelta: -100,
|
||||||
|
betGoldDelta: 100,
|
||||||
|
minGoldAfter: 500,
|
||||||
};
|
};
|
||||||
await db.inputEvent.create({
|
await db.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -336,7 +345,8 @@ integration('database command queue', () => {
|
|||||||
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]);
|
||||||
expect(handle).toHaveBeenCalledOnce();
|
expect(handle).toHaveBeenCalledOnce();
|
||||||
expect(mutation).not.toHaveBeenCalled();
|
expect(mutation).not.toHaveBeenCalled();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
it.each(['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'RECONCILING', 'COMPLETED'])(
|
||||||
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
'handles pre-opening user commands in %s without treating them as scheduled turns',
|
||||||
@@ -630,6 +640,8 @@ integration('database command queue', () => {
|
|||||||
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
const metaId = 'integration:engine:suspended-tournament-bet-meta';
|
||||||
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
const rollbackId = 'integration:engine:suspended-tournament-bet-rollback';
|
||||||
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
const unrelatedId = 'integration:engine:suspended-resource-adjustment';
|
||||||
|
const atomicBetId = 'integration:engine:suspended-atomic-bet';
|
||||||
|
const atomicJoinId = 'integration:engine:suspended-atomic-join';
|
||||||
await db.inputEvent.createMany({
|
await db.inputEvent.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
@@ -679,6 +691,41 @@ integration('database command queue', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await db.inputEvent.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
requestId: atomicBetId,
|
||||||
|
target: 'ENGINE',
|
||||||
|
eventType: 'tournamentAdjustGeneral',
|
||||||
|
actorUserId: 'user-7',
|
||||||
|
payload: {
|
||||||
|
type: 'tournamentAdjustGeneral',
|
||||||
|
requestId: atomicBetId,
|
||||||
|
userId: 'user-7',
|
||||||
|
generalId: 7,
|
||||||
|
reason: 'tournamentBet',
|
||||||
|
goldDelta: -100,
|
||||||
|
betGoldDelta: 100,
|
||||||
|
minGoldAfter: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
requestId: atomicJoinId,
|
||||||
|
target: 'ENGINE',
|
||||||
|
eventType: 'tournamentAdjustGeneral',
|
||||||
|
actorUserId: 'user-7',
|
||||||
|
payload: {
|
||||||
|
type: 'tournamentAdjustGeneral',
|
||||||
|
requestId: atomicJoinId,
|
||||||
|
userId: 'user-7',
|
||||||
|
generalId: 7,
|
||||||
|
reason: 'tournamentJoin',
|
||||||
|
goldDelta: -200,
|
||||||
|
minGoldAfter: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
const queue = new DatabaseTurnDaemonCommandQueue(db);
|
||||||
await expect(queue.drain()).resolves.toEqual([
|
await expect(queue.drain()).resolves.toEqual([
|
||||||
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
expect.objectContaining({ type: 'adjustGeneralResources', requestId: resourceId, reason: 'tournamentBet' }),
|
||||||
@@ -688,7 +735,15 @@ integration('database command queue', () => {
|
|||||||
requestId: rollbackId,
|
requestId: rollbackId,
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
}),
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'tournamentAdjustGeneral',
|
||||||
|
requestId: atomicBetId,
|
||||||
|
reason: 'tournamentBet',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: atomicJoinId } })).resolves.toMatchObject({
|
||||||
|
status: 'PENDING',
|
||||||
|
});
|
||||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId: resourceId } })).resolves.toMatchObject({
|
||||||
status: 'PROCESSING',
|
status: 'PROCESSING',
|
||||||
processingGameTick: 321n,
|
processingGameTick: 321n,
|
||||||
|
|||||||
@@ -224,6 +224,14 @@ integration('monthly catalog boundary persistence', () => {
|
|||||||
currentYear: 190,
|
currentYear: 190,
|
||||||
currentMonth: 12,
|
currentMonth: 12,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
|
clockBaseTime: new Date('2026-07-25T01:50:00.000Z'),
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockPhase: 'MANUAL',
|
||||||
|
clockWallAnchor: new Date('2026-07-25T01:50:00.000Z'),
|
||||||
|
clockRevision: 1n,
|
||||||
|
deadlineGeneration: 1n,
|
||||||
config: scenarioConfig,
|
config: scenarioConfig,
|
||||||
meta: {
|
meta: {
|
||||||
hiddenSeed: 'monthly-boundary-core-catalog',
|
hiddenSeed: 'monthly-boundary-core-catalog',
|
||||||
|
|||||||
@@ -157,6 +157,23 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
});
|
});
|
||||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
try {
|
try {
|
||||||
|
// An API writer may have recorded a gap since this daemon loaded its world.
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: worldRow.id },
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
...world.getState().meta,
|
||||||
|
playAuditGap: {
|
||||||
|
serverId: scenarioCode,
|
||||||
|
firstYear: 192,
|
||||||
|
firstMonth: 12,
|
||||||
|
lastYear: 192,
|
||||||
|
lastMonth: 12,
|
||||||
|
stage: 'persistence',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const checkpoint = world.captureState();
|
const checkpoint = world.captureState();
|
||||||
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||||
const queued = world.peekDirtyState().pendingAuditDiplomacy;
|
const queued = world.peekDirtyState().pendingAuditDiplomacy;
|
||||||
@@ -170,11 +187,11 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||||
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
|
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
|
||||||
try {
|
try {
|
||||||
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure');
|
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued);
|
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
|
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
|
||||||
currentMonth: 1,
|
currentMonth: 2,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await db.diplomacy.findUniqueOrThrow({
|
await db.diplomacy.findUniqueOrThrow({
|
||||||
@@ -185,7 +202,7 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).toMatchObject({ stateCode: 1, term: 1 });
|
).toMatchObject({ stateCode: 0, term: 6 });
|
||||||
} finally {
|
} finally {
|
||||||
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
|
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
|
||||||
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
|
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
|
||||||
@@ -193,18 +210,25 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
|
|
||||||
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
|
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
|
||||||
world.queueAuditDecision(decision);
|
world.queueAuditDecision(decision);
|
||||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`);
|
await db.$executeRawUnsafe(
|
||||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`);
|
`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`
|
||||||
|
);
|
||||||
|
await db.$executeRawUnsafe(
|
||||||
|
`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback');
|
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]);
|
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
|
||||||
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
|
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1);
|
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(2);
|
||||||
} finally {
|
} finally {
|
||||||
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
|
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
|
||||||
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
|
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
|
||||||
}
|
}
|
||||||
|
// New observations after recovery can be stored; discarded batches do not retry themselves.
|
||||||
|
for (const event of queued) world.queueAuditDiplomacy(event);
|
||||||
|
world.queueAuditDecision(decision);
|
||||||
await hooks.hooks.flushChanges?.({
|
await hooks.hooks.flushChanges?.({
|
||||||
lastTurnTime: '0193-02-01T00:00:00.000Z',
|
lastTurnTime: '0193-02-01T00:00:00.000Z',
|
||||||
processedGenerals: 0,
|
processedGenerals: 0,
|
||||||
@@ -313,9 +337,9 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||||
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
|
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
|
||||||
try {
|
try {
|
||||||
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure');
|
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
|
||||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals);
|
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
|
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
|
||||||
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
|
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
|
||||||
@@ -331,7 +355,7 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
await db.playAuditDiplomacyEvent.count({
|
await db.playAuditDiplomacyEvent.count({
|
||||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
|
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
|
||||||
})
|
})
|
||||||
).toBe(6);
|
).toBe(0);
|
||||||
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
|
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
|
||||||
await hooks.flushChanges();
|
await hooks.flushChanges();
|
||||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
||||||
@@ -349,6 +373,18 @@ integration('monthly diplomacy persistence', () => {
|
|||||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' },
|
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' },
|
||||||
})
|
})
|
||||||
).toBe(6);
|
).toBe(6);
|
||||||
|
const savedDecision = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
|
||||||
|
const nation = world.getNationById(nationIds[0]!)!;
|
||||||
|
world.updateNation(nation.id, { gold: nation.gold + 1 });
|
||||||
|
world.queueAuditDecision({
|
||||||
|
...decision,
|
||||||
|
summary: { ...decision.summary, selectedReason: 'changed after update' },
|
||||||
|
});
|
||||||
|
await hooks.flushChanges();
|
||||||
|
expect(await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } })).toEqual(savedDecision);
|
||||||
|
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
|
||||||
|
expect(world.hasPendingAuditRecords()).toBe(false);
|
||||||
|
expect(world.getState().meta.playAuditGap).toMatchObject({ firstYear: 192, firstMonth: 12 });
|
||||||
} finally {
|
} finally {
|
||||||
await hooks.close();
|
await hooks.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { City, Nation } from '@sammo-ts/logic';
|
import type { City, Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -186,6 +187,31 @@ describe('invader monthly actions', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves new nation audit heads through ruler initialization and restart', async () => {
|
||||||
|
const harness = buildHarness();
|
||||||
|
const { world, environment } = harness;
|
||||||
|
world.updateWorldMeta({ serverId: 'new-nation-audit' });
|
||||||
|
initializeAuditPolicies(world);
|
||||||
|
const handler = createRaiseInvaderHandler({
|
||||||
|
getWorld: () => world,
|
||||||
|
reservedTurns: harness.reservedTurns,
|
||||||
|
env: buildCommandEnv(scenarioConfig),
|
||||||
|
});
|
||||||
|
await handler([10, 150, 100, 20], environment, event);
|
||||||
|
const created = world.peekDirtyState().createdNations;
|
||||||
|
expect(created.length).toBeGreaterThan(0);
|
||||||
|
for (const nation of created) {
|
||||||
|
expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4);
|
||||||
|
}
|
||||||
|
world.consumeDirtyState();
|
||||||
|
initializeAuditPolicies(world);
|
||||||
|
expect(
|
||||||
|
world
|
||||||
|
.peekDirtyState()
|
||||||
|
.pendingAuditPolicies.filter((policy) => created.some((nation) => nation.id === policy.nationId))
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
|
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
|
||||||
const harness = buildHarness();
|
const harness = buildHarness();
|
||||||
const handler = createRaiseInvaderHandler({
|
const handler = createRaiseInvaderHandler({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { initializeAuditPolicies } from '../src/playAudit/policy.js';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseScenarioGeneralPoolCandidate,
|
parseScenarioGeneralPoolCandidate,
|
||||||
@@ -228,6 +229,21 @@ describe('RaiseNPCNation monthly action', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves new nation audit heads through ruler initialization and restart', async () => {
|
||||||
|
const { world, handler, environment } = buildHarness();
|
||||||
|
world.updateWorldMeta({ serverId: 'new-nation-audit' });
|
||||||
|
initializeAuditPolicies(world);
|
||||||
|
await handler([], environment, event);
|
||||||
|
const created = world.peekDirtyState().createdNations;
|
||||||
|
expect(created.length).toBeGreaterThan(0);
|
||||||
|
for (const nation of created) {
|
||||||
|
expect(Object.keys(nation.meta._playAuditPolicy ?? {})).toHaveLength(4);
|
||||||
|
}
|
||||||
|
world.consumeDirtyState();
|
||||||
|
initializeAuditPolicies(world);
|
||||||
|
expect(world.peekDirtyState().pendingAuditPolicies).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
|
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
|
||||||
const info = {
|
const info = {
|
||||||
generalName: '부장후보',
|
generalName: '부장후보',
|
||||||
@@ -337,6 +353,32 @@ describe('RaiseNPCNation monthly action', () => {
|
|||||||
"id": 2,
|
"id": 2,
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"meta": {
|
"meta": {
|
||||||
|
"_playAuditPolicy": {
|
||||||
|
"DEFENCE": {
|
||||||
|
"hash": "ffb1e0b6bbf1680af65800cfc1b166afbd68149217e2b039c95b4ce4db0d20aa",
|
||||||
|
"id": "6329bea23c4db57a8c6bfb7d4c81e9f5a3e59171c883e9d22c5ba97dfb7d486c",
|
||||||
|
"revision": 1,
|
||||||
|
"serverId": "fixture-server",
|
||||||
|
},
|
||||||
|
"NPC_GENERAL_PRIORITY": {
|
||||||
|
"hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062",
|
||||||
|
"id": "28bc6ff38cc5b1b250f6a738a6745b5d581badf34bbcaf191e5e8710c17263ec",
|
||||||
|
"revision": 1,
|
||||||
|
"serverId": "fixture-server",
|
||||||
|
},
|
||||||
|
"NPC_NATION_PRIORITY": {
|
||||||
|
"hash": "0a6202f188859cb41dee0f08388e9da21babf6f4b569f7b18abf0453caaa1062",
|
||||||
|
"id": "feefa645ba10e0fb8c2c78e0c189f9effe8a1c3d2adf4c0d866300d4123f3748",
|
||||||
|
"revision": 1,
|
||||||
|
"serverId": "fixture-server",
|
||||||
|
},
|
||||||
|
"NPC_VALUES": {
|
||||||
|
"hash": "1906fb445a401a46f1279dbba3f3d572e9b213daf6c0f0d31cf8beeee3c77104",
|
||||||
|
"id": "c6fe336ffe5870cc90e2abab797d050810400404f7ad51b3ceea987b21eb3baa",
|
||||||
|
"revision": 1,
|
||||||
|
"serverId": "fixture-server",
|
||||||
|
},
|
||||||
|
},
|
||||||
"bill": 100,
|
"bill": 100,
|
||||||
"can_국기변경": 1,
|
"can_국기변경": 1,
|
||||||
"gennum": 1,
|
"gennum": 1,
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
|||||||
|
|
||||||
const buildWorld = (
|
const buildWorld = (
|
||||||
general = buildGeneral(),
|
general = buildGeneral(),
|
||||||
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
|
options: {
|
||||||
|
autorunLimit?: boolean;
|
||||||
|
scenarioEffect?: ScenarioEffectKey | null;
|
||||||
|
scenarioConst?: Record<string, unknown>;
|
||||||
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const state: TurnWorldState = {
|
const state: TurnWorldState = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -126,7 +130,7 @@ const buildWorld = (
|
|||||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
||||||
iconPath: '',
|
iconPath: '',
|
||||||
map: {},
|
map: {},
|
||||||
const: { maxTrainByWar: 100, maxAtmosByWar: 100 },
|
const: options.scenarioConst ?? {},
|
||||||
environment: {
|
environment: {
|
||||||
mapName: 'test',
|
mapName: 'test',
|
||||||
unitSet: '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([
|
it.each([
|
||||||
'event_UnlimitedDefenceThresholdChange',
|
'event_UnlimitedDefenceThresholdChange',
|
||||||
'event_StrongAttacker',
|
'event_StrongAttacker',
|
||||||
@@ -284,14 +335,18 @@ describe('my information world commands', () => {
|
|||||||
] satisfies ScenarioEffectKey[])(
|
] satisfies ScenarioEffectKey[])(
|
||||||
'preserves the %s scenario that waives the no-defence penalty',
|
'preserves the %s scenario that waives the no-defence penalty',
|
||||||
async (scenarioEffect) => {
|
async (scenarioEffect) => {
|
||||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
|
const fixture = buildWorld(buildGeneral({ train: 110, atmos: 150 }), { scenarioEffect });
|
||||||
await fixture.handler.handle({
|
await fixture.handler.handle({
|
||||||
type: 'setMySetting',
|
type: 'setMySetting',
|
||||||
userId: 'user-7',
|
userId: 'user-7',
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
settings: { defence_train: 999 },
|
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
Reference in New Issue
Block a user